Noon report sanity-checker: app, deterministic validators, LLM layer, tests, demo data, field notes
0846808 | """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 | |
| 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 | |