noon-report-checker / validators.py
tarunagarwal1981's picture
Noon report sanity-checker: app, deterministic validators, LLM layer, tests, demo data, field notes
0846808
Raw
History Blame Contribute Delete
7.07 kB
"""Deterministic noon-report checks. No LLM — fast, certain, never hallucinates.
Findings use the FuelSense-style taxonomy:
severity: CRITICAL | ERROR | WARNING
category: MISSING_DATA | OUT_OF_RANGE | INCONSISTENT_DATA
"""
from dataclasses import dataclass, asdict
import schema
from schema import FIELDS, REQUIRED, BY_KEY
@dataclass
class Finding:
severity: str # CRITICAL | ERROR | WARNING
category: str # MISSING_DATA | OUT_OF_RANGE | INCONSISTENT_DATA
field: str
message: str
def as_row(self):
return [self.severity, self.category, self.field, self.message]
def _num(v):
"""Coerce to float, return None if not numeric."""
try:
return float(v)
except (TypeError, ValueError):
return None
def check_missing(report: dict) -> list[Finding]:
out = []
for key in REQUIRED:
val = report.get(key)
if val is None or str(val).strip() == "":
out.append(Finding(
"CRITICAL", "MISSING_DATA",
BY_KEY[key].label,
f"Required field '{BY_KEY[key].label}' is missing.",
))
return out
def check_ranges(report: dict) -> list[Finding]:
out = []
for f in FIELDS:
if f.lo is None and f.hi is None:
continue
v = _num(report.get(f.key))
if v is None:
continue
if (f.lo is not None and v < f.lo) or (f.hi is not None and v > f.hi):
out.append(Finding(
"ERROR", "OUT_OF_RANGE", f.label,
f"{f.label} = {v}{f.unit} is outside expected range "
f"[{f.lo}, {f.hi}]{f.unit}.",
))
return out
def check_consistency(report: dict) -> list[Finding]:
"""Cross-field arithmetic. The high-value checks officers miss."""
out = []
hours = _num(report.get("steaming_hours"))
dist = _num(report.get("distance_run"))
speed = _num(report.get("avg_speed"))
# 1. distance / hours should match reported avg speed (within 5%)
if hours and dist is not None and speed is not None and hours > 0:
implied = dist / hours
if speed > 0 and abs(implied - speed) / speed > 0.05:
out.append(Finding(
"ERROR", "INCONSISTENT_DATA", "Avg speed",
f"Reported avg speed {speed}kn disagrees with "
f"distance/hours = {implied:.2f}kn (>5% off).",
))
# 2. FO ROB carry-over: prev_rob - total_cons should ≈ reported ROB
prev_rob = _num(report.get("fo_rob_prev"))
me = _num(report.get("me_fo_cons")) or 0
ae = _num(report.get("ae_fo_cons")) or 0
rob = _num(report.get("fo_rob"))
bunkered = _num(report.get("fo_bunkered")) or 0
if prev_rob is not None and rob is not None:
expected = prev_rob - (me + ae) + bunkered
if abs(expected - rob) > 1.0: # >1 mt drift
out.append(Finding(
"CRITICAL", "INCONSISTENT_DATA", "FO ROB",
f"FO ROB {rob}mt doesn't reconcile: "
f"prev {prev_rob} - cons {me+ae} + bunkered {bunkered} "
f"= {expected:.1f}mt (drift {expected-rob:+.1f}mt).",
))
# 3. draft sanity: aft usually >= fwd when laden/trimmed by stern
fwd = _num(report.get("draft_fwd"))
aft = _num(report.get("draft_aft"))
if fwd is not None and aft is not None and fwd - aft > 0.5:
out.append(Finding(
"WARNING", "INCONSISTENT_DATA", "Draft",
f"Trimmed by head: fwd {fwd}m > aft {aft}m by "
f"{fwd-aft:.1f}m. Confirm intentional.",
))
return out
def check_advanced(report: dict) -> list[Finding]:
"""Higher-order reconciliations. Still pure arithmetic + explicit rules —
no LLM. Thresholds live in schema.py and are read at call time so they can
be tuned (or patched in tests) without touching this file."""
out = []
hours = _num(report.get("steaming_hours"))
dist = _num(report.get("distance_run"))
speed = _num(report.get("avg_speed"))
rpm = _num(report.get("rpm_avg"))
slip = _num(report.get("slip_pct"))
me = _num(report.get("me_fo_cons"))
wind = _num(report.get("wind_force"))
# 1. Slip reconciliation. Engine distance = RPM * pitch * minutes, in nm.
# Computed slip = (engine_dist - observed_dist) / engine_dist.
# DISABLED unless a real propeller pitch is configured — we never invent
# the constant we'd be checking against.
pitch = schema.PROP_PITCH_M
if (pitch and rpm and hours and hours > 0
and dist is not None and slip is not None):
engine_dist = rpm * pitch * (hours * 60.0) / 1852.0 # revs*m -> nm
if engine_dist > 0:
computed_slip = (engine_dist - dist) / engine_dist * 100.0
if abs(computed_slip - slip) > schema.SLIP_TOLERANCE_PCT:
out.append(Finding(
"ERROR", "INCONSISTENT_DATA", "Slip",
f"Reported slip {slip}% disagrees with slip computed from "
f"RPM x pitch = {computed_slip:.1f}% "
f"(>{schema.SLIP_TOLERANCE_PCT:g} pts off).",
))
# 2. ME fuel-oil burn rate outside the plausible band (mt per steaming hour).
if me is not None and hours and hours > 0:
rate = me / hours
if (rate < schema.ME_FO_RATE_LO_MT_PER_H
or rate > schema.ME_FO_RATE_HI_MT_PER_H):
out.append(Finding(
"WARNING", "INCONSISTENT_DATA", "ME FO consumption",
f"ME burn rate {rate:.2f} mt/h is outside the typical band "
f"[{schema.ME_FO_RATE_LO_MT_PER_H}, "
f"{schema.ME_FO_RATE_HI_MT_PER_H}] mt/h. Confirm cons vs. hours.",
))
# 3. High slip in calm water — usually hull fouling or a data error,
# not weather.
if (slip is not None and wind is not None
and slip > schema.HIGH_SLIP_PCT and wind <= schema.CALM_WIND_BF):
out.append(Finding(
"WARNING", "INCONSISTENT_DATA", "Slip",
f"High slip {slip}% in calm conditions (BF{wind:g}). "
f"Check hull fouling or a distance/RPM entry error.",
))
# 4. High speed sustained in heavy weather — verify speed/distance entry.
if (speed is not None and wind is not None
and wind >= schema.ROUGH_WIND_BF and speed >= schema.HIGH_SPEED_KN):
out.append(Finding(
"WARNING", "INCONSISTENT_DATA", "Average speed",
f"Avg speed {speed}kn sustained in heavy weather (BF{wind:g}) "
f"looks high. Verify distance and steaming hours.",
))
return out
def run_all(report: dict) -> list[Finding]:
findings = []
findings += check_missing(report)
findings += check_ranges(report)
findings += check_consistency(report)
findings += check_advanced(report)
# severity sort: CRITICAL first
order = {"CRITICAL": 0, "ERROR": 1, "WARNING": 2}
findings.sort(key=lambda f: order.get(f.severity, 9))
return findings