돌아가기 ULG → 시계열 데이터 변환 가이드
고객으로부터 PX4 ULog 파일(`.ulg`)과 차트 요구사항을 받아, 시계열 어노테이터용 **dm_schema JSON**을 만드는 전체 과정을 다룹니다.

:::note[용어]
이 문서에서 사용하는 트랙, 채널 등의 용어 정의는 [시계열 어노테이터 스키마 — 용어 정의](/annotator-schema/시계열#용어-정의)를 참고하세요.
:::

## 전체 흐름

```mermaid
flowchart TD
  subgraph 고객 제공물
    A["① ULG 파일들 (.ulg)"]
    B["② 차트 그룹 정의서<br/>(어떤 센서를 묶을지, 그룹 순서, 채널 이름)"]
  end

  subgraph 내부 작업
    C["③ 트랙 설정 파일 작성<br/>(track-config.yaml)<br/>⏱ 고객별 1회"]
    D["④ 변환 스크립트 실행<br/>(ulg2dm.py)<br/>⏱ 파일 수만큼 반복"]
    E["⑤ 스키마 검증<br/>(validate_dm_schema.py)<br/>⏱ 변환 후 매번"]
  end

  subgraph 결과물
    F["⑥ dm_schema JSON<br/>(어노테이터에 로드 가능)"]
  end

  B --> C
  A --> D
  C --> D
  D --> E
  E -->|✓ 통과| F
  E -->|✗ 오류| D
```

| 구분 | 빈도 | 설명 |
|---|---|---|
| 트랙 설정 파일 작성 | 고객별 **1회** | 고객 요구사항을 YAML로 변환 |
| 변환 스크립트 실행 | ULG 파일 수만큼 **반복** | 공통 스크립트, 설정만 교체 |
| 스키마 검증 | 변환 후 **매번** | 결과 JSON이 올바른지 확인 |

---

## 사전 준비

### Python 패키지 설치

**Python 3.8 이상**이 필요합니다.

| 패키지 | 용도 |
|---|---|
| `pyulog` | PX4 ULog 바이너리 파일을 파싱하여 채널 데이터를 추출 |
| `pyyaml` | 트랙 설정 파일(YAML)을 읽기 위해 사용 |
| `numpy` | 서로 다른 샘플레이트의 센서 데이터를 공통 타임그리드로 리샘플링(선형 보간) |

```bash
python -m venv .venv && source .venv/bin/activate
pip install pyulog pyyaml numpy
```

### 샘플 파일 다운로드

실습에 필요한 파일을 다운로드합니다:

| 파일 | 설명 | 다운로드 |
|---|---|---|
| `log_55_2026-2-11-15-59-02.ulg` | PX4 비행 로그 샘플 (12MB) | [다운로드](/samples/log_55_2026-2-11-15-59-02.ulg) |
| `ulg2dm.py` | ULG → dm_schema 변환 스크립트 | [다운로드](/samples/ulg2dm.py) |
| `track-config.yaml` | 트랙 설정 파일 예시 | [다운로드](/samples/track-config.yaml) |
| `validate_dm_schema.py` | dm_schema 검증 스크립트 | [다운로드](/samples/validate_dm_schema.py) |

---

## 1단계: 고객 요구사항 분석

### 고객이 제공하는 것

1. **Raw ULG 파일** — PX4 오토파일럿이 기록한 바이너리 센서 로그
2. **차트 그룹 정의** — 어떤 센서값들을 함께 보여줄지 (예: 엑셀, 문서, 구두)

고객 요구사항 예시:

> 1. 자세 (roll, pitch, yaw 각도)
> 2. GPS 위치 (위도, 경도, 고도)
> 3. 로컬 위치 (NED 좌표계 x, y, z)
> 4. 배터리 (전압, 전류)
> 5. 모터 출력 (motor 0~3)

### ULG 파일의 채널 목록 확인

고객의 요구사항을 YAML 설정 파일로 변환하려면, ULG 파일에 **어떤 채널이 존재하는지** 먼저 확인해야 합니다:

```bash
python ulg2dm.py --input log_55_2026-2-11-15-59-02.ulg --list-topics
```

출력 예시:

```
파일: log_55_2026-2-11-15-59-02.ulg
시간: 286.6초
토픽 수: 61

  actuator_motors[0] (2866 samples, ~10.0Hz)
    - timestamp_sample
    - control[0]
    - control[1]
    - control[2]
    - control[3]
    ...

  battery_status[0] (1434 samples, ~5.0Hz)
    - voltage_v
    - voltage_filtered_v
    - current_a
    ...

  vehicle_attitude_setpoint[0] (5731 samples, ~20.0Hz)
    - roll_body
    - pitch_body
    - yaw_body
    ...

  vehicle_gps_position[0] (1433 samples, ~5.0Hz)
    - lat
    - lon
    - alt
    ...
```

:::tip[채널 이름은 PX4 메시지 규격]
채널 이름과 필드 이름은 [PX4 uORB 메시지 정의](https://docs.px4.io/main/en/msg_docs/)에서 확인할 수 있습니다. 고객이 "roll 각도"라고 하면 `vehicle_attitude_setpoint.roll_body` 또는 `vehicle_attitude.q[0~3]` 중 어떤 것인지 확인이 필요합니다.
:::

---

## 2단계: 트랙 설정 파일 작성

고객의 차트 그룹 정의를 `track-config.yaml` 형식으로 변환합니다. **고객별 1회만 작성**하면 됩니다.

### YAML 형식

:::note[샘플레이트(sample rate)란?]
1초에 몇 번 데이터를 기록하는지를 나타내는 값입니다. 단위는 **Hz**(헤르츠)입니다.
예를 들어 `10Hz`는 1초에 10번(0.1초 간격), `1Hz`는 1초에 1번 데이터를 기록합니다.
ULG 파일에서는 채널마다 샘플레이트가 다릅니다 (GPS 5Hz, 자세 20Hz 등). 변환 시 모든 채널을 하나의 공통 샘플레이트로 맞추는 과정이 필요합니다.
:::

```yaml
# 공통 설정
sample_rate: 10          # 모든 채널을 이 Hz로 리샘플링
time_axis:
  origin: absolute       # absolute | relative | boot (아래 설명 참고)
  format: "HH:mm:ss"    # Day.js 포맷 문자열
  timezone: UTC          # 고객이 원하는 타임존 (ULG에는 타임존 정보 없음)

# 차트 그룹 정의 (순서대로 표시됨)
tracks:
  - id: attitude                    # 트랙 고유 ID (kebab-case)
    name: "자세 (Attitude)"         # 화면 표시 이름
    chart_type: line                # line | scatter | psd | fft
    channels:
      - topic: vehicle_attitude_setpoint   # ULG 채널 이름
        field: roll_body                   # 채널 내 필드 이름
        name: "Roll"                       # 화면 표시 이름
        unit: "rad"                        # 변환 후 단위
        color: "#42a5f5"                   # 채널 색상
        scale: 1.0                         # 값 변환 계수 (선택, 기본값 1.0)
```

:::note[`origin` 옵션]
| 값 | 의미 |
|---|---|
| `absolute` | Unix Epoch 기준 밀리초 **(어노테이터 기본 작동 기준)** |
| `relative` | 시작 시간으로부터의 경과 밀리초 |
| `boot` | 시스템 부팅 시간 기준 밀리초 |

어노테이터는 `absolute`만 지원합니다. 변환 스크립트는 GPS UTC 시각을 이용해 항상 `absolute`로 변환합니다.
:::

:::caution[`scale` — 단위 변환이 필요한 경우]
PX4의 일부 채널은 raw 값의 단위가 직관적이지 않습니다. `scale` 필드로 변환할 수 있습니다:
- GPS lat/lon: raw 값은 `degE7` (1e-7도) → `scale: 1e-7`로 deg 변환
- GPS alt: raw 값은 `mm` → `scale: 0.001`로 m 변환
- `scale`을 지정하지 않으면 기본값 `1.0` (변환 없음)
:::

### 매핑 규칙

| 고객 요구사항 | YAML 설정 |
|---|---|
| "자세 차트에 roll, pitch, yaw" | 1개 track, 3개 channels |
| "GPS 위치 차트" | 1개 track, lat/lon/alt channels |
| 차트 순서 | tracks 배열 순서 = 화면 표시 순서 |
| 차트 이름 | `track.name` |
| 센서값 이름 | `channel.name` |

### 주의사항

:::caution[채널별 샘플레이트가 다릅니다]
ULG 파일에서 각 채널은 독립적인 샘플레이트로 기록됩니다 (예: attitude 20Hz, GPS 5Hz, battery 5Hz).
변환 스크립트는 `sample_rate`에 지정된 값으로 **모든 채널을 선형 보간(리샘플링)** 합니다.

- 원본보다 높은 sample_rate: 보간된 값이 추가됨 (해상도 향상 아님)
- 원본보다 낮은 sample_rate: 데이터가 간략해짐 (파일 크기 감소)
- **권장**: 가장 낮은 채널의 샘플레이트 이상으로 설정 (보통 5~10Hz)
:::

---

## 3단계: 변환 실행

### 단일 파일 변환

```bash
python ulg2dm.py \
  --input log_55_2026-2-11-15-59-02.ulg \
  --config track-config.yaml \
  --output output.json
```

출력:
```
변환 완료: output.json
  시간: 286.6초, 샘플: 2867, 트랙: 5, 채널: 15
```

### 일괄 변환

```bash
python ulg2dm.py \
  --input-dir ./ulg_files/ \
  --config track-config.yaml \
  --output-dir ./json_output/
```

### 존재하지 않는 채널/필드 처리

설정 파일에 적은 채널이나 필드가 ULG 파일에 없으면 경고를 출력하고 해당 채널을 건너뜁니다:

```
[경고] 건너뛴 채널 (1개):
  - sensor_baro.pressure (채널 없음)
```

---

## 4단계: 결과 검증

변환된 JSON이 시계열 어노테이터 스키마에 맞는지 검증합니다.

### 검증 실행

```bash
python validate_dm_schema.py output.json
```

성공 시:
```
============================================================
검증: output.json
============================================================
  ✓ 검증 통과
    시간: 286.6초
    샘플: 2867개 @ 10Hz
    트랙: 5개
    채널: 15개
```

### 오류 발생 시

검증 스크립트는 **어디가 왜 잘못되었는지**와 **해결 방법**을 함께 안내합니다:

```
============================================================
검증: broken.json
============================================================
  ✗ 3개 오류 발견:

  ✗ [channels.attitude__roll] 길이(100)가 timestamps 길이(2867)와 불일치합니다.
    → 모든 채널의 값 배열은 timestamps와 동일한 길이여야 합니다.

  ✗ [channelMeta.attitude__roll.color] 필수 필드 'color'가 없습니다.

  ✗ [meta.duration] duration(100.0s)이 startTime/endTime 차이(286.6s)와 불일치합니다.
    → duration = (endTime - startTime) / 1000 이어야 합니다.
```

### 일괄 검증

```bash
python validate_dm_schema.py --dir ./json_output/
```

---

## 변환 결과 JSON 구조

변환 결과는 [시계열 어노테이터 스키마](/annotator-schema/시계열)에 정의된 형식을 따릅니다.

```jsonc
{
  "meta": {
    "startTime": 1613693139911,    // epoch_ms
    "endTime": 1613693426511,
    "duration": 286.6,             // 초
    "nSamples": 2867,
    "sampleRate": 10,              // Hz
    "timeAxis": {
      "origin": "absolute",
      "format": "HH:mm:ss",
      "timezone": "UTC"
    }
  },
  "timestamps": [1613693139911, 1613693140011, ...],  // epoch_ms 배열
  "tracks": [
    {
      "id": "attitude",
      "name": "자세 (Attitude)",
      "chartType": "line",
      "channels": [
        "vehicle_attitude_setpoint__roll_body",
        "vehicle_attitude_setpoint__pitch_body",
        "vehicle_attitude_setpoint__yaw_body"
      ]
    }
    // ...
  ],
  "channels": {
    "vehicle_attitude_setpoint__roll_body": [0.01, 0.012, ...],
    // ...
  },
  "channelMeta": {
    "vehicle_attitude_setpoint__roll_body": {
      "name": "Roll",
      "unit": "rad",
      "color": "#42a5f5",
      "chartType": "line"
    }
    // ...
  }
}
```

:::note[채널 ID 네이밍 규칙]
채널 ID는 `{채널명}__{필드명}` 형식으로 자동 생성됩니다. 배열 인덱스의 대괄호는 언더스코어로 치환됩니다.
- `vehicle_attitude_setpoint` + `roll_body` → `vehicle_attitude_setpoint__roll_body`
- `actuator_motors` + `control[0]` → `actuator_motors__control_0`
:::

---

## 부록: 자주 사용하는 PX4 채널

| 채널 | 주요 필드 | 용도 |
|---|---|---|
| `vehicle_attitude_setpoint` | `roll_body`, `pitch_body`, `yaw_body` | 자세 설정값 |
| `vehicle_attitude` | `q[0]~q[3]` | 자세 쿼터니언 |
| `vehicle_local_position` | `x`, `y`, `z`, `vx`, `vy`, `vz` | 로컬 위치/속도 (NED) |
| `vehicle_global_position` | `lat`, `lon`, `alt` | 글로벌 위치 (EKF 융합, **lat/lon은 deg 단위**) |
| `vehicle_gps_position` | `lat`, `lon`, `alt`, `vel_n_m_s` | GPS 원시 데이터 (**lat/lon은 degE7, alt는 mm**. `scale` 필요) |
| `sensor_gps` | `lat`, `lon`, `alt`, `vel_n_m_s` | PX4 v1.14+ 에서 `vehicle_gps_position` 대신 사용 |
| `battery_status` | `voltage_v`, `current_a`, `discharged_mah` | 배터리 |
| `actuator_motors` | `control[0]~[3]` | 모터 출력 |
| `actuator_outputs` | `output[0]~[7]` | PWM 출력 |
| `sensor_combined` | `gyro_rad[0~2]`, `accelerometer_m_s2[0~2]` | IMU 원시 |
| `vehicle_acceleration` | `xyz[0]`, `xyz[1]`, `xyz[2]` | 가속도 |
| `vehicle_angular_velocity` | `xyz[0]`, `xyz[1]`, `xyz[2]` | 각속도 |
| `cpuload` | `load`, `ram_usage` | 시스템 리소스 |

:::tip[전체 채널 레퍼런스]
PX4 메시지 전체 목록은 [PX4 uORB Message Reference](https://docs.px4.io/main/en/msg_docs/)에서 확인할 수 있습니다.
:::