File size: 7,072 Bytes
0846808 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | """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
|