Slice 92: API mapper floor dimensions (SAP +0.25m + exposed-floor + NI→None)

Three coupled API-mapper fixes that close the cert 001479 floor-W/K
gap from +4.39 to EXACT 0.

(1) Upper-floor room_height_m += 0.25 m

SAP 10.2 convention: every storey above the lowest adds 0.25 m to the
lodged room_height for the joist/floor-void contribution (cohort
Elmhurst mapper already applies this via `_UPPER_FLOOR_HEIGHT_ADD_M`
at line 2338). The API schema lodges the raw internal height; the
cascade volume computation needs the +0.25 m before computing party-
wall area and ventilation ACH. For cert 001479 Main floor=1, raw
lodge 2.28 m vs worksheet 2.53 m — without the fix, party W/K was
short by 0.87 (party_wall_length × delta_height × U).

(2) `is_exposed_floor=True` when `bp.floor_heat_loss == 1`

API integer code 1 on `floor_heat_loss` signals an exposed floor (a
bp's lowest storey hanging over an unheated space or external air).
Mirrors the cohort Elmhurst mapper's `_is_floor_exposed_to_unheated_
space` for the API path. Applied only to the lowest storey (floor==0)
per the cohort 000490/000487 fixture convention. For cert 001479
Ext2 (cantilevered upper-storey extension over external air), this
routes the cascade through Table 20's `u_exposed_floor` (U=1.20)
rather than the BS EN ISO 13370 ground-floor formula.

(3) `floor_insulation_thickness="NI" → None` for cascade default

API certs commonly lodge "NI" (no measured thickness) on floors that
aren't actually uninsulated — for newer age bands (I-M with non-zero
Table 19 defaults: 25/75/100/100/140 mm) the cascade should use the
age-band default insulation rather than treating "NI" as explicit
zero. Translate "NI" → None at the mapper boundary so `u_floor`
reaches the Table 19 fallback. For cert 001479 Ext1 (age M, suspended
timber, NI lodged) the cascade now returns U=0.20 via the age-M
140 mm default — previously gave U=1.05 from treating thickness as 0.

**Floor W/K is now EXACT for cert 001479** (23.1705 ✓).

Impact on cert 001479 API path:
  Before Slice 87: +3.0752 SAP delta
  After  Slice 90: +1.5298
  After  Slice 91: +1.0970
  After  Slice 92: +1.0022 (floor W/K exact; remaining gap is in
                            windows / gains — Slice 93)

Golden cert residual updates: 7 of 10 expectations shifted from the
floor cascade improvements (NI→None changed many certs with age I-M
extensions). Spec-compliance shifts; new residuals committed.

Pyright: mapper.py 33 → 33.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 08:09:28 +00:00
parent 2cebba28dc
commit 8e752e5720
2 changed files with 151 additions and 93 deletions

View file

@ -626,18 +626,9 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
@ -765,24 +756,28 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness, wall_insulation_thickness=bp.wall_insulation_thickness,
floor_heat_loss=bp.floor_heat_loss, floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=bp.floor_insulation_thickness, # API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
# uninsulated — for newer age bands (I-M) the
# cascade should fall back to the Table 19 age-band
# default insulation. Translate "NI" → None at the
# mapper boundary so `u_floor` reaches its age-band
# default branch instead of the "explicit zero"
# path. Cert 001479 Ext1 (age M, suspended timber,
# NI lodged) gets U=0.20 via the age-M 140 mm
# default; previously cascade returned U=1.05.
floor_insulation_thickness=(
None if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction, roof_construction=bp.roof_construction,
roof_insulation_location=bp.roof_insulation_location, roof_insulation_location=bp.roof_insulation_location,
@ -917,24 +912,28 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness, wall_insulation_thickness=bp.wall_insulation_thickness,
floor_heat_loss=bp.floor_heat_loss, floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=bp.floor_insulation_thickness, # API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
# uninsulated — for newer age bands (I-M) the
# cascade should fall back to the Table 19 age-band
# default insulation. Translate "NI" → None at the
# mapper boundary so `u_floor` reaches its age-band
# default branch instead of the "explicit zero"
# path. Cert 001479 Ext1 (age M, suspended timber,
# NI lodged) gets U=0.20 via the age-M 140 mm
# default; previously cascade returned U=1.05.
floor_insulation_thickness=(
None if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction, roof_construction=bp.roof_construction,
roof_insulation_location=bp.roof_insulation_location, roof_insulation_location=bp.roof_insulation_location,
@ -1086,24 +1085,28 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness, wall_insulation_thickness=bp.wall_insulation_thickness,
floor_heat_loss=bp.floor_heat_loss, floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=bp.floor_insulation_thickness, # API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
# uninsulated — for newer age bands (I-M) the
# cascade should fall back to the Table 19 age-band
# default insulation. Translate "NI" → None at the
# mapper boundary so `u_floor` reaches its age-band
# default branch instead of the "explicit zero"
# path. Cert 001479 Ext1 (age M, suspended timber,
# NI lodged) gets U=0.20 via the age-M 140 mm
# default; previously cascade returned U=1.05.
floor_insulation_thickness=(
None if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction, roof_construction=bp.roof_construction,
# Surface human-readable strings derived from the # Surface human-readable strings derived from the
@ -1295,24 +1298,28 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness, wall_insulation_thickness=bp.wall_insulation_thickness,
floor_heat_loss=bp.floor_heat_loss, floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=bp.floor_insulation_thickness, # API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
# uninsulated — for newer age bands (I-M) the
# cascade should fall back to the Table 19 age-band
# default insulation. Translate "NI" → None at the
# mapper boundary so `u_floor` reaches its age-band
# default branch instead of the "explicit zero"
# path. Cert 001479 Ext1 (age M, suspended timber,
# NI lodged) gets U=0.20 via the age-M 140 mm
# default; previously cascade returned U=1.05.
floor_insulation_thickness=(
None if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction, roof_construction=bp.roof_construction,
# Surface human-readable strings derived from the # Surface human-readable strings derived from the
@ -1573,24 +1580,28 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [], bp.floor_heat_loss,
room_height_m=_measurement_value(fd.room_height), ),
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
)
for fd in (bp.sap_floor_dimensions or [])
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness, wall_insulation_thickness=bp.wall_insulation_thickness,
floor_heat_loss=bp.floor_heat_loss, floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=bp.floor_insulation_thickness, # API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
# uninsulated — for newer age bands (I-M) the
# cascade should fall back to the Table 19 age-band
# default insulation. Translate "NI" → None at the
# mapper boundary so `u_floor` reaches its age-band
# default branch instead of the "explicit zero"
# path. Cert 001479 Ext1 (age M, suspended timber,
# NI lodged) gets U=0.20 via the age-M 140 mm
# default; previously cascade returned U=1.05.
floor_insulation_thickness=(
None if bp.floor_insulation_thickness == "NI"
else bp.floor_insulation_thickness
),
flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness, flat_roof_insulation_thickness=bp.flat_roof_insulation_thickness,
roof_construction=bp.roof_construction, roof_construction=bp.roof_construction,
# Surface human-readable strings derived from the # Surface human-readable strings derived from the
@ -2050,6 +2061,53 @@ def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
return _API_ROOF_CONSTRUCTION_TO_STR.get(value) if value is not None else None return _API_ROOF_CONSTRUCTION_TO_STR.get(value) if value is not None else None
# API `floor_heat_loss` integer that signals an exposed floor (lowest
# storey of a bp that hangs over an unheated space or external air).
# Cohort cert 001479 Ext2 lodges floor_heat_loss=1 on its cantilevered
# upper-storey extension; the Summary mapper sets is_exposed_floor=True
# on the lowest floor for this case (routes through Table 20's u_exposed
# _floor cascade at U=1.20 instead of BS EN ISO 13370 ground-floor).
_API_FLOOR_HEAT_LOSS_EXPOSED: Final[int] = 1
def _api_build_sap_floor_dimensions(
fds: List[Any],
floor_heat_loss: Optional[int],
) -> List[SapFloorDimension]:
"""Build per-bp `SapFloorDimension` list with the SAP10.2 conventions
the API schema doesn't lodge directly:
1. **Upper-floor room height +0.25 m** (SAP convention for the
joist/floor-void contribution; the ground floor uses the lodged
value directly). Cohort cert 001479 Main floor=1 lodges
room_height=2.28 m the worksheet uses 2.53 m. Without the
+0.25 m the cascade volume is short ventilation low
SAP overshoot.
2. **`is_exposed_floor=True`** when `bp.floor_heat_loss` == 1 (the
lowest floor of a cantilevered or over-unheated-space bp routes
through Table 20's `u_exposed_floor` cascade at U=1.20 rather
than the BS EN ISO 13370 ground-floor formula). Applied only to
the lowest storey (floor==0) per the cohort 000490/000487
fixture convention.
"""
is_exposed = floor_heat_loss == _API_FLOOR_HEAT_LOSS_EXPOSED
out: List[SapFloorDimension] = []
for fd in fds or []:
raw_height = _measurement_value(fd.room_height)
height = raw_height if fd.floor == 0 else raw_height + _UPPER_FLOOR_HEIGHT_ADD_M
out.append(SapFloorDimension(
room_height_m=height,
total_floor_area_m2=_measurement_value(fd.total_floor_area),
party_wall_length_m=_measurement_value(fd.party_wall_length),
heat_loss_perimeter_m=_measurement_value(fd.heat_loss_perimeter),
floor=fd.floor,
floor_insulation=fd.floor_insulation,
floor_construction=fd.floor_construction,
is_exposed_floor=is_exposed and fd.floor == 0,
))
return out
def _api_resolve_sloping_ceiling_thickness( def _api_resolve_sloping_ceiling_thickness(
roof_construction: Optional[int], roof_construction: Optional[int],
roof_insulation_thickness: Union[str, int, None], roof_insulation_thickness: Union[str, int, None],

View file

@ -95,8 +95,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0300-2747-7640-2526-2135", cert_number="0300-2747-7640-2526-2135",
actual_sap=78, actual_sap=78,
expected_sap_resid=+2, expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-1.9082, expected_pe_resid_kwh_per_m2=-0.9139,
expected_co2_resid_tonnes_per_yr=-1.0829, expected_co2_resid_tonnes_per_yr=-0.9974,
notes=( notes=(
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed " "Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
"(no Table 4b code). Cert lodges open_flues_count=1 + " "(no Table 4b code). Cert lodges open_flues_count=1 + "
@ -110,17 +110,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation( _GoldenExpectation(
cert_number="0390-2954-3640-2196-4175", cert_number="0390-2954-3640-2196-4175",
actual_sap=60, actual_sap=60,
expected_sap_resid=-4, expected_sap_resid=-5,
expected_pe_resid_kwh_per_m2=-31.5482, expected_pe_resid_kwh_per_m2=-28.4884,
expected_co2_resid_tonnes_per_yr=-3.0251, expected_co2_resid_tonnes_per_yr=-2.7466,
notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.", notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.",
), ),
_GoldenExpectation( _GoldenExpectation(
cert_number="6035-7729-2309-0879-2296", cert_number="6035-7729-2309-0879-2296",
actual_sap=70, actual_sap=70,
expected_sap_resid=-5, expected_sap_resid=-5,
expected_pe_resid_kwh_per_m2=+34.4963, expected_pe_resid_kwh_per_m2=+37.7305,
expected_co2_resid_tonnes_per_yr=+0.7742, expected_co2_resid_tonnes_per_yr=+0.8510,
notes=( notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"Slice 59 per-bp window apportionment tightens all 3 " "Slice 59 per-bp window apportionment tightens all 3 "
@ -133,8 +133,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="7536-3827-0600-0600-0276", cert_number="7536-3827-0600-0600-0276",
actual_sap=68, actual_sap=68,
expected_sap_resid=+2, expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-15.8298, expected_pe_resid_kwh_per_m2=-11.7633,
expected_co2_resid_tonnes_per_yr=-0.4207, expected_co2_resid_tonnes_per_yr=-0.3124,
notes=( notes=(
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, " "Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and " "Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
@ -147,8 +147,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="8135-1728-8500-0511-3296", cert_number="8135-1728-8500-0511-3296",
actual_sap=72, actual_sap=72,
expected_sap_resid=+1, expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-16.3714, expected_pe_resid_kwh_per_m2=-13.0069,
expected_co2_resid_tonnes_per_yr=-0.2836, expected_co2_resid_tonnes_per_yr=-0.2200,
notes=( notes=(
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
"blocked_chimneys_count=1. Slice 59 per-bp window apportionment " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment "
@ -159,9 +159,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation( _GoldenExpectation(
cert_number="2130-1033-4050-5007-8395", cert_number="2130-1033-4050-5007-8395",
actual_sap=82, actual_sap=82,
expected_sap_resid=+3, expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-51.0953, expected_pe_resid_kwh_per_m2=-44.8941,
expected_co2_resid_tonnes_per_yr=+0.1517, expected_co2_resid_tonnes_per_yr=+0.2250,
notes=( notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, " "End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays " "postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
@ -180,8 +180,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0390-2254-6420-2126-5561", cert_number="0390-2254-6420-2126-5561",
actual_sap=65, actual_sap=65,
expected_sap_resid=+1, expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-8.9202, expected_pe_resid_kwh_per_m2=-3.5091,
expected_co2_resid_tonnes_per_yr=-0.0942, expected_co2_resid_tonnes_per_yr=-0.0136,
notes=( notes=(
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, " "End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
"no PV, no secondary, postcode LN12 (PCDB Table 172 match). " "no PV, no secondary, postcode LN12 (PCDB Table 172 match). "