Commit graph

6247 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
3c0ac98122 feat(calculator): thread per-end-use fuel codes + PV export onto SapResult
ADR-0014 BillDerivation attributes each end-use (HEATING / HOT_WATER /
SECONDARY / APPLIANCES / COOKING) to a fuel carrier and credits PV
export. SapResult already carried the per-end-use kWh but not WHICH
fuel each end-use burns, nor the annual exported kWh — so a downstream
SapResult->EnergyBreakdown adapter could not pick the right tariff.

Surfaces five output-only fields, threaded exactly like the recently
merged appliances/cooking change (2f039aeb):
  main_heating_fuel_code      RdSAP10 Table 32 / SAP 10.2 Table 12 fuel
  main_2_heating_fuel_code    code column (the lodged fuel code, e.g.
  secondary_heating_fuel_code mains gas 26). None when the corresponding
  hot_water_fuel_code         system is absent / fuel not resolvable.
  pv_exported_kwh_per_yr      SAP 10.2 Appendix M1 §3-4 annual export kWh
                              (0.0 when no PV).

cert_to_inputs.py populates the four fuel codes from the existing
resolvers the cost/CO2 cascade already uses — `_main_fuel_code`,
`_secondary_fuel_code`, `_water_heating_fuel_code` (not reinvented);
Main 2 is the second `main_heating_details` entry, guarded for length.
There is a single CalculatorInputs construction site (cert_to_demand_
inputs delegates to cert_to_inputs). `pv_exported_kwh_per_yr` already
existed on CalculatorInputs; SapResult collapses its Optional to 0.0.

HARD CONSTRAINT honoured — output-only, zero rating drift. These fields
do NOT feed ECF / total_fuel_cost_gbp / co2_kg_per_yr / primary_energy_*
/ sap_score / any monthly value. Every golden-fixture, Elmhurst e2e
SapResult pin, section cascade pin, and heating-corpus residual stays
byte-identical: calculator suite 1658 -> 1661 passed (+3 new tests),
4 skipped, 0 failed before and after. pyright net-zero (51 -> 51 in
domain/; no new errors in the touched test files).

New tests: a synthetic threading test (four fuel codes + PV export pass
unchanged through calculate_sap_from_inputs; None PV collapses to 0.0)
and a cert-level pin (mains-gas combi cert 000516 -> main fuel code 26,
no Main 2, secondary 30, HW 26). Synthetic CalculatorInputs / SapResult
fixtures updated for the new SapResult fields (defaults cover Inputs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:59:24 +00:00
Jun-te Kim
8b9dcc73f2 fix 2026-06-05 17:24:17 +00:00
Daniel Roth
c6f94b576b remove commented out import from dockerfile 2026-06-05 16:17:08 +00:00
Daniel Roth
cf6c63f059 correct orchestrator tests 2026-06-05 16:13:04 +00:00
Daniel Roth
11fd88bcb0 rename database environment variables 2026-06-05 16:04:33 +00:00
Daniel Roth
e84de954fb define MagicPlanConfig class to get environment variables 2026-06-05 15:46:32 +00:00
Jun-te Kim
0320f240cd
Merge pull request #1184 from Hestia-Homes/feature/landlord_data
Feature/landlord data
2026-06-05 16:40:00 +01:00
Jun-te Kim
9427a6d40b description reverted 2026-06-05 15:38:36 +00:00
Jun-te Kim
411ea79b80 permissions 2026-06-05 15:37:21 +00:00
Daniel Roth
198d2afdb1 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 14:35:56 +00:00
Daniel Roth
8e349704b1 move magic plan handler to applications/ 2026-06-05 14:33:26 +00:00
Daniel Roth
641420be00 [UNRELATED] update sero address list for sharepoint file renaming 2026-06-05 14:22:09 +00:00
Jun-te Kim
6778c427bc
Merge pull request #1181 from Hestia-Homes/feature/landlord_data
property override
2026-06-05 15:16:06 +01:00
Daniel Roth
37b5a3a6e5 move domain code out of datatypes/domain 2026-06-05 14:07:28 +00:00
Daniel Roth
050f983152 Extract door height from API response into height_mm 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:53:22 +00:00
Daniel Roth
db3477d6bb Extract door height from API response into height_mm 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:49:57 +00:00
Daniel Roth
b3b4ae2191 Convert Door.width_mm to store actual millimetres (multiply size.x by 1000)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:30:54 +00:00
Daniel Roth
bffac89abb Fix pyright strict violations in mapper and test imports 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:10:59 +00:00
Daniel Roth
5797ddbda6 Persist window and door ventilation via SQLModel tables 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:06:15 +00:00
Daniel Roth
192a3cf20f Persist window and door ventilation via SQLModel tables 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 13:02:47 +00:00
Daniel Roth
0211fb8092 Migrate all MagicPlan tests to single new-format fixture 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:59:56 +00:00
Daniel Roth
8deaba1f94 Glass door ventilation carries opening_type from custom_displayable_fields 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:54:58 +00:00
Daniel Roth
45925d48eb Reclassify doorglass wall items as Window domain objects 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:54:04 +00:00
Daniel Roth
cdefa65887 Reclassify doorglass wall items as Window domain objects 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:53:45 +00:00
Daniel Roth
1dd3baeac5 Map door custom_displayable_fields to DoorVentilation 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:52:36 +00:00
Daniel Roth
06816c3f9c Map door custom_displayable_fields to DoorVentilation 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:51:57 +00:00
Daniel Roth
cda2091e23 Window with no custom_displayable_fields yields ventilation=None 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:51:28 +00:00
Daniel Roth
9a3987b4f1 Map window custom_displayable_fields to WindowVentilation 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:49:52 +00:00
Daniel Roth
10bbf0bb60 Map window custom_displayable_fields to WindowVentilation 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:47:50 +00:00
Jun-te Kim
b07db1ef6b property override 2026-06-05 12:18:13 +00:00
Daniel Roth
5a582bbff0 Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 11:01:28 +00:00
KhalimCK
3bdfa0287c
Merge pull request #1169 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-05 11:50:11 +01:00
Khalim Conn-Kowlessar
a6b798218f fix: normalize empty API sap_roof_windows to None for round-trip fidelity
`test_epc_property_data_round_trips[RdSAP-Schema-21.0.1]` failed with
`sap_roof_windows: None != []` — a normalization mismatch, not lost data.
The 21.0.1 fixture has no roof windows, but the 21.0.1 API mapper emitted
an empty list `[]` while the domain field defaults to None
(`Optional[List[SapRoofWindow]] = None`), the 21.0.0 path yields None, and
the persistence reload yields None (roof windows aren't stored yet — doc
§2.4). Append `or None` so "no roof windows" has one canonical
representation across mapper paths and the round-trip.

No data-loss change: a cert WITH roof windows still produces the
populated list (test_golden_fixtures pins a 6-roof-window cert), and the
§2.4 roof-window persistence gap remains separately tracked. Full
sap10_calculator + documents_parser + epc-repository suites pass (2420);
pyright unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:45:44 +00:00
Daniel Roth
cfdd5a146a
Merge pull request #1179 from Hestia-Homes/increase-rds-storage
Increase size of RDS from 20GB to 50GB
2026-06-05 11:40:18 +01:00
Daniel Roth
47dd6ca20e postgres go bigger 2026-06-05 10:39:05 +00:00
Khalim Conn-Kowlessar
5597a8b87e review: bind test inputs in Arrange for the wall-insulation-thickness tests
PR feedback (dancafc): the `_api_resolve_wall_insulation_thickness` tests
passed literals straight into the Act call. Bind them as named variables
in Arrange (`lodged_thickness`, `measured_value_mm`, `ni_lodgement`) and
have the asserts reference those names, so the Act line reads
declaratively and the inputs/expectations are stated once. Applied to all
three tests in the class. No behaviour change; tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:26:17 +00:00
Khalim Conn-Kowlessar
86b875af35 review: clearer room-in-roof area variable names in heat_transmission
PR feedback (dancafc): the simplified room-in-roof branch used cryptic
locals. Rename for clarity (behaviour-unchanged; the geom dict keys and
the builder-function locals are untouched):

  rr_a_rr      -> rr_roof_area          (the worksheet's simplified A_RR)
  rr_common    -> rr_common_wall_area
  rr_gable     -> rr_gable_area
  a_rr_final   -> rr_residual_roof_area  (leftover roof-going area after
                                          deducting perimeter walls/gables
                                          /rooflights — takes the roof U)

Names now mirror the `rr_*_area_m2` geom keys they read from and say
"area of what". Added a one-line note that `rr_roof_area` is the RdSAP 10
§3.10.1 A_RR. Pyright unchanged; 1087 heat-transmission/cascade-pin tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:18:58 +00:00
Daniel Roth
ebd6f1623f Merge branch 'main' into feature/handle-new-magicplan-response-structure 2026-06-05 10:16:14 +00:00
Khalim Conn-Kowlessar
77f90e144e review: store epc_building_part.wall_insulation_thickness as JSONB
PR feedback (dancafc): the SQLModel column was Optional[str], but the
domain `SapBuildingPart.wall_insulation_thickness` is Optional[Union[str,
int]] — `_api_resolve_wall_insulation_thickness` returns an int mm when the
API lodges `wall_insulation_thickness == "measured"` (SAP 10.2 §5.7 /
Table 8). The plain str column round-trips that int back as the string
"100", corrupting the Table 8 insulated-wall U-value lookup.

This column was missed in the round-trip-fidelity §1 JSONB sweep
(#1129) — its `Union[str, int]` sibling `roof_insulation_thickness` was
converted, but `wall_insulation_thickness` was not, and no 21.0.0/21.0.1
fixture lodges "measured" so the gap stayed latent. Convert to JSONB
(matching `roof_insulation_thickness` / `flat_roof_insulation_thickness`),
align the column type to Optional[Union[str, int]] (also removes a pyright
type-mismatch), record it in the migration doc §1, and add a round-trip
guard test asserting an int survives as an int (fails as '100' == 100 on
the old str column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:07:24 +00:00
Khalim Conn-Kowlessar
c882cb2c95 review: typehint Optional locals around _parse_thickness_mm call sites
PR feedback (dancafc): `_parse_thickness_mm` handles a None input and
returns Optional[int], so its call-return locals — and the Optional[str]
raws they read from `_local_val` — read clearer when annotated. Annotates
`thickness_raw`/`ins_thickness_raw: Optional[str]` and
`thickness_mm`/`insulation_thickness_mm: Optional[int]` at all four call
sites (_wall_details_from_lines, _alternative_walls_from_lines,
_roof_details_from_lines, _floor_details_from_lines), plus the adjacent
`u_val_raw`/`default_u` Optional pair in _floor_details_from_lines for
consistency. Matches the project convention of typehinting call-return
locals. No behaviour change; pyright clean, 569 parser tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:56:06 +00:00
Khalim Conn-Kowlessar
218840db98 docs: handover for the open window-extraction work on the double_glazing fixture
Captures the diagnosis so the next agent doesn't re-derive it: what's done
(S0380.235-237), what's confirmed correct (calculator U-adjustment, party
wall, glazing labels), the worksheet pin targets, and the two open causes —
crucially the 000516 trap (byte-identical Summary data classified as a roof
window there but a wall window here, so flipping the U>3 rule regresses
000516). Includes a rebuildable tracer recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:47:29 +00:00
Khalim Conn-Kowlessar
14a27c7a61 Merge branch 'feature/per-cert-mapper-validation' of https://github.com/Hestia-Homes/Model into feature/per-cert-mapper-validation
# Conflicts:
#	backend/documents_parser/tests/test_summary_pdf_mapper_chain.py
#	tests/domain/sap10_calculator/worksheet/test_heat_transmission.py
2026-06-05 09:47:29 +00:00
Khalim Conn-Kowlessar
8133521c43 S0380.237: map "Secondary glazing - Low emissivity" → SAP 10.2 code 12
Completes the secondary-glazing family. S0380.235 mapped the unknown-data
(7) and normal-emissivity (11) secondary variants; the RdSAP-21.0.1
`glazed_type` enum also defines code 12 "secondary glazing, low
emissivity", whose Elmhurst §11 label "Secondary glazing - Low
emissivity" was unmapped and would strict-raise. Cascade code 12 carries
the same daylight/solar bucket as 7/11 (g_L=0.80, g⊥=0.76); the lodged
manufacturer U/g drive §3/§6. With this the double family (codes 1/2/3/
7/13 via their Elmhurst phrasings) and the secondary family (4/11/12) are
fully covered. Coverage test extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:35:35 +00:00
Khalim Conn-Kowlessar
ea35bed24c S0380.236: extension party-wall type read independently of "As Main Wall"
RdSAP 10 §3.3: "As Main Wall: Yes" makes an extension inherit the main
dwelling's external wall CONSTRUCTION only — the party wall type is
lodged separately per building part in the Summary §7 block and may
differ. `_extract_extensions` was copying `main_walls.party_wall_type`
into the inherited WallDetails, so every extension reused the main's
party wall U.

On the double_glazing fixture (Summary_001431) the Main lodges party
"CU Cavity masonry unfilled" (SAP10 wall_construction 4 → u_party_wall
0.5) but the 1st Extension lodges "U Unable to determine" (→ 0 → RdSAP
default 0.25). Pre-fix both building parts used 0.5, inflating worksheet
(32) party-wall heat loss by 6.56 W/K (Ext1 26.25 m² × 0.25). After the
fix worksheet (32) is exact: ours 32.573 vs worksheet 32.5725.

Now reads the extension's own "Party Wall Type" from its §7 chunk,
falling back to the main's only when the extension lodges none. Adds a
fixture + test asserting Main=4 / Ext=0 with distinct u_party_wall.
Suite 2413 pass; no cohort regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:19:43 +00:00
Khalim Conn-Kowlessar
3e45b7fa3b S0380.235: map the remaining Elmhurst §11 glazing labels to SAP 10.2 Table 6b
The double_glazing recommendation fixture (Summary_001431) exercises every
RdSAP-21 §11 glazing lodging in one cert; five labels were missing from
`_ELMHURST_GLAZING_LABEL_TO_SAP10` and strict-raised `UnmappedElmhurstLabel`:

  "Secondary glazing"                     -> 7   (Table 6b "secondary glazing", g_L 0.80)
  "Secondary glazing - Normal emissivity" -> 11  (RdSAP-21 secondary normal-E, g_L 0.80)
  "Triple pre 2002"                       -> 10  (triple pre-2002, g_L 0.70)
  "Triple with unknown install date"      -> 6   (generic triple glazed, g_L 0.70)
  "Single glazing, known data"            -> 15  (single known-data, g_L 0.90)

The glazing code's only cascade effect is the §5 (66)..(67) daylight factor
g_L in `_G_LIGHT_BY_GLAZING_CODE` (single 0.90 / double+secondary 0.80 /
triple 0.70); the lodged manufacturer U-value and solar_transmittance drive
§3 / §6 directly (`_g_perpendicular` prefers the lodged value). Codes are the
semantically-exact RdSAP-21 rows within the correct g_L bucket, kept distinct
for the strict-raise audit trail. Adds a full-coverage test over all 13
distinct labels. Suite 2413 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:15:11 +00:00
Khalim Conn-Kowlessar
6d4fa5dd3b docs: handover — fold in S0380.232-234 (case-19 PV closure) + open causes
Records the three PV slices shipped (D_PV off-peak exclusion, weighted
dwelling import price, Appendix G4 diverter), the resulting case-19 state
(SAP 50.33→51.34, rounds to lodged 51), and the two remaining case-19
causes (winter Appendix-M EPV monthly shape; fabric (33) +1.0). Adds the
`2100-5421` worst-offender diagnosis (a 352 m² uninsulated solid-wall
dwelling on the as-built-insulated-assumed roof-U front, not a flats bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:06:20 +00:00
Khalim Conn-Kowlessar
9521d52403 S0380.234: PV diverter (Appendix G4) — diverts surplus PV to the cylinder
SAP 10.2 Appendix G4 (PDF p.72-73). A PV diverter routes surplus PV
generation (the would-be export EPV,m × (1 − βm)) to an immersion heater
in the hot-water cylinder. Per G4 step 4:

    SPV,diverter,m = EPV,m × (1 − βm) × 0.8 × fPV,diverter,storageloss

(0.8 = cylinder heat-acceptance; fPV,diverter,storageloss = 0.9 for the
higher storage temperature), clamped to ≤ (62)m + (63a)m, and entered as
the negative worksheet (63b)m (step 5). The β factor is computed on the
PRE-diverter (219) per the §3a note (lines 5485-5486). Effects:
  - (64)m = (62)m + (63b)m → less main-system water-heating fuel (219);
  - export drops to EPV,ex,m = EPV,m(1 − βm) + (63b)m / 0.9 (§4 p.94
    line 5501); the onsite dwelling portion EPV,m × βm is unchanged.

Inclusion (G4 step 1) requires ALL of: a PV system connected to the
dwelling; a cylinder larger than (43) average daily HW use; no solar
water heating; no battery — else the diverter is disregarded.

Three layers:
  - extractor reads Summary §19 "Diverter present"; schema 21.0.0/21.0.1
    SapEnergySource gains `pv_diverter` (API `sap_energy_source.pv_diverter`);
  - `Renewables.pv_diverter_present` + domain `SapEnergySource.pv_diverter_present`,
    set in both the Elmhurst and API mapper paths;
  - `_pv_diverter_monthly_kwh` applies the G4 math after the β split;
    `cert_to_inputs` recomputes (219) and the PV export.

On simulated case 19 (electric storage heaters, 7-hour, PV + diverter):
SAP continuous 50.33 → 51.34 (worksheet 51.2221; both round to the
lodged 51), cost (255) 1847.5 → 1812.3 (ws 1816.6), CO2 (272) 3331 →
3120 (ws 3126), with (233a) dwelling 1280.6 (ws 1280.4). The residual
+0.11 SAP is an upstream winter Appendix-M monthly-EPV-shape gap +
fabric (33) +1.0, tracked as the next case-19 cause. Suite: 2412 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:59:12 +00:00
Khalim Conn-Kowlessar
d4a8c02b54 S0380.233: PV self-consumption credited at Table 12a weighted import rate
SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): "apply the normal
import electricity price to PV energy used within the dwelling and the
'electricity sold to grid, PV' price from Table 12 to the energy
exported. In the case of the former, use a weighted average of high and
low rates (Table 12a)."

`_pv_dwelling_import_price_gbp_per_kwh` was returning the bare off-peak
LOW rate (5.50 p/kWh on a 7-hour tariff) for the PV-used-in-dwelling
credit. PV self-consumption displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), which on an off-peak tariff
bills at the Table 12a Grid 2 ALL_OTHER_USES weighted blend, not the low
rate. On simulated case 19 the worksheet (252)/(269) credits
PV-used-in-dwelling at 14.3110 p/kWh = 0.90 × 15.29 + 0.10 × 5.50; we
credited it at 5.50, under-crediting onsite PV by ~£0.088/kWh on every
off-peak PV cert.

Fix delegates to `_other_fuel_cost_gbp_per_kwh(tariff, prices)` (the same
ALL_OTHER_USES rate): STANDARD tariff still returns the flat Table 32
code 30 13.19 p/kWh (golden cohort unchanged — all 2412 tests pass);
off-peak returns the weighted high/low blend. Call sites now pass the
resolved `_rdsap_tariff(epc)`. The now-unused
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic` (its only caller)
is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:57:40 +00:00
Khalim Conn-Kowlessar
212b0c92ab S0380.232: D_PV excludes low-rate portion of off-peak electric main heating
SAP 10.2 Appendix M1 §3a (PDF p.93, lines 5470-5476): "E_space,m =
(211)m + (213)m + (215)m, where (211), (213) and/or (215) should be
included only where the fuel code applied to them in Section 10a of the
SAP worksheet is 30, 32, 34, 35 or 38 (i.e. electricity not at the
low-rate)."

The PV-eligible demand D_PV,m was adding 100% of the main space-heating
fuel (211)m whenever the main's Table-12 code was in the eligible set
(30, …), ignoring the off-peak high/low split that §10a already bills
via `_space_heating_fuel_cost_gbp_per_kwh`. Electric STORAGE heaters on
a 7-hour tariff are charged wholly at the low rate (Table 12a Grid 1 SH
fraction 0.00; worksheet (240) high-rate cost = 0), so none of (211)
may enter D_PV — but the cascade counted it all, inflating R_PV,m =
E_PV,m / D_PV,m and therefore the β onsite-PV split in the heating
months.

Fix mirrors the cost-side rate split: `_main_space_heating_high_rate_
fraction(main, tariff)` returns the high-rate portion (1.0 for
non-electric / STANDARD, the published Grid 1 SH fraction otherwise,
0.0 when the Grid 1 SH row is unwired → 100% low rate), and
`_pv_eligible_demand_monthly_kwh` scales the (211)m contribution by it.
Backward-compatible: STANDARD-tariff electric mains and the gas-main /
electric-secondary PV cohort are unchanged (fraction 1.0).

On simulated case 19 (electric storage heaters, 7-hour, PV) this takes
β_Jan 0.894 → 0.792, matching the worksheet 0.791, and the summer months
(no main heating) already pinned exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:07:04 +00:00
Khalim Conn-Kowlessar
2a29b29aa5 docs: handover — fold in S0380.227-231 + mapper parity work; refresh error landscape + worksheet asks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:23:07 +00:00