제3장: Phase 1 데이터 형식 — 메시지 봉투·측정 패킷·바이너리·아카이브

WIA-ENE-040의 Phase 1은 표준의 나머지 모든 부분이 기대는 토대다. Phase 2 API는 Phase 1 메시지를 돌려주고, Phase 3 음향 스택은 그 메시지를 조각내어 나르며, Phase 4 저장소는 그것을 받아 적재한다. 두 시스템이 depth 값 3547.2가 무엇을 뜻하는지, 또는 체크섬이 어느 바이트를 덮는지에 대해 서로 다르게 이해한다면, 그 뒤에 아무리 API를 잘 설계해도 두 시스템의 데이터는 맞춰지지 않는다. 이 장은 PHASE-1-DATA-FORMAT.md를 필드 단위로 읽으면서 메시지를 만들고 검증하는 방법을 보이고, 명세가 구현자에게 추측을 떠넘기는 지점마다 표시를 해 둔다. 그 지점에는 구체적인 권고를 붙이되, 표준의 규범 문장과 혼동되지 않도록 "이 책의 권고"임을 밝힌다.

Phase 1은 네 개의 층으로 이루어지며, 아래 절들도 그 순서를 따른다. 공통 JSON 메시지 봉투, 형식이 정해진 네 가지 페이로드(해양 관측·측심·시료·텔레메트리), 음향 링크를 위한 선택적 바이너리 인코딩, 그리고 끝난 임무를 보관하는 아카이브 구조다. 검증 규칙은 이 네 층 모두에 걸쳐 있으므로 별도의 절에서 다룬다.

3.1 기본 메시지 봉투

Phase 1 메시지는 무엇을 싣든 최상위 멤버 아홉 개를 가진 JSON 객체다. 명세의 필드 표(§2.1)는 간략하므로, 아래 표는 각 필드에 대해 구현자가 실제로 지켜야 할 규칙을 덧붙였다. 명세가 규칙을 밝힌 경우에는 §2.1과 §3.1을 따랐고, 밝히지 않은 경우에는 이 책의 권고임을 표시했다.

필드형식필수규칙
wiaVersion문자열예명세 버전과 일치해야 한다. 표는 "시맨틱 버저닝"이라 적었지만 예시는 "1.0"을 쓰고, 바이너리 헤더에는 주·부 버전을 담을 자리밖에 없다. 권고: MAJOR.MINOR로 보내며, 패치 릴리스는 전송 형식을 바꾸지 않는다.
messageType열거형예열거된 유형 가운데 하나다. §2.2–2.5에 페이로드가 정의된 것은 OCEANOGRAPHIC_DATA, BATHYMETRIC_DATA, SAMPLE_METADATA, VEHICLE_TELEMETRY이고, 표에는 이 밖에 DATA_PACKET, TELEMETRY, COMMAND와 "등"이 적혀 있다.
timestampISO 8601 문자열예Z 접미사를 붙인 UTC, 밀리초 정밀도다. 예: 2025-01-15T14:30:00.000Z. 전송 시각이 아니라 측정 시각이다.
sequenceNumber정수예sourceId별로 단조 증가한다. 바이너리 헤더는 이 값을 32비트에 담으므로 JSON 값도 0–4,294,967,295 안에 두는 것이 좋다(권고).
sourceId문자열예영문자·숫자·하이픈·밑줄만 허용한다(§3.1). 명세에는 길이 제한이 없다. 권고: 64자 이하, 운용 기관 안에서 유일할 것.
priority열거형예LOW, NORMAL, HIGH, CRITICAL 중 하나다. 바이너리 헤더에서는 0–3이 된다.
payload객체예구조는 messageType이 결정한다.
metadata객체아니요임무·기관·선박·품질 상태·시각 동기화 기록 같은 자유 형식 맥락이다. 체크섬 대상이 아니다(3.5 참조).
checksum문자열예SHA256: 뒤에 페이로드 다이제스트를 16진수로 붙인다. 입력의 정규형은 명세에 없으며, 3.5에서 정한다.

식별과 순서: sourceId, sequenceNumber, timestamp

이 세 필드는 함께 한 레코드를 누가 만들었고 그 생산자의 이력에서 어디에 놓이는지를 말해 준다. (sourceId, sequenceNumber) 쌍은 중복을 가려내는 자연스러운 키다. 프레임을 재전송하는 음향 링크나 하루치 텔레메트리를 다시 흘려보내는 선박–육상 동기화는 같은 메시지를 여러 번 전달하기 마련인데, 이 쌍을 키로 저장하는 수신 측은 별도 장치 없이도 멱등 적재를 얻는다. 같은 쌍은 유실도 드러낸다. 순번이 1041에서 1045로 건너뛰었다면 메시지 세 개가 빠진 것이고, 수신 측은 그것을 다시 요청하거나 공백으로 기록할 수 있다.

"단조 증가"는 깨지기 쉽다. 잠수 도중 재부팅되어 카운터를 0부터 다시 세는 잠수정 컴퓨터가 있다면, 엄격한 수신 측은 그 뒤의 메시지를 모두 거부한다. 해법은 두 가지다. 증가시키기 전에 카운터를 비휘발성 저장소에 기록하거나, 전원을 켤 때마다 sourceId 접미사를 바꾸는 것이다(부팅 7회차라면 ROV-EXAMPLE-01_B7). 어느 쪽을 택했는지는 §6.2가 요구하는 적합성 문서에 적어 둔다. 수신 측은 sourceId에서 의미를 해석해 내서는 안 되며, 잠수정 유형은 페이로드에 담겨야 한다.

messageType: 닫아야 할 열린 목록

필드 표는 DATA_PACKET, TELEMETRY, COMMAND와 "등"을 허용하고, 페이로드 절은 VEHICLE_TELEMETRY를 쓰며, Phase 3(§6)은 RESPONSE를 더한다. 그런데 §3.1이 검증기에게 대조하라고 요구하는 닫힌 목록은 어디에도 공표되어 있지 않다. 더 안전한 수신 측은 알고 있는 유형만 받아들이며, 3.5의 검증기도 그렇게 한다. 이 책은 TELEMETRY를 VEHICLE_TELEMETRY의 미해결 별칭으로 보고, v1.x 개정에서 유형 등록부를 공표할 것을 제안한다(제8장).

priority와 metadata

priority는 전송 계층에 주는 힌트이지 데이터 품질에 대한 진술이 아니다. Phase 3은 이 값을 QoS 등급에 대응시키므로, 느린 음향 링크에서 CRITICAL 누수 경보는 쌓여 있는 NORMAL CTD 레코드를 앞지른다. 일상 데이터를 HIGH로 표시하면 이 장치가 무력해진다.

metadata는 선택 항목이고 형식이 자유롭다. 명세의 예시는 임무, 기관, 선박, 수석 과학자, dataQuality 상태를 담고 있다. 체크섬 바깥에 있으므로 하류 시스템이 덧붙이는 정보를 담기에 알맞고, 변경으로부터 보호해야 할 정보를 두기에는 부적절하다. 표준에 이름은 없지만 현장에서 정해 둘 만한 멤버가 두 가지 있다. 메시지를 제4장의 과학 기록과 잇는 expeditionId와 diveId(2.4절에서 권고한 규약), 그리고 timeSync 블록(3.5)이다.

3.2 OCEANOGRAPHIC_DATA

해양 관측 패킷은 센서 플랫폼 주변 바닷물의 한순간을 담는다. 어디에 있는지, 물이 어떤 상태인지, 물이 어떻게 움직이는지다. 아래 예시는 §2.2의 구조를 따른다. 식별자와 기관은 가상이며, 측정값은 예시이지만 서로 물리적으로 모순되지 않게 맞추었다. 체크섬은 실제 값이다. 3.5에서 정의하는 정규형으로 이 페이로드를 직렬화한 SHA-256 다이제스트다.

{
  "wiaVersion": "1.0",
  "messageType": "OCEANOGRAPHIC_DATA",
  "timestamp": "2025-01-15T14:30:00.000Z",
  "sequenceNumber": 12345,
  "sourceId": "ROV-EXAMPLE-01",
  "priority": "NORMAL",
  "payload": {
    "location": {
      "latitude": 36.7977, "longitude": -121.8472,
      "depth": 3547.2, "altitude": 12.5,
      "coordinateSystem": "WGS84",
      "accuracy": { "horizontal": 2.3, "vertical": 0.8, "unit": "meters" }
    },
    "environment": {
      "temperature":     { "value": 2.47,   "unit": "celsius", "sensorId": "CTD-07-T", "calibrationDate": "2024-11-20" },
      "pressure":        { "value": 357.6,  "unit": "bar",     "sensorId": "CTD-07-P", "calibrationDate": "2024-11-20" },
      "salinity":        { "value": 34.68,  "unit": "PSU",     "sensorId": "CTD-07-C", "calibrationDate": "2024-11-20" },
      "dissolvedOxygen": { "value": 3.1,    "unit": "mg/L",    "sensorId": "DO-02",    "calibrationDate": "2024-10-05" },
      "soundVelocity":   { "value": 1513.6, "unit": "m/s",     "sensorId": "SV-01",    "calibrationDate": "2024-09-30" }
    },
    "waterCurrent": { "speed": 0.08, "direction": 147.5, "unit": "m/s", "directionReference": "true-north" }
  },
  "metadata": {
    "expeditionId": "EXP-2025-011",
    "diveId": "DIVE-ROV-2025-003",
    "institution": "Example Ocean Institute",
    "researchVessel": "R/V Example",
    "dataQuality": "VALIDATED",
    "timeSync": { "source": "GPS", "lastSync": "2025-01-15T11:02:10.000Z", "estimatedDriftSeconds": 0.04 }
  },
  "checksum": "SHA256:d24fca65e101a56d472ccbc1f9cd031dd519b1f237009ab0c458ec1df64b9bac"
}

위치 블록

latitude와 longitude는 coordinateSystem이 밝히듯 WGS84 십진 도(度)다. depth는 해면에서 아래쪽을 양수로 하는 미터 단위 값이며, 해저면이 아니라 센서나 잠수정의 수심을 가리킨다. altitude는 잠수정의 해저면 위 높이로, 보통 DVL이나 고도계로 잰다. 따라서 둘을 더하면 그 지점의 수심 추정값이 된다(예시에서는 3547.2 + 12.5 ≈ 3,560 m). 같은 해역의 측심 자료와 비교하는 간단한 교차 검증에 쓸 수 있다.

accuracy 블록은 수평·수직 불확도를 미터로 주지만 신뢰수준을 밝히지 않는다. 같은 USBL 측위라도 1표준편차로 적은 값과 95%로 적은 값은 대략 두 배 차이가 난다. 권고: "confidence": 0.95처럼 신뢰수준을 명시한다. IHO S-44에 따른 수로 측량 관행은 불확도를 95%로 보고하므로, 이를 따르면 자료가 해도 제작 기관에 넘어갈 때 환산이 필요 없다.

측정값 객체

environment의 각 항목은 맨 숫자가 아니라 value, unit, sensorId, calibrationDate를 가진 객체다. Phase 1에서 가장 쓸모 있는 설계 결정이 바로 이것이다. 단위가 없는 값은 모호하다. 센서가 적혀 있지 않은 값은 나중에 그 센서의 표류가 발견되어도 보정할 수 없다. 교정일이 없는 값은 항해가 끝난 뒤 도착한 교정 성적서와 짝지을 수 없다. 출처 정보는 바이트를 차지하지만, 덕분에 아카이브는 몇 년 뒤에도 영향을 받은 값만 정확히 골라 보정할 수 있다.

실무에서 이 객체가 제대로 쓰이려면 두 가지를 보완해야 한다. 첫째, 단위 문자열에 통제 어휘가 필요하다. 명세는 celsius, bar, PSU, mg/L, pH, NTU, m/s를 쓴다. 아래 표는 각각을 Phase 4가 내보내는 CF 규약 NetCDF에서 흔히 쓰는 표기에 대응시키고, 함정을 적었다. 둘째, 품질 플래그는 값 자체에 붙어야 한다. 범위 위반은 메시지 전체가 아니라 측정값 하나의 성질이기 때문이다. 그래서 3.5의 스키마 조각은 선택 멤버 qualityFlag를 추가한다.

물리량Phase 1 단위 문자열흔히 쓰는 CF/UDUNITS 표기비고
수온celsiusdegC섭씨를 유지한다. 교환 과정에서 켈빈으로 바꾸어도 얻는 것은 없고 오류만 생기기 쉽다(제7장 참조).
압력bardbar1 bar = 10 dbar. 절대압인지 해수압(절대압에서 표준 대기압 1기압을 뺀 값)인지 밝힌다.
염분PSU1(무차원)실용염분(PSS-78)에는 단위가 없고 "PSU"는 표지일 뿐이다. TEOS-10의 절대염분은 g/kg 단위의 다른 물리량이다.
용존산소mg/Lmg L-1 또는 umol kg-1해양 자료 저장소는 µmol/kg을 선호하는 경우가 많다. 환산에 밀도가 필요하므로 말없이 변환하지 않는다.
pHpH1척도(total, seawater, NBS)도 함께 기록한다. 척도가 다르면 값도 다르다.
탁도NTU표준 CF 단위 없음기기 의존적이므로 센서 모델을 자료와 함께 보관한다.
음속m/sm s-1멀티빔 처리의 음선 추적에 들어간다(3.3).

압력은 한 번 더 들여다볼 것

명세의 예시는 3547.2 m에 있는 센서의 압력을 354.72 bar로 적었다. 수심을 정확히 10으로 나눈 값, 곧 제1장의 어림 규칙이지 측정값이 아니다. ρ ≈ 1025 kg/m³로 정수압을 계산하면 해수압은 약 356.6 bar, 절대압은 약 357.6 bar이며, 위 예시는 이 절대압 값을 썼다. 여기서 두 가지를 정해야 한다. 이 값은 절대압인가 해수압인가(CTD는 관례적으로 해수압을 데시바로 보고한다)? 그리고 측정값인가, 수심에서 유도한 값인가? 유도한 값에 압력 센서의 sensorId를 붙여서는 안 된다.

waterCurrent

해류는 측정값 객체의 모음이 아니라 별도의 블록으로, speed, direction, 공통 unit, directionReference로 이루어진다. 여기서 두 가지 관례를 못박아야 한다. 하나는 기준 방위다. 명세는 true-north를 적었지만, 자북 기준을 섞으면 모든 벡터가 그 지점의 편각만큼 소리 없이 회전한다. 다른 하나는 방향이 물이 흘러가는 쪽(해양학의 일반 관례)인지 흘러오는 쪽(기상학의 관례)인지다. 명세는 이를 밝히지 않는다. 권고: 흘러가는 방향으로 정하고 적합성 문서에 적는다. 이 블록에는 sensorId와 calibrationDate도 없으므로 환경 항목과 같은 형식으로 추가한다.

3.3 BATHYMETRIC_DATA

측심 메시지는 음탐기, 대개 멀티빔 음향측심기가 잰 해저 수심을, 그것을 해석하는 데 필요한 설정과 함께 싣는다. 네 개의 블록으로 이루어진다.

surveyArea는 northWest와 southEast 두 모서리로 된 경계 상자, gridUnit을 동반한 gridResolution, 좌표계를 준다. 모서리 방식은 180° 경선을 가로지르는 조사에서 무너진다. 서쪽 모서리의 경도가 동쪽 모서리보다 커지기 때문이다. 수신 측은 이를 뒤집힌 상자로 보고 거부할 것이 아니라, RFC 7946의 GeoJSON 경계 상자 규칙처럼 날짜변경선 횡단으로 해석해야 한다.

sonarConfiguration은 음탐기 유형(예시에서는 MULTIBEAM), 모델, 주파수, 빔 폭, 스와스 폭을 각각 단위와 함께 기록한다. 스와스 폭은 거리가 아니라 각도다(예시에서 165°). 해저에서 덮는 폭은 고도에 따라 커져 대략 2·h·tan(θ/2)이 되며, 165°라면 해저면 위 높이의 약 15배다. 주파수와 플랫폼도 서로 맞아야 한다. 400 kHz 음탐기는 세밀한 해상도를 주지만 도달 거리가 수백 미터에 불과하므로, 3,547 m 측심값을 가진 400 kHz 레코드는 음탐기가 해저 가까이 붙은 잠수정에 실려 있을 때만 말이 된다. 명세의 예시는 출처가 AUV이므로 여기에 해당한다.

soundings[]가 본래의 페이로드다. 각 측심점은 위도, 경도, 수심, uncertainty, intensity, beamAngle, quality를 가진다. 여기서 depth는 그 빔 발자국 지점의 해저 수심으로, 음탐기 수심에 음선 추적으로 구한 거리의 수직 성분을 더해 계산한다. 잠수정의 수심이 아니며, 둘을 혼동하는 것은 흔한 가져오기(import) 버그다. 명세가 열어 둔 관례가 필요한 필드는 셋이다.

processingParameters는 측심값을 그것을 만든 처리 과정과 묶는다. 사용한 음속 프로파일의 식별자(SVP-…), 조석 보정 적용 여부, 음선 추적 방법(SNELL), 인공잡음 제거 실행 여부다. 이 가운데 가장 중요한 것은 SVP 참조다. 음속은 수온·염분·압력에 따라 달라지고, 잘못된 프로파일은 바깥쪽 빔을 모두 휘게 만든다. 참조가 남아 있으면 처리 담당자는 정확히 그 프로파일을 찾아내고, 더 나은 프로파일이 생겼을 때 측심값을 다시 처리할 수 있다.

3.4 SAMPLE_METADATA와 VEHICLE_TELEMETRY

SAMPLE_METADATA: 채취의 순간

시료 메시지는 실물 시료를 채취한 순간을 기록한다. sampleId, sampleType, collectionMethod(예: MANIPULATOR_ARM), habitat과 substrate를 포함한 위치, 환경 스냅숏, 사진·영상 참조가 달린 specimen 기술, 보존 상태를 포함한 container, collector, permits가 그것이다. Phase 1은 이 사건을 포착하고, 그 뒤의 시료 이력은 제4장의 시료 관리 이력 레코드가 추적한다.

명세 예시의 네 가지 세부 사항은 그대로 베껴서는 안 된다.

VEHICLE_TELEMETRY: 상태와 건전성

텔레메트리는 양이 가장 많은 메시지 유형이자 운용자가 실시간으로 지켜보는 메시지다. 페이로드는 고정 정보인 vehicle 블록(유형, 모델, 제조사, 일련번호, maxDepthRating), 자세와 고도를 포함한 position 블록, 추진기별 상태, power 블록, systems 블록(유압, 카메라, 조명, 매니퓰레이터, 음탐기, 통신), 그리고 alerts 배열로 이루어진다.

예시에서 실무적인 요점 네 가지가 나온다.

{
  "alertId": "ALR-0007",
  "severity": "HIGH",
  "code": "GROUND_FAULT",
  "subsystem": "power",
  "message": "Insulation resistance below threshold on thruster bus",
  "raisedAt": "2025-01-15T14:29:58.412Z",
  "value": { "value": 0.8, "unit": "Mohm", "sensorId": "GFD-01", "calibrationDate": "2024-12-01" },
  "acknowledged": false
}

3.5 검증

Phase 1의 검증에는 서로 다른 세 가지 결과가 있고, 구현이 잘못되는 것은 대개 이 셋을 뒤섞을 때다. 필수 필드 누락, 잘못된 시각 형식, 체크섬 불일치 같은 구조 실패는 그 메시지를 메시지로서 신뢰할 수 없다는 뜻이다. 거부하고 거부 사실을 기록해야 한다. 범위 위반은 메시지는 온전하지만 어떤 값이 예상 범위를 벗어났다는 뜻이다. 명세의 지시는 이를 버리지 말고 OUT_OF_RANGE로 표시하라는 것이다. 수심과 압력의 불일치, 전력과 전압×전류의 불일치 같은 타당성 우려는 권고 수준이며 품질 보고서에 들어간다.

필수 필드(§3.1)

모든 메시지는 유효한 wiaVersion, ISO 8601 UTC timestamp, 출처별로 증가하는 sequenceNumber, 허용 문자 집합 안의 sourceId, 목록에 있는 messageType, 페이로드와 일치하는 checksum을 갖추어야 한다. 단조 증가 규칙은 상태가 있어야만 검사할 수 있다. 검증기가 그 출처에서 마지막으로 본 순번을 알아야 하므로, 아래 코드는 그 값을 매개변수로 받는다.

범위 한계(§3.2)

변수최솟값최댓값단위한계의 근거와 주의점
수심011,000m가장 깊은 해구를 약간의 여유와 함께 포함한다.
수온−2400°C하한은 해수의 어는점(염분 35에서 약 −1.9 °C) 부근이고, 상한은 열수 분출공 유체를 받아들인다.
압력01,100bar밀도를 일정하다고 보고 계산하면 11,000 m에서 절대압이 약 1,107 bar이므로, 이 한계는 수심 한계보다 약간 좁다. 최대 수심 부근에서는 신중히 표시한다(아래 참조).
염분050PSU심해 염수 웅덩이는 50을 넘을 수 있다. 실제 측정값이므로 버리지 말고 표시한다.
pH014pH큰 오류만 걸러 낸다. 외양의 값은 좁은 범위에 있다.
용존산소020mg/L무산소 해분에서는 0이 정상값이다.
위도−9090도WGS84.
경도−180180도WGS84. ±180은 같은 경선이다.

이 한계들은 일부러 넉넉하게 잡은 것이다. 특이한 해양 현상이 아니라 단위 오류(켈빈으로 적힌 수온, 센티미터로 적힌 수심)를 잡기 위한 장치다. 여기서 두 가지 결론이 나온다. 첫째, 범위 안에 있다고 좋은 값은 아니다. 3,500 m에서 25 °C라는 값은 범위 검사를 통과하지만, 센서가 열수 플룸 안에 있지 않은 한 거의 틀림없이 잘못된 값이다. 이를 걸러 내는 것은 타당성 층의 몫이다. 둘째, 최대 수심에서 압력 한계와 수심 한계가 정확히 맞지 않는다. 11,000 m 가까이 정당하게 내려간 잠수정은 1,100 bar를 넘는 압력을 보고할 수 있다. 구현은 압력 한계를 수심 한계에서 유도하거나 약간 높은 현장 한계를 문서화해야 한다. 제8장은 이를 정오표 제안 항목으로 올린다.

탁도, 음속, 선수각, 피치, 롤, 고도에는 범위가 정해져 있지 않다. 현장 프로파일에서 이를 추가할 수 있으며(예: 선수각 0 이상 360 미만), 그렇게 추가한 규칙은 §6.2에 따른 편차로 문서화한다.

OUT_OF_RANGE는 어디에 두는가

§3.2는 범위를 벗어난 값을 "데이터 품질 지표 'OUT_OF_RANGE'로 표시해야 한다(MUST)"고 하지만, 그 지표를 어디에 둘지는 말하지 않는다. metadata.dataQuality에 두면 메시지 전체, 그러니까 멀쩡한 나머지 여섯 개 환경값까지 딱지가 붙는다. 더 나은 자리는 값 자체다. 측정값 객체 안에 "qualityFlag": "OUT_OF_RANGE"를 두고, 검증기 보고서에 요약 목록을 남긴다. location.depth처럼 맨 숫자인 값은 JSON 경로를 키로 하여 보고서에 기록한다. 플래그는 수신 측이 붙이는 것이며 체크섬이 걸린 페이로드를 바꾸어서는 안 된다. 따라서 검증기는 플래그를 메시지와 나란히 보고하고, 저장 계층은 페이로드를 다시 쓰는 대신 저장된 페이로드 옆에 플래그를 기록한다.

시각 동기화(§3.3)

타임스탬프는 NTP나 GPS로 동기화하여 UTC와 ±1초 이내로 맞아야 한다. 해상과 실시간 통신이 없는 잠수정은 시각 표류를 메타데이터에 기록해야 한다. 어려운 점은 자유 구동 시계가 꾸준히 표류한다는 것이다. 예로 오차가 20 ppm인 수정 발진기를 들면 하루에 약 1.7초가 빨라지거나 늦어지므로, 긴 자율 임무는 보정하지 않으면 ±1초 범위를 벗어날 수 있다. 일반적인 방법은 투입 직전과 회수 직후에 잠수정 시계를 GPS 시각과 비교하고, 그 차이를 잠수 기간에 걸쳐 선형으로 배분하는 것이다. 두 번의 비교가 모두 기록되어 있어야만 가능하며, 이를 담는 것이 timeSync 블록이다.

"timeSync": {
  "source": "GPS",
  "preDive":  { "at": "2025-01-14T21:10:03.000Z", "offsetSeconds": -0.012 },
  "postDive": { "at": "2025-01-16T02:47:40.000Z", "offsetSeconds": -1.861 },
  "correction": "LINEAR",
  "note": "offset = vehicle clock minus GPS-derived UTC; timestamps in this archive are corrected"
}

함정이 하나 더 있다. GPS 시스템 시각은 UTC가 아니다. 윤초를 적용하지 않으며 현재 UTC보다 18초 앞서 있다. 수신기는 보통 UTC를 출력하지만, 원시 GPS 시각을 읽는 기록기는 허용된 ±1초를 훨씬 넘게 어긋난다.

체크섬과 정규화

§3.1은 "페이로드와 일치하는 유효한 체크섬"을 요구하고 §2.1은 SHA256:<hex> 형식을 보여 주지만, 어느 바이트를 해시하는지는 말하지 않는다. 같은 JSON 객체라도 직렬화 방법은 여럿이다. 키 순서, 공백, 2.0과 2, é와 \u00e9가 모두 다를 수 있다. 보기 좋게 들여쓴 출력을 해시한 생산자와, 파싱한 객체를 다시 직렬화한 수신자는 거의 일치하지 않는다. 올바른 두 구현이 서로 맞물리지 않는 가장 흔한 원인이 이것이다.

이 책의 권고는 payload 멤버만을 JSON 정규화 체계(JCS, RFC 8785)로 직렬화한 UTF-8 바이트를 해시하는 것이다. JCS는 키를 정렬하고 숫자와 문자열 표기를 ECMAScript 규칙으로 고정하므로, 이를 따르는 라이브러리라면 어느 것이든 같은 바이트를 만든다. 다이제스트는 SHA256: 접두사 뒤에 소문자 16진수로 적는다. Phase 4 예시는 sha256:이라고 쓰므로 수신 측은 접두사를 대소문자 구분 없이 받아야 한다. 주의할 점이 두 가지 있다. 다른 언어의 범용 JSON 직렬화기는 JCS가 아니다. 예컨대 파이썬의 json.dumps(sort_keys=True)는 JCS가 2로 쓰는 값을 2.0으로 쓰므로, JCS 라이브러리를 써야 한다. 그리고 명세의 해양 관측 예시에 적힌 다이제스트 e3b0c442…b855는 빈 입력의 SHA-256이다. 검증기는 이를 곧바로 거부해야 한다. 받아들인다는 것은 체크섬이 한 번도 계산되지 않았다는 뜻이다.

스키마 조각과 검증기

아래 JSON Schema(draft 2020-12)는 봉투의 구조 규칙과 재사용 가능한 측정값 객체 정의를 담는다. messageType 목록은 3.1에서 본 열린 목록을 이 책이 해석한 것이다. 스키마로는 단조 증가, 체크섬의 정확성, 범위 플래그를 표현할 수 없으므로 이는 코드 층이 맡는다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.org/wia/ene-040/phase1/envelope.json",
  "type": "object",
  "required": ["wiaVersion", "messageType", "timestamp", "sequenceNumber",
               "sourceId", "priority", "payload", "checksum"],
  "properties": {
    "wiaVersion":     { "type": "string", "pattern": "^1\\.[0-9]+$" },
    "messageType":    { "enum": ["OCEANOGRAPHIC_DATA", "BATHYMETRIC_DATA",
                                 "SAMPLE_METADATA", "VEHICLE_TELEMETRY",
                                 "DATA_PACKET", "COMMAND", "RESPONSE"] },
    "timestamp":      { "type": "string",
                        "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$" },
    "sequenceNumber": { "type": "integer", "minimum": 0, "maximum": 4294967295 },
    "sourceId":       { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$" },
    "priority":       { "enum": ["LOW", "NORMAL", "HIGH", "CRITICAL"] },
    "payload":        { "type": "object" },
    "metadata":       { "type": "object" },
    "checksum":       { "type": "string", "pattern": "^(SHA256|sha256):[0-9a-f]{64}$" }
  },
  "$defs": {
    "measuredValue": {
      "type": "object",
      "required": ["value", "unit", "sensorId", "calibrationDate"],
      "properties": {
        "value":           { "type": "number" },
        "unit":            { "type": "string", "minLength": 1 },
        "sensorId":        { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
        "calibrationDate": { "type": "string", "format": "date" },
        "qualityFlag":     { "type": "string" }
      }
    }
  }
}

TypeScript 검증기는 스키마 라이브러리 없이 돌 수 있도록 스키마 수준 규칙을 직접 적용하고, 위의 정규화 방식으로 체크섬을 확인하며, 세 가지 결과를 분리해 돌려준다. errors가 있으면 메시지를 받아들일 수 없고, flags는 받아들인 메시지에 따라붙는다. Node.js에서 내장 crypto 모듈만으로 동작한다.

import { createHash } from "node:crypto";

type Flag = { path: string; code: "OUT_OF_RANGE"; value: number; min: number; max: number };
type Issue = { path: string; code: string; detail: string };
export type Result = { accepted: boolean; errors: Issue[]; flags: Flag[] };

// Phase 1 §3.2 — limits keyed by the envelope path where each quantity lives
const RANGES: Record<string, [number, number]> = {
  "payload.location.depth": [0, 11000],
  "payload.location.latitude": [-90, 90],
  "payload.location.longitude": [-180, 180],
  "payload.environment.temperature.value": [-2, 400],
  "payload.environment.pressure.value": [0, 1100],
  "payload.environment.salinity.value": [0, 50],
  "payload.environment.pH.value": [0, 14],
  "payload.environment.dissolvedOxygen.value": [0, 20],
};
const EXPECTED_UNIT: Record<string, string> = {
  "payload.environment.temperature": "celsius",
  "payload.environment.pressure": "bar",
  "payload.environment.dissolvedOxygen": "mg/L",
};
const REQUIRED = ["wiaVersion", "messageType", "timestamp", "sequenceNumber",
                  "sourceId", "priority", "payload", "checksum"];
const PRIORITIES = new Set(["LOW", "NORMAL", "HIGH", "CRITICAL"]);
const TYPES = new Set(["OCEANOGRAPHIC_DATA", "BATHYMETRIC_DATA", "SAMPLE_METADATA",
  "VEHICLE_TELEMETRY", "DATA_PACKET", "COMMAND", "RESPONSE"]);  // working list, see 3.1
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";

// RFC 8785 (JCS)-compatible for ordinary JSON data: sorted keys, ES number/string form
export function canonical(v: unknown): string {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
  const o = v as Record<string, unknown>;
  return "{" + Object.keys(o).sort()
    .filter((k) => o[k] !== undefined)
    .map((k) => JSON.stringify(k) + ":" + canonical(o[k])).join(",") + "}";
}
export function payloadChecksum(payload: unknown): string {
  return "SHA256:" + createHash("sha256").update(canonical(payload), "utf8").digest("hex");
}

function get(obj: any, path: string): any {
  return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
}

export function validate(msg: any, lastSeq?: number): Result {
  const errors: Issue[] = [];
  const flags: Flag[] = [];
  for (const f of REQUIRED)
    if (msg?.[f] === undefined) errors.push({ path: f, code: "MISSING_FIELD", detail: "required" });
  if (errors.length) return { accepted: false, errors, flags };

  if (!/^\d+\.\d+$/.test(msg.wiaVersion) || msg.wiaVersion.split(".")[0] !== "1")
    errors.push({ path: "wiaVersion", code: "UNSUPPORTED_VERSION", detail: msg.wiaVersion });
  if (!TS_RE.test(msg.timestamp) || Number.isNaN(Date.parse(msg.timestamp)))
    errors.push({ path: "timestamp", code: "BAD_TIMESTAMP", detail: msg.timestamp });
  if (!/^[A-Za-z0-9_-]{1,64}$/.test(msg.sourceId))
    errors.push({ path: "sourceId", code: "BAD_SOURCE_ID", detail: msg.sourceId });
  if (!TYPES.has(msg.messageType))
    errors.push({ path: "messageType", code: "UNKNOWN_TYPE", detail: String(msg.messageType) });
  if (!PRIORITIES.has(msg.priority))
    errors.push({ path: "priority", code: "BAD_PRIORITY", detail: String(msg.priority) });
  if (!Number.isInteger(msg.sequenceNumber) || msg.sequenceNumber < 0)
    errors.push({ path: "sequenceNumber", code: "BAD_SEQUENCE", detail: String(msg.sequenceNumber) });
  else if (lastSeq !== undefined && msg.sequenceNumber <= lastSeq)
    errors.push({ path: "sequenceNumber", code: "NOT_MONOTONIC", detail: `${msg.sequenceNumber} <= ${lastSeq}` });

  const sum = String(msg.checksum);
  if (!/^sha256:[0-9a-f]{64}$/i.test(sum) || sum.toLowerCase().endsWith(EMPTY_SHA256))
    errors.push({ path: "checksum", code: "BAD_CHECKSUM_FORMAT", detail: sum });
  else if (sum.slice(7).toLowerCase() !== payloadChecksum(msg.payload).slice(7))
    errors.push({ path: "checksum", code: "CHECKSUM_MISMATCH", detail: "payload altered or non-canonical" });

  for (const [prefix, unit] of Object.entries(EXPECTED_UNIT)) {
    const m = get(msg, prefix);
    if (m && m.unit !== unit)
      errors.push({ path: prefix + ".unit", code: "UNEXPECTED_UNIT", detail: `${m.unit} (expected ${unit})` });
  }
  // Range rules flag, they never reject: a 380 °C vent reading is data, not an error
  for (const [path, [min, max]] of Object.entries(RANGES)) {
    const v = get(msg, path);
    if (typeof v === "number" && (v < min || v > max))
      flags.push({ path, code: "OUT_OF_RANGE", value: v, min, max });
  }
  return { accepted: errors.length === 0, errors, flags };
}

체크섬이 올바르고 수심을 11,020.4 m로 보고하는 랜더 메시지를 넣으면, 검증기는 accepted: true와 함께 플래그 하나, {path: "payload.location.depth", code: "OUT_OF_RANGE", value: 11020.4, min: 0, max: 11000}를 돌려준다. 같은 메시지에 빈 입력 다이제스트를 넣으면 BAD_CHECKSUM_FORMAT으로 거부된다. 이 구분이 설계의 요점이다. 그 수심은 센서 고장일 수도, 아주 깊은 해구일 수도 있으며, 어느 쪽인지 판단하는 것은 과학자의 일이다. 반면 깨진 체크섬은 그 데이터를 전혀 믿을 수 없다는 뜻이다.

3.6 바이너리 형식

JSON은 읽기 쉽고 스스로를 설명하지만 비싸다. 위의 해양 관측 예시만 해도 1 KB가 훌쩍 넘고, 음향 링크(제6장)는 기껏해야 초당 수 킬로비트, 흔히 그보다 훨씬 적게 나른다. 그래서 §4는 선택 사항으로 바이너리 형식을 정의한다. 32바이트 고정 헤더 뒤에 Protocol Buffers 3판으로 인코딩한 페이로드가 이어진다.

32바이트 헤더

 byte  0        4      6    7    8                16       20               28       32
       +--------+------+----+----+----------------+--------+----------------+--------+
       | magic  | ver  |type|prio|  timestamp     |  seq   | sourceId hash  | length |
       | 4 B    | 2 B  | 1B | 1B |  8 B (ns)      |  4 B   | 8 B            | 4 B    |
       +--------+------+----+----+----------------+--------+----------------+--------+
         "WIAE"  maj.min            since Unix epoch          SHA-256 prefix   payload bytes

§4.1의 표는 오프셋과 크기만 주고 바이트 순서, 부호 여부, 그리고 버전·유형·출처 해시의 계산 방법은 주지 않는다. 이것이 없으면 적합한 인코더 두 개가 서로 호환되지 않는 바이트를 만들 수 있다. 이 책은 다음과 같이 정한다.

코드messageType상태
0x00(무효)0으로 채워진 버퍼가 메시지로 오인되지 않도록 예약한다.
0x01OCEANOGRAPHIC_DATA§2.2에 페이로드 정의
0x02BATHYMETRIC_DATA§2.3에 페이로드 정의
0x03SAMPLE_METADATA§2.4에 페이로드 정의
0x04VEHICLE_TELEMETRY§2.5에 페이로드 정의
0x05DATA_PACKET§2.1에 이름만 있고 페이로드 미정의
0x10, 0x11COMMAND, RESPONSEPhase 3이 운반(제6장)
0xF0–0xFF사설 용도기관 내부 실험용. 기관 간에 교환하지 않는다.

헤더에는 무결성 필드가 없다. 실시간 음향 링크에서는 Phase 3 프레임마다 CRC-32가 붙으므로 문제가 되지 않는다. 그러나 잠수정에 파일로 써 두었다가 나중에 올리는 것처럼 저장 후 전달되는 바이너리 메시지라면, 페이로드의 SHA-256을 페이로드 자체나 아카이브 매니페스트(3.7)에 담아야 한다.

인코더와 디코더

파이썬 struct 형식 문자열 >IHBBqIQI는 레이아웃 전체를 한 줄로 표현한다. >는 빅엔디언이고, 이어서 4바이트 매직, 2바이트 버전, 1바이트 두 개, 부호 있는 8바이트 타임스탬프, 4바이트 순번, 8바이트 해시, 4바이트 길이가 오며, 합계 32바이트다.

import struct, hashlib
from datetime import datetime, timezone

HEADER = struct.Struct('>IHBBqIQI')   # big-endian, 32 bytes
MAGIC = 0x57494145                    # b"WIAE"
PRIORITY = {"LOW": 0, "NORMAL": 1, "HIGH": 2, "CRITICAL": 3}

def source_hash(source_id: str) -> int:
    """First 8 bytes of SHA-256(UTF-8 sourceId), as unsigned big-endian."""
    return int.from_bytes(hashlib.sha256(source_id.encode("utf-8")).digest()[:8], "big")

def iso_to_ns(ts: str) -> int:
    dt = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
    return (int(dt.timestamp()) * 1_000_000_000) + dt.microsecond * 1_000

def encode_header(version, msg_type, priority, ts_iso, seq, source_id, payload_len):
    major, minor = (int(x) for x in version.split(".")[:2])
    return HEADER.pack(MAGIC, (major << 8) | minor, msg_type, PRIORITY[priority],
                       iso_to_ns(ts_iso), seq, source_hash(source_id), payload_len)

def decode_header(buf: bytes) -> dict:
    if len(buf) < HEADER.size:
        raise ValueError("short header")
    magic, ver, mtype, prio, ts_ns, seq, src, plen = HEADER.unpack_from(buf)
    if magic != MAGIC:
        raise ValueError(f"bad magic 0x{magic:08X}")
    if prio > 3:
        raise ValueError("priority out of range")
    return {"version": f"{ver >> 8}.{ver & 0xFF}", "messageType": mtype,
            "priority": prio, "timestampNs": ts_ns, "sequenceNumber": seq,
            "sourceHash": f"{src:016x}", "payloadLength": plen}

if __name__ == "__main__":
    h = encode_header("1.0", 0x01, "NORMAL", "2025-01-15T14:30:00.000Z",
                      12345, "ROV-EXAMPLE-01", 118)
    assert len(h) == 32
    print(h.hex(" "))
    print(decode_header(h))

스크립트로 실행하면 출처 ROV-EXAMPLE-01의 헤더를 인코딩해 아래 바이트를 출력한다. 여기서는 필드별로 나누어 적었다.

57 49 41 45                 magic   "WIAE"
01 00                       version 1.0
01                          type    0x01 OCEANOGRAPHIC_DATA (working code)
01                          prio    1 = NORMAL
18 1a e4 18 75 1e 10 00     time    1736951400000000000 ns = 2025-01-15T14:30:00Z
00 00 30 39                 seq     12345
7f fa 1d 74 27 53 2e 15     source  SHA-256("ROV-EXAMPLE-01")[0:8]
00 00 00 76                 length  118 payload bytes follow

매직은 글자로 읽히고, 01 00은 버전 1.0, 타임스탬프는 2025-01-15T14:30:00Z로 풀리며, 00 00 30 39는 순번 12345다. 디코더는 매직부터 확인해야 하고, 적힌 페이로드 길이가 실제로 받은 바이트 수를 넘지 않는지 반드시 확인해야 한다. 이 확인이 손상된 길이 필드가 버퍼 초과 읽기로 번지는 것을 막는다.

Protobuf 페이로드

§4.2는 페이로드에 Protocol Buffers 3판을 쓰고 스키마 정의는 /schemas/protobuf/에 있다고 적는다. 그러나 이 책이 검토한 명세 패키지에는 그 파일들이 들어 있지 않았으므로, 현재로서는 구현자가 .proto 파일을 직접 작성하고 문서화해야 한다. 아래 스케치는 해양 관측 페이로드를 예로 그 방법을 보인다. JSON 속성 이름을 맵 키와 필드 이름으로 그대로 살려 두 형식 사이를 기계적으로 대응시킬 수 있게 했다. 규범 스키마가 아니라 예시다.

syntax = "proto3";
package wia.ene040.v1;          // illustrative sketch, not the normative schema

message MeasuredValue {
  double value            = 1;
  string unit             = 2;  // same vocabulary as the JSON form
  string sensor_id        = 3;
  string calibration_date = 4;  // YYYY-MM-DD
  string quality_flag     = 5;  // e.g. "OUT_OF_RANGE"; empty when none
}

message Location {
  double latitude  = 1;
  double longitude = 2;
  double depth     = 3;         // metres, positive down
  double altitude  = 4;         // metres above seabed
}

message OceanographicData {
  Location location                  = 1;
  map<string, MeasuredValue> environment = 2;  // key = JSON property name
}

Protobuf는 필드 이름을 없애고 정수를 압축해 공간을 아끼지만, double은 여전히 8바이트를 차지하고 단위와 교정 문자열은 메시지마다 되풀이된다. 실용적인 절충안은 단위, 센서 ID, 교정일을 잠수마다 한 번 보내고 이후에는 색인으로 참조하는 것이다. Phase 3 자체의 압축 레코드와 그 한계는 제6장에서 살펴본다.

3.7 임무 아카이브와 JSONL

잠수가 끝나면 그 메시지들은 아카이브가 된다. §5.1은 임무마다 디렉터리 하나를 두도록 권고한다.

mission-<id>/
├── metadata.json
├── telemetry/
│   ├── vehicle-telemetry-001.jsonl
│   └── ...
├── oceanographic/
│   ├── ctd-data-001.jsonl
│   └── ...
├── bathymetry/
│   ├── multibeam-raw/
│   ├── multibeam-processed/
│   └── bathymetry-grid.tiff
├── samples/
│   ├── biological/
│   ├── geological/
│   └── chemical/
├── imagery/
│   ├── photos/
│   ├── video/
│   └── imagery-index.json
└── logs/
    ├── mission-log.txt
    └── event-log.jsonl

telemetry/와 oceanographic/에는 시계열을 JSON Lines로 담는다. bathymetry/에는 제조사 원시 파일, 처리된 측심값, 그리고 GIS 도구가 바로 여는 GeoTIFF 격자를 둔다. imagery/에는 각 파일을 시각·위치와 연결하는 imagery-index.json과 함께 사진·영상을 두고, logs/에는 사람이 쓴 임무 일지와 기계가 읽는 이벤트 로그를 나란히 둔다. 음탐기 원시 파일은 제조사 형식 그대로 남는다. 아카이브는 이를 포장할 뿐 대체하지 않는다.

시계열을 위한 JSON Lines

§5.2는 JSON Lines를 권고한다. 한 줄에 완전한 JSON 객체 하나를 두고, 바깥 배열은 두지 않는 형식이다. 긴 기록에 잘 맞는다. 잠수가 진행되는 동안 파일에 덧붙여 쓸 수 있고, 중간에 멈춰도 잃는 것은 기껏해야 마지막 한 줄이며, 도구가 기가바이트 단위 파일을 메모리에 올리지 않고 한 줄씩 흘려 읽을 수 있다. 명세의 예시 줄은 완전한 봉투가 아니라 압축된 행이다.

{"timestamp":"2025-01-15T14:30:00.000Z","depth":3547.2,"temp":2.47}
{"timestamp":"2025-01-15T14:30:01.000Z","depth":3547.3,"temp":2.46}
{"timestamp":"2025-01-15T14:30:02.000Z","depth":3547.4,"temp":2.45}

이 행들은 봉투가 주던 것, 곧 단위, 센서 식별, 교정일, 체크섬을 잃었다. 줄마다 완전한 봉투를 저장하면 각 줄이 스스로를 설명하지만 크기가 대략 한 자릿수 커진다. 아니면 압축 행을 유지하고 열 정의를 metadata.json에 한 번 적는다. 고속 데이터에는 두 번째가 알맞다. 단, 그 정의를 실제로 적어 두어야 한다. 어느 쪽이든 바이트 순서 표식(BOM) 없는 UTF-8로, 레코드마다 줄바꿈 하나를 두고, 타임스탬프 순서대로 쓴다.

아카이브 매니페스트로서의 metadata.json

명세는 metadata.json이라는 이름만 정하고 내용은 정의하지 않는다. 이 책은 이를 아카이브의 매니페스트로 쓸 것을 권고한다. Phase 2 임무 및 제4장 레코드와 연결되는 임무·탐사·잠수 식별자, 표준 버전, 아카이브에 든 출처들, 시각 동기화 기록, 각 압축 시계열의 열 정의, 그리고 모든 파일의 SHA-256 다이제스트를 적는다. 다이제스트는 3.6에서 지적한 무결성 공백을 메우고, 저장소가 전송 경로를 믿지 않고도 업로드를 검증하게 해 준다. 도서관과 아카이브가 같은 목적으로 쓰는 BagIt 포장 형식(RFC 8493)의 발상과 같다. 아래 예시는 설명용이며 다이제스트는 줄여 적었다.

{
  "wiaVersion": "1.0",
  "missionId": "MISSION-2025-003",
  "expeditionId": "EXP-2025-011",
  "diveId": "DIVE-ROV-2025-003",
  "sources": [
    { "sourceId": "ROV-EXAMPLE-01", "type": "ROV", "maxDepthRating": 6000 }
  ],
  "timeSync": { "source": "GPS", "correction": "LINEAR", "maxResidualSeconds": 0.3 },
  "series": {
    "oceanographic/ctd-data-001.jsonl": {
      "columns": {
        "timestamp": { "type": "ISO8601", "timezone": "UTC" },
        "depth":     { "unit": "m", "positive": "down", "sensorId": "CTD-07-P" },
        "temp":      { "unit": "celsius", "sensorId": "CTD-07-T", "calibrationDate": "2024-11-20" }
      }
    }
  },
  "files": {
    "oceanographic/ctd-data-001.jsonl": "sha256:3f9c…",
    "bathymetry/bathymetry-grid.tiff":  "sha256:b41e…"
  }
}

정리: 구현자가 결정해야 할 것

Phase 1은 중요한 것들을 제대로 갖추었다. 식별과 순서를 담은 공통 봉투, 값마다 붙는 단위와 교정 출처, 버리지 않고 표시하는 범위 규칙, 압축된 바이너리 경로, 개방형 형식에 기반한 아카이브 구조가 그것이다. 동시에 여러 결정을 열어 두었고, 상호운용성은 그 결정들을 모두가 같은 방식으로 내릴 때에만 성립한다. 아래 표는 이 장에서 제기한 결정 사항과 이 책 전체에서 쓰는 권고를 모았다. 제8장은 이를 정오표 제안 목록으로 정리한다.

명세의 공백이 책의 권고
체크섬 입력이 정의되지 않음. 예시 다이제스트는 빈 입력의 해시payload의 JCS(RFC 8785) 바이트에 대한 SHA-256. 빈 입력 다이제스트는 거부
바이너리의 바이트 순서, 버전 패킹, 출처 해시 방법 미정빅엔디언, 주/부 버전 각 1바이트, SHA-256(sourceId)의 앞 8바이트
바이너리 메시지 유형 코드 없음, 닫힌 messageType 목록 없음작업용 등록부(3.6). 공표 전까지 편차로 기록
OUT_OF_RANGE 플래그의 위치 미정값별 qualityFlag와 검증기 보고서. 페이로드는 절대 다시 쓰지 않음
압력 한계가 수심 한계에서의 정수압보다 약간 낮음. 압력 기준 미표기수심에서 한계를 유도하거나 현장 한계를 올림. 절대압/해수압 명시
단위 누락(후방산란 강도, 유압, 시료 환경)모든 곳에 측정값 객체 사용
정확도의 신뢰수준, 해류 방향 관례 미표기신뢰수준 명시(IHO S-44처럼 95%). 해류는 흘러가는 방향
Protobuf 스키마가 언급만 되고 제공되지 않음.proto 파일을 작성·공개하고 이름을 일대일 대응
metadata.json 내용 미정의열 단위, 시각 동기화, 파일 다이제스트를 담은 매니페스트

다음 장은 메시지에서 레코드로 넘어간다. 이 메시지들에 과학적 맥락을 부여하는 탐사, 잠수, 관찰, 시료, 열수 분출공, 측심 조사 객체를 다룬다.