포스트

RunWay 1.4 (7) 1초 모자란 다이얼과 끊기는 페이스 줄

지난 글에서 고친 것들을 들고 다시 나갔다. 같은 날 짧게 세 번, 그리고 5.3km를 왕복으로 한 번 뛰었다. 기록이 쌓인 김에 Debrief도 같이 봤다.

고친 것들은 의도대로 됐는데 그 옆에서 다섯 개가 더 나왔다. 넷은 고쳤고 하나는 아직 모른다. 반대로 3km에서만 확인했던 결정 하나가 더 긴 왕복에서 버티는 것도 봤다.


1초가 모자란 다이얼

워치에서 Digital Crown으로 페이스 오차를 40초에 맞췄는데 화면에 39로 떴다.

처음엔 표시만 틀린 줄 알았는데, 저장된 기록을 열어보니 아니었다.

목표 페이스가 7분 19초로 저장된 미션 플라이트 기록

맨 위에 PACE 7'19"/km가 적혀 있다. 7분 20초로 맞춘 러닝이다. 다른 기록은 11'39"/km였는데 그것도 11분 40초로 맞춘 것이었다.

둘 다 9로 끝난다. 오차 40초가 39로 나온 것과 같은 일이 목표 페이스에도 벌어지고 있었다.

(같은 그림에서 PACE 줄이 위로 여러 번 빠져나가 사라지는 게 보인다. 지난 글에서 범위 밖 값을 줄 끝에 눌러 붙이지 않기로 한 그 동작이다. 숨 고르려고 걸을 때마다 그렇게 나가고, 그 자리를 짚으면 숫자는 판독 패널에 그대로 뜬다.)


버림이 숨어 있던 자리

크라운은 5초 단위로 끊어주게 해뒀다.

1
2
3
4
5
6
7
8
.digitalCrownRotation(
    $crownValue,
    from: 5,
    through: 60,
    by: 5,
    // 생략
)
.onChange(of: crownValue) { _, newVal in deviation = newVal }

그런데 by: 5는 회전을 5 단위로 끊어줄 뿐 값이 정확히 5의 배수가 된다는 보장이 아니다. 크라운 회전량이 부동소수점으로 누적되기 때문에 39.9999... 같은 값에 멈춘다.

그걸 화면에 이렇게 찍고 있었다.

1
Text("±\(Int(deviation))")

Int()는 반올림이 아니라 버림이다. 39.9999는 39가 된다.

표시만의 문제였으면 덜 나빴을 텐데, 같은 값이 저장까지 갔다.

1
2
3
4
let modeAData = ModeA(target: .pace,
                      targetPace: Double(paceSeconds) / 60.0,
                      paceDeviation: Int(deviation),
                      targetDistance: preset.distance)

GPWS가 판정하는 기준도 1초씩 좁았다는 뜻이다. 40초로 잡은 줄 알았는데 실제로는 39초로 돌고 있었다.


값이 떨어지는 자리에서 반올림

고치는 자리가 두 군데로 보였다. 찍을 때 반올림하거나, 값이 들어올 때 반올림하거나.

후자로 갔다.

1
2
3
// 크라운이 by: 5 로 끊겨도 누적 오차 때문에 39.9999 같은 값에 멈춘다.
// Int() 는 버림이라 그대로 두면 40 을 고른 게 39 로 보인다.
.onChange(of: crownValue) { _, newVal in deviation = newVal.rounded() }

찍는 자리에서 고치면 화면만 맞고 저장되는 값은 그대로 틀린다. 값이 처음 떨어지는 자리에서 맞춰야 그 뒤로 지나가는 모든 곳이 같이 맞는다.

같은 모양이 다이얼 네 개에 전부 있었다. 목표 페이스, 페이스 오차, 목표 심박, 심박 오차. 넷 다 Double 크라운 값을 Int()로 찍고 있었다.


반만 고쳐진 화면

고치고 나서 워치에서 다시 맞춰봤다.

오차는 40으로 맞게 뜨는데 목표 페이스는 7분 19초로 남아 있는 워치 화면

오차는 ±40 sec로 맞게 나온다. 그런데 목표 페이스는 여전히 7'19"/km다.

7’19”는 439초다. 다이얼은 5초 단위라 439는 애초에 고를 수 없는 값이다. 440만 나올 수 있다. 크라운은 제대로 맞았는데 다른 자리에서 또 깎이고 있다는 뜻이었다.

페이스는 분 단위 Double로 저장된다. 440초가 7.333333333333333이 된다. 그걸 화면에 쓰려고 다시 분과 초로 쪼갠다.

1
2
let minutes = Int(flight.missionTargetPace)
let seconds = Int((flight.missionTargetPace - Double(minutes)) * 60)

(7.333333333333333 - 7.0) * 60은 19.999999999999982다. Int()가 또 19로 버린다.

초에서 분으로 갔다가 다시 초로 돌아오는 왕복에서 정밀도를 잃는다. 크라운을 아무리 정확히 맞춰도 보여주는 자리에서 1초가 사라진다.


왜 어떤 값만 틀렸나

전체 초를 먼저 반올림하고 나서 쪼개게 바꿨다. 초가 60으로 반올림되는 경우도 전체 초로 다루면 자연히 다음 분으로 넘어간다.

1
2
3
4
static func minuteSecond(_ pace: Double) -> (minutes: Int, seconds: Int) {
    let total = Int((pace * 60).rounded())
    return (total / 60, total % 60)
}

다이얼 범위를 전부 돌려봤다.

고른 초전후
3005’005’00
3806’196’20
4006’406’40
4407’197’20
4557’347’35
65910’5810’59
70011’3911’40
90015’0015’00

60으로 나누어떨어지는 값은 전에도 맞았다. 5분, 6분 40초, 15분은 Double로 정확히 표현되니까 왕복해도 손실이 없다. 그래서 전부 틀리는 게 아니라 어떤 값에서만 틀렸고, 그만큼 늦게 알아챘다.

같은 식이 세 화면에 있었다. 워치 요약, 아이폰 요약, 그리고 FLIGHT DATA RECORDER의 미션 바다. 공유 유틸 한 곳으로 모았다.

왼쪽은 7분 19초로 뜨던 화면, 오른쪽은 7분 20초로 고쳐진 화면


숫자가 바뀌자 잘린 글자

고친 화면을 보면 왼쪽에 없던 게 하나 생겼다. TARGET PACE가 TARGET PA...로 잘렸다.

두 화면의 글자 수는 같다. 7'19"/km와 7'20"/km 둘 다 여덟 자다. 그런데 Orbitron은 숫자마다 폭이 달라서 20이 19보다 몇 포인트 넓다. 그 몇 포인트가 한 줄을 넘겼다.

줄이 이렇게 생겼었다.

1
2
3
4
5
6
HStack(spacing: 8) {
    Image(systemName: icon)
    Text(label)
    Spacer()
    Text(value)
}

자리가 모자라면 SwiftUI가 둘 중 하나를 자르는데, 어느 쪽을 자를지 정해주지 않았다. 그래서 라벨이 잘렸다.

값은 절대 자르지 않고 라벨이 조금 줄어들게 바꿨다.

1
2
3
4
5
6
7
Text(label)
    .lineLimit(1)
    .minimumScaleFactor(0.8)
Spacer(minLength: 6)
Text(value)
    .lineLimit(1)
    .fixedSize(horizontal: true, vertical: false)

1이 2로 바뀌는 것만으로 레이아웃이 깨질 만큼 빠듯했다는 뜻이다. 고치기 전에는 마침 좁은 숫자가 들어가 있어서 안 보였다.

TARGET PACE 가 잘리지 않고 7분 20초가 그대로 보이는 워치 화면

고치고 다시 찍은 화면이다. 라벨이 끝까지 보이고 값도 그대로다.


걷는데 끊기는 페이스 줄

FLIGHT DATA RECORDER를 열어보니 PACE 줄이 중간에 끊겨 있었다.

페이스 줄이 오른쪽에서 끊긴 Free Flight 기록

멈춘 적이 없는 구간이다. 계속 걷기만 했다. 워치 단독으로 뛴 기록에서도 같았고, 워치 화면의 실시간 페이스도 순간순간 끊겼다.


너무 예민하게 고른 기준

지난 글에서 “값이 아니라 멈춰 있었냐로 가른다”고 적었다. 그때 고른 값이 이거였다.

1
2
let isBelowMovingSpeed = lowSpeedSince != nil
let pace = (isWithinStartupGrace || isBelowMovingSpeed) ? 0 : flightData.pace

lowSpeedSince는 속도가 정지 기준 아래로 떨어진 순간 채워진다.

1
2
3
4
5
if compensatedSpeed < stationarySpeedThreshold {   // 0.5 m/s
    if lowSpeedSince == nil {
        lowSpeedSince = location.timestamp
    }
    // 생략

걷기는 평균 1.4m/s라 기준보다 세 배 가까이 빠르다. 그래서 안 걸릴 줄 알았는데, 평균이 그렇다는 거지 순간 속도가 그렇다는 게 아니었다. GPS가 재는 순간 속도는 걸음마다 출렁여서 0.5m/s 아래로 수시로 내려간다.

그때마다 페이스가 0(기록 없음)으로 저장되고, 5초 간격 표본이 하필 그 순간에 걸리면 줄이 끊긴다.


이미 있던 2초짜리 확정

같은 파일에 쓸 수 있는 값이 하나 더 있었다.

1
isStationary = pastStartupGrace && location.timestamp.timeIntervalSince(lowSpeedSince!) >= stationaryDuration

isStationary는 그 저속이 2초 이상 이어져야 켜진다. 한 샘플로는 안 켜진다.

처음에 이걸 안 고른 이유가 있었다. 2초 늦게 켜지니까 그 사이에 페이스가 부풀 거라고 봤다. 그런데 이번에 실제로 돌려보니 생각보다 훨씬 덜 부푼다.

멈춘 뒤스무딩 속도페이스
1초96.8%1.03배
2초91.4%1.09배
3초84.9%1.18배
5초70.4%1.42배

이중 EMA라 두 단계를 거치면서 감쇠가 더 느려진다. 2초면 속도가 9%밖에 안 줄고, 7분 페이스가 7분 39초로 보이는 정도다. 걷는 페이스로 읽히지 사람이 못 내는 값이 아니다.

554분 같은 값이 나오는 건 시작 직후 구간이고 그건 따로 막고 있다. 중간에 멈추는 경우는 2초를 기다려도 사람이 낼 수 있는 범위 안이다.

1
let pace = (isWithinStartupGrace || isStationary) ? 0 : flightData.pace

시작 유예는 따로 뒀다. 아직 신뢰할 속도가 한 번도 안 들어온 상태는 “멈춤”이 아니라 “안 읽힘”이라 성격이 다르다.


설명이 덮고 있던 자리

이걸 고칠지 말지 한 번 망설였다. 읽는 법 시트에 이미 이렇게 적어뒀기 때문이다.

선이 끊긴 구간 값이 기록되지 않은 구간이에요. 페이스는 멈춰 서 있는 동안 기록하지 않아요. 속도가 0에 가까우면 계산상 페이스가 끝없이 커져서, 숫자로 남기면 실제로 뛴 구간을 읽을 수 없게 되거든요.

틀린 설명이 아니다. 실제로 그 구간은 기록이 없고, 그 이유도 맞게 적혀 있다. 그러니 설명대로 동작하고 있었던 셈이고, 그대로 두고 넘어갈 수도 있었다.

그런데 다시 보니 순서가 거꾸로였다. 저 문구는 “멈춰 있을 때”를 설명하려고 쓴 건데, 실제로 끊긴 구간은 멈춘 적이 없는 구간이었다. 설명은 멀쩡한데 그 설명이 가리키는 상황이 아니었던 것이다.

설명이 있다는 게 그 동작이 맞다는 뜻은 아니다. 오히려 설명이 있어서 “아 그래서 끊기는구나” 하고 한 번 넘어갔다. 없었으면 더 일찍 이상하다고 봤을 것 같다.


안 고치고 남긴 구멍

lowSpeedSince와 isStationary 둘 다 이 게이트 안에서만 갱신된다.

1
2
3
if location.speedAccuracy >= 0 {
    // 정지 판단과 속도 스무딩
}

그래서 저속으로 판정된 뒤에 GPS 정확도가 나빠지면, 믿을 만한 빠른 샘플이 올 때까지 그 판정이 그대로 걸려 있는다. 터널이나 건물 밑에서 길게 끊길 수 있는 경로다.

이번 수정은 2초 확정을 거치게 해서 잘못 걸릴 확률을 줄이지만 걸림 자체를 없애지는 않는다. 마지막으로 믿은 샘플의 시각을 들고 있다가 오래됐으면 “모름”으로 두는 쪽이 맞는데, 이번엔 안 건드렸다.


모든 점보다 아래에 깔린 평균선

위 그림을 다시 보면 PACE 줄 맨 아래에 가로선이 하나 있다. Free Flight 기록에 그려지는 평균 페이스 기준선이다.

그려진 모든 점보다 아래에 있다. 아래는 느린 쪽이니까 “이 러닝 내내 평균보다 빨랐다”로 읽힌다. 그럴 리가 없다.

평균을 어떻게 내고 있었는지 봤다.

1
let rawPace = (Double(totalTime) / 60) / totalDistance

전체 시간을 거리로 나눈다. 멈춰 서 있던 시간도 전체 시간에 들어간다. 요약 화면에 적는 평균 페이스로는 이게 맞다. 실제로 그만큼 걸렸으니까.

그런데 PACE 줄이 그리는 건 멈춘 구간을 뺀 표본들이다. 쉰 적이 한 번이라도 있으면 전체 평균은 움직일 때의 어떤 값보다도 느리다. 기준선이 바닥에 깔릴 수밖에 없었다.

같은 모수로 비교해야 비교가 된다. 그려지는 값들로 평균을 냈다.

1
2
3
let moving = samples.map(\.pace).filter { $0.isFinite && $0 > 0 }
guard !moving.isEmpty else { return nil }
return (center: moving.reduce(0, +) / Double(moving.count), tolerance: 0)

요약 화면의 평균 페이스는 그대로 뒀다. 두 숫자가 다른 건 맞고, 읽는 법 시트에 “멈춰 있던 시간은 빼고 냈어요”를 적어 뒀다.


보이지 않는 15초

오차를 ±15초로 좁게 잡고 뛰었더니 목표 띠가 거의 안 보였다.

오차 띠가 두 줄로만 겨우 보이는 기록

줄의 세로 폭은 그 러닝 값들이 보통 모여 있는 범위로 잡는다. 걷기가 섞이면 그 폭이 몇 분까지 벌어진다. 거기에 15초는 몇 px이다. 채우기가 opacity(0.1)이라 사실상 안 보이고, 위아래 오차선과 가운데 목표선 셋이 겹쳐 한 줄로 뭉갰다.


두껍게 그리면 생기는 거짓말

띠를 최소 높이만큼 보장하는 방법이 제일 쉬웠는데 안 했다. 그러면 ±15초로 잡은 목표가 더 느슨했던 것처럼 읽힌다.

세로 폭을 좁혀서 띠가 커 보이게 하는 것도 생각했다. 그러면 띠는 잘 보이지만 줄 밖으로 나가는 구간이 많아져서 러닝의 모양을 잃는다. 그리고 좁은 오차로 걷기를 섞어 뛴 기록은 애초에 “목표를 많이 벗어났다”가 사실이라, 그림이 그렇게 보이는 게 맞다.

그래서 높이는 그대로 두고 진하기만 올렸다.

1
2
3
4
5
let thinness = max(0, min(1, (thinBandHeight - bandHeight) / thinBandHeight))
context.fill(
    Path(CGRect(x: 0, y: Swift.min(upper, lower), width: size.width, height: bandHeight)),
    with: .color(color.opacity(0.1 + 0.22 * thinness))
)

얇을수록 진하게 칠한다. 5px보다 얇으면 가운데 목표선은 아예 안 그린다. 어차피 칠해둔 띠 안에 있고, 셋이 겹치면 뭉개지기만 한다.


다시 나온 종료 신호

지난 글 끝에 못 고쳤다고 적어둔 게 있다. 아이폰에서 시작하고 워치에서 끝냈을 때 아이폰 화면이 홈으로 안 돌아가는 문제다.

이번에 재현됐다.

  1. 아이폰에서 러닝 시작
  2. 5km쯤 걷기
  3. 워치에서 종료
  4. 아이폰이 종료되지 않음
  5. 아이폰에서도 버튼을 눌러 직접 종료
  6. Logbook에는 기록이 하나만 저장됨

6번이 눈에 걸린다. 중복 저장은 안 됐다. 같은 세션 id로 이미 저장된 기록이 있으면 건너뛰는 가드가 제 일을 했거나, 애초에 원격 경로의 저장이 돌지 않았거나 둘 중 하나다.


남아 있지 않던 로그

증상만으로는 후보가 안 좁혀진다. 보관된 신호가 늦게 배달된 것인지, 이전 종료의 처리중 표시가 남은 것인지, 버려진 뷰모델이 먼저 받은 것인지, 신호가 아예 안 온 것인지.

그래서 지난 글에서 각 단계에 로그를 심어뒀다. 재현됐으니 받아보면 될 줄 알았는데 안 됐다.

1
logger.info("[\(step, privacy: .public)] \(detail, privacy: .public)")

info는 통합 로그에서 메모리에만 머물고 디스크에 안 써진다. 그 순간 Console로 스트리밍을 보고 있으면 보이지만, 지나가면 사라진다.

Console의 기기 보기가 실시간 스트림만 보여준다는 것도 그제서야 알았다. 러닝하는 동안 아이폰을 맥에 연결해두고 스트리밍을 켜고 있어야 한다는 뜻이다.

간헐적으로 나는 버그를 잡으려고 심은 로그인데, 언제 날지 모르니 미리 켜둘 수가 없다. 방법 자체가 성립하지 않았다.

1
logger.notice("[\(step, privacy: .public)] \(detail, privacy: .public)")

notice는 디스크에 써진다. 러닝이 끝나고 돌아와서 받아올 수 있다.

1
2
log collect --device --last 2h --output ~/runway.logarchive
log show ~/runway.logarchive --predicate 'category == "RemoteStop"'

로그를 심는 것과 로그가 남는 것은 다른 일이었다. 코드만 보고 가설을 세우지 말자고 해서 로그로 갔는데, 그 로그가 남는지를 확인 안 했다.


비교군이 생긴 Debrief

3km를 기준으로 계속 뛰다 보니 비슷한 거리 기록이 쌓였다. 오늘 처음으로 분석이 나왔다.

평소보다 빨랐다는 비교 결과가 뜬 로그북

평소보다 0:36/km 빨랐어요 평소보다 심박이 8bpm 높았어요 비슷한 거리(±5%) 러닝 4개와 비교했어요

맨 아래 줄이 중요하다. 무엇과 비교했는지를 같이 적는다. 이게 없으면 “평소”가 뭔지 알 수가 없고, 거리가 다른 러닝까지 섞였는지도 모른다.

페이스가 빨라졌는데 심박도 같이 올라간 것까지 보여준다. 둘 중 하나만 보면 “좋아졌다”로 읽히는데, 같이 놓으면 그만큼 더 힘을 쓴 것이라는 게 보인다.


5.3km 왕복에서 깨지지 않은 것

문제만 적었는데 버틴 것도 있다. 이번에 5.3km를 왕복으로 걸었고 코스가 제대로 그려졌다. 바로 위에서 적은, 워치에서 끝냈는데 아이폰이 안 끝난 그 러닝이다. 종료는 어긋났는데 그리는 쪽은 멀쩡했다.

왕복은 코스 그림이 제일 쉽게 무너지는 조건이다. 돌아오는 길이 가는 길 위에 그대로 겹쳐 그려지기 때문이다. 그래서 46번 글에서 지나온 구간을 밝게 칠하던 걸 걷어내고, 항공기 뒤 2분만 밝은 꼬리로 긋는 방식으로 바꿨다. 한 바퀴만 돌아도 코스 전체가 밝아져서 밝기 구분이 아무 뜻도 없어지기 때문이었다.

그때까지 왕복은 3km에서만 봤다. 거리가 늘면 같은 길 위에 겹치는 양도 늘어나는데, 5.3km에서도 지금 어느 방향으로 가던 중인지가 계속 읽혔다.

꼬리를 거리가 아니라 시간으로 잡은 것도 같이 확인됐다. 이번엔 걷는 구간이 많이 섞였는데, 거리로 잡았으면 그 구간에서 꼬리가 거의 안 자라서 멈춰 있던 시간이 그림에서 사라졌을 것이다.

바꿀 때는 3km 한 번으로 정한 결정이었다. 더 긴 왕복에서 한 번 더 확인된 게 이번 소득이다.


정리

다섯 개가 나왔는데 넷이 같은 모양이었다.

 쓰던 값실제로 필요했던 값
다이얼Int() 버림반올림
페이스 끊김한 샘플로 켜지는 lowSpeedSince2초를 버텨야 켜지는 isStationary
평균선멈춘 시간 포함한 전체 평균그려지는 표본의 평균
오차 띠고정 진하기얇을수록 진하게

앞의 셋은 이미 앱 안에 맞는 값이 있었다. rounded()도, isStationary도, 표본 배열도 전부 그 자리에 있었는데 옆에 있는 다른 값을 쓰고 있었다. 새로 만들어야 했던 건 하나도 없다.

고르는 순간에는 셋 다 그럴듯했다. 버림과 반올림은 1 차이고, lowSpeedSince는 더 빨리 반응하고, 전체 평균은 요약에 쓰는 바로 그 숫자다. 틀린 게 드러난 건 전부 실기기에서 뛰고 난 뒤였다.

종료 신호 건은 아직 못 고쳤다. 재현은 됐는데 로그가 안 남아 있어서 다음 재현을 기다린다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.