Nothing had ever run the real `Controller` through the transition inverter B's commissioning performs on 2026-09-12. `tools/rehearse_roster.py` does, over a COPY of a state file given as an input path, and reports the difference: every store key that moved, appeared, vanished or was STRANDED under a name no machine reads; every entity id before and after with orphans named; and whether the maintenance history MAINT-05 moved once survived being moved again. ⭐ WHAT IT FOUND, AND IT IS SAFETY-44's ANSWER. A store that ran pre-DEV-02 flat and declares a multi-inverter roster comes up with its SAFETY-02 latch DISARMED unless the first roster entry happens to be called `inverter-a`. `Controller.__init__` hands `migrate_unit_keys()` the hardcoded `UNIT_A`, so the latch lands in `unit/inverter-a/sign_tripped`; a roster of ["garage","shed"] reads `unit/garage/sign_tripped`, which is absent. And `_grade_store_migration()` - the guard written for exactly this - sees the migration's OWN misdirected key satisfy its `namespaced_latched` test and returns without re-arming. Measured, not argued: the file says `sign_tripped: true`, no inverter is latched, nothing is held at 0 W. The same state file onto a roster whose first entry IS `inverter-a` comes up latched, so the finding is attributable to the name. The staged upgrade reaches it too: the flat start stamps `schema_version`, and the roster start's migration then takes its early return. ⚠️ NO RUNTIME CODE IS CHANGED HERE. The fix to `migrate_unit_keys()` belongs to SAFETY-44 reopened as a defect, shown red before it; this commit is the instrument that shows it red. THE CHECK CAN FAIL, AND WAS SEEN TO. `test_rehearse.py` establishes two CLEAN CONTROLS first - two transitions the shipped code gets right, on which the harness must say nothing - and then breaks the shipped migration and the entity-id composition six ways in a private copy of the tree, requiring each to turn its control from green to red WITH THE RIGHT FINDING NAMED. One mutation is required to SURVIVE, with the reason written down. The synthetic state carries maintenance history and a legacy-shaped latched `sign_tripped` with no `schema_version` beside it, because state built by the happy path cannot detect a migration that only mishandles legacy shapes. ⚠️ THE FIRST DRAFT OF THE HARNESS HAD HABIT 4 IN IT and its own battery caught it - MUTATION.md's thirteenth instance, third in an instrument. The entity diff composed both sides with the same build, so renaming the MQTT device moved every id equally and it reported "no orphans" about a change that orphans everything. `--live-entities` is the fix: a reference that does not move with the code. THE RETREAT is supported and does not corrupt state - the flat rollback view keeps the latch and the first pack's history live - but it orphans eight HA entities, and the ROUND TRIP is not supported: a maintenance cycle completed while rolled back is discarded on re-advance. Stated in DOCS.md as a decision. Fetching /data/state.json, the options blob and the entity list are OWNER STEPS and are written down. ⚠️ Nothing here was run against real state and nothing here claims to have been. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
43 lines
1.5 KiB
YAML
43 lines
1.5 KiB
YAML
# ARCH-02 fixture: THE SHAPE AFTER INVERTER B IS COMMISSIONED.
|
|
#
|
|
# ⚠️ THE FIRST ROSTER ENTRY IS **NOT** CALLED `inverter-a`, AND THAT IS THE
|
|
# WHOLE FIXTURE. `MUTATION.md` habit 4's seventh instance is MAINT-05 shipping
|
|
# `migrate_maint_keys(store, UNIT_A, ...)` under a comment saying it must not:
|
|
# every two-pack fixture in the suite named its first entry `inverter-a`, so
|
|
# `UNIT_A` and `inverters[0].name` were the same string in every check that
|
|
# existed and the defect was invisible. Names that are not `inverter-a` are what
|
|
# make a misdirected namespace something a rehearsal can SEE.
|
|
#
|
|
# `roster-inverter-a.yaml` beside this file is the same roster with the first
|
|
# entry named `inverter-a`, so the two can be compared - see DOCS.md.
|
|
setpoint_entity: number.goodwe_setpoint
|
|
soc_entity: sensor.goodwe_battery_soc_pct
|
|
batt_entity: sensor.goodwe_battery_power_w
|
|
batt_invert: false
|
|
max_w: 5000
|
|
bms_soc_floor: 10
|
|
meter_source: 'off'
|
|
meter_entity: sensor.p1_active_power
|
|
gain: 0.6
|
|
slew_w: 0
|
|
deadband_w: 15
|
|
target_grid_w: 0
|
|
step_w: 10
|
|
heartbeat_s: 10
|
|
stale_input_s: 15
|
|
maintenance_discharge_w: 2000
|
|
maintenance_charge_w: 2000
|
|
inverters:
|
|
- name: inverter-a
|
|
setpoint_entity: number.goodwe_setpoint
|
|
soc_entity: sensor.goodwe_battery_soc_pct
|
|
batt_entity: sensor.goodwe_battery_power_w
|
|
max_w: 5000
|
|
bms_soc_floor: 10
|
|
- name: inverter-b
|
|
setpoint_entity: number.goodwe_b_setpoint
|
|
soc_entity: sensor.goodwe_b_battery_soc_pct
|
|
batt_entity: sensor.goodwe_b_battery_power_w
|
|
max_w: 4600
|
|
bms_soc_floor: 10
|