Chapters 1 and 2 explained what desertification is and how vegetation, soil, rainfall and land use are observed from orbit and on the ground. This chapter turns those observations into data. PHASE 1 of WIA-ENE-057, the Data Format Specification, defines a small family of JSON records that let a satellite pipeline, a soil-moisture gateway, a field survey team and a restoration-project office describe the same place in the same vocabulary. Every later part of the standard (the REST API in Chapter 4, the streaming and messaging protocols in Chapter 5, the LDN and GIS integrations in Chapter 6) exchanges these records, so a mistake made here travels through every later layer.
The chapter reads the specification closely. For each record type it explains what each group of fields means, how the groups relate to the science introduced in Chapter 2, and where the published examples are ambiguous or internally inconsistent. All examples use the specification's running example location, SAHEL-REGION-001 (latitude 14.5, longitude −4.0, 10,000 ha). The numbers inside them are illustrative example values copied from or derived from the specification. They are not measurements of any real place in the Sahel and should never be quoted as such. The chapter ends with a JSON Schema fragment and a short validator that you can adapt for your own ingest pipeline.
PHASE 1 defines six top-level objects that carry observations or activities, plus one composite object that summarises them for a reporting period. Each JSON document wraps exactly one record under a key that names its type (for example {"vegetationData": {…}}), so a receiver can tell the type from the top-level key before it parses the body.
vegetationData: spectral vegetation indices, cover fractions, biomass and a trend summary for one location at one time.soilData: moisture by depth, texture, chemistry, degradation indicators and water-retention properties.erosionData: an erosion assessment, covering its type, severity, driving factors and recommended interventions.rainfallData: precipitation totals, seasonal split, event intensity and comparison with historical normals for a period.landUseData: how the area of the location is divided among land-use classes, how it is managed and how much pressure people and livestock put on it.restorationProject: a project record covering timeline, interventions, progress, outcomes and funding.monitoringReport: a composite, period-based assessment that combines the others into a desertification risk score, key indicators, alerts and recommendations.The split follows the four observable pillars from Chapter 1. Vegetation, soil (with erosion as a specialised view of it), climate and human pressure each get their own record, because they come from different instruments and cadences and are owned by different teams. A MODIS composite arrives every 16 days, a soil probe can report every hour, a rainfall season is summarised once a year and a land-use survey may be repeated only every few years. Folding all of them into one record would force the fastest source to wait for the slowest one.
Records do not nest inside a location object. They point to it through locationId, and that string is the join key for the whole data model. It follows that a location must be registered before data about it is accepted (the API enforces this, as Chapter 4 shows), and that the identifier must never be reused for a different piece of land. Each record type also has its own identifier. The specification shows the patterns below by example rather than defining them formally.
| Record | Identifier field | Example identifier | Time field(s) |
|---|---|---|---|
vegetationData | id | VEG-20251225-SAHEL-001 | timestamp (date-time) |
soilData | id | SOIL-20251225-SAHEL-001 | timestamp (date-time) |
erosionData | none in the example | – | assessmentDate (date only) |
rainfallData | id | RAIN-2025-SAHEL-001 | period.start / period.end (dates) |
landUseData | none in the example | – | timestamp (date-time) |
restorationProject | projectId | REST-2025-SAHEL-001 | timeline.* (dates) |
monitoringReport | reportId | REPORT-2025-Q4-SAHEL-001 | reportingPeriod.* (dates, quarter, year) |
The patterns carry information: a type prefix, a date or year (and quarter for reports), a region token and a three-digit sequence number. That makes them easy to read in logs, but they should still be treated as opaque strings. Do not parse the date back out of VEG-20251225-… to find the measurement time; use timestamp. Two gaps in the published examples are worth closing in any implementation. erosionData and landUseData have no identifier of their own, so there is nothing to deduplicate against when the same assessment is sent twice. It is a reasonable implementation choice to add an id following the same convention (for example an ERO- or LU- prefix). Under the schema-evolution rule discussed in Section 3.8 this is an additive, non-breaking extension. Until it is standardised, document it in your profile manifest.
Timestamps are ISO 8601 strings. In practice you should restrict them to the RFC 3339 profile with an explicit UTC offset, as in 2025-12-25T10:00:00Z. A local time without an offset is ambiguous once data crosses time zones. Several fields are dates without a time (assessmentDate, period.start, milestone completionDate). Keep them as dates; do not turn them into midnight timestamps, because a midnight UTC value can land on a different calendar day when displayed in a western time zone.
Positions use decimal degrees on WGS 84, the datum that GPS receivers and most satellite products use (EPSG:4326 when expressed as a coordinate reference system). vegetationData states this explicitly in coordinates.datum; soilData and restorationProject give latitude and longitude without a datum. Treat the missing datum as WGS 84, but have your writers always fill it in. Note also that PHASE 1 names the fields latitude and longitude, whereas GeoJSON arrays are ordered longitude first. The axis-order traps this creates are covered in Chapter 5.
locationId; monitoringReport summarises the records of one location for one reporting period.Figure 3-1 also shows cardinality. One location has many vegetation, soil, erosion and rainfall records over time, usually one current land-use record, zero or more restoration projects and one monitoring report per period. The restoration project is the odd one out, because its footprint (location.area.total, 1,000 ha in the example) is smaller than the monitored location (10,000 ha). A project is carried out within a location, and a large project could in principle span several locations. If that happens, model it as several project records or add a list of location references. Do not stretch one locationId to cover both.
vegetationData in DetailThe vegetation record is the one most implementations produce first, because it can be filled automatically from satellite products. The example below is the specification's record with one correction. The published example gives NDVI and EVI a scale of "0-1", but the validation rules in §7.2 of the same document accept NDVI from −1 to 1, and Chapter 2 showed that water and some bare surfaces really do produce negative values. The example therefore states "-1 to 1". All values are illustrative.
{
"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" }
}
}
Each spectral index is an object, not a bare number, because a value on its own cannot be compared or trusted. value is the physical index value. source names the sensor or product family and resolution the pixel size. Two NDVI values of 0.35 from a 250 m MODIS composite and from a 10 m Sentinel-2 scene describe different things: the coarse pixel mixes shrubs, bare soil and fields into one number, while the fine pixel may fall entirely on one crop field. A consumer that mixes them in one time series without looking at source and resolution will find trends that are really sensor changes. For the same reason, store the physical value and not the raw digital number. MODIS vegetation-index products, for instance, deliver scaled integers that must be multiplied by the product's scale factor before they mean anything. The record should contain the result of that step.
lai uses a different shape (value, unit, description) because leaf area index is a physical ratio in m²/m², not a normalised index. It is zero or greater and in drylands is usually small. The specification marks NDVI and EVI as required and LAI as optional, which fits the fact that LAI is often unavailable or model-derived.
coverage divides the observed area into vegetation, bare ground, water and built-up surface, as percentages. In the example they add up exactly: 45.2 + 42.8 + 2.0 + 10.0 = 100.0. That is a real constraint, even though the field table does not state it, and a validator should flag records whose classes do not sum to 100 within a small tolerance. Remember what these fractions are. They are a classification result for this record's footprint and method, and they are not comparable with the land-use areas in landUseData. The specification's own examples show why: the vegetation record puts built-up cover at 10 % and the land-use record for the same location puts it at 1 %. Different methods and definitions produce different answers, so neither figure should be used to "correct" the other.
biomass.aboveGround and biomass.belowGround are given in tonnes of dry matter per hectare. Above-ground biomass can be estimated from field plots or from models driven by satellite data. Below-ground biomass is almost never measured directly and is usually derived with a root-to-shoot ratio. It is good practice to record how each was obtained, even though the published record has no field for it. An additive method string fits the evolution rules.
trendAnalysis summarises the recent history of the index: monthlyChange, annualChange and fiveYearTrend as signed changes, a categorical status (declining, stable or improving) and a confidence between 0 and 1. The specification does not say whether the changes are absolute index differences or relative changes. Read at face value, −0.05 per month is an absolute NDVI difference, and that is how this book interprets it. Whichever reading you implement, publish it, because a relative interpretation would multiply the apparent decline several times over. Also keep in mind the caveat from Chapter 2: a declining index in a year with a poor rainy season is not evidence of degradation on its own. The status is only as good as the method behind it, and the method is not part of the record.
metadata holds provenance and quality: sensorType (satellite, in-situ, drone and so on), cloudCover (a percentage) and a coarse quality label. Cloud cover is the most important of these for optical indices, because a cloud-contaminated pixel lowers NDVI and can make a healthy location look degraded.
| Field | Type | Required | Meaning and notes |
|---|---|---|---|
id | string | Yes | Record identifier; treat as opaque |
locationId | string | Yes | Join key to the registered location |
timestamp | ISO 8601 | Yes | Time of observation; use UTC with offset |
coordinates | object | Yes | Latitude/longitude in decimal degrees, WGS 84 |
indices.ndvi.value | number | Yes | NDVI; §1.2 prints "0-1", but §7.2 validation accepts −1 to 1, which is the physical range |
indices.evi.value | number | Yes | EVI; same range note as NDVI |
coverage.vegetationCover | number | Yes | Percentage of vegetated surface; all cover classes should sum to 100 |
trendAnalysis.status | enum | Yes | declining | stable | improving |
trendAnalysis.confidence | number | No | Fraction 0–1, not a percentage |
metadata.cloudCover | number | No | Percentage of the scene or pixel window obscured |
soilData and erosionDataThe soil record opens with moisture.surfaceLevel (12.5 % in the example) and a depthProfile array. Each element carries a depth band as a string ("0-10cm", "10-30cm", "30-60cm"), a moisture value and a temperature. The profile matters because dryland vegetation responds to where water is, not just how much there is. A light shower wets the top few centimetres and evaporates within days, while deeper moisture supports shrubs and trees through the dry season. In the example, moisture decreases with depth (15.2, 12.8, 10.1) and so does temperature (28.5, 26.2, 24.8).
Two things are left implicit. First, the moisture unit is only "percentage". It does not say whether this is volumetric water content (volume of water per volume of soil, which is what most capacitance and TDR probes report) or gravimetric content (mass of water per mass of dry soil). The two differ by a factor equal to the bulk density, so with the example's 1.45 g/cm³ the difference is large. State which one you use. Second, temperature has no unit at all; degrees Celsius is the only plausible reading, but it should be written down. Encoding depth as a free string also makes range queries awkward. An implementation can keep the string for compatibility and add numeric topCm/bottomCm fields next to it.
soilType gives a textural class name (sandy-loam) and the sand/silt/clay split behind it (65/25/10 %, which sums to 100 and falls in the sandy-loam class of the USDA texture triangle). Texture changes slowly and controls infiltration, water-holding capacity and susceptibility to wind erosion. Sandy soils with little clay and organic matter are exactly the ones that blow away once their cover is lost.
chemistry lists pH, organic matter, nitrogen, phosphorus and potassium. It is the weakest part of the published format, because all units share a single sentence: "ppm for nutrients, percentage for organic matter". A machine cannot parse that reliably, and it produces implausible readings. The example's nitrogen value of 0.08 would be a trace amount in ppm and makes more sense as a percentage of total nitrogen, so it should not be cited as a measurement. Nutrient values also depend on the extraction method (available versus total phosphorus, for instance), which the record does not capture. Section 3.8 recommends a per-field unit object, and chemistry is the first place to apply it.
degradationIndicators groups four symptoms of degraded soil, each with a categorical level and a quantitative value:
erosionRisk: a score on a 0–10 scale (7.5, "high" in the example). The scale is stated, but the method that produces the score is not, so it can only be compared within one implementation.compaction: bulk density in g/cm³ (1.45). Higher bulk density means less pore space, slower infiltration and harder root penetration, and heavy trampling by livestock raises it.salinization: electrical conductivity in dS/m (0.8, "low"). A common soil-science convention treats a saturated-paste extract above about 4 dS/m as saline. Because EC values depend on how the extract was prepared (saturated paste versus 1:1 or 1:5 soil-water ratios), the preparation should be recorded with the value.crusting: a severity and a thickness. The example gives a thickness in centimetres, but surface crusts on dryland soils are usually only millimetres thick. Treat centimetre values with suspicion (possibly a unit error) and do not quote the example figure.waterRetention gives field capacity (18.5 %), wilting point (8.2 %) and plant-available water (10.3 %). These values carry their own consistency check: available water is by definition field capacity minus wilting point, and 18.5 − 8.2 = 10.3. Field capacity must be greater than the wilting point. A record where it is not has swapped or mistyped values and should be rejected, not merely flagged.
erosionData is a separate record because an erosion assessment is an interpretation, not a raw measurement. It brings together wind or rainfall data, cover and soil state and turns them into a judgement. Its erosionType is wind in the example, and water is the other main case. severity combines a rating (severe) with a soilLossRate in tonnes per hectare per year (15.2 in the example). As Chapter 2 explained, water erosion is usually estimated with an RUSLE-type model, while wind erosion depends on wind speed, surface roughness, cover and moisture, which are the very drivers recorded here.
factors captures those drivers: average and gust wind speed in km/h, vegetation cover, soil moisture and a categorical surface roughness. Notice that factors.vegetationCover (35.2) differs from coverage.vegetationCover in the vegetation record for the same date (45.2). This is not necessarily an error, because an erosion model may use cover within a specific exposed zone or at a different scale. It does show why a derived record should say where its inputs came from. Adding the id of the vegetation and soil records used (for instance an inputs array) makes the assessment reproducible. interventions is a list of kebab-case tokens (windbreak-establishment, cover-crop-planting, mulching). Keep them from a controlled list so that restoration planning in Chapter 4 can match them.
rainfallData and landUseDataUnlike the vegetation and soil records, a rainfall record describes a period: period.start, period.end and period.type (annual in the example). totals.annualTotal is 325.4 mm, and seasonalDistribution splits it into a wet season (June to September, 285.2 mm, 87.6 %) and a dry season (40.2 mm, 12.4 %). Both pairs add up (285.2 + 40.2 = 325.4; 87.6 + 12.4 = 100), which gives a validator two easy checks. The strong concentration in a few months is typical of monsoon-fed drylands and explains why the timing of rain matters as much as its annual total.
intensityMetrics describes how the rain arrived: average event size, the largest single event, the number of rainy days, the number of heavy-rain days and the threshold that defines "heavy" (25 mm in the example; the unit is implied by the totals). The threshold is stored in the record for good reason. Heavy-rain definitions vary between agencies, and a count of heavy-rain days means nothing without the threshold behind it.
historicalComparison places the period against 10-year and 30-year averages (425.8 mm and 485.2 mm in the example). The deviation of −23.6 is a percentage relative to the 10-year average: (325.4 − 425.8) / 425.8 ≈ −23.6 %. The field does not say which baseline it uses, so either name it (for example deviationFrom10yr) or document it. Against the 30-year average the same year is about 33 % below normal, and a reader cannot tell which comparison is meant.
These keys also illustrate a small but real interoperability problem. 10yearAverage and 30yearAverage are valid JSON member names, but they begin with a digit. In JavaScript they need bracket access (h["10yearAverage"]), and many code generators and ORMs cannot map them to identifiers without renaming. Keep the published names on the wire for compatibility, but expect to map them in your code.
droughtIndex carries an SPI value (−1.8 in the example) with a category label. Use the standard McKee classes described in Chapter 2 rather than the label printed in the example: under those classes −1.8 is "severely dry", and the example's label of "moderate drought" is not correct. The simplest fix is to derive the category from the value in code, record the SPI time scale (SPI-3, SPI-12 and so on), and never accept a free-text category from a sender. eventRecords lists individual storms with date, amount, duration and intensity. It repeats the mixed-unit habit ("mm for amount, hours for duration"), which Section 3.8 addresses.
landUseData is an area budget for the whole location: area.total is 10,000 ha, and classification divides it into agriculture (4,500 ha), grazing (3,200 ha), forest (1,200 ha), barren (800 ha), water (200 ha) and built (100 ha). The areas sum to 10,000 and the percentages to 100, and a validator should check both. Each class carries attributes suited to it: crop list and intensity for agriculture, canopy cover and condition for forest, and animal units and stocking rate for grazing. The stocking rate is derivable from the other fields: 2,400 animal units on 3,200 ha gives 0.75 AU/ha, which matches the example. Storing both the inputs and the result is convenient, but the validator should then confirm that they agree.
managementPractices records tillage, irrigation, fertilisation, pest management and a list of soil-conservation measures. pressure gives population density, livestock density, fuelwood collection and an overall pressure level. Two of these values have no units. Population density is conventionally people per km², but livestock density (240 in the example) cannot be reconciled with the grazing figures above under any obvious unit. Treat it as undefined until the unit is stated, and do not compute with it.
restorationProjectThe restoration record describes a response rather than an observation. Its identifier is projectId (REST-2025-SAHEL-001), and it adds a human-readable name and the implementing organisation. location ties the project to SAHEL-REGION-001 and gives the project's own footprint (1,000 ha). timeline has a start date, a planned end date and a currentPhase token (implementation). objectives is a list of tokens such as soil-stabilization and vegetation-recovery. As with erosion interventions, keep these tokens in a published vocabulary rather than free text, so that projects can be grouped and compared.
interventions has three sub-objects that correspond to the restoration options introduced in Chapter 1:
reforestation: planted area (450 ha), a species array (in the example Acacia senegal, quantity 25,000, survival 78.5 %, and Balanites aegyptiaca, quantity 15,000, survival 82.3 %), a planting method and a spacing string. Survival is a percentage of the quantity planted, so a derived "surviving trees" figure can be computed per species. Record when survival was measured, because a figure taken one year after planting says little about year five.soilConservation.measures: an array of measures of different kinds. The half-moon entry carries an area and a count, while the stone-bund entry carries a length and a unit. Heterogeneous objects in one array are hard to validate. Give each measure a type from a controlled list and a schema per type (a JSON Schema oneOf keyed on type works well).waterManagement.techniques: rainwater-harvesting structures with a count and a total capacity in cubic metres.progress has an overall completion percentage and a list of milestones, each with a status and either a completion date or a partial progress percentage. This is where the PUT …/progress endpoint in Chapter 4 writes. Because a progress update replaces values, the milestone name is effectively a key and must stay stable between updates.
outcomes is divided into environmental (vegetation-cover increase, erosion reduction, carbon sequestration, biodiversity) and social (beneficiaries, jobs, income change). These are the fields most likely to be quoted in funding reports, and the most likely to be overstated. None of them says against what baseline or by what method it was measured. The example's carbon figure also contradicts the carbon calculation in PHASE 4 for the same project, so it should not be used; Chapter 6 works through a carbon conversion step by step. An implementation that exposes outcome figures should require a method and a baseline reference for each, and should be able to show the underlying monitoring records.
funding gives a total budget, the amount spent and a currency, plus a list of sources. The example allows a clean cross-check: two sources of 300,000 and 200,000 add up to the 500,000 USD total, and the 325,000 spent is within budget. Use an ISO 4217 currency code, as the example does, and never add up amounts across currencies without an explicit conversion.
monitoringReportThe monitoring report is the record most readers will actually see. It covers one location for one period (reportingPeriod with start, end, quarter and year; the example is Q4 2025) and presents four blocks.
desertificationRisk contains an overallScore on a 0–100 scale, a category, and a factors object with four entries (vegetation, soil, climate and human activity). Each factor has a score, a weight and a categorical status. Higher scores mean higher risk, and the weights (0.30, 0.25, 0.25 and 0.20) sum to 1.0, so the natural reading is a weighted sum:
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
The published example prints 68.5, which does not follow from its own weights and scores. Chapter 4 discusses the scoring algorithm in full. For the data format the lesson is simpler: a record that contains both inputs and a derived result should be checked for consistency, and the report should keep the factor scores and weights so that anyone can recompute the total. The category (high-risk in the example) is a label applied to the score, but PHASE 1 does not define the score bands behind the labels. An implementation must publish its own thresholds, and a consumer must not assume that "high-risk" from one provider means the same as "high-risk" from another.
keyIndicators repeats headline values from the underlying records: NDVI and its trend, soil moisture, organic matter and erosion risk, annual rainfall and its deviation. Duplicating values is convenient for display but creates a second copy that can drift. Add references to the source records' identifiers so that the report can be regenerated and audited.
alerts is a list of objects with severity, type (for example rapid-vegetation-decline), a free-text message and a date. The machine-readable parts are type and severity. The message is for humans, and in the published example it asserts a percentage decline that cannot be derived from the other values in the report. Carry the numbers that triggered the alert in structured fields instead, as the webhook payload in Chapter 4 does with currentValue, previousValue, change and threshold. recommendations is a list of free-text strings. Treat it as advisory output of whatever model or expert produced the report, not as data.
certifications holds three fields: ldnCompliant (boolean), unccdReported (boolean) and verificationStatus (a string). These are among the most sensitive fields in the model, because they look like official status. A few precautions keep them honest. unccdReported should only be true when the data was actually used in a national report submitted through the Convention's reporting process, and that submission is made by the country, not by a data platform. ldnCompliant needs a stated basis. As Chapter 1 explained, LDN is assessed on three sub-indicators under the "one out, all out" principle, so a boolean with no reference to the assessment behind it proves nothing. verificationStatus takes the value "blockchain-anchored" in the example. In WIA conformance the anchoring mechanism is a Sigstore signature with a Rekor transparency-log entry (Chapter 8). Any other anchoring is an optional, implementation-specific design and should be described as such.
Section 7.2 of PHASE 1 defines four validation rules and, importantly, two different ways of responding to a failure:
| Field | Rule | On failure |
|---|---|---|
| NDVI | Between −1 and 1 | Reject the record |
| Soil moisture | Between 0 and 100 % | Flag for review if above 60 % |
| Rainfall | Non-negative | Flag negative values |
| Coordinates | Valid latitude/longitude | Reject the record |
Reject means the record is refused and the sender receives an error. Flag means the record is accepted but queued for review. The distinction follows from the physics. An NDVI of 1.5 or a latitude of 95 cannot occur, so storing them only pollutes the time series. A soil moisture of 65 % is unusual (most mineral soils saturate well below that), but it can occur after a flood, in organic soils or when a probe sits in standing water. That is why the rule says flag rather than reject. A negative rainfall total is almost always a sensor or processing fault, but flagging it keeps the evidence for diagnosis. Moisture outside 0–100 % is physically impossible, and this book's validator treats it as a rejection. The rule table does not state this explicitly, so declare it if you do the same.
The four rules only look at single fields. The examples contain several relationships between fields that a production validator should also check. The specification does not list them as rules, so treat them as recommended practice and flag rather than reject unless the relationship is definitional:
| Record | Check | Suggested response |
|---|---|---|
vegetationData | Cover classes sum to 100 (± small tolerance) | Flag |
soilData | Sand + silt + clay = 100 | Flag |
soilData | Field capacity > wilting point | Reject (definitional) |
soilData | Available water = field capacity − wilting point | Flag |
rainfallData | Seasonal totals sum to the period total; heavy-rain days ≤ rainy days | Flag |
landUseData | Class areas sum to area.total; percentages sum to 100 | Flag |
landUseData | Stocking rate = animal units ÷ grazing area | Flag |
restorationProject | Funding sources sum to totalBudget; spent ≤ total | Flag |
monitoringReport | Weights sum to 1; overallScore = Σ weight × score | Flag |
Single-field constraints are best expressed declaratively. The fragment below describes the core of vegetationData in JSON Schema 2020-12. It is written for this book and is not an official WIA schema; the $id is a placeholder. Two limitations are worth knowing. JSON Schema cannot express "these four numbers sum to 100", which is why the cross-field checks need code. In 2020-12, format: "date-time" is an annotation by default, so the validator library must be configured to enforce formats if you want malformed timestamps to fail.
{
"$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 }
}
}
The Python module below implements the §7.2 table and several of the cross-field checks. It returns a list of findings, each marked reject or flag, so the calling service can decide between an HTTP error and a review queue. It is deliberately dependency-free. In production you would run the JSON Schema first and this module second.
"""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}")
Running the module prints two rejections (latitude 95 and NDVI 1.5) and one flag (cover classes summing to 95). These are the kinds of negative test vectors Chapter 8 asks every conformance claim to include. A few details in the code are deliberate. The num helper excludes booleans, because in Python True is an integer and would otherwise pass as the number 1. Record types the validator does not know are passed through rather than rejected, so that additive extensions do not break older receivers. Floating-point sums are compared with a tolerance, because 45.2 + 42.8 + 2.0 + 10.0 is not exactly 100.0 in binary arithmetic.
Section 7.1 adds a qualityMetrics object with four percentages (completeness, accuracy, timeliness and consistency), a validation method and the time of the last validation. The names follow common data-quality dimensions, but the specification does not define how any of them is computed. Before publishing such numbers, write down the definitions: for example, completeness as the share of expected records received for the period, timeliness as the share delivered within the agreed latency, and consistency as the share passing the cross-field checks above. Without definitions, a figure like "accuracy 92.3 %" cannot be compared between providers and should not be presented as a measured property.
PHASE 1 records carry units in several different ways. Some objects have a unit member next to the value (biomass.aboveGround, erosionData.severity.soilLossRate). Some have one unit for a group of sibling values (coverage, waterRetention). Some put several units into one sentence (chemistry, eventRecords). Some have no unit at all (soil temperature, population and livestock density, heavy-rain threshold). Unit strings also vary in spelling: "tons/hectare", "tons/hectare/year", "km/h", "g/cm³".
For an implementation, three practices remove most of this ambiguity without breaking compatibility. First, give every quantity a unit, using the {"value": …, "unit": …} pattern the specification already uses for biomass, and allow a group-level unit only when every member truly shares it. Second, spell units from a controlled vocabulary. The Unified Code for Units of Measure (UCUM) is a widely used choice for machine-readable unit codes; a short enumerated list agreed within your deployment also works. Third, be explicit about percentages versus fractions. The format uses both (cover and moisture as 0–100, trend confidence as 0–1), and a validator can only catch a 0.87 written where 87 was meant if the schema says which is expected.
PHASE 1 is at version 1.0.0 and states backward compatibility with 0.9.x. It mentions "automated conversion tools" for migration, but the published repository does not include them, so plan to write your own mapping if you hold older data. Annex H applies Semantic Versioning 2.0.0 to the document: a major version for backward-incompatible changes to normative requirements, a minor version for new requirements that do not break implementations, and a patch version for editorial fixes.
Annex E adds a practical rule for schema evolution. Additive changes, such as new optional fields or new error codes, are non-breaking. Renaming or removing a field, even in an error payload, requires a version bump. Annex E says a minor bump is enough for this, whereas Annex H reserves backward-incompatible changes for major versions. From a consumer's point of view a removed field breaks parsers, so the conservative approach is to treat renames and removals as breaking and to follow Annex H. The same annexes differ on how long an old major version stays available alongside a new one (90 days in Annex F, 180 days in Annex H). Plan for the longer window.
For receivers, the matching discipline is tolerance. Ignore unknown members instead of rejecting them, so that the additive extensions recommended in this chapter (identifiers for erosion and land-use records, input references, method fields, numeric depth bounds) and any future optional fields can be introduced without coordinated upgrades. For senders, the discipline is restraint. Never change the meaning or unit of an existing field in place; add a new field and deprecate the old one.
Few deployments implement every record type. A remote soil station may produce only soil and rainfall data, and a donor's reporting portal may consume only monitoring reports and restoration projects. Annex I lets an implementation say so honestly through a profile manifest. The manifest lists the PHASE 1 requirements the implementation satisfies (supported), satisfies in part (partial, with a reason) or does not implement (excluded, with a justification). It is signed with the same Sigstore key as the software bill of materials. Named profiles are versioned: a deployment advertising WIA-P1-DATA-FORMAT-Edge-only/2 claims the second major version of the "Edge-only" profile. The specification does not assign numbered identifiers to individual requirements, so the IDs in the sketch below are illustrative.
{
"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" }
]
}
The profile manifest is a narrower companion to the implementation manifest defined in PHASE 2 (standard slug, version, build digest, SBOM URL and per-requirement status), which Chapter 4 introduces and Chapter 8 uses for conformance evidence. What matters at the data-format level is that "partial" and "excluded" are respectable answers. A station that states it cannot supply 30-year rainfall normals is more useful to an auditor than one that fills the field with a guess.
Section 9 of PHASE 1 lists ISO 19115 (geographic metadata), the OGC WMS/WFS/WCS services, UNCCD LDN reporting and W3C Verifiable Credentials, each with a check mark. These are self-declared alignments by the authors of the specification, not certifications by those bodies. Describe them as "aligned with" or "designed to map onto" in your own documentation. The concrete mappings are the subject of later chapters: OGC services and GeoJSON in Chapter 5, LDN reporting in Chapter 6 and verifiable evidence in Chapter 8.
PHASE 1 gives WIA-ENE-057 a compact, readable data model. Six observation and activity records, joined by locationId, feed one composite monitoring report. The published examples are internally consistent in many places: cover fractions, texture, available water, seasonal rainfall, land-use areas and funding all add up. Those sums are what a validator should check. The examples are weaker in a few specific places. Several units are missing or bundled into one string. The NDVI scale label contradicts the validation rule. The risk score does not match its own weights, the SPI category label is wrong, and some outcome and certification fields sound more official than the data behind them. A careful implementation keeps the wire format compatible, adds explicit units, identifiers and input references as additive fields, validates both single fields and cross-field sums, and declares in a signed profile manifest exactly which parts of the format it supports. The next chapter puts these records to work through the PHASE 2 REST API.