📦 목차

제3장: WIA-ENE-057 데이터 포맷 (PHASE 1)

제1장과 제2장에서는 사막화가 무엇이며 식생·토양·강수·토지 이용을 위성과 현장에서 어떻게 관측하는지 살펴보았다. 이 장에서는 그 관측을 데이터로 옮긴다. WIA-ENE-057의 PHASE 1 데이터 포맷 규격은 소수의 JSON 레코드 계열을 정의한다. 위성 처리 파이프라인, 토양 수분 게이트웨이, 현장 조사팀, 복원 사업 사무국은 이 레코드로 같은 장소를 같은 어휘로 기술할 수 있다. 표준의 뒷부분인 제4장의 REST API, 제5장의 스트리밍·메시징 프로토콜, 제6장의 LDN·GIS 연계도 모두 이 레코드를 주고받는다. 그래서 여기서 생긴 실수는 이후 모든 계층으로 그대로 번진다.

이 장은 규격을 꼼꼼히 읽어 나간다. 레코드 유형마다 필드 묶음의 의미와 제2장에서 다룬 과학과의 관계를 설명하고, 공개된 예시가 모호하거나 내부적으로 맞지 않는 지점을 짚는다. 모든 예시는 규격의 공통 예시 위치인 SAHEL-REGION-001(위도 14.5, 경도 −4.0, 10,000 ha)을 사용한다. 예시 속 수치는 규격에서 가져왔거나 규격 값으로 계산한 예시용 값이다. 사헬 지역 어느 곳의 실측값도 아니며 그런 뜻으로 인용해서는 안 된다. 장 끝에는 각자의 수집 파이프라인에 맞게 고쳐 쓸 수 있는 JSON Schema 조각과 짧은 검증기를 싣는다.

3.1 레코드 계열과 식별자

관측 레코드 여섯 가지와 종합 레코드 하나

PHASE 1은 관측이나 활동을 담는 최상위 객체 여섯 가지와, 한 보고 기간에 대해 이들을 요약하는 종합 객체 하나를 정의한다. JSON 문서 하나는 레코드 하나를 유형 이름의 키 아래에 감싼다(예: {"vegetationData": {…}}). 수신 측은 본문을 파싱하기 전에 최상위 키만 보고 유형을 알 수 있다.

이렇게 나눈 것은 제1장에서 말한 네 가지 관측 축을 따른 결과다. 식생, 토양(침식은 토양을 특화해 본 것이다), 기후, 인간 압력은 각각 레코드를 따로 가진다. 측정 기기와 주기가 다르고, 데이터를 책임지는 팀도 다르기 때문이다. MODIS 합성 영상은 16일마다 들어오고, 토양 센서는 매시간 보고할 수 있다. 강우 계절은 1년에 한 번 요약되고, 토지 이용 조사는 몇 년에 한 번 반복될 수도 있다. 이들을 한 레코드에 몰아넣으면 가장 빠른 원천이 가장 느린 원천을 기다려야 한다.

식별자와 조인 키

레코드는 위치 객체 안에 중첩되지 않고 locationId로 위치를 가리킨다. 이 문자열이 데이터 모델 전체의 조인 키다. 따라서 위치는 그에 관한 데이터를 받기 전에 먼저 등록되어 있어야 하고(API가 이를 강제한다는 점은 제4장에서 본다), 한 식별자를 다른 땅에 다시 써서는 안 된다. 레코드 유형마다 고유 식별자도 따로 있다. 규격은 아래 패턴을 정식으로 정의하지 않고 예시로만 보여 준다.

표 3-1. PHASE 1의 레코드 유형, 식별자, 시간 필드
레코드식별자 필드식별자 예시간 필드
vegetationDataidVEG-20251225-SAHEL-001timestamp(일시)
soilDataidSOIL-20251225-SAHEL-001timestamp(일시)
erosionData예시에 없음–assessmentDate(날짜만)
rainfallDataidRAIN-2025-SAHEL-001period.start / period.end(날짜)
landUseData예시에 없음–timestamp(일시)
restorationProjectprojectIdREST-2025-SAHEL-001timeline.*(날짜)
monitoringReportreportIdREPORT-2025-Q4-SAHEL-001reportingPeriod.*(날짜, 분기, 연도)

식별자 패턴에는 정보가 들어 있다. 유형 접두어, 날짜나 연도(보고서는 분기까지), 지역 토큰, 세 자리 일련번호다. 로그에서 읽기에는 편하지만 식별자는 불투명한 문자열로 다뤄야 한다. VEG-20251225-…에서 날짜를 거꾸로 뽑아 측정 시각으로 쓰지 말고 timestamp를 써야 한다. 공개 예시에는 구현에서 메워 둘 만한 빈틈이 두 가지 있다. erosionData와 landUseData에는 자체 식별자가 없어서, 같은 평가가 두 번 전송되어도 중복을 가려낼 기준이 없다. 같은 관례를 따라 id를 추가하는 것(예: ERO-, LU- 접두어)은 합리적인 구현 선택이다. 3.8절에서 다룰 스키마 진화 규칙에 따르면 이는 호환성을 깨지 않는 추가 확장이다. 다만 표준화되기 전까지는 프로파일 매니페스트에 적어 두어야 한다.

시간과 위치

타임스탬프는 ISO 8601 문자열이다. 실무에서는 2025-12-25T10:00:00Z처럼 UTC 오프셋을 명시한 RFC 3339 프로파일로 제한하는 편이 좋다. 오프셋 없는 현지 시각은 데이터가 시간대를 넘는 순간 모호해진다. 시각 없이 날짜만 담는 필드도 여럿 있다(assessmentDate, period.start, 마일스톤의 completionDate). 이들은 날짜 그대로 두어야 한다. 자정 타임스탬프로 바꾸면 UTC 자정이 서쪽 시간대에서는 전날로 표시될 수 있다.

위치는 WGS 84 기준의 십진 도(度)로 표기한다. WGS 84는 GPS 수신기와 대부분의 위성 산출물이 쓰는 측지계이며, 좌표참조계로 쓸 때는 EPSG:4326이다. vegetationData는 coordinates.datum에 이를 명시하지만, soilData와 restorationProject는 측지계 없이 위도·경도만 준다. 측지계가 빠졌으면 WGS 84로 간주하되, 레코드를 작성하는 쪽은 항상 채우게 해야 한다. 또 PHASE 1은 필드 이름을 latitude, longitude로 쓰는데 GeoJSON 배열은 경도가 먼저 온다. 여기서 생기는 축 순서 함정은 제5장에서 다룬다.

locationId 중심의 PHASE 1 개체 관계 0..n 0..n 0..n 0..n 0..1 (현행) 0..n 기간당 1건 vegetationData soilData erosionData rainfallData landUseData restorationProject location locationId = SAHEL-REGION-001 monitoringReport 여섯 레코드 요약
그림 3-1. PHASE 1의 개체 관계. 모든 관측·활동 레코드는 locationId로 등록된 위치 하나를 가리키며, monitoringReport는 한 위치의 레코드를 한 보고 기간 단위로 요약한다.

그림 3-1에는 관계의 다중도도 표시되어 있다. 한 위치는 시간이 지나면서 식생·토양·침식·강수 레코드를 여러 건 가지며, 현행 토지 이용 레코드는 보통 한 건이다. 복원 사업은 0건 이상, 모니터링 보고서는 기간당 한 건이다. 복원 사업만은 성격이 다르다. 사업 면적(location.area.total, 예시에서 1,000 ha)이 모니터링 위치(10,000 ha)보다 작다. 사업은 위치 안에서 수행되는 것이고, 큰 사업이라면 원칙적으로 여러 위치에 걸칠 수도 있다. 그런 경우에는 사업 레코드를 여러 건으로 나누거나 위치 참조 목록을 추가해야 한다. locationId 하나를 늘려 양쪽을 다 덮으려 해서는 안 된다.

3.2 vegetationData 상세

식생 레코드는 위성 산출물로 자동으로 채울 수 있어서 대부분의 구현이 가장 먼저 생산하는 레코드다. 아래 예시는 규격의 레코드에서 한 군데만 고친 것이다. 공개 예시는 NDVI와 EVI의 scale을 "0-1"로 적었지만, 같은 문서 §7.2의 검증 규칙은 NDVI를 −1부터 1까지 허용한다. 제2장에서 보았듯 물과 일부 나지(裸地)는 실제로 음수 값을 낸다. 그래서 예시에는 "-1 to 1"로 적었다. 모든 값은 예시용이다.

{
  "vegetationData": {
    "id": "VEG-20251225-SAHEL-001",
    "locationId": "SAHEL-REGION-001",
    "timestamp": "2025-12-25T10:00:00Z",
    "coordinates": { "latitude": 14.5, "longitude": -4.0, "datum": "WGS84" },
    "indices": {
      "ndvi": { "value": 0.35, "scale": "-1 to 1", "source": "MODIS", "resolution": "250m" },
      "evi":  { "value": 0.28, "scale": "-1 to 1", "source": "MODIS", "resolution": "250m" },
      "lai":  { "value": 1.2, "unit": "m²/m²", "description": "Leaf Area Index" }
    },
    "coverage": {
      "vegetationCover": 45.2,
      "bareGround": 42.8,
      "water": 2.0,
      "built": 10.0,
      "unit": "percentage"
    },
    "biomass": {
      "aboveGround": { "value": 2.4, "unit": "tons/hectare" },
      "belowGround": { "value": 1.8, "unit": "tons/hectare" }
    },
    "trendAnalysis": {
      "monthlyChange": -0.05,
      "annualChange": -0.15,
      "fiveYearTrend": -0.42,
      "status": "declining",
      "confidence": 0.87
    },
    "metadata": { "sensorType": "satellite", "cloudCover": 5, "quality": "high" }
  }
}

지수

분광 지수는 맨 숫자가 아니라 객체로 표현한다. 값 하나만으로는 비교할 수도 믿을 수도 없기 때문이다. value는 물리적 지수 값이고, source는 센서나 산출물 계열, resolution은 화소 크기다. 250 m MODIS 합성 영상의 NDVI 0.35와 10 m Sentinel-2 영상의 NDVI 0.35는 서로 다른 것을 말한다. 거친 화소는 관목·나지·농경지를 한 숫자로 섞어 버리지만, 고운 화소는 한 농경지 안에 온전히 들어갈 수도 있다. 소비 측이 source와 resolution을 보지 않고 둘을 한 시계열에 섞으면 실은 센서가 바뀐 것일 뿐인 추세를 발견하게 된다. 같은 이유로 원시 디지털 값이 아니라 물리 값을 저장해야 한다. 예컨대 MODIS 식생지수 산출물은 정수로 스케일된 값을 제공하므로 산출물의 스케일 계수를 곱해야 의미 있는 값이 된다. 레코드에는 그 변환을 마친 결과를 담는다.

lai는 모양이 다르다(value, unit, description). 엽면적지수는 정규화 지수가 아니라 m²/m² 단위의 물리적 비율이기 때문이다. 값은 0 이상이며 건조지에서는 보통 작다. 규격은 NDVI와 EVI를 필수로, LAI를 선택으로 두는데, LAI를 구할 수 없거나 모델로 추정하는 경우가 많다는 현실과 맞는 선택이다.

피복 비율과 바이오매스

coverage는 관측 면적을 식생, 나지, 수면, 시가지로 나누어 백분율로 표시한다. 예시에서는 45.2 + 42.8 + 2.0 + 10.0 = 100.0으로 정확히 맞는다. 필드 표에는 적혀 있지 않지만 실제로 존재하는 제약이며, 검증기는 부류 합이 작은 허용 오차 안에서 100이 되지 않는 레코드를 플래그해야 한다. 이 비율이 무엇인지도 기억해 두어야 한다. 이 레코드의 관측 범위와 방법으로 얻은 분류 결과이지, landUseData의 토지 이용 면적과 비교할 수 있는 값이 아니다. 규격의 예시가 그 이유를 보여 준다. 같은 위치에 대해 식생 레코드는 시가지 피복을 10 %로, 토지 이용 레코드는 1 %로 적었다. 방법과 정의가 다르면 답도 달라진다. 어느 한쪽 수치로 다른 쪽을 "보정"해서는 안 된다.

biomass.aboveGround와 biomass.belowGround는 헥타르당 건물량(乾物量) 톤으로 표시한다. 지상부 바이오매스는 현장 조사구나 위성 자료 기반 모델로 추정할 수 있다. 지하부 바이오매스는 직접 측정하는 경우가 거의 없고 대개 뿌리/줄기 비율로 환산한다. 공개 레코드에는 해당 필드가 없지만 각 값을 어떻게 얻었는지 기록해 두는 것이 좋은 관행이다. method 문자열을 추가하는 방식은 진화 규칙에도 맞는다.

추세 분석과 메타데이터

trendAnalysis는 지수의 최근 이력을 요약한다. monthlyChange, annualChange, fiveYearTrend는 부호 있는 변화량이고, status는 범주(declining, stable, improving)이며, confidence는 0과 1 사이의 값이다. 규격은 변화량이 지수의 절대 차이인지 상대 변화율인지 밝히지 않는다. 문자 그대로 읽으면 월 −0.05는 NDVI의 절대 차이이며, 이 책도 그렇게 해석한다. 어느 쪽으로 구현하든 해석을 공개해야 한다. 상대 변화로 해석하면 겉보기 감소 폭이 몇 배로 커지기 때문이다. 제2장의 주의 사항도 함께 기억해야 한다. 우기가 부실했던 해에 지수가 떨어졌다는 사실만으로는 황폐화의 증거가 되지 않는다. status의 품질은 그 뒤의 방법론에 달려 있는데, 방법론은 레코드에 담기지 않는다.

metadata는 출처와 품질을 담는다. sensorType(위성, 현장, 드론 등), cloudCover(백분율), 거친 등급의 quality 라벨이다. 광학 지수에서는 이 가운데 구름량이 가장 중요하다. 구름에 오염된 화소는 NDVI를 낮춰 건강한 위치를 황폐한 것처럼 보이게 만든다.

표 3-2. vegetationData 주요 필드(규격 §1.2, 주석 추가)
필드형식필수의미와 주석
idstring예레코드 식별자. 불투명 문자열로 다룬다
locationIdstring예등록된 위치로 가는 조인 키
timestampISO 8601예관측 시각. 오프셋을 붙인 UTC 사용
coordinatesobject예십진 도 단위 위도·경도, WGS 84
indices.ndvi.valuenumber예NDVI. §1.2는 "0-1"로 적었지만 §7.2 검증은 물리적 범위인 −1~1을 허용한다
indices.evi.valuenumber예EVI. 범위 주석은 NDVI와 같다
coverage.vegetationCovernumber예식생 피복 백분율. 모든 피복 부류의 합은 100이어야 한다
trendAnalysis.statusenum예declining | stable | improving
trendAnalysis.confidencenumber아니요0~1 비율. 백분율이 아니다
metadata.cloudCovernumber아니요장면이나 화소 창 가운데 구름에 가려진 비율(%)

3.3 soilData와 erosionData

깊이별 수분

토양 레코드는 moisture.surfaceLevel(예시에서 12.5 %)과 depthProfile 배열로 시작한다. 배열 원소마다 문자열 깊이 구간("0-10cm", "10-30cm", "30-60cm"), 수분 값, 온도가 들어 있다. 건조지 식생은 물의 양뿐 아니라 물이 어디에 있는지에 반응하므로 이 단면이 중요하다. 가벼운 소나기는 표층 몇 센티미터만 적시고 며칠 안에 증발하지만, 깊은 층의 수분은 건기 내내 관목과 나무를 지탱한다. 예시에서는 깊어질수록 수분(15.2, 12.8, 10.1)과 온도(28.5, 26.2, 24.8)가 함께 낮아진다.

명시되지 않은 것이 두 가지 있다. 첫째, 수분 단위가 "percentage"뿐이다. 체적 함수율(토양 부피당 물의 부피로, 정전용량식·TDR 센서 대부분이 보고하는 값)인지 중량 함수율(건토 질량당 물의 질량)인지 밝히지 않는다. 둘은 용적밀도만큼 차이가 나므로, 예시의 1.45 g/cm³라면 차이가 크다. 어느 쪽인지 적어야 한다. 둘째, 온도에는 단위가 아예 없다. 섭씨가 유일하게 그럴듯한 해석이지만 그래도 적어 두어야 한다. 깊이를 자유 문자열로 적으면 범위 질의도 불편해진다. 호환성을 위해 문자열은 유지하고, 옆에 숫자형 topCm/bottomCm 필드를 추가하는 방법이 있다.

토성, 화학 성분, 황폐화 지표

soilType은 토성 부류 이름(sandy-loam, 사양토)과 그 근거인 모래/미사/점토 비율(65/25/10 %)을 준다. 합은 100이며 USDA 토성 삼각도에서 사양토에 속한다. 토성은 천천히 변하며 침투, 보수력, 풍식 취약성을 좌우한다. 점토와 유기물이 적은 사질 토양은 피복을 잃는 순간 바람에 날려 가는 바로 그 토양이다.

chemistry에는 pH, 유기물, 질소, 인, 칼륨이 들어 있다. 공개 포맷에서 가장 취약한 부분인데, 모든 단위가 "ppm for nutrients, percentage for organic matter"라는 한 문장에 묶여 있기 때문이다. 기계가 안정적으로 해석할 수 없는 표기이고, 실제로 믿기 어려운 값을 낳는다. 예시의 질소 0.08은 ppm이라면 흔적량에 불과하고 전질소 백분율로 보는 편이 자연스럽다. 따라서 측정값으로 인용해서는 안 된다. 양분 값은 추출법(유효 인산인지 전 인산인지 등)에 따라서도 달라지는데, 레코드는 이를 담지 않는다. 3.8절에서 필드별 단위 객체를 권장하며, 가장 먼저 적용할 곳이 chemistry다.

degradationIndicators는 황폐한 토양의 증상 네 가지를 범주 등급과 정량 값으로 묶는다.

waterRetention은 포장용수량(18.5 %), 위조점(8.2 %), 유효수분(10.3 %)을 준다. 여기에는 정합성 검사가 내장되어 있다. 유효수분은 정의상 포장용수량에서 위조점을 뺀 값이고, 18.5 − 8.2 = 10.3이다. 포장용수량은 위조점보다 커야 한다. 그렇지 않은 레코드는 값이 뒤바뀌었거나 잘못 입력된 것이므로 플래그에 그치지 말고 거부해야 한다.

침식 평가

erosionData가 별도 레코드인 이유는 침식 평가가 원시 측정이 아니라 해석이기 때문이다. 바람이나 강우 자료, 피복, 토양 상태를 모아 하나의 판단으로 바꾼다. erosionType은 예시에서 wind이고, 다른 주요 유형은 water다. severity는 등급(severe)과 헥타르당 연간 톤 단위의 soilLossRate(예시 15.2)를 함께 담는다. 제2장에서 설명했듯 수식은 보통 RUSLE 계열 모델로 추정한다. 풍식은 풍속, 지표 거칠기, 피복, 수분에 좌우되는데, 바로 여기에 기록되는 요인들이다.

factors는 그 요인을 담는다. km/h 단위의 평균·순간 최대 풍속, 식생 피복, 토양 수분, 범주형 지표 거칠기다. factors.vegetationCover(35.2)는 같은 날짜 식생 레코드의 coverage.vegetationCover(45.2)와 다르다는 점에 주목하자. 침식 모델은 노출된 특정 구역이나 다른 축척의 피복을 쓸 수 있으므로 반드시 오류라고 할 수는 없다. 다만 파생 레코드가 입력의 출처를 밝혀야 하는 이유를 잘 보여 준다. 사용한 식생·토양 레코드의 id를 넣어 두면(예: inputs 배열) 평가를 재현할 수 있다. interventions는 케밥 표기 토큰 목록이다(windbreak-establishment, cover-crop-planting, mulching). 제4장의 복원 계획이 이 토큰과 짝을 맞출 수 있도록 통제된 목록에서 고르게 해야 한다.

3.4 rainfallData와 landUseData

한 레코드에 담긴 한 해의 비

식생·토양 레코드와 달리 강수 레코드는 기간을 기술한다. period.start, period.end, period.type(예시는 annual)이다. totals.annualTotal은 325.4 mm이고, seasonalDistribution은 이를 우기(6~9월, 285.2 mm, 87.6 %)와 건기(40.2 mm, 12.4 %)로 나눈다. 두 쌍 모두 합이 맞으므로(285.2 + 40.2 = 325.4, 87.6 + 12.4 = 100) 검증기가 쉽게 확인할 항목이 둘 생긴다. 강수가 몇 달에 몰리는 것은 몬순 영향을 받는 건조지의 전형적인 모습이며, 비가 언제 오는지가 연 총량만큼 중요한 이유다.

intensityMetrics는 비가 어떻게 내렸는지 기술한다. 평균 강우 사상 크기, 최대 단일 사상, 강우 일수, 호우 일수, 그리고 "호우"의 기준값(예시 25 mm, 단위는 총량 필드에서 유추된다)이다. 기준값을 레코드에 담는 데는 이유가 있다. 호우 정의는 기관마다 다르므로 기준값 없는 호우 일수는 의미가 없다.

평년값과의 비교

historicalComparison은 해당 기간을 10년 평균과 30년 평균(예시 425.8 mm, 485.2 mm)에 비춰 본다. deviation −23.6은 10년 평균 대비 백분율이다. (325.4 − 425.8) / 425.8 ≈ −23.6 %. 필드 자체는 어느 기준인지 말하지 않으므로 이름에 드러내거나(예: deviationFrom10yr) 문서화해야 한다. 30년 평균에 비추면 같은 해가 평년보다 약 33 % 적은데, 읽는 사람은 어느 비교인지 알 수 없다.

이 키들은 작지만 실제로 존재하는 상호운용 문제도 보여 준다. 10yearAverage와 30yearAverage는 유효한 JSON 멤버 이름이지만 숫자로 시작한다. JavaScript에서는 대괄호로 접근해야 하고(h["10yearAverage"]), 코드 생성기나 ORM 상당수는 이름을 바꾸지 않고는 식별자로 매핑하지 못한다. 전송 형식에서는 호환성을 위해 공개된 이름을 유지하되, 코드에서는 매핑이 필요하다고 예상해 두어야 한다.

droughtIndex는 SPI 값(예시 −1.8)과 범주 라벨을 담는다. 예시에 인쇄된 라벨 대신 제2장에서 설명한 McKee 표준 등급을 써야 한다. 그 등급으로 −1.8은 "심한 건조(severely dry)"이며, 예시의 "보통 가뭄(moderate drought)" 라벨은 틀렸다. 가장 간단한 해법은 범주를 코드에서 값으로부터 도출하고, SPI 시간 척도(SPI-3, SPI-12 등)를 기록하며, 송신 측이 보낸 자유 문자열 범주를 받아들이지 않는 것이다. eventRecords는 개별 강우 사상을 날짜, 양, 지속 시간, 강도와 함께 나열한다. 여기서도 단위를 한 문장에 섞는 습관("mm for amount, hours for duration")이 되풀이되는데, 3.8절에서 다룬다.

면적 예산으로서의 토지 이용

landUseData는 위치 전체의 면적 예산이다. area.total은 10,000 ha이고, classification은 이를 농경지(4,500 ha), 방목지(3,200 ha), 산림(1,200 ha), 황무지(800 ha), 수면(200 ha), 시가지(100 ha)로 나눈다. 면적 합은 10,000, 백분율 합은 100이며 검증기는 둘 다 확인해야 한다. 부류마다 알맞은 속성이 붙는다. 농경지에는 작물 목록과 집약도, 산림에는 수관 피복과 상태, 방목지에는 가축 단위와 사육 밀도가 붙는다. 사육 밀도는 다른 필드로 계산할 수 있다. 가축 2,400 AU를 3,200 ha에 두면 0.75 AU/ha로 예시와 일치한다. 입력값과 결과를 함께 저장하면 편리하지만, 그렇다면 검증기가 둘의 일치를 확인해야 한다.

managementPractices는 경운, 관개, 시비, 병해충 관리, 토양 보전 대책 목록을 기록한다. pressure는 인구 밀도, 가축 밀도, 땔감 채취, 종합 압력 수준을 준다. 이 가운데 두 값에는 단위가 없다. 인구 밀도는 관례상 km²당 인구수이지만, 가축 밀도(예시 240)는 어떤 자연스러운 단위로도 위의 방목 수치와 맞춰지지 않는다. 단위가 명시될 때까지 정의되지 않은 값으로 취급하고 계산에 쓰지 말아야 한다.

3.5 restorationProject

복원 레코드는 관측이 아니라 대응을 기술한다. 식별자는 projectId(REST-2025-SAHEL-001)이고, 사람이 읽는 사업명과 수행 기관이 더해진다. location은 사업을 SAHEL-REGION-001에 연결하고 사업 자체의 면적(1,000 ha)을 준다. timeline에는 시작일, 계획 종료일, currentPhase 토큰(implementation)이 있다. objectives는 soil-stabilization, vegetation-recovery 같은 토큰 목록이다. 침식 대책과 마찬가지로 이 토큰도 자유 문자열이 아니라 공개된 어휘로 관리해야 사업을 묶고 비교할 수 있다.

개입 활동

interventions에는 제1장에서 소개한 복원 수단에 대응하는 하위 객체 세 개가 있다.

진척, 성과, 재원

progress는 전체 completion 백분율과 milestones 목록을 가진다. 마일스톤마다 상태와, 완료일 또는 부분 진척률이 붙는다. 제4장의 PUT …/progress 엔드포인트가 값을 쓰는 곳이 여기다. 진척 갱신은 값을 교체하므로 마일스톤 name이 사실상 키 역할을 하며, 갱신 사이에 바뀌어서는 안 된다.

outcomes는 environmental(식생 피복 증가, 침식 감소, 탄소 격리, 생물다양성)과 social(수혜자, 일자리, 소득 변화)로 나뉜다. 재원 보고서에 가장 자주 인용되는 필드이자 가장 과장되기 쉬운 필드다. 어느 항목도 무엇을 기준선으로, 어떤 방법으로 측정했는지 말하지 않는다. 예시의 탄소 수치는 같은 사업에 대한 PHASE 4의 탄소 계산과도 어긋나므로 사용하지 말아야 한다. 탄소 환산은 제6장에서 단계별로 계산해 본다. 성과 수치를 노출하는 구현은 항목마다 방법과 기준선 참조를 요구하고, 그 근거가 된 모니터링 레코드를 보여 줄 수 있어야 한다.

funding은 총예산, 집행액, 통화, 재원 목록을 준다. 예시로 깔끔한 교차 검증을 해 볼 수 있다. 두 재원 300,000과 200,000의 합은 총액 500,000 USD와 같고, 집행액 325,000은 예산 안에 있다. 예시처럼 ISO 4217 통화 코드를 쓰고, 명시적인 환산 없이 서로 다른 통화의 금액을 더해서는 안 된다.

3.6 monitoringReport

모니터링 보고서는 대부분의 독자가 실제로 보게 되는 레코드다. 한 위치의 한 기간(reportingPeriod의 시작, 종료, 분기, 연도. 예시는 2025년 4분기)을 다루며 네 개의 블록으로 이루어진다.

위험 블록

desertificationRisk는 0~100 척도의 overallScore, category, 네 항목(식생, 토양, 기후, 인간 활동)을 가진 factors 객체로 이루어진다. 요인마다 score, weight, 범주형 status가 있다. 점수가 높을수록 위험이 크고 가중치(0.30, 0.25, 0.25, 0.20)의 합이 1.0이므로, 자연스러운 해석은 가중합이다.

R = Σ wᵢ · sᵢ
  = 0.30 × 35.0 + 0.25 × 72.0 + 0.25 × 85.0 + 0.20 × 90.0
  = 10.5 + 18.0 + 21.25 + 18.0
  = 67.75

공개 예시는 68.5를 적었는데, 이는 예시 자신의 가중치와 점수로 나오지 않는 값이다. 점수 산출 알고리즘은 제4장에서 자세히 다룬다. 데이터 포맷 차원의 교훈은 더 단순하다. 입력과 파생 결과를 함께 담는 레코드는 정합성을 검사해야 하고, 보고서는 누구나 총점을 다시 계산할 수 있도록 요인 점수와 가중치를 보존해야 한다. category(예시 high-risk)는 점수에 붙이는 라벨이지만, PHASE 1은 라벨 뒤의 점수 구간을 정의하지 않는다. 구현은 자체 임계값을 공개해야 하며, 소비 측은 한 제공자의 "high-risk"가 다른 제공자의 "high-risk"와 같다고 가정해서는 안 된다.

지표, 경보, 권고

keyIndicators는 원천 레코드의 대표 값을 되풀이한다. NDVI와 그 추세, 토양 수분·유기물·침식 위험, 연 강수량과 편차다. 값을 복제하면 표시에는 편하지만 따로 어긋날 수 있는 두 번째 사본이 생긴다. 보고서를 다시 생성하고 감사할 수 있도록 원천 레코드의 식별자 참조를 추가해야 한다.

alerts는 severity, type(예: rapid-vegetation-decline), 자유 문자열 message, 날짜를 가진 객체 목록이다. 기계가 읽을 부분은 유형과 심각도다. 메시지는 사람을 위한 것이며, 공개 예시의 메시지는 보고서의 다른 값으로는 도출되지 않는 감소율을 주장한다. 대신 경보를 촉발한 수치를 구조화된 필드로 전달해야 한다. 제4장의 웹훅 페이로드가 currentValue, previousValue, change, threshold로 그렇게 한다. recommendations는 자유 문자열 목록이다. 보고서를 만든 모델이나 전문가의 참고용 출력으로 다루고, 데이터로 취급하지 않는다.

인증 필드

certifications에는 ldnCompliant(불리언), unccdReported(불리언), verificationStatus(문자열) 세 필드가 있다. 공식 지위처럼 보이기 때문에 모델 전체에서 가장 민감한 필드에 속한다. 몇 가지 예방책으로 정직하게 유지할 수 있다. unccdReported는 해당 데이터가 협약의 보고 절차를 통해 제출된 국가 보고서에 실제로 쓰였을 때만 true여야 한다. 그 제출은 데이터 플랫폼이 아니라 국가가 한다. ldnCompliant에는 근거가 명시되어야 한다. 제1장에서 설명했듯 LDN은 세 하위 지표를 "하나라도 나빠지면 전체 불합격(one out, all out)" 원칙으로 평가하므로, 근거가 된 평가를 가리키지 않는 불리언은 아무것도 증명하지 못한다. 예시에서 verificationStatus의 값은 "blockchain-anchored"이다. WIA 적합성에서 앵커링 방식은 Sigstore 서명과 Rekor 투명성 로그 항목이다(제8장). 그 밖의 앵커링은 선택적이고 구현에 고유한 설계이며, 그렇게 설명해야 한다.

3.7 검증과 데이터 품질

규범 규칙

PHASE 1 §7.2는 검증 규칙 네 가지를 정의하고, 중요한 점으로 실패에 대응하는 방식을 두 가지로 구분한다.

표 3-3. PHASE 1 검증 규칙(규격 §7.2)
필드규칙실패 시
NDVI−1 이상 1 이하레코드 거부
토양 수분0~100 %60 % 초과 시 검토 플래그
강수량음수가 아닐 것음수 값 플래그
좌표유효한 위도·경도레코드 거부

거부는 레코드를 받지 않고 송신자에게 오류를 돌려준다는 뜻이다. 플래그는 레코드를 받아들이되 검토 대기열에 올린다는 뜻이다. 이 구분은 물리적 사실에서 나온다. NDVI 1.5나 위도 95는 일어날 수 없는 값이므로 저장하면 시계열만 오염된다. 토양 수분 65 %는 드문 값이지만(대부분의 광질 토양은 그보다 훨씬 낮은 값에서 포화된다), 홍수 뒤나 유기질 토양, 센서가 고인 물에 잠긴 경우에는 나올 수 있다. 그래서 규칙이 거부가 아니라 플래그를 말한다. 음수 강수량은 거의 언제나 센서나 처리 과정의 결함이지만, 플래그로 남겨 두면 원인 진단의 증거가 된다. 0~100 %를 벗어난 수분은 물리적으로 불가능하며, 이 책의 검증기는 이를 거부로 처리한다. 규칙 표에 명시된 내용은 아니므로 같은 방식을 택한다면 그 사실을 선언해야 한다.

교차 필드 검사

네 규칙은 개별 필드만 본다. 예시에는 운영용 검증기가 함께 확인해야 할 필드 사이의 관계가 여럿 들어 있다. 규격은 이들을 규칙으로 나열하지 않으므로 권장 관행으로 취급하고, 관계가 정의상 성립해야 하는 경우가 아니라면 거부 대신 플래그로 처리한다.

표 3-4. 권장 교차 필드 검사
레코드검사권장 대응
vegetationData피복 부류 합 = 100(작은 허용 오차)플래그
soilData모래 + 미사 + 점토 = 100플래그
soilData포장용수량 > 위조점거부(정의상 관계)
soilData유효수분 = 포장용수량 − 위조점플래그
rainfallData계절 합 = 기간 총량, 호우 일수 ≤ 강우 일수플래그
landUseData부류 면적 합 = area.total, 백분율 합 = 100플래그
landUseData사육 밀도 = 가축 단위 ÷ 방목 면적플래그
restorationProject재원 합 = totalBudget, 집행액 ≤ 총액플래그
monitoringReport가중치 합 = 1, overallScore = Σ 가중치 × 점수플래그

JSON Schema 조각

단일 필드 제약은 선언적으로 표현하는 것이 가장 좋다. 아래 조각은 vegetationData의 핵심을 JSON Schema 2020-12로 기술한 것이다. 이 책을 위해 작성한 것으로 WIA 공식 스키마가 아니며, $id는 자리표시자다. 한계 두 가지를 알아 두어야 한다. JSON Schema로는 "이 네 숫자의 합이 100"을 표현할 수 없으므로 교차 필드 검사에는 코드가 필요하다. 또 2020-12에서 format: "date-time"은 기본적으로 주석(annotation)일 뿐이다. 형식이 잘못된 타임스탬프를 실패로 처리하려면 검증 라이브러리에서 형식 검사를 켜야 한다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://example.org/schemas/wia-ene-057/1.0.0/vegetationData.json",
  "title": "WIA-ENE-057 vegetationData (illustrative fragment)",
  "type": "object",
  "required": ["id", "locationId", "timestamp", "coordinates",
               "indices", "coverage", "trendAnalysis"],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^VEG-[0-9]{8}-[A-Z0-9]+(-[A-Z0-9]+)*-[0-9]{3}$"
    },
    "locationId": { "type": "string", "minLength": 1 },
    "timestamp":  { "type": "string", "format": "date-time" },
    "coordinates": {
      "type": "object",
      "required": ["latitude", "longitude"],
      "properties": {
        "latitude":  { "type": "number", "minimum": -90,  "maximum": 90 },
        "longitude": { "type": "number", "minimum": -180, "maximum": 180 },
        "datum":     { "const": "WGS84" }
      }
    },
    "indices": {
      "type": "object",
      "required": ["ndvi", "evi"],
      "properties": {
        "ndvi": { "$ref": "#/$defs/spectralIndex" },
        "evi":  { "$ref": "#/$defs/spectralIndex" },
        "lai": {
          "type": "object",
          "required": ["value"],
          "properties": { "value": { "type": "number", "minimum": 0 } }
        }
      }
    },
    "coverage": {
      "type": "object",
      "required": ["vegetationCover"],
      "properties": {
        "vegetationCover": { "$ref": "#/$defs/percent" },
        "bareGround":      { "$ref": "#/$defs/percent" },
        "water":           { "$ref": "#/$defs/percent" },
        "built":           { "$ref": "#/$defs/percent" },
        "unit":            { "const": "percentage" }
      }
    },
    "trendAnalysis": {
      "type": "object",
      "required": ["status"],
      "properties": {
        "status":     { "enum": ["declining", "stable", "improving"] },
        "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
      }
    }
  },
  "$defs": {
    "spectralIndex": {
      "type": "object",
      "required": ["value"],
      "properties": {
        "value":      { "type": "number", "minimum": -1, "maximum": 1 },
        "source":     { "type": "string" },
        "resolution": { "type": "string" }
      }
    },
    "percent": { "type": "number", "minimum": 0, "maximum": 100 }
  }
}

작은 검증기

아래 Python 모듈은 §7.2 표와 교차 필드 검사 일부를 구현한다. 결과는 reject 또는 flag 표시가 붙은 발견 사항 목록이다. 호출하는 서비스는 이를 보고 HTTP 오류를 돌려줄지 검토 대기열에 넣을지 결정한다. 일부러 외부 의존성 없이 작성했다. 운영 환경에서는 JSON Schema를 먼저, 이 모듈을 그다음에 실행하면 된다.

"""Minimal PHASE-1 record validator (illustrative, not a reference implementation)."""
import math
from dataclasses import dataclass

REJECT, FLAG = "reject", "flag"


@dataclass
class Finding:
    level: str     # REJECT: refuse the record; FLAG: accept but queue for review
    path: str
    message: str


def num(x):
    """True for JSON numbers only (Python's bool is a subclass of int)."""
    return isinstance(x, (int, float)) and not isinstance(x, bool)


def get(obj, path):
    for key in path.split("."):
        if not isinstance(obj, dict) or key not in obj:
            return None
        obj = obj[key]
    return obj


def close(a, b, tol=0.05):
    return math.isclose(a, b, abs_tol=tol)


def check_coordinates(rec, out):
    lat, lon = get(rec, "coordinates.latitude"), get(rec, "coordinates.longitude")
    if not (num(lat) and -90 <= lat <= 90):
        out.append(Finding(REJECT, "coordinates.latitude", f"invalid latitude {lat!r}"))
    if not (num(lon) and -180 <= lon <= 180):
        out.append(Finding(REJECT, "coordinates.longitude", f"invalid longitude {lon!r}"))


def validate_vegetation(v, out):
    check_coordinates(v, out)
    for idx in ("ndvi", "evi"):
        val = get(v, f"indices.{idx}.value")
        if not (num(val) and -1 <= val <= 1):
            out.append(Finding(REJECT, f"indices.{idx}.value", f"out of range [-1, 1]: {val!r}"))
    parts = [get(v, f"coverage.{k}") for k in ("vegetationCover", "bareGround", "water", "built")]
    if all(num(p) for p in parts) and not close(sum(parts), 100.0):
        out.append(Finding(FLAG, "coverage", f"classes sum to {sum(parts):.1f}, expected 100"))


def check_moisture(path, val, out):
    if not (num(val) and 0 <= val <= 100):
        out.append(Finding(REJECT, path, f"moisture outside 0-100 %: {val!r}"))
    elif val > 60:
        out.append(Finding(FLAG, path, f"moisture {val} % above 60 %, review"))


def validate_soil(s, out):
    check_coordinates(s, out)
    check_moisture("moisture.surfaceLevel", get(s, "moisture.surfaceLevel"), out)
    for i, layer in enumerate(get(s, "moisture.depthProfile") or []):
        check_moisture(f"moisture.depthProfile[{i}].moisture", layer.get("moisture"), out)
    fc, wp = get(s, "waterRetention.fieldCapacity"), get(s, "waterRetention.wiltingPoint")
    aw = get(s, "waterRetention.availableWater")
    if num(fc) and num(wp):
        if fc <= wp:
            out.append(Finding(REJECT, "waterRetention", "fieldCapacity must exceed wiltingPoint"))
        elif num(aw) and not close(aw, fc - wp):
            out.append(Finding(FLAG, "waterRetention.availableWater",
                               f"{aw} != fieldCapacity - wiltingPoint ({fc - wp:.1f})"))


def validate_rainfall(r, out):
    total = get(r, "totals.annualTotal")
    if num(total) and total < 0:
        out.append(Finding(FLAG, "totals.annualTotal", f"negative rainfall {total}"))
    wet = get(r, "seasonalDistribution.wetSeason.total")
    dry = get(r, "seasonalDistribution.drySeason.total")
    if num(total) and num(wet) and num(dry) and not close(wet + dry, total):
        out.append(Finding(FLAG, "seasonalDistribution", "seasons do not add up to annual total"))


def validate_land_use(l, out):
    total = get(l, "area.total")
    classes = get(l, "classification") or {}
    areas = [c.get("area") for c in classes.values() if isinstance(c, dict)]
    if num(total) and areas and all(num(a) for a in areas) and not close(sum(areas), total, 0.5):
        out.append(Finding(FLAG, "classification", f"class areas {sum(areas)} != total {total}"))


def validate_restoration(p, out):
    budget = get(p, "funding.totalBudget")
    sources = get(p, "funding.sources") or []
    amounts = [s.get("amount") for s in sources]
    if num(budget) and amounts and all(num(a) for a in amounts) and sum(amounts) != budget:
        out.append(Finding(FLAG, "funding.sources", f"sources sum {sum(amounts)} != totalBudget {budget}"))


VALIDATORS = {
    "vegetationData": validate_vegetation,
    "soilData": validate_soil,
    "rainfallData": validate_rainfall,
    "landUseData": validate_land_use,
    "restorationProject": validate_restoration,
}


def validate(document):
    findings = []
    for key, body in document.items():
        fn = VALIDATORS.get(key)
        if fn is None:
            continue  # unknown record types are passed through, not rejected
        if not isinstance(body, dict):
            findings.append(Finding(REJECT, key, "record body must be an object"))
            continue
        fn(body, findings)
    return findings


if __name__ == "__main__":
    bad = {"vegetationData": {
        "coordinates": {"latitude": 95, "longitude": -4.0},
        "indices": {"ndvi": {"value": 1.5}, "evi": {"value": 0.28}},
        "coverage": {"vegetationCover": 45.2, "bareGround": 42.8, "water": 2.0, "built": 5.0},
    }}
    for f in validate(bad):
        print(f"{f.level:6} {f.path}: {f.message}")

모듈을 실행하면 거부 두 건(위도 95, NDVI 1.5)과 플래그 한 건(피복 부류 합 95)이 출력된다. 제8장이 모든 적합성 주장에 포함하라고 요구하는 음성 시험 벡터가 바로 이런 종류다. 코드의 몇 가지 세부는 의도한 것이다. num 도우미는 불리언을 제외한다. Python에서 True는 정수여서 그대로 두면 숫자 1로 통과하기 때문이다. 검증기가 모르는 레코드 유형은 거부하지 않고 통과시킨다. 그래야 추가 확장이 구형 수신 측을 깨뜨리지 않는다. 부동소수점 합은 허용 오차를 두고 비교한다. 이진 연산에서 45.2 + 42.8 + 2.0 + 10.0은 정확히 100.0이 되지 않기 때문이다.

품질 지표

§7.1은 백분율 네 개(완전성, 정확도, 적시성, 일관성)와 검증 방법, 마지막 검증 시각을 담는 qualityMetrics 객체를 추가한다. 이름은 널리 쓰이는 데이터 품질 차원을 따르지만, 규격은 각 값을 어떻게 계산하는지 정의하지 않는다. 이런 수치를 공개하기 전에 정의를 적어 두어야 한다. 예를 들어 완전성은 해당 기간에 기대한 레코드 중 실제로 받은 비율, 적시성은 합의된 지연 시간 안에 도착한 비율, 일관성은 위 교차 필드 검사를 통과한 비율로 정할 수 있다. 정의가 없으면 "정확도 92.3 %" 같은 수치는 제공자끼리 비교할 수 없고, 측정된 속성처럼 제시해서도 안 된다.

3.8 단위, 버전 관리, 스키마 진화

단위는 값 바로 옆에

PHASE 1 레코드는 단위를 여러 방식으로 표기한다. 값 옆에 unit 멤버를 두는 객체가 있고(biomass.aboveGround, erosionData.severity.soilLossRate), 형제 값 묶음에 unit 하나를 두는 객체가 있다(coverage, waterRetention). 여러 단위를 한 문장에 넣은 곳도 있고(chemistry, eventRecords), 단위가 아예 없는 곳도 있다(토양 온도, 인구·가축 밀도, 호우 기준값). 단위 문자열의 철자도 제각각이다. "tons/hectare", "tons/hectare/year", "km/h", "g/cm³" 등이다.

구현에서는 세 가지 관행으로 호환성을 깨지 않고 모호함 대부분을 없앨 수 있다. 첫째, 모든 양에 단위를 붙인다. 규격이 바이오매스에 이미 쓰는 {"value": …, "unit": …} 패턴을 쓰고, 묶음 수준의 unit은 모든 멤버가 정말로 같은 단위를 쓸 때만 허용한다. 둘째, 단위를 통제된 어휘로 표기한다. UCUM(Unified Code for Units of Measure)은 기계가 읽을 수 있는 단위 코드로 널리 쓰이는 선택지이며, 배치 안에서 합의한 짧은 열거 목록도 괜찮다. 셋째, 백분율과 비율을 분명히 구분한다. 이 포맷은 둘을 모두 쓴다(피복과 수분은 0~100, 추세 confidence는 0~1). 87이어야 할 자리에 0.87이 들어온 것은 스키마가 어느 쪽을 기대하는지 밝혀야만 검증기가 잡아낼 수 있다.

버전과 호환성

PHASE 1은 1.0.0 버전이며 0.9.x와의 하위 호환을 명시한다. 이전을 위한 "자동 변환 도구"를 언급하지만 공개 저장소에는 포함되어 있지 않다. 옛 데이터를 가지고 있다면 매핑을 직접 작성할 계획을 세워야 한다. 부속서 H는 문서에 시맨틱 버저닝 2.0.0을 적용한다. 규범 요건의 하위 호환을 깨는 변경은 메이저, 구현을 깨지 않는 새 요건은 마이너, 편집상 수정은 패치 버전이다.

부속서 E는 스키마 진화에 관한 실무 규칙을 더한다. 새 선택 필드나 새 오류 코드 같은 추가 변경은 호환성을 깨지 않는다. 필드 이름을 바꾸거나 필드를 제거하는 것은 오류 페이로드 안에서라도 버전 갱신을 요구한다. 부속서 E는 이 경우 마이너 갱신으로 충분하다고 하지만, 부속서 H는 하위 호환을 깨는 변경을 메이저 버전에 배정한다. 소비 측에서 보면 필드가 사라지면 파서가 깨지므로, 이름 변경과 제거를 호환 단절로 보고 부속서 H를 따르는 것이 보수적인 접근이다. 두 부속서는 새 메이저 버전과 옛 버전의 병행 기간도 다르게 적는다(부속서 F는 90일, 부속서 H는 180일). 더 긴 기간을 기준으로 계획해야 한다.

수신 측이 지킬 원칙은 관용이다. 모르는 멤버는 거부하지 말고 무시해야 한다. 그래야 이 장에서 권한 추가 확장(침식·토지 이용 레코드의 식별자, 입력 참조, 방법 필드, 숫자형 깊이 경계)과 앞으로 생길 선택 필드를 일제히 업그레이드하지 않고도 도입할 수 있다. 송신 측이 지킬 원칙은 절제다. 기존 필드의 의미나 단위를 제자리에서 바꾸지 말고, 새 필드를 추가한 뒤 옛 필드를 폐기 예고해야 한다.

지원 범위 선언: 프로파일

모든 레코드 유형을 구현하는 배치는 드물다. 원격 토양 관측소는 토양과 강수 데이터만 만들 수 있고, 공여 기관의 보고 포털은 모니터링 보고서와 복원 사업만 소비할 수도 있다. 부속서 I는 구현이 이를 프로파일 매니페스트로 정직하게 밝히게 한다. 매니페스트는 PHASE 1 요건 가운데 충족하는 것(supported), 일부만 충족하는 것(partial, 사유 포함), 구현하지 않는 것(excluded, 근거 포함)을 나열하고, 소프트웨어 자재명세서(SBOM)와 같은 Sigstore 키로 서명한다. 명명 프로파일에는 버전이 붙는다. WIA-P1-DATA-FORMAT-Edge-only/2를 내건 배치는 "Edge-only" 프로파일의 두 번째 메이저 버전을 따른다고 주장하는 것이다. 규격은 개별 요건에 번호형 식별자를 부여하지 않으므로 아래 예의 ID는 예시다.

{
  "standardSlug": "desertification-prevention",
  "phase": "PHASE-1-DATA-FORMAT",
  "phaseVersion": "1.0.0",
  "profile": { "named": "WIA-P1-DATA-FORMAT-Edge-only", "version": 2 },
  "requirements": [
    { "id": "P1-VEG-RECORD",       "status": "supported" },
    { "id": "P1-SOIL-RECORD",      "status": "supported" },
    { "id": "P1-RAIN-RECORD",      "status": "partial",
      "reason": "daily gauge totals only; no 30-year normal available at this station" },
    { "id": "P1-RESTORATION-RECORD", "status": "excluded",
      "justification": "edge station does not manage restoration projects" },
    { "id": "P1-VALIDATION-7.2",   "status": "supported" }
  ]
}

프로파일 매니페스트는 PHASE 2가 정의하는 구현 매니페스트(표준 슬러그, 버전, 빌드 다이제스트, SBOM URL, 요건별 상태)를 좁힌 짝이다. 구현 매니페스트는 제4장에서 소개하고 제8장에서 적합성 증거로 활용한다. 데이터 포맷 수준에서 중요한 것은 "partial"과 "excluded"가 떳떳한 답이라는 점이다. 30년 강수 평년값을 제공할 수 없다고 밝힌 관측소가 추측으로 필드를 채운 관측소보다 감사자에게 더 쓸모 있다.

표준 정합

PHASE 1 §9는 ISO 19115(지리 메타데이터), OGC WMS/WFS/WCS 서비스, UNCCD LDN 보고, W3C 검증 가능한 자격증명(Verifiable Credentials)을 체크 표시와 함께 나열한다. 이는 규격 작성자가 스스로 선언한 정합이지 해당 기관의 인증이 아니다. 자체 문서에서는 "~에 정합하도록 설계됨" 또는 "~에 매핑되도록 설계됨"으로 표현해야 한다. 구체적인 매핑은 뒤의 장에서 다룬다. OGC 서비스와 GeoJSON은 제5장, LDN 보고는 제6장, 검증 가능한 증거는 제8장이다.

장 요약

PHASE 1은 WIA-ENE-057에 간결하고 읽기 쉬운 데이터 모델을 제공한다. locationId로 이어진 관측·활동 레코드 여섯 가지가 종합 모니터링 보고서 하나로 모인다. 공개 예시는 여러 곳에서 내부 정합성을 갖추고 있다. 피복 비율, 토성, 유효수분, 계절 강수, 토지 이용 면적, 재원이 모두 합이 맞는다. 검증기가 확인해야 할 것이 바로 이 합이다. 약한 곳도 몇 군데 분명히 있다. 여러 단위가 빠졌거나 한 문자열에 묶여 있다. NDVI 척도 라벨이 검증 규칙과 어긋난다. 위험 점수가 자신의 가중치와 맞지 않고, SPI 범주 라벨이 틀렸으며, 일부 성과·인증 필드는 그 뒤의 데이터보다 공식적으로 들린다. 신중한 구현은 전송 형식의 호환성을 유지하면서 명시적 단위, 식별자, 입력 참조를 추가 필드로 더한다. 또 단일 필드와 교차 필드 합을 모두 검증하고, 포맷의 어느 부분을 지원하는지 서명된 프로파일 매니페스트로 정확히 선언한다. 다음 장에서는 PHASE 2 REST API로 이 레코드를 실제로 움직여 본다.