라이덕
인턴·신입근거: AI 기반 피트니스 인텔리전스 기술을 개발하는 헬스케어 스타트업입니다.
※ 실제 산출물이 아닌 AI 예측 시안입니다.
공고는 라이덕을 AI 기반 피트니스 인텔리전스 기술을 개발하는 헬스케어 스타트업으로 소개하며, 전세계 8만 명 이상이 서비스를 쓴다고 밝힙니다. 개발팀은 Claude Code·Codex 같은 AI 에이전트로 개발하며, 사람의 일을 AI 가 낸 결과를 데이터 정확성 관점에서 직접 확인하는 것으로 적습니다. 공고 원문은 피트니스까지만 밝히지만 라이덕 앱은 사이클 파워미터 기록으로 FTP(훈련 강도를 정하는 기준 파워) 같은 지표를 계산하는 서비스이고, 그 지표가 실제 실력과 어긋나면 라이더의 모든 훈련 강도가 함께 틀립니다. 튄 값 1초, 두 번 올라온 라이딩, 신호 대기 0W 하나로 판정이 뒤집히는 계산이라, AI 가 짠 코드를 데이터로 확인하는 이 팀의 일하는 방식이 가장 많이 쓰이는 자리입니다. 우대사항이 Garmin·Wahoo·TrainingPeaks 데이터에 익숙한 사람을 찾는 것도 같은 지점을 가리킵니다. 이 포트폴리오는 1초 파워 기록에서 FTP 를 바꿀 때를 판정하는 계산과, 그 계산을 확인하는 테스트를 보여줍니다. 운동생리학이나 사이클링 코칭 전문성은 주장하지 않습니다.
라이덕 개발팀은 Claude Code, Codex 같은 AI 에이전트로 개발하고, 사람이 맡는 일을 'AI가 낸 결과를 동작, 예외 상황, 보안, 데이터 정확성 관점에서 직접 확인' 하는 것으로 정의합니다. FTP 판정은 튄 값 1초, 두 번 올라온 같은 라이딩, 신호 대기 0W 하나로 결과가 뒤집히는 계산이라, 이 편은 구현을 에이전트에 맡기고 그 결과가 맞는지 확인하는 테스트와 두 번째 구현을 중심에 둡니다.
자격요건은 수학적·논리적 사고와 자료구조·알고리즘·DB 의 기본 원리를 실제 문제에 적용한 경험을 묻고, 우대사항은 Garmin, Wahoo, TrainingPeaks 같은 운동 분석 서비스의 데이터에 익숙한 사람을 찾습니다. 1초 파워에서 길이별 최고 평균을 누적합으로 한 번에 구하고 네 점에 임계 파워 곡선을 최소제곱으로 맞추는 일이 그 두 요구가 만나는 자리입니다.
전세계 8만 명 이상의 사용자가 라이덕 서비스를 쓰고, 주요업무는 사용자 동작부터 API, 데이터까지 흐름을 따라가며 오류의 원인을 찾고 라이덕을 직접 써 보며 불편한 점을 찾는 일입니다. FTP 가 실제 실력과 어긋나면 그 라이더의 훈련 강도가 모두 틀리므로, 판정 카드에 근거가 된 라이딩 구간과 뺀 값을 그대로 보여 라이더가 숫자를 거슬러 올라가게 합니다.
라이덕은 파워미터 기록으로 운동능력 지표를 계산해 훈련을 안내하는 사이클링 앱이고, 이 화면은 그 앱의 FTP 설정 옆에서 지금 FTP 를 바꿀 때인지를 판정합니다. 최근 42일 1초 파워에서 튄 값을 빼고 3·5·12·20분 최고 평균을 구해 CP(짧은 최고 기록 여러 개로 추정한, 오래 버틸 수 있는 파워의 상한)를 맞추고, 그 CP 에 0.95 를 곱한 FTP 환산값을 지금 FTP 와 견줍니다. 환산값이 3% 넘게 높으면 올릴 때로 보고, 5분 평균 파워가 지금 FTP 의 95% 이상으로 올라섰다가 20분을 채우기 전에 80% 아래로 떨어진 라이딩이 두 번 이상이면서 환산값도 5% 넘게 낮으면 내려 볼 때로 봅니다. 20분 이상 파워를 재며 탄 라이딩이 두 번이 안 되면 판단을 미루고, 라이더는 판정 옆의 근거 라이딩과 뺀 값을 본 뒤 바꾸기를 눌러 새 FTP 로 훈련 구간을 다시 잡습니다.
파워미터를 단 라이덕 사용자(사이클 동호인). 앱을 열면 지금 FTP를 올릴지·내릴지·그대로 둘지와 그 근거가 된 라이딩 구간을 보고, 바꾸기를 누르면 다음 훈련 강도가 새 FTP로 다시 잡힌다
라이더가 FTP 를 늦게 바꾸는 것은 기록이 모자라서가 아닙니다. 앱은 라이딩마다 평균과 최고 파워를 보여주지만, 그 숫자가 지금 FTP 를 넘어섰는지는 서로 다른 날의 3분·20분 기록을 한 곡선으로 묶어야 보이고, 기기가 튄 1초나 신호 대기 0W, 가민과 스트라바로 두 번 올라온 라이딩이 그 판단을 흔듭니다. 튄 값과 정지를 걸러 낸 CP 환산값을 지금 FTP 와 견주고, 내릴 때는 실제로 버티지 못한 시도가 쌓였는지도 보면 라이더는 FTP 를 바꿀 때를 앱에서 바로 압니다.
FIT 파일을 변환한 라이딩 기록과 FTP 이력을 넣으면 앱의 내 FTP 카드에 판정과 FTP 환산값, 근거가 된 라이딩 구간, 뺀 튄 값이 뜹니다. 라이더가 바꾸기를 누르면 새 FTP 가 이력에 쌓이고 일곱 훈련 구간의 목표 와트가 다시 계산됩니다. 가민·와후 기기와의 자동 동기화는 이 범위 밖입니다.
CP 와 W′ 를 알고 만든 합성 라이딩과 기준값 바로 안팎의 라이더 40명으로 판정 테스트를 매번 돌리고, ChatGPT Codex 가 같은 판정식만 보고 따로 짠 구현과 판정이 갈리는 라이더를 모읍니다. 공개 GoldenCheetah OpenData 파워 파일에도 돌려 판단 보류 비율과 실패로 센 라이딩 가운데 인터벌 라이딩 비율을 재고, 파워 곡선 탭에 네 최고 평균과 맞춘 곡선을 그립니다.
2주차에 돌린 공개 파워 파일에서 튄 값을 사람이 직접 표시한 정답으로 2,000W·1,000W 규칙이 놓친 초와 잘못 뺀 초를 세고, 같은 정답으로 학습한 작은 1D CNN 과 견준 표를 냅니다. 판정은 계속 규칙으로 하고, 바꾸기를 누른 라이더는 다음 주 훈련 탭에서 새 FTP 로 다시 계산된 목표 파워를 봅니다.
라이더가 앱을 열면 지금 FTP 를 올릴 때인지, 낮춰 볼 때인지, 그대로 둘지와 FTP 환산값, 근거가 된 3·5·12·20분 최고 평균 구간, 뺀 튄 값이 한 카드에 뜨고, 바꾸기를 누르면 일곱 훈련 구간의 와트가 새 FTP 로 다시 나옵니다.
정확히 1,200초인 라이딩은 판정에 들어가고, 환산값이 정확히 3.0% 높은 라이더는 올릴 때가 아니라 유지로 남습니다. 신호 대기 70초의 0W 는 버티지 못한 시도 셈에서 빠지고, 가민과 스트라바로 두 번 올라온 라이딩은 한 번으로 셉니다.
심박 없이 2,140W 로 튄 1초, 앞 초보다 1,107W 뛴 1초, heart_rate 가 null 인 라이딩, 1,080초짜리 라이딩, 파워 없는 라이딩을 섞은 기록 30종을 넣어도 응답 형식이 깨지지 않고, 판정에서 뺀 라이딩과 초는 이유를 달고 카드 아래에 남습니다.
CP 와 W′ 를 알고 만든 합성 라이딩과 경계 라이더 40명을 돌리면, 판정과 FTP 환산값이 기대와 같은 라이더 수가 남습니다. 단, 정답과 데이터를 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
위 결과는 더미데이터 자가채점이며, 이 판정대로 FTP 를 바꾼 라이더가 다음 훈련을 끝까지 더 잘 소화하는지는 라이덕의 실제 라이더 기록과 훈련 완료 기록이 있어야 측정됩니다. 이 포트폴리오는 그 측정을 바로 돌릴 수 있는 상태로 만듭니다.
라이더 여섯 명의 기록과 기대 판정은 모두 제가 만든 합성 데이터라 자가채점 결과는 성능 증명이 아닙니다. 판정을 따라 FTP 를 바꾼 라이더가 다음 훈련을 끝까지 소화하는지가 이 앱의 진짜 물음인데, 그 답은 라이덕의 실제 훈련 완료 기록에만 있습니다.
rider_5523 은 경계 두 개에 동시에 걸쳐 있습니다. 하늘공원 업힐이 정확히 1,200초라 '이상' 을 '초과' 로 읽으면 판단 보류로, 환산값이 정확히 3.0% 높아 '넘게' 를 '이상' 으로 읽으면 올릴 때로 바뀝니다. 신호 대기 70초를 0W 그대로 평균에 넣으면 rider_4460 이 내려 볼 때로, 2초 차이로 두 번 올라온 라이딩을 따로 세면 rider_2051 이 판단 보류에서 풀리고, 42일 창을 하루 넓히면 rider_0412 의 8월 27일 팔당 왕복이 들어와 20분 최고 평균이 288W 에서 300W 로 바뀝니다.
언덕 반복이나 인터벌처럼 일부러 쉬어 가는 라이딩도 오르막 뒤 내리막에서 직전 300초 평균이 지금 FTP 의 80% 아래로 내려가 버티지 못한 시도로 셉니다. rider_0412 의 남산 업힐 3회가 그 예이고, 내려 볼 때는 환산값 5% 하락을 같이 요구해 이 오탐 하나로 FTP 가 내려가지는 않지만, 인터벌 라이딩을 셈에서 빼는 규칙은 2주차에 공개 파워 파일로 그 비율을 잰 뒤 정합니다.
CP 맞춤과 0.95 환산은 짧은 기록에 크게 기대는 추정이라, 3분·5분을 전력으로 달려 본 적이 없는 라이더는 CP 가 낮게 나와 실제보다 낮은 FTP 를 권하게 됩니다. 라이덕 앱에 이미 있는 FTP 추정치와 이 환산값이 라이더마다 얼마나 벌어지는지 재는 일은 실제 도입에서 가장 먼저 할 도입 단계 과제입니다.
튄 값 규칙은 심박계가 없는 라이더의 진짜 전력 질주와 기기 오류를 가리지 못합니다. 3주차의 사람 표시 정답과 1D CNN 비교가 그 빈틈의 크기를 재는 자리이고, 판정은 그때도 규칙으로 합니다.
이 편은 1초 파워 기록에서 FTP 를 바꿀 때를 판정하는 계산과 그 계산을 확인하는 테스트를 다룹니다. 운동생리학이나 사이클링 코칭 전문성은 주장하지 않습니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
라이덕 앱으로 들어온 라이딩 한 건이 한 행입니다. FIT 파일 업로드나 스트라바 연동으로 들어오며, 같은 라이딩이 가민과 스트라바로 두 번 들어온 경우와 파워미터 없이 탄 라이딩(has_power 거짓), 제목을 안 붙인 라이딩(title null)이 섞입니다.
| ride_id | rider_id | started_at | duration_s | has_power | source | title |
|---|---|---|---|---|---|---|
| ride_51207 | rider_0412 | 2026-10-05T06:10:00+09:00 | 4,860 | true | garmin | 남산 업힐 3회 |
| ride_50944 | rider_0412 | 2026-09-27T07:00:00+09:00 | 6,420 | true | wahoo | 북악 스카이웨이 |
| ride_50812 | rider_0412 | 2026-10-02T14:03:40+09:00 | 2,310 | true | garmin | — |
| ride_49870 | rider_0412 | 2026-08-27T08:00:00+09:00 | 10,200 | true | garmin | 팔당 왕복 |
| ride_51003 | rider_1188 | 2026-09-24T19:30:00+09:00 | 3,600 | true | wahoo | FTP 20분 도전 |
아래 JSON Schema 에 맞는 라이딩 행을 더 만들어 주세요. {"type":"object","properties":{"ride_id":{"type":"string"},"rider_id":{"type":"string"},"started_at":{"type":"string","format":"date-time"},"duration_s":{"type":"integer","minimum":0},"has_power":{"type":"boolean"},"source":{"type":"string","enum":["garmin","wahoo","strava"]},"title":{"type":["string","null"]}},"required":["ride_id","rider_id","started_at","duration_s","has_power","source","title"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 같은 rider_id 에서 started_at 이 몇 초 차이이고 duration_s 가 1% 안쪽으로 다른 garmin·strava 짝, (2) duration_s 가 정확히 1200 인 라이딩과 1199 인 라이딩, (3) has_power 가 false 인 긴 라이딩, (4) as_of 앞 42일 창 바로 밖에서 시작한 라이딩, (5) title 이 null 인 라이딩. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"ride_id":{"type":"string"},"rider_id":{"type":"string"},"started_at":{"type":"string","format":"date-time"},"duration_s":{"type":"integer","minimum":0},"has_power":{"type":"boolean"},"source":{"type":"string","enum":["garmin","wahoo","strava"]},"title":{"type":["string","null"]}},"required":["ride_id","rider_id","started_at","duration_s","has_power","source","title"]}라이딩마다 1초 간격으로 남은 파워와 심박입니다. 실제로는 라이딩 하나에 수천 줄이라 여기에는 판정을 가르는 초만 실었고, 나머지 초는 validation_notes 의 [더미 1초 기록 만드는 법] 구간표로 만듭니다. 심박 스트랩이 없거나 끊긴 초는 heart_rate 가 null 입니다.
| ride_id | t | watts | heart_rate |
|---|---|---|---|
| ride_50812 | 1,646 | 312 | 158 |
| ride_50812 | 1,647 | 2,140 | — |
| ride_50812 | 1,648 | 318 | 158 |
| ride_51240 | 2,980 | 205 | 149 |
| ride_51240 | 2,981 | 1,312 | 149 |
아래 JSON Schema 에 맞는 1초 파워 행을 더 만들어 주세요. 한 ride_id 의 t 는 0부터 1씩 늘어나게 이어서 만듭니다. {"type":"object","properties":{"ride_id":{"type":"string"},"t":{"type":"integer","minimum":0,"description":"라이딩 시작부터 지난 초입니다."},"watts":{"type":"integer","minimum":0},"heart_rate":{"type":["integer","null"]}},"required":["ride_id","t","watts","heart_rate"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) heart_rate 가 null 이고 watts 가 2,000 을 넘는 1초, (2) 앞 초보다 watts 가 1,000 넘게 뛴 1초와 정확히 1,000 뛴 1초, (3) 신호 대기처럼 watts 가 0 으로 수십 초 이어지는 구간, (4) 내리막에서 watts 가 0~80 사이로 오래 이어지는 구간, (5) 심박계 없이 탄 라이딩의 연속된 heart_rate null. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"ride_id":{"type":"string"},"t":{"type":"integer","minimum":0,"description":"라이딩 시작부터 지난 초입니다."},"watts":{"type":"integer","minimum":0},"heart_rate":{"type":["integer","null"]}},"required":["ride_id","t","watts","heart_rate"]}FTP 를 바꿀 때마다 한 행이 쌓이는 이력입니다. 라이더마다 set_at 이 가장 최근인 행의 ftp_w 가 지금 기준이고, 램프 테스트로 정한 값과 라이더가 직접 넣은 값, 앱 추정값이 섞입니다.
| rider_id | ftp_w | set_at | source |
|---|---|---|---|
| rider_0412 | 236 | 2026-05-02T08:00:00+09:00 | ramp_test |
| rider_0412 | 248 | 2026-08-10T21:30:00+09:00 | ramp_test |
| rider_1188 | 240 | 2026-06-30T20:15:00+09:00 | ramp_test |
| rider_1188 | 265 | 2026-09-20T22:10:00+09:00 | manual |
| rider_2051 | 205 | 2026-04-20T19:00:00+09:00 | ramp_test |
아래 JSON Schema 에 맞는 FTP 이력 행을 더 만들어 주세요. {"type":"object","properties":{"rider_id":{"type":"string"},"ftp_w":{"type":"integer","minimum":1},"set_at":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["ramp_test","manual","app_estimate"]}},"required":["rider_id","ftp_w","set_at","source"]} 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 램프 테스트 값보다 10% 넘게 높게 직접 넣은 manual 행, (2) 같은 라이더의 이력이 세 줄 이상이고 가장 늦은 행이 42일 창 안에 있는 경우, (3) 이력이 한 줄뿐인 라이더. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"rider_id":{"type":"string"},"ftp_w":{"type":"integer","minimum":1},"set_at":{"type":"string","format":"date-time"},"source":{"type":"string","enum":["ramp_test","manual","app_estimate"]}},"required":["rider_id","ftp_w","set_at","source"]}| Claude | Claude Code 가 판정 엔진을 구현합니다. 판정식 원문과 기준값 안팎 테스트를 먼저 커밋해 두고, 테스트가 모두 통과한 코드만 받습니다. |
| ChatGPT | 2주차에 ChatGPT Codex 가 같은 판정식 원문만 보고 엔진을 따로 구현합니다. 두 구현의 판정이 갈리는 라이더를 모아 어느 쪽이 판정식을 잘못 읽었는지 사람이 원문과 대조해 정합니다. |
| 딥러닝 | 3주차에 앞뒤 수십 초의 파워·심박 창을 입력으로 받는 1D 합성곱 신경망을 사람이 표시한 튄 값 정답으로 학습시켜, 2,000W·1,000W 규칙과 놓친 초·잘못 뺀 초를 견줍니다. 판정에는 규칙만 씁니다. |
| Python | 판정 엔진과 API 의 언어입니다. NumPy 누적합으로 1초 파워에서 3·5·12·20분 최고 평균을 한 번 훑어 구하고, 네 점에 P = W′/t + CP 를 최소제곱으로 맞춥니다. |
| FastAPI | 앱이 부르는 판정 API 입니다. 라이더 한 명의 최근 42일 기록을 읽어 판정과 근거를 JSON 으로 돌려주고, 바꾸기 요청이 오면 FTP 이력에 새 행을 씁니다. |
| PostgreSQL | 라이딩, 1초 파워, FTP 이력을 담습니다. power_samples 는 (ride_id, t) 를 기본키로 두어 한 라이딩을 시간 순서대로 읽고, rides 는 (rider_id, started_at) 색인으로 42일 창을 자릅니다. |
| React Native | 라이더가 여는 앱입니다. Expo 로 판정 카드, 근거 라이딩 구간, 뺀 값 목록, 바꾸기 버튼, 하단 탭을 만듭니다. |
| TypeScript | 앱 코드의 언어이고, 판정 API 응답 타입을 앱과 같은 정의로 씁니다. |
| 자료구조·알고리즘 | 버티지 못한 시도는 0W 초를 뺀 300초 큐로 평균을 밀어 가며 올라섬·버팀·떨어짐 세 상태를 바꿔 찾습니다. 라이딩 하나를 한 번 훑는 O(n) 입니다. |
| FIT 파일 | Garmin·Wahoo 기기가 남기는 FIT 파일을 fitdecode 로 읽어 rides·power_samples 행으로 바꿉니다. 제 라이딩 기록이나 공개 GoldenCheetah OpenData 파워 파일을 씁니다. |
✓ pass → 판정 카드와 바꾸기 버튼이 돌면 그 자체로 라이더가 쓸 수 있는 앱입니다. 더 키우려면 판정 테스트 40명을 매번 돌리고 Codex 가 따로 짠 두 번째 구현과 판정을 견주는 단계로 갑니다.
✓ pass → 2주차까지로도 완결입니다. 더 키우려면 공개 파워 파일의 튄 값을 사람이 표시해 규칙이 놓친 초를 재고, 새 FTP 를 다음 주 훈련에 반영하는 단계로 갑니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · rider_id + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
지금 FTP 유지FTP 유지FTP를 올릴 때올릴 때FTP를 내려 볼 때내려 볼 때기록이 모자라 판단 보류기록 부족라이더 한 명의 지금 FTP 를 바꿀 때인지와 그 근거를 남기는 형식입니다. 판정, 환산값, 뺀 라이딩과 초는 판정식이 정하고, summary 는 그 숫자로 채운 규칙 문장입니다.
{"type":"object","properties":{"rider_id":{"type":"string","description":"판정한 라이더입니다. 화면의 줄 키입니다."},"as_of":{"type":"string"},"verdict":{"type":"string","enum":["지금 FTP 유지","FTP를 올릴 때","FTP를 내려 볼 때","기록이 모자라 판단 보류"]},"ftp_w":{"type":["integer","null"],"description":"ftp_history 에서 그 rider_id 의 set_at 이 가장 최근인 행의 ftp_w 입니다."},"ftp_set_at":{"type":["string","null"]},"qualifying_rides":{"type":"integer","minimum":0,"description":"as_of 앞 42일에 시작한 rides 가운데 has_power 가 참이고 duration_s 가 1,200 이상인 라이딩 수입니다. 같은 rider_id 에서 started_at 차이 60초 이하, duration_s 차이 1% 이하인 라이딩은 하나로 셉니다."},"excluded_rides":{"type":"array","items":{"type":"object","properties":{"ride_id":{"type":"string"},"reason":{"type":"string","enum":["outside_42_days","no_power","under_1200s","duplicate_upload"]},"kept_ride_id":{"type":["string","null"]}},"required":["ride_id","reason","kept_ride_id"]}},"excluded_seconds":{"type":"array","items":{"type":"object","properties":{"ride_id":{"type":"string"},"t":{"type":"integer"},"watts":{"type":"integer"},"rule":{"type":"string","enum":["over_2000w_without_hr","jump_over_1000w"]}},"required":["ride_id","t","watts","rule"]}},"cp_w":{"type":["number","null"],"description":"evidence 의 window_s 180·300·720·1200 최고 평균 네 점에 P = W′/t + CP 를 x = 1/t, y = P 의 최소제곱 직선으로 맞춘 절편을 소수 둘째 자리에서 반올림(0.05 는 올림)한 값입니다."},"w_prime_j":{"type":["integer","null"],"description":"같은 직선의 기울기(J)를 정수로 반올림한 값입니다."},"ftp_estimate_w":{"type":["number","null"],"description":"cp_w * 0.95 를 소수 둘째 자리에서 반올림한 FTP 환산값입니다."},"ftp_change_pct":{"type":["number","null"],"description":"(ftp_estimate_w - ftp_w) / ftp_w * 100 을 소수 둘째 자리에서 반올림한 값입니다. 판정은 이 반올림 값으로 합니다."},"attempts":{"type":["array","null"],"items":{"type":"object","properties":{"ride_id":{"type":"string"},"rise_t":{"type":"integer","description":"0W 를 뺀 직전 300초 평균이 ftp_w * 0.95 이상이 된 초입니다."},"rise_avg5_w":{"type":"integer"},"outcome":{"type":"string","enum":["failed","held","ride_ended"]},"end_t":{"type":"integer"},"end_avg5_w":{"type":"integer"},"riding_s":{"type":"integer","description":"rise_t 부터 end_t 까지 0W 를 뺀 초 수입니다."},"zero_w_s_skipped":{"type":"integer"}},"required":["ride_id","rise_t","rise_avg5_w","outcome","end_t","end_avg5_w","riding_s","zero_w_s_skipped"]}},"failed_attempt_rides":{"type":["integer","null"],"minimum":0,"description":"attempts 에 outcome 이 failed 인 항목이 하나라도 있는 ride_id 수입니다."},"summary":{"type":"string","description":"라이더가 판정 카드에서 읽는 두 문장입니다. 판정 결과의 숫자로 채우는 규칙 문장입니다."},"evidence":{"type":"array","items":{"type":"object","properties":{"dataset":{"type":"string","enum":["rides","power_samples","ftp_history"]},"field":{"type":"string"},"ride_id":{"type":["string","null"]},"start_t":{"type":["integer","null"]},"window_s":{"type":["integer","null"]},"agg":{"type":"string","enum":["count","sum","avg","min","max"]},"where":{"type":"string"},"value":{"description":"agg 가 없으면 수록된 행의 그 컬럼 값 그대로이고, agg 가 avg 면 ride_id 의 start_t 부터 window_s 초 동안 튄 값을 뺀 watts 평균입니다."}},"required":["dataset","field","value"]}}},"required":["rider_id","as_of","verdict","ftp_w","qualifying_rides","excluded_rides","excluded_seconds","cp_w","w_prime_j","ftp_estimate_w","ftp_change_pct","attempts","failed_attempt_rides","summary","evidence"]}[
{
"rider_id": "rider_0412",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "FTP를 올릴 때",
"ftp_w": 248,
"ftp_set_at": "2026-08-10T21:30:00+09:00",
"qualifying_rides": 3,
"excluded_rides": [
{
"ride_id": "ride_49870",
"reason": "outside_42_days",
"kept_ride_id": null
}
],
"excluded_seconds": [
{
"ride_id": "ride_50812",
"t": 1647,
"watts": 2140,
"rule": "over_2000w_without_hr"
}
],
"cp_w": 274.6,
"w_prime_j": 16079,
"ftp_estimate_w": 260.9,
"ftp_change_pct": 5.2,
"attempts": [
{
"ride_id": "ride_50944",
"rise_t": 2896,
"rise_avg5_w": 236,
"outcome": "held",
"end_t": 4096,
"end_avg5_w": 253,
"riding_s": 1200,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51207",
"rise_t": 1694,
"rise_avg5_w": 236,
"outcome": "failed",
"end_t": 2199,
"end_avg5_w": 198,
"riding_s": 467,
"zero_w_s_skipped": 38
},
{
"ride_id": "ride_51207",
"rise_t": 3782,
"rise_avg5_w": 236,
"outcome": "failed",
"end_t": 4192,
"end_avg5_w": 198,
"riding_s": 410,
"zero_w_s_skipped": 0
}
],
"failed_attempt_rides": 1,
"summary": "최근 42일 기록으로 낸 FTP 환산값이 260.9W 로 지금 FTP 248W 보다 5.2% 높습니다. 9월 27일 북악 스카이웨이에서 20분 평균 288W 를 냈고, 10월 2일 라이딩의 2,140W 1초는 심박 없이 튄 값이라 뺐습니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 248
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-08-10T21:30:00+09:00"
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51207",
"start_t": 3740,
"window_s": 180,
"value": 364
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51207",
"start_t": 3690,
"window_s": 300,
"value": 328
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_50944",
"start_t": 2880,
"window_s": 720,
"value": 297
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_50944",
"start_t": 2760,
"window_s": 1200,
"value": 288
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 3
},
{
"dataset": "power_samples",
"field": "watts",
"ride_id": "ride_50812",
"where": "t 1647 · heart_rate null · 뺀 값",
"value": 2140
},
{
"dataset": "rides",
"field": "started_at",
"ride_id": "ride_49870",
"where": "42일 창 밖",
"value": "2026-08-27T08:00:00+09:00"
}
]
},
{
"rider_id": "rider_1188",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "FTP를 내려 볼 때",
"ftp_w": 265,
"ftp_set_at": "2026-09-20T22:10:00+09:00",
"qualifying_rides": 3,
"excluded_rides": [],
"excluded_seconds": [],
"cp_w": 243.6,
"w_prime_j": 11488,
"ftp_estimate_w": 231.4,
"ftp_change_pct": -12.7,
"attempts": [
{
"ride_id": "ride_51003",
"rise_t": 1474,
"rise_avg5_w": 252,
"outcome": "failed",
"end_t": 2412,
"end_avg5_w": 211,
"riding_s": 938,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51150",
"rise_t": 2492,
"rise_avg5_w": 253,
"outcome": "failed",
"end_t": 2854,
"end_avg5_w": 211,
"riding_s": 338,
"zero_w_s_skipped": 24
}
],
"failed_attempt_rides": 2,
"summary": "지금 FTP 265W 근처에서 20분을 채우지 못하고 무너진 라이딩이 두 번이고, 최근 42일 기록으로 낸 FTP 환산값도 231.4W 로 12.7% 낮습니다. 9월 20일에 직접 넣은 265W 를 낮춰 다음 훈련 강도를 다시 잡아 볼 때입니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 265
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-09-20T22:10:00+09:00"
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51150",
"start_t": 2360,
"window_s": 180,
"value": 306
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51150",
"start_t": 2330,
"window_s": 300,
"value": 284
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51003",
"start_t": 1260,
"window_s": 720,
"value": 262
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51003",
"start_t": 1200,
"window_s": 1200,
"value": 250
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 3
},
{
"dataset": "power_samples",
"field": "watts",
"ride_id": "ride_51003",
"where": "t 2412 · 첫 실패 시도가 무너진 초",
"value": 168
}
]
},
{
"rider_id": "rider_2051",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "기록이 모자라 판단 보류",
"ftp_w": 210,
"ftp_set_at": "2026-07-15T20:40:00+09:00",
"qualifying_rides": 1,
"excluded_rides": [
{
"ride_id": "ride_50620",
"reason": "no_power",
"kept_ride_id": null
},
{
"ride_id": "ride_50990",
"reason": "under_1200s",
"kept_ride_id": null
},
{
"ride_id": "ride_51078",
"reason": "duplicate_upload",
"kept_ride_id": "ride_51077"
}
],
"excluded_seconds": [],
"cp_w": null,
"w_prime_j": null,
"ftp_estimate_w": null,
"ftp_change_pct": null,
"attempts": null,
"failed_attempt_rides": null,
"summary": "최근 42일에 파워를 재며 20분 이상 탄 라이딩이 1회라 이번에는 FTP 를 판정하지 않았습니다. 10월 1일 반포 라이딩은 가민과 스트라바로 두 번 들어와 한 번으로 셌고, 9월 29일 퇴근 라이딩은 18분이라 빠졌습니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 210
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-07-15T20:40:00+09:00"
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 1
},
{
"dataset": "rides",
"field": "started_at",
"ride_id": "ride_51078",
"where": "ride_51077 과 2초 차이 · strava",
"value": "2026-10-01T20:10:02+09:00"
},
{
"dataset": "rides",
"field": "duration_s",
"ride_id": "ride_50990",
"value": 1080
},
{
"dataset": "rides",
"field": "has_power",
"ride_id": "ride_50620",
"value": false
}
]
},
{
"rider_id": "rider_3307",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "지금 FTP 유지",
"ftp_w": 238,
"ftp_set_at": "2026-07-28T21:00:00+09:00",
"qualifying_rides": 3,
"excluded_rides": [],
"excluded_seconds": [
{
"ride_id": "ride_51240",
"t": 2981,
"watts": 1312,
"rule": "jump_over_1000w"
}
],
"cp_w": 244.5,
"w_prime_j": 14299,
"ftp_estimate_w": 232.3,
"ftp_change_pct": -2.4,
"attempts": [
{
"ride_id": "ride_51032",
"rise_t": 2621,
"rise_avg5_w": 227,
"outcome": "held",
"end_t": 3821,
"end_avg5_w": 226,
"riding_s": 1200,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51120",
"rise_t": 1251,
"rise_avg5_w": 227,
"outcome": "failed",
"end_t": 1723,
"end_avg5_w": 190,
"riding_s": 472,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51240",
"rise_t": 1046,
"rise_avg5_w": 227,
"outcome": "failed",
"end_t": 1813,
"end_avg5_w": 190,
"riding_s": 749,
"zero_w_s_skipped": 18
}
],
"failed_attempt_rides": 2,
"summary": "지금 FTP 238W 근처에서 20분을 못 채운 라이딩이 두 번 있었지만, 9월 21일 유명산 업힐의 20분 평균 256W 로 낸 FTP 환산값이 232.3W 로 2.4% 낮을 뿐이라 FTP 를 그대로 둡니다. 10월 5일 라이딩에서 앞 초보다 1,107W 뛴 1초는 튄 값이라 뺐습니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 238
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-07-28T21:00:00+09:00"
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51032",
"start_t": 2940,
"window_s": 180,
"value": 324
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51032",
"start_t": 2880,
"window_s": 300,
"value": 292
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51032",
"start_t": 2520,
"window_s": 720,
"value": 265
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51032",
"start_t": 2400,
"window_s": 1200,
"value": 256
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 3
},
{
"dataset": "power_samples",
"field": "watts",
"ride_id": "ride_51240",
"where": "t 2981 · 앞 초 205W 에서 뜀 · 뺀 값",
"value": 1312
}
]
},
{
"rider_id": "rider_4460",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "지금 FTP 유지",
"ftp_w": 270,
"ftp_set_at": "2026-08-05T07:40:00+09:00",
"qualifying_rides": 3,
"excluded_rides": [],
"excluded_seconds": [],
"cp_w": 264.8,
"w_prime_j": 15270,
"ftp_estimate_w": 251.6,
"ftp_change_pct": -6.8,
"attempts": [
{
"ride_id": "ride_51099",
"rise_t": 2065,
"rise_avg5_w": 257,
"outcome": "held",
"end_t": 3265,
"end_avg5_w": 242,
"riding_s": 1200,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51188",
"rise_t": 1165,
"rise_avg5_w": 257,
"outcome": "failed",
"end_t": 1726,
"end_avg5_w": 215,
"riding_s": 561,
"zero_w_s_skipped": 0
},
{
"ride_id": "ride_51302",
"rise_t": 979,
"rise_avg5_w": 257,
"outcome": "held",
"end_t": 2249,
"end_avg5_w": 255,
"riding_s": 1200,
"zero_w_s_skipped": 70
}
],
"failed_attempt_rides": 1,
"summary": "FTP 환산값이 251.6W 로 지금 FTP 270W 보다 6.8% 낮지만, 20분을 못 채우고 무너진 라이딩은 10월 4일 1회라 FTP 를 그대로 둡니다. 10월 7일 라이딩의 신호 대기 70초는 0W 라 셈에서 뺐고, 그 뒤로 20분을 채웠습니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 270
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-08-05T07:40:00+09:00"
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51099",
"start_t": 2200,
"window_s": 180,
"value": 349
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51099",
"start_t": 2150,
"window_s": 300,
"value": 317
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51099",
"start_t": 1900,
"window_s": 720,
"value": 286
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51099",
"start_t": 1800,
"window_s": 1200,
"value": 277
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 3
},
{
"dataset": "power_samples",
"field": "watts",
"ride_id": "ride_51302",
"where": "t 1862~1931 · 0W 70초 · 시도 셈에서 뺌",
"value": 0
}
]
},
{
"rider_id": "rider_5523",
"as_of": "2026-10-09T00:00:00+09:00",
"verdict": "지금 FTP 유지",
"ftp_w": 250,
"ftp_set_at": "2026-08-20T20:00:00+09:00",
"qualifying_rides": 2,
"excluded_rides": [],
"excluded_seconds": [],
"cp_w": 271.1,
"w_prime_j": 15634,
"ftp_estimate_w": 257.5,
"ftp_change_pct": 3,
"attempts": [
{
"ride_id": "ride_51210",
"rise_t": 3158,
"rise_avg5_w": 238,
"outcome": "held",
"end_t": 4358,
"end_avg5_w": 256,
"riding_s": 1200,
"zero_w_s_skipped": 0
}
],
"failed_attempt_rides": 0,
"summary": "FTP 환산값이 257.5W 로 지금 FTP 250W 보다 정확히 3.0% 높습니다. 올릴 때는 3% 를 넘어야 해서 이번에는 그대로 두고, 다음 라이딩이 들어오면 다시 봅니다.",
"evidence": [
{
"dataset": "ftp_history",
"field": "ftp_w",
"value": 250
},
{
"dataset": "ftp_history",
"field": "set_at",
"value": "2026-08-20T20:00:00+09:00"
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51210",
"start_t": 5640,
"window_s": 180,
"value": 358
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51210",
"start_t": 5600,
"window_s": 300,
"value": 323
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51210",
"start_t": 3240,
"window_s": 720,
"value": 293
},
{
"dataset": "power_samples",
"field": "watts",
"agg": "avg",
"ride_id": "ride_51210",
"start_t": 3000,
"window_s": 1200,
"value": 284
},
{
"dataset": "rides",
"field": "ride_id",
"agg": "count",
"where": "as_of 앞 42일 시작 · has_power 참 · duration_s 1,200 이상 · 같은 라이딩은 하나로",
"value": 2
},
{
"dataset": "rides",
"field": "duration_s",
"ride_id": "ride_51266",
"where": "정확히 1,200초라 셈에 들어감",
"value": 1200
},
{
"dataset": "power_samples",
"field": "watts",
"ride_id": "ride_51210",
"where": "t 3000 · heart_rate null 이지만 2,000W 이하라 남김",
"value": 281
}
]
}
][자동 검증 · 코드가 판정합니다] 1. verdict 값이 지금 FTP 유지·FTP를 올릴 때·FTP를 내려 볼 때·기록이 모자라 판단 보류 네 값 중 하나인지, evidence 의 agg 가 count·sum·avg·min·max 안에 있는지 확인합니다. 2. rider_id·as_of·verdict·ftp_w·qualifying_rides·excluded_rides·excluded_seconds·cp_w·w_prime_j·ftp_estimate_w·ftp_change_pct·attempts·failed_attempt_rides·summary·evidence 필수 필드가 모두 존재하는지 확인합니다. 3. qualifying_rides 를 다시 계산합니다. as_of 앞 42일(2026-08-28T00:00:00+09:00 포함, as_of 미포함)에 started_at 이 있는 rides 가운데 has_power 가 참이고 duration_s 가 1,200 이상인 라이딩을 세되, 같은 rider_id 에서 started_at 차이가 60초 이하이고 duration_s 차이가 1% 이하인 라이딩은 하나로 셉니다. qualifying_rides 가 2 미만이면 verdict 는 기록이 모자라 판단 보류이고 cp_w·w_prime_j·ftp_estimate_w·ftp_change_pct·attempts·failed_attempt_rides 는 null 입니다. 4. FTP 환산값을 다시 계산합니다. evidence 에서 window_s 가 180·300·720·1200 인 avg 네 개로 x = 1/window_s, y = 평균의 최소제곱 직선(파워 = W′/시간 + CP)을 맞춰 절편을 cp_w, 기울기를 w_prime_j 로 두고, ftp_estimate_w = cp_w * 0.95, ftp_change_pct = (ftp_estimate_w - ftp_w) / ftp_w * 100 을 각각 소수 둘째 자리에서 반올림(0.05 는 올림)한 값과 맞춰 봅니다. ftp_w 는 그 rider_id 의 ftp_history 에서 set_at 이 가장 최근인 행에서 읽습니다. 5. verdict 가 FTP를 올릴 때 이면 ftp_change_pct 는 3.0 보다 큽니다. ftp_change_pct 가 3.0 이하이면 이 판정은 나오지 않습니다. 6. verdict 가 FTP를 내려 볼 때 이면 failed_attempt_rides 는 2 이상이고 ftp_change_pct 는 -5.0 보다 작습니다. 어느 한쪽이라도 어긋나면 이 판정 대신 지금 FTP 유지가 나옵니다. 7. attempts 를 다시 계산합니다. outcome 이 failed 이면 rise_avg5_w 는 ftp_w * 0.95 이상, end_avg5_w 는 ftp_w * 0.80 미만, riding_s 는 1,200 미만이고, held 이면 riding_s 가 1,200 입니다. 모든 항목에서 end_t - rise_t = riding_s + zero_w_s_skipped 이고, failed 가 하나라도 있는 ride_id 수가 failed_attempt_rides 입니다. 8. excluded_seconds 를 수록된 power_samples 행에서 다시 계산합니다. rule 이 over_2000w_without_hr 이면 그 초의 watts 는 2,000 초과이고 heart_rate 는 null 이며, jump_over_1000w 이면 같은 ride_id 의 바로 앞 초보다 watts 가 1,000 넘게 큽니다. 9. evidence 의 field 이름이 세 데이터셋에 실재하는 컬럼인지 대조합니다. 10. agg 가 없는 evidence value 가 수록된 행의 원문 값과 같은지 확인합니다. 11. summary 가 존댓말 문장으로 끝나고, 그 안의 W 와 % 숫자가 ftp_w·ftp_estimate_w·ftp_change_pct·evidence 의 값과 같은지 확인합니다. [수동 검증 · 사람이 봅니다] 1. 제 라이딩 기록을 넣어 판정과 근거 구간이 실제 기억(그날 힘들었던 오르막, 신호 대기)과 맞는지 직접 보고 적습니다. 2. 두 구현의 판정이 갈린 라이더는 어느 쪽이 판정식을 잘못 읽었는지 사람이 판정식 원문과 대조해 정합니다. [판정 차례 · 둘 이상 걸릴 때] ① 기록이 모자라 판단 보류 → ② FTP를 올릴 때 → ③ FTP를 내려 볼 때 → ④ 지금 FTP 유지. 20분 이상인 파워 라이딩이 두 번이 안 되면 CP 를 맞출 20분 최고 평균부터 믿기 어려워 맨 앞에 둡니다. 두 방향 판정은 ftp_change_pct 의 부호가 반대라 같이 성립하지 않습니다. [하드 케이스 · 판정이 갈리는 이유] rider_5523 의 ride_51266 은 duration_s 가 정확히 1,200 이라 셈에 들어가며, 1,200 을 넘어야 센다고 읽으면 qualifying_rides 가 1 이 되어 판단 보류로 바뀝니다. 같은 라이더의 환산값 257.5W 는 지금 FTP 250W 보다 정확히 3.0% 높아 넘지 않았으므로 올릴 때가 아닙니다. rider_4460 의 ride_51302 는 t 1862~1931 의 70초가 0W 라 시도 셈에서 빠지고, 그 뒤로 1,200초를 채워 held 입니다. 0W 를 그대로 평균에 넣으면 직전 300초 평균이 216W 아래로 내려가 failed 가 되고 failed_attempt_rides 가 2 로 늘어, 환산값 -6.8% 에 더해 내려 볼 때로 뒤집힙니다. rider_2051 의 ride_51078 은 ride_51077 과 started_at 이 2초, duration_s 가 2초 달라 같은 라이딩으로 합치며, 따로 세면 판단 보류가 풀립니다. ride_50812 t 1647 의 2,140W 는 심박 기록 없이 2,000W 를 넘어 빼고, ride_51240 t 2981 의 1,312W 는 앞 초 205W 보다 1,107W 높아 뺍니다. ride_51210 t 3000 의 281W 는 heart_rate 가 null 이지만 2,000W 이하라 남깁니다. ride_49870 은 2026-08-27 에 시작해 42일 창 밖입니다. 이 라이딩에는 20분 넘게 300W 를 낸 구간이 있어, 창을 하루 넓혀 넣으면 rider_0412 의 12분·20분 최고 평균이 300W 로 바뀝니다. rider_0412 의 ride_51207 은 남산 첫 오르막 뒤 내리막과 마지막 오르막 뒤 내리막에서 직전 300초 평균이 198.4W 아래로 내려가 failed 가 두 번 나오지만, 실패는 라이딩 단위라 failed_attempt_rides 는 1 입니다. [확정된 판정식과 다른 점] 세 판정식은 그대로 씁니다. 판정식이 정하지 않은 것을 정했습니다. 지금 FTP 유지는 세 조건 어디에도 걸리지 않은 라이더입니다. 지금 ftp_w 는 ftp_history 에서 set_at 이 가장 최근인 행이고, 42일은 as_of 앞 42 * 24시간이며 started_at 이 그 안에 있는 라이딩만 씁니다. 같은 rider_id 에서 started_at 차이 60초 이하, duration_s 차이 1% 이하인 라이딩은 하나로 보고 garmin·wahoo 쪽을 남깁니다. 3·5·12·20분 최고 평균은 튄 값만 뺀 실제 시간 창으로 구하며 0W 는 평균에 그대로 들어가고, 0W 를 빼는 것은 버티지 못한 시도를 셀 때뿐입니다. 한 시도가 끝나면 평균이 ftp_w * 0.95 아래로 내려간 뒤에야 다음 시도를 세고, 실패는 라이딩 단위로 세어 한 라이딩에 여럿이어도 한 번입니다. 반올림은 소수 둘째 자리에서 0.05 를 올리고, 판정은 반올림한 값으로 합니다. [더미 1초 기록 만드는 법] power_samples 에는 판정을 가르는 초만 실었고, 나머지 초는 아래 구간표로 만듭니다. 라이딩마다 '시작초 와트' 값을 다음 구간 시작초 바로 앞까지 같은 값으로 채우고, 마지막 구간은 duration_s 까지 잇습니다. heart_rate 는 140 이고, power_samples 에 실린 초는 그 행의 watts·heart_rate 로 덮어씁니다. 이 표로 만든 기록에서 evidence 의 최고 평균 스무 개와 attempts 열두 건이 그대로 다시 나옵니다. ride_51207: 0초 170W · 600초 200W · 1,500초 255W · 2,000초 0W · 2,038초 150W · 2,400초 225W · 2,900초 200W · 3,690초 274W · 3,740초 364W · 3,920초 274W · 3,990초 150W · 4,300초 180W ride_50944: 0초 180W · 1,200초 200W · 2,760초 276W · 2,880초 297W · 3,600초 274W · 3,960초 230W · 4,300초 180W ride_50812: 0초 190W · 1,600초 315W · 1,700초 185W ride_49870: 0초 190W · 5,000초 300W · 6,200초 185W ride_51003: 0초 150W · 600초 170W · 1,200초 250W · 1,260초 262W · 1,980초 261W · 2,100초 218W · 2,300초 215W · 2,340초 214W · 2,400초 100W ride_51150: 0초 180W · 1,600초 200W · 2,330초 251W · 2,360초 306W · 2,540초 251W · 2,630초 190W · 2,700초 0W · 2,724초 190W · 3,300초 170W ride_51290: 0초 160W ride_51032: 0초 170W · 1,000초 190W · 2,400초 231W · 2,520초 248W · 2,880초 238W · 2,940초 324W · 3,120초 250W · 3,180초 232W · 3,240초 247W · 3,360초 246W · 3,600초 220W · 4,000초 170W ride_51120: 0초 180W · 1,000초 235W · 1,500초 175W · 2,300초 170W ride_51240: 0초 180W · 800초 236W · 1,300초 0W · 1,318초 236W · 1,600초 172W · 2,400초 205W ride_51099: 0초 150W · 600초 205W · 1,800초 262W · 1,900초 264W · 2,150초 269W · 2,200초 349W · 2,380초 269W · 2,450초 264W · 2,560초 263W · 2,870초 266W · 2,950초 265W · 3,000초 240W · 3,500초 170W ride_51188: 0초 190W · 900초 265W · 1,500초 200W · 2,200초 170W ride_51302: 0초 180W · 700초 262W · 1,862초 0W · 1,932초 255W · 2,400초 170W ride_51210: 0초 200W · 3,000초 271W · 3,240초 293W · 3,960초 270W · 4,190초 269W · 4,200초 245W · 5,600초 271W · 5,640초 358W · 5,820초 271W · 5,840초 270W · 5,900초 245W ride_51266: 0초 220W ride_51077: 0초 190W ride_51078: 0초 190W ride_50990: 0초 175W 시도 셈은 튄 값으로 뺀 초와 0W 초를 건너뛴 마지막 300초의 평균으로 하고, 300초가 차기 전에는 올라섬을 보지 않습니다. rise_avg5_w 는 그 초의 평균을 올림, end_avg5_w 는 내림한 정수라 95%·80% 부등호 검사가 반올림에 흔들리지 않습니다. riding_s 는 rise_t 다음 초부터 end_t 까지 0W 가 아닌 초 수이고, 그사이 0W 초는 zero_w_s_skipped 로 셉니다. [출처 · 공고에 없는 낱말] 공고 원문이 밝히는 사업은 AI 기반 피트니스 인텔리전스까지이고, Garmin·Wahoo·TrainingPeaks 는 우대사항에 나옵니다. 사이클링, 파워미터, FTP 는 라이덕 앱 소개(https://riduck.com)와 회사 정보에서 가져온 낱말이고, CP 모델과 0.95 환산은 사이클링 훈련 분석에서 쓰는 일반 계산입니다. 42일 창과 기준값은 이 편의 판정식이 정한 값입니다. [평가 러너 · 2주차] CP 와 W′ 를 정해 두고 만든 합성 1초 파워 라이딩으로 3·5·12·20분 최고 평균과 cp_w 가 1W 안에서 되돌아오는지, 튄 값 두 종류와 0W 정지를 넣은 라이딩에서 excluded_seconds 와 attempts 가 기대값과 같은지 봅니다. 기준값 바로 안쪽과 바깥쪽을 짝으로 둔 라이더 40명(duration_s 1,199·1,200, ftp_change_pct 3.0·3.1, -5.0·-5.1, 실패 라이딩 1·2, 심박 없는 2,000W·2,001W, 1,000W·1,001W 뜀, 42일 창 시작 시각과 1초 전)을 매번 돌리고, Claude Code 구현과 Codex 구현의 판정·cp_w 가 다른 라이더를 표로 뽑습니다. 공개 GoldenCheetah OpenData 파워 파일에서는 판단 보류 비율과, 실패로 센 라이딩 가운데 인터벌·언덕 반복 라이딩의 비율을 잽니다.