이 페이지의 계약 — CSV 컬럼 순서·파일명, Data Layer 경로·키, 샘플 JSON 스키마 — 은 공개 스펙입니다. 하나 바꾸면 writer·reader 양쪽과 다운스트림 분석 코드가 같이 깨집니다.
Room 스키마
엔티티는 두 모듈에서 거의 같은 Room @Entity입니다. 폰 DB는 v3 —
MIGRATION_1_2가 favorite 컬럼을, MIGRATION_2_3가 wide ticks 테이블을 더합니다(둘 다 비파괴, destructive fallback 없음).
다중 센서: wide ticks 테이블
이제는 여러 센서를 한꺼번에 모읍니다. 워치 틱 하나가 wide row(SensorTick → Room
ticks) 한 행이 되고, 값이 없는 피처 컬럼은 null입니다. 폰도 같은 wide 스키마를 그대로
미러링합니다. 심박 전용 samples 테이블은 옛 세션 호환용으로 남겨 둡니다. 어떤 센서를
켤지와 수집 주기는 폰 설정에서 워치로 Data Layer를 타고 내려갑니다
(WatchConfigSync → ConfigListenerService).
| field | type | 센서 그룹 | notes |
|---|---|---|---|
id | Long | — | PK, 자동 생성 |
sessionId | String | — | indexed FK → sessions |
tickIndex | Int | — | 0부터, 세션 내 단조 증가 |
timestampWatchMs | Long | — | 저장 순간 워치 wall clock |
timestampPhoneReceivedMs | Long? | — | 폰 수신 시각 |
timestampIso | String | — | offset 포함 ISO-8601 |
hr | Int? | 심박 | bpm, raw 저장(0 포함) |
hrIbiMs | Int? | 심박 | DataPoint 마지막 IBI(ms) |
hrStatus | Int? | 심박 | SDK raw 상태 |
skinTemp | Double? | 피부온도 | Watch5+ 한정 |
steps | Double? | Health Services | |
distance | Double? | Health Services | m |
speed | Double? | Health Services | m/s |
cadence | Double? | Health Services | steps/min |
accelX / Y / Z | Double? | 모션 (SensorManager) | m/s² |
accelMag | Double? | 모션 | |a| 크기 |
gyroX / Y / Z | Double? | 모션 | rad/s |
magX / Y / Z | Double? | 모션 | µT |
baro | Double? | 모션 | hPa |
lat / lon | Double? | GPS | WGS-84 degrees |
watchModel | String? | — | |
phoneModel | String? | — | mobile 모듈만 |
createdAtMs | Long | — | row 생성 시각 |
피처 컬럼은 모두 nullable입니다. null은 "이 틱에 해당 센서 데이터가 없음"(센서 없음·꺼짐·탈착)을
뜻합니다. 이 wide 형태가 곧 ML·이상탐지 피처 행렬이라
SELECT * FROM ticks ORDER BY tickIndex면 join 없이 그대로 나옵니다.
HeartRateSession
| field | type | notes |
|---|---|---|
sessionId | String | PK. 워치에서 만든 UUID |
startedAtMs | Long | 시작 시각(워치 wall clock) |
endedAtMs | Long? | 진행 중엔 null |
startedAtIso | String | ISO-8601 + offset |
endedAtIso | String? | |
sampleCount | Int | stop 때 확정 |
watchModel | String? | Build.MODEL (워치) |
phoneModel | String? | Build.MODEL (폰). 수신 때 채움 |
favorite | Boolean | 폰 전용. 별표, 기본 false |
원본 보존 정책
분석에 그대로 넘기려고 raw 레코드를 손대지 않습니다. 수집 시점에:
- 빠진 1초 구간을 채우지 않습니다. 빈칸은 빈칸.
hr == 0도 저장.- off-wrist·비정상
status도 저장. - 중복 제거·smoothing·clamping 없음.
거르는 건 분석의 몫
hr <= 0 필터는 차트·지도 같은 시각화에서만 씁니다. Room·CSV에선
절대 거르지 않습니다. 데이터 무결성 요구사항입니다.
타임스탬프
timestampWatchMs— 틱을 저장하는 순간의 워치System.currentTimeMillis(). 수집 시각의 기준입니다.timestampIso— offset 포함 ISO-8601. 예:2026-06-02T13:45:01.123+09:00(OffsetDateTime+ISO_OFFSET_DATE_TIME).timestampPhoneReceivedMs— 폰onDataChanged시점의System.currentTimeMillis().- SDK
DataPoint타임스탬프는 센서 래퍼 안에서 fallback으로만 씁니다. 저장값timestampWatchMs는 늘 저장 순간의 워치 시계입니다.
Watch → Phone 전송
MessageClient가 아니라 DataClient를 씁니다. DataClient는
DataItem을 영속화하고 재연결 때 알아서 다시 동기화해서, 폰이 끊긴 동안 버퍼 역할을 대신해 줍니다.
워치 Room이 source of truth라 큐에 걸리거나 실패한 전송이 있어도 데이터는 잃지 않습니다.
경로
/session/{sessionId}— 세션 메타._updatedAt을 단조 증가시켜 세션 종료 업데이트가 매번 이전과 다른 payload가 되도록 합니다(DataItem은 내용이 같으면 dedupe되기 때문입니다)./ticks/{sessionId}/{batchSeq}— wide 센서 틱 배치. put마다batchSeq가 달라 덮어쓰기를 막습니다. 서비스가 ~1초에 한 번 flush합니다.
DataMap 키
sessionId, startedAtMs, startedAtIso, endedAtMs, endedAtIso,
sampleCount, watchModel, ticks 틱 배치 JSON (ticks 문자열 = JSON 배열)
{ "i": 0, "tsW": 1717305901123, "tsI": "2026-06-02T13:45:01.123+09:00",
"hr": 72, "hrIbi": 833, "hrStatus": 0,
"steps": 1.0, "speed": 0.8, "accelX": 0.12, "baro": 1013.2,
"lat": 37.123, "lon": 127.456 }
값이 없는 센서 컬럼은 JSON에서 아예 빠집니다(null-omitting). 수신 측은
optInt2·optDouble2로 방어적으로 읽습니다.
writer와 reader는 함께 바꿉니다
WearDataLayerClient)와 reader
(MobileDataLayerListenerService)가 같이 움직여야 합니다. 한쪽만 고치면 깨집니다.
신뢰성
- 워치는 배치를 큐에 넣기 전에 Room에 씁니다. 폰이 끊겨도 로컬 저장은 계속되고, 다시 붙으면 DataClient가 큐를 동기화합니다.
putDataItem(...).setUrgent()로 즉시 동기화를 요청합니다.- DataClient 너머의 영속 재전송 큐는 아직 TODO입니다.
CSV 내보내기
- 세션마다 Export CSV → 파일 작성 → 안드로이드 공유 시트(
ACTION_SEND,text/csv)로 나갑니다.FileProvider(${applicationId}.fileprovider,res/xml/file_paths.xml, external-cacheexports/). - 파일명:
sensor_session_YYYYMMDD_HHMMSS.csv(startedAtMs로컬 시각). - 헤더와 추출기는
TickColumns.kt에 함께 정의된 단일 소스라 헤더·데이터 행이 절대 어긋나지 않습니다. - nullable 컬럼은 null이면 빈 문자열(ML 로더가 NaN으로 읽음). 모델명은 최소한으로 CSV 따옴표 처리합니다(콤마·따옴표·개행).
- 틱은
tickIndex오름차순으로 씁니다.
헤더 (28열 고정 순서 — 이상탐지 피처 행렬 순서)
session_id,tick_index,timestamp_iso,timestamp_watch_ms,timestamp_phone_received_ms,
hr,hr_ibi_ms,hr_status,skin_temp,
steps,distance,speed,cadence,
accel_x,accel_y,accel_z,accel_mag,gyro_x,gyro_y,gyro_z,mag_x,mag_y,mag_z,baro,
lat,lon,watch_model,phone_model legacy 심박 CSV
samples 스키마
(session_id,sample_index,…,heart_rate_bpm,ibi_ms,status,…)로 남아 있습니다.
새 세션은 위 wide ticks 포맷만 씁니다. 새 컬럼은 뒤에 더하고 기존 순서는 건드리지 않습니다.