JSON Schema
This schema is frozen. The visualiser depends on exact field names and types. Do not rename fields or change null behaviour. The firmware (
data_logger.cpp) and the visualiser (/viz) both implement exactly this shape.
Full Schema
{
"run_id": "run_001",
"total_duration_s": 480.0,
"sectors_completed": 4,
"path": [
{ "t_s": 0.0, "x_mm": 0.0, "y_mm": 0.0 }
],
"samples": [
{
"id": "W1",
"type": "WATER",
"sector": 1,
"terrain": "Urban",
"value_ntu": 42.3,
"value_pct": null,
"t_s": 45.2
},
{
"id": "S1",
"type": "SOIL",
"sector": 1,
"terrain": "Urban",
"value_ntu": null,
"value_pct": 68.5,
"t_s": 78.1
}
]
}
Field Reference
Top-Level
| Field | Type | Description |
|---|---|---|
run_id |
string | Unique per run (run_<millis>) — a fresh document opens at every run start |
total_duration_s |
number | Run time in seconds (live during the run, frozen at End Run) |
sectors_completed |
integer | Count of distinct sectors with at least one sample |
path |
array | Position estimate, logged at 1 Hz while the run is live (capped at 480 points = 8 min) |
samples |
array | Measurement objects, max 12 |
Run clock: all timestamps count from run start (the first FWD / BWD / LEFT / RIGHT / SAMPLE_* command) — never from power-on. End Run freezes the document; the next drive/sample opens a fresh one, so back-to-back practice runs never bleed together.
Sample Object
| Field | Type | Description |
|---|---|---|
id |
string | "W1"–"W4" for water, "S1"–"S8" for soil, numbered per type in collection order |
type |
"WATER" or "SOIL" |
Sensor used |
sector |
integer | 1–4; 0 = unknown (no sector selected, or out of range 1–4) |
terrain |
string | From TERRAINS[] lookup by sector; "Unknown" for sector 0 |
value_ntu |
number or null | NTU for WATER; null for SOIL |
value_pct |
number or null | Moisture % for SOIL; null for WATER |
t_s |
number | Seconds from run start |
Path Point
| Field | Type | Description |
|---|---|---|
t_s |
number | Seconds from run start |
x_mm |
number | Estimated X (origin = start position) |
y_mm |
number | Estimated Y |
Path is a speed-model estimate (no encoders) — treat it as illustrative coverage, not ground truth. Samples carry no coordinates; sector labelling is the operator's job in RC mode.
Null Field Rules
| Type | value_ntu |
value_pct |
|---|---|---|
| WATER | number | null |
| SOIL | null |
number |
Both fields always present; the unused one is explicitly null, never omitted.
Terrain Names
Filled by firmware from the sector number via TERRAINS[] in data_logger.cpp:
| Sector | terrain |
|---|---|
| 0 | "Unknown" |
| 1 | "Urban" |
| 2 | "Field" |
| 3 | "Desert" |
| 4 | "Stone" |
The full lookup is TERRAINS[5] = {"Unknown", "Urban", "Field", "Desert", "Stone"}.
Storage and Access
- Every sample is also printed to serial with units in the
[SAMPLE] S<n> <TYPE> <value>format — parsed bylive_plot.pyand rulebook-approved on its own. Never change this line's shape. GET http://192.168.4.1/datareturns the full run JSON at any time.GET http://192.168.4.1/samplesreturns a lightweight samples-only feed (no 48KB path array) — polled every 2s bylive_plot.pyduring the run.- End Run persists
results.jsonto SPIFFS and freezes the document. - After a reboot,
/dataserves the savedresults.json— judges can still pull data if the robot power-cycled. live_plot.pyadditionally appends every sample tosamples.csvon the laptop — a second approved output format for free.
To archive: on RobotAP, use the RC page ⬇ Download JSON (or open 192.168.4.1/data), save the JSON, back it up to two devices, then drag-drop into the visualiser.