Chapter 4 — Scripting and data formats
Lesson map
Lesson 04 published. Map, example, EX013.
flowchart TD
N0["Learning outcomes and connection"]
N1["4.1 Formats you will meet"]
N2["4.2 A validation order that fails closed"]
N3["4.3 Record coordinates"]
N4["4.5 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5
N5 --> EX["Worked example"]
EX --> Q["Exercise EX013"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["4.1 Formats you will meet"]
N2["4.2 A validation order that fails closed"]
N3["4.3 Record coordinates"]
N4["4.5 Practical activity"]
N5["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> Q["EX013"]
[Learning outcomes and connection]
|
v
[4.1 Formats you will meet]
|
v
[4.2 A validation order that fails closed]
|
v
[4.3 Record coordinates]
|
v
[4.5 Practical activity]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX013]
Learning outcomes and connection
After this chapter you should be able to:
- parse CSV and JSON operational data with the standard library, without hand-written string splitting;
- validate every record against an explicit schema: field count, units, numeric form, finiteness, timestamp format and physical range;
- report each problem with its record coordinates (file and line) so another person can find it;
- choose between reject and quarantine, and summarize results in machine-readable form.
The technician's scripts in later chapters — decoders, auditors, detectors — all read data someone else produced. This chapter makes them strict about what they accept, because a script that silently "fixes" bad input produces confident wrong answers.
4.1 Formats you will meet
- CSV — exports from historians, HMIs and spreadsheets. RFC 4180 describes the common format, including quoting: a field containing a comma must be enclosed in double quotes (RFC 4180). Splitting lines on commas breaks on the first quoted value; use
csv.reader, which also tracksline_num. - JSON — APIs, configuration exports and tool output (RFC 8259). Use
json.loads; note that Python's parser accepts the non-standard tokensNaNandInfinityunless you passparse_constantto reject them. - Timestamps — use RFC 3339 / ISO 8601 with an explicit offset, ideally UTC with
Z(RFC 3339). A timestamp without an offset is ambiguous by up to a day and across daylight-saving changes. - Binary frames — Chapter 2's
structdecoding; always check lengths before unpacking.
4.2 A validation order that fails closed
Check in an order where each step can assume the previous ones passed:
- Structure: the right number of fields. If not, nothing else in the record can be trusted — reject.
- Identity and type: known station and tag; unit as the schema says (a level in feet is not a level in metres); value parses as a number.
- Finiteness:
float("nan")andfloat("inf")parse successfully but are not measurements — reject. - Time: the timestamp parses in the agreed format; it is not in the future beyond a small tolerance.
- Plausibility: value within the physical range of the instrument and process.
Reject what cannot be interpreted (wrong structure, wrong unit, not a number). Quarantine what is interpretable but implausible (out of range, future time): a real sensor fault or a real attack both look like this, and an operator must see it rather than have it silently dropped. Never coerce: turning "12,5" into 12.5 or 125 by guessing the decimal separator creates data that was never measured.
4.3 Record coordinates
Every finding should say where it came from — file, line, and field — so that someone else can verify it. csv.reader.line_num counts physical lines read so far, including the header, so the first data row is line 2. Quoted fields containing newlines make physical lines differ from records; line_num reports the line where the record ended, which is still a usable coordinate.
When you paste a finding into a ticket, keep the line number next to the station name, so the next person can open the export and go straight to the row. If a quoted field spans lines, say so, because the coordinate then marks the end of the record rather than its start.
4.4 Worked example: validating a station export (synthetic)
An export from Kestrel Water's historian lists one reading per station. The analysis time is 06:00Z on 12 October 2026.
Lab 4 (save as lab04.py, run with python3 lab04.py):
# Lab 4 - validate a telemetry export with record coordinates (offline, standard library only)
# Synthetic CSV export from Kestrel Water stations; "now" is 2026-10-12T06:00:00Z.
import csv, io, json, math
from datetime import datetime, timezone
export = """station,tag,value,unit,ts
KW-01,LEVEL_M,3.42,m,2026-10-12T05:58:00Z
KW-02,CL2_MGL,0.61,mg/L,2026-10-12T05:58:00Z
KW-03,LEVEL_M,,m,2026-10-12T05:58:00Z
KW-04,CL2_MGL,7.9,mg/L,2026-10-12T05:58:00Z
KW-05,FLOW_LS,"12,5",L/s,2026-10-12T05:58:00Z
KW-06,LEVEL_M,2.10,ft,2026-10-12T05:58:00Z
KW-07,LEVEL_M,2.95,m,2026-10-12 05:58
KW-08,LEVEL_M,nan,m,2026-10-12T05:58:00Z
KW-09,FLOW_LS,41.0,L/s,2026-10-12T07:15:00Z
KW-10,LEVEL_M,1.88,m,2026-10-12T05:58:00Z,extra
"""
SCHEMA = {"LEVEL_M": ("m", 0.0, 12.0), "CL2_MGL": ("mg/L", 0.0, 5.0), "FLOW_LS": ("L/s", 0.0, 400.0)}
now = datetime(2026, 10, 12, 6, 0, tzinfo=timezone.utc)
result = {"accept": [], "reject": [], "quarantine": []}
reader = csv.reader(io.StringIO(export))
header = next(reader)
for row in reader:
where = f"line {reader.line_num}"
if len(row) != len(header): result["reject"].append((where, f"{len(row)} fields, expected {len(header)}")); continue
r = dict(zip(header, row))
unit, lo, hi = SCHEMA[r["tag"]]
if r["unit"] != unit: result["reject"].append((where, f"{r['station']} unit {r['unit']!r}, expected {unit!r}")); continue
try: v = float(r["value"])
except ValueError: result["reject"].append((where, f"{r['station']} value {r['value']!r} not a number")); continue
if not math.isfinite(v): result["reject"].append((where, f"{r['station']} value not finite")); continue
try:
ts = datetime.strptime(r["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError: result["reject"].append((where, f"{r['station']} timestamp {r['ts']!r} not RFC 3339 UTC")); continue
if ts > now: result["quarantine"].append((where, f"{r['station']} timestamp {(ts - now).seconds // 60} min in the future")); continue
if not lo <= v <= hi: result["quarantine"].append((where, f"{r['station']} {r['tag']}={v} outside {lo}-{hi}")); continue
result["accept"].append((where, r["station"]))
for k in ("reject", "quarantine"):
for where, why in result[k]: print(f"{k:<10} {where:<8} {why}")
print(json.dumps({k: len(v) for k, v in result.items()}))
Expected output (Python 3.13.5):
reject line 4 KW-03 value '' not a number
reject line 6 KW-05 value '12,5' not a number
reject line 7 KW-06 unit 'ft', expected 'm'
reject line 8 KW-07 timestamp '2026-10-12 05:58' not RFC 3339 UTC
reject line 9 KW-08 value not finite
reject line 11 6 fields, expected 5
quarantine line 5 KW-04 CL2_MGL=7.9 outside 0.0-5.0
quarantine line 10 KW-09 timestamp 75 min in the future
{"accept": 2, "reject": 6, "quarantine": 2}
Only two of ten rows are accepted. Each rejection names its line: an empty value (line 4), a decimal comma inside a quoted field (line 6), a level in feet (line 7), a timestamp without seconds or offset (line 8), nan (line 9) and an extra field (line 11). The quarantined rows are the interesting ones: chlorine at 7.9 mg/L is outside the instrument range — a sensor fault, a scaling error or a manipulated value — and KW-09's timestamp is 75 minutes in the future, which in Chapter 7 you will learn to suspect as a clock problem. Both are passed to a person with their coordinates.
The JSON summary on the last line is deliberately machine-readable, so that a monitoring job can alert when the rejection count rises.
4.5 Practical activity
Add a station-identity check to Lab 4: only KW-01 to KW-12 are valid. Then add a row KW-13,LEVEL_M,2.0,m,2026-10-12T05:58:00Z and confirm it is rejected with its line number. Finally, make the timestamp parser accept an explicit +00:00 offset as well as Z without accepting naive timestamps.
Reject KW-13 with its line number and a reason that names the allowed set. For timestamps, accept both Z and an explicit plus-zero offset, and still reject a bare local time. Re-run the lab and confirm that the JSON summary's reject count rises by one for the new station.
4.6 Common errors and limits
- Splitting CSV on commas.
- Coercing ambiguous values instead of rejecting them.
- Letting
nanthrough becausenan > hiis false. - Accepting naive timestamps and assuming local time.
- Reporting "6 rejected" with no coordinates, which nobody can check.
-
Lab 4 checks single readings; rate-of-change and stuck values need a series (Chapter 8).
-
Letting nan through because comparisons with it are false, so an out-of-range check never fires.
- Reporting only totals with no coordinates, so nobody can find the bad rows.
- Coercing a decimal comma into a float and writing the invented value into the historian.
Exercises — chapter 4
EX013 — Predict before running. Objective: reason about the validator. Inputs: Lab 4 with three new rows: KW-11,LEVEL_M,12.0,m,2026-10-12T05:59:00Z; KW-12,LEVEL_M,inf,m,2026-10-12T05:59:00Z; KW-02,CL2_MGL,-0.0,mg/L,2026-10-12T05:59:00Z. Steps: predict each verdict and the new summary, then run. Expected evidence: predictions, actual output, explanation of any surprise. Rubric: 3 = accept, reject (not finite), accept; summary {"accept": 4, "reject": 7, "quarantine": 2}; notes −0.0 should be normalized; 2 = one wrong but explained; 1 = guesses; 0 = none.
EX014 — Reject or quarantine? Objective: apply the distinction. Inputs: (a) flow −3.0 L/s; (b) unit "M" instead of "m"; (c) level 11.9 m on a 12 m tank; (d) timestamp 4 s in the future. Steps: decide and justify. Expected evidence: four verdicts. Rubric: 3 = (a) quarantine (interpretable, implausible; might be reverse flow or offset); (b) reject or normalize only if the schema explicitly allows case-insensitive units — not guess; (c) accept, perhaps with a high-level alarm; (d) accept within an agreed clock tolerance; 2 = one weak; 1 = all reject; 0 = none.
EX015 — JSON with traps. Objective: harden JSON parsing. Inputs: {"station": "KW-03", "value": NaN}. Steps: show what json.loads returns by default and write the one-line change that rejects it. Expected evidence: code and behaviour. Rubric: 3 = default returns nan float; json.loads(s, parse_constant=lambda c: (_ for _ in ()).throw(ValueError(c))) or an equivalent function that raises; explains RFC 8259 does not allow NaN; 2 = works without explanation; 1 = attempted; 0 = none.
EX016 — Summary for monitoring. Objective: design a machine-readable result. Inputs: Lab 4. Steps: extend the JSON summary with counts by reason and by station, and propose one alert condition on it. Expected evidence: example JSON and the condition. Rubric: 3 = stable keys, counts by reason, alert such as "any quarantine of CL2_MGL, or rejects > 10 % of rows for two consecutive exports"; 2 = no reason breakdown; 1 = prose only; 0 = none.
Chapter synthesis
Operational data arrive malformed, ambiguous and occasionally hostile. Parse with real parsers, validate in an order that fails closed, never coerce, and report every finding with coordinates. Reject what you cannot interpret; quarantine what you can interpret but should not believe; and keep summaries machine-readable so that the validator itself can be monitored.
Continue studying — chapter 04 of 18
← Chapter 03 · Course chapter index · Chapter 05 →
Continue to the next authored lesson.