canmet-btap 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. canmet_btap-0.2.1/PKG-INFO +72 -0
  2. canmet_btap-0.2.1/README.md +60 -0
  3. canmet_btap-0.2.1/btap/__init__.py +9 -0
  4. canmet_btap-0.2.1/btap/_compat.py +195 -0
  5. canmet_btap-0.2.1/btap/_sdk.py +56 -0
  6. canmet_btap-0.2.1/btap/audit/__init__.py +11 -0
  7. canmet_btap-0.2.1/btap/audit/coverage.py +73 -0
  8. canmet_btap-0.2.1/btap/audit/log.py +123 -0
  9. canmet_btap-0.2.1/btap/costing/__init__.py +14 -0
  10. canmet_btap-0.2.1/btap/costing/data/costs.csv +1969 -0
  11. canmet_btap-0.2.1/btap/costing/data/costs_local_factors.csv +2315 -0
  12. canmet_btap-0.2.1/btap/costing/data/envelope/README.md +30 -0
  13. canmet_btap-0.2.1/btap/costing/data/envelope/constructions.json +1337 -0
  14. canmet_btap-0.2.1/btap/costing/data/envelope/materials_glazing.csv +61 -0
  15. canmet_btap-0.2.1/btap/costing/data/envelope/materials_opaque.csv +214 -0
  16. canmet_btap-0.2.1/btap/costing/data/envelope/thermal_bridging.csv +71 -0
  17. canmet_btap-0.2.1/btap/costing/data/hvac/README.md +19 -0
  18. canmet_btap-0.2.1/btap/costing/data/hvac/hvac_vent_ahu.csv +925 -0
  19. canmet_btap-0.2.1/btap/costing/data/hvac/materials_hvac.csv +1686 -0
  20. canmet_btap-0.2.1/btap/costing/data/hvac/mech_sizing.json +502 -0
  21. canmet_btap-0.2.1/btap/costing/data/lighting/README.md +23 -0
  22. canmet_btap-0.2.1/btap/costing/data/lighting/lighting.csv +364 -0
  23. canmet_btap-0.2.1/btap/costing/data/lighting/lighting_sets.csv +2667 -0
  24. canmet_btap-0.2.1/btap/costing/data/lighting/materials_lighting.csv +267 -0
  25. canmet_btap-0.2.1/btap/costing/data/locations.csv +75 -0
  26. canmet_btap-0.2.1/btap/costing/envelope/__init__.py +12 -0
  27. canmet_btap-0.2.1/btap/costing/envelope/assemblies.py +103 -0
  28. canmet_btap-0.2.1/btap/costing/envelope/database.py +203 -0
  29. canmet_btap-0.2.1/btap/costing/envelope/envelope_costs.py +237 -0
  30. canmet_btap-0.2.1/btap/costing/envelope/interpolate.py +93 -0
  31. canmet_btap-0.2.1/btap/costing/envelope/quantify.py +177 -0
  32. canmet_btap-0.2.1/btap/costing/envelope/report.py +91 -0
  33. canmet_btap-0.2.1/btap/costing/envelope/thermal_bridging_costs.py +161 -0
  34. canmet_btap-0.2.1/btap/costing/hvac/__init__.py +0 -0
  35. canmet_btap-0.2.1/btap/costing/hvac/database.py +202 -0
  36. canmet_btap-0.2.1/btap/costing/hvac/geometry.py +366 -0
  37. canmet_btap-0.2.1/btap/costing/hvac/ledger.py +78 -0
  38. canmet_btap-0.2.1/btap/costing/hvac/quantify_equipment.py +546 -0
  39. canmet_btap-0.2.1/btap/costing/hvac/report.py +168 -0
  40. canmet_btap-0.2.1/btap/costing/hvac/ventilation.py +892 -0
  41. canmet_btap-0.2.1/btap/costing/lighting/__init__.py +0 -0
  42. canmet_btap-0.2.1/btap/costing/lighting/database.py +150 -0
  43. canmet_btap-0.2.1/btap/costing/lighting/fixtures.py +338 -0
  44. canmet_btap-0.2.1/btap/costing/lighting/report.py +54 -0
  45. canmet_btap-0.2.1/btap/costing/shw.py +214 -0
  46. canmet_btap-0.2.1/btap/modeling/__init__.py +430 -0
  47. canmet_btap-0.2.1/btap/modeling/envelope/__init__.py +0 -0
  48. canmet_btap-0.2.1/btap/modeling/envelope/constructions.py +254 -0
  49. canmet_btap-0.2.1/btap/modeling/envelope/geometry.py +91 -0
  50. canmet_btap-0.2.1/btap/modeling/geometry/__init__.py +0 -0
  51. canmet_btap-0.2.1/btap/modeling/geometry/bar.py +1466 -0
  52. canmet_btap-0.2.1/btap/modeling/geometry/footprint.py +705 -0
  53. canmet_btap-0.2.1/btap/modeling/geometry/helpers.py +61 -0
  54. canmet_btap-0.2.1/btap/modeling/geometry/plan.py +318 -0
  55. canmet_btap-0.2.1/btap/modeling/geometry/plan_query.py +241 -0
  56. canmet_btap-0.2.1/btap/modeling/geometry/plan_svg.py +313 -0
  57. canmet_btap-0.2.1/btap/modeling/geometry/render.py +250 -0
  58. canmet_btap-0.2.1/btap/modeling/geometry/render_worker.py +63 -0
  59. canmet_btap-0.2.1/btap/modeling/geometry/wizards.py +1784 -0
  60. canmet_btap-0.2.1/btap/modeling/hvac/__init__.py +0 -0
  61. canmet_btap-0.2.1/btap/modeling/hvac/builder.py +130 -0
  62. canmet_btap-0.2.1/btap/modeling/hvac/canonical.py +154 -0
  63. canmet_btap-0.2.1/btap/modeling/hvac/catalog.py +104 -0
  64. canmet_btap-0.2.1/btap/modeling/hvac/catalog_icons.py +138 -0
  65. canmet_btap-0.2.1/btap/modeling/hvac/catalog_report.py +1886 -0
  66. canmet_btap-0.2.1/btap/modeling/hvac/classify.py +854 -0
  67. canmet_btap-0.2.1/btap/modeling/hvac/components/__init__.py +0 -0
  68. canmet_btap-0.2.1/btap/modeling/hvac/components/coils.py +324 -0
  69. canmet_btap-0.2.1/btap/modeling/hvac/components/curves.py +152 -0
  70. canmet_btap-0.2.1/btap/modeling/hvac/components/ecm_air.py +270 -0
  71. canmet_btap-0.2.1/btap/modeling/hvac/components/schedules.py +73 -0
  72. canmet_btap-0.2.1/btap/modeling/hvac/data/5ZoneNoHVAC.osm +13395 -0
  73. canmet_btap-0.2.1/btap/modeling/hvac/data/curves.json +194 -0
  74. canmet_btap-0.2.1/btap/modeling/hvac/data/sizing.json +225 -0
  75. canmet_btap-0.2.1/btap/modeling/hvac/data/systems.json +1155 -0
  76. canmet_btap-0.2.1/btap/modeling/hvac/naming.py +110 -0
  77. canmet_btap-0.2.1/btap/modeling/hvac/systems/__init__.py +0 -0
  78. canmet_btap-0.2.1/btap/modeling/hvac/systems/ashp_baseboard.py +69 -0
  79. canmet_btap-0.2.1/btap/modeling/hvac/systems/base_system.py +140 -0
  80. canmet_btap-0.2.1/btap/modeling/hvac/systems/baseboards.py +33 -0
  81. canmet_btap-0.2.1/btap/modeling/hvac/systems/baseboards_only.py +18 -0
  82. canmet_btap-0.2.1/btap/modeling/hvac/systems/doas.py +49 -0
  83. canmet_btap-0.2.1/btap/modeling/hvac/systems/doas_pthp.py +121 -0
  84. canmet_btap-0.2.1/btap/modeling/hvac/systems/doas_vrf.py +53 -0
  85. canmet_btap-0.2.1/btap/modeling/hvac/systems/evap_cooler.py +60 -0
  86. canmet_btap-0.2.1/btap/modeling/hvac/systems/fan_coils.py +160 -0
  87. canmet_btap-0.2.1/btap/modeling/hvac/systems/furnace.py +50 -0
  88. canmet_btap-0.2.1/btap/modeling/hvac/systems/hp_plant_fancoils.py +241 -0
  89. canmet_btap-0.2.1/btap/modeling/hvac/systems/mau_ptac.py +129 -0
  90. canmet_btap-0.2.1/btap/modeling/hvac/systems/plant_loops.py +221 -0
  91. canmet_btap-0.2.1/btap/modeling/hvac/systems/psz.py +219 -0
  92. canmet_btap-0.2.1/btap/modeling/hvac/systems/unit_heaters.py +31 -0
  93. canmet_btap-0.2.1/btap/modeling/hvac/systems/vav_reheat.py +206 -0
  94. canmet_btap-0.2.1/btap/modeling/hvac/systems/vrf.py +26 -0
  95. canmet_btap-0.2.1/btap/modeling/hvac/systems/wshp.py +115 -0
  96. canmet_btap-0.2.1/btap/modeling/hvac/systems/zone_ervs.py +33 -0
  97. canmet_btap-0.2.1/btap/modeling/hvac/systems/zone_terminal.py +122 -0
  98. canmet_btap-0.2.1/btap/modeling/hvac/teardown.py +97 -0
  99. canmet_btap-0.2.1/btap/modeling/hvac/validation.py +40 -0
  100. canmet_btap-0.2.1/btap/necb/__init__.py +31 -0
  101. canmet_btap-0.2.1/btap/necb/cli.py +740 -0
  102. canmet_btap-0.2.1/btap/necb/compliance.py +1538 -0
  103. canmet_btap-0.2.1/btap/necb/data/decisions.json +801 -0
  104. canmet_btap-0.2.1/btap/necb/data/eui_targets_2025.json +37 -0
  105. canmet_btap-0.2.1/btap/necb/data/ghg_factors_2025.json +69 -0
  106. canmet_btap-0.2.1/btap/necb/data/necb_rules_2020.json +122 -0
  107. canmet_btap-0.2.1/btap/necb/data/necb_rules_2025.json +152 -0
  108. canmet_btap-0.2.1/btap/necb/decisions.py +78 -0
  109. canmet_btap-0.2.1/btap/necb/envelope/__init__.py +108 -0
  110. canmet_btap-0.2.1/btap/necb/envelope/climate.py +116 -0
  111. canmet_btap-0.2.1/btap/necb/envelope/data/README.md +41 -0
  112. canmet_btap-0.2.1/btap/necb/envelope/data/envelope_rules_2020.json +310 -0
  113. canmet_btap-0.2.1/btap/necb/envelope/data/envelope_rules_2025.json +311 -0
  114. canmet_btap-0.2.1/btap/necb/envelope/data/table_c1.json +11552 -0
  115. canmet_btap-0.2.1/btap/necb/envelope/fenestration.py +84 -0
  116. canmet_btap-0.2.1/btap/necb/envelope/prescriptive.py +409 -0
  117. canmet_btap-0.2.1/btap/necb/envelope/reference.py +468 -0
  118. canmet_btap-0.2.1/btap/necb/envelope/rules.py +107 -0
  119. canmet_btap-0.2.1/btap/necb/envelope/thermal_bridging.py +193 -0
  120. canmet_btap-0.2.1/btap/necb/eui_archetypes.py +690 -0
  121. canmet_btap-0.2.1/btap/necb/hvac/__init__.py +53 -0
  122. canmet_btap-0.2.1/btap/necb/hvac/checker.py +243 -0
  123. canmet_btap-0.2.1/btap/necb/hvac/data/README.md +47 -0
  124. canmet_btap-0.2.1/btap/necb/hvac/data/efficiencies_2020.json +2738 -0
  125. canmet_btap-0.2.1/btap/necb/hvac/data/efficiencies_2025.json +2839 -0
  126. canmet_btap-0.2.1/btap/necb/hvac/data/reference_rules_2020.json +1155 -0
  127. canmet_btap-0.2.1/btap/necb/hvac/data/reference_rules_2025.json +1157 -0
  128. canmet_btap-0.2.1/btap/necb/hvac/efficiency.py +1609 -0
  129. canmet_btap-0.2.1/btap/necb/hvac/energy_recovery.py +197 -0
  130. canmet_btap-0.2.1/btap/necb/hvac/reference.py +1689 -0
  131. canmet_btap-0.2.1/btap/necb/lighting/__init__.py +127 -0
  132. canmet_btap-0.2.1/btap/necb/lighting/_legacy_2011.py +288 -0
  133. canmet_btap-0.2.1/btap/necb/lighting/apply_lights.py +422 -0
  134. canmet_btap-0.2.1/btap/necb/lighting/data/README.md +81 -0
  135. canmet_btap-0.2.1/btap/necb/lighting/data/daylighting_controls_4_2_1_6.json +805 -0
  136. canmet_btap-0.2.1/btap/necb/lighting/data/exterior_lighting_2020.json +62 -0
  137. canmet_btap-0.2.1/btap/necb/lighting/data/led_lighting_2020.json +2781 -0
  138. canmet_btap-0.2.1/btap/necb/lighting/data/lighting_rules_2020.json +162 -0
  139. canmet_btap-0.2.1/btap/necb/lighting/data/lighting_rules_2025.json +166 -0
  140. canmet_btap-0.2.1/btap/necb/lighting/data/lpd_building_types_2025.json +136 -0
  141. canmet_btap-0.2.1/btap/necb/lighting/data/lpd_space_functions_2025.json +1291 -0
  142. canmet_btap-0.2.1/btap/necb/lighting/daylight_control_requirement.py +430 -0
  143. canmet_btap-0.2.1/btap/necb/lighting/daylighted_areas.py +448 -0
  144. canmet_btap-0.2.1/btap/necb/lighting/daylighting.py +513 -0
  145. canmet_btap-0.2.1/btap/necb/lighting/exterior.py +103 -0
  146. canmet_btap-0.2.1/btap/necb/lighting/reference.py +103 -0
  147. canmet_btap-0.2.1/btap/necb/lighting/reference_daylighting.py +152 -0
  148. canmet_btap-0.2.1/btap/necb/lighting/storage_garage/__init__.py +297 -0
  149. canmet_btap-0.2.1/btap/necb/lighting/storage_garage/perimeter.py +233 -0
  150. canmet_btap-0.2.1/btap/necb/lighting/storage_garage/schedules.py +219 -0
  151. canmet_btap-0.2.1/btap/necb/loads/__init__.py +72 -0
  152. canmet_btap-0.2.1/btap/necb/loads/apply.py +429 -0
  153. canmet_btap-0.2.1/btap/necb/loads/data/README.md +43 -0
  154. canmet_btap-0.2.1/btap/necb/loads/data/loads_rules_2020.json +99 -0
  155. canmet_btap-0.2.1/btap/necb/loads/data/loads_rules_2025.json +100 -0
  156. canmet_btap-0.2.1/btap/necb/loads/data/schedules_2020.json +8442 -0
  157. canmet_btap-0.2.1/btap/necb/loads/data/space_types_2020.json +25277 -0
  158. canmet_btap-0.2.1/btap/necb/loads/schedules.py +129 -0
  159. canmet_btap-0.2.1/btap/necb/loads/space_types.py +41 -0
  160. canmet_btap-0.2.1/btap/necb/report/__init__.py +121 -0
  161. canmet_btap-0.2.1/btap/necb/report/charts.py +84 -0
  162. canmet_btap-0.2.1/btap/necb/report/checklist.py +144 -0
  163. canmet_btap-0.2.1/btap/necb/report/html.py +184 -0
  164. canmet_btap-0.2.1/btap/necb/report/model_query.py +128 -0
  165. canmet_btap-0.2.1/btap/necb/report/sections.py +781 -0
  166. canmet_btap-0.2.1/btap/necb/report/svg.py +60 -0
  167. canmet_btap-0.2.1/btap/necb/shw/__init__.py +60 -0
  168. canmet_btap-0.2.1/btap/necb/shw/data/README.md +30 -0
  169. canmet_btap-0.2.1/btap/necb/shw/data/shw_rules_2020.json +257 -0
  170. canmet_btap-0.2.1/btap/necb/shw/data/shw_rules_2025.json +265 -0
  171. canmet_btap-0.2.1/btap/necb/shw/demand.py +317 -0
  172. canmet_btap-0.2.1/btap/necb/shw/efficiency.py +403 -0
  173. canmet_btap-0.2.1/btap/necb/shw/prescriptive.py +130 -0
  174. canmet_btap-0.2.1/btap/necb/shw/reference.py +37 -0
  175. canmet_btap-0.2.1/btap/necb/tiers.py +143 -0
  176. canmet_btap-0.2.1/btap/simulation/__init__.py +59 -0
  177. canmet_btap-0.2.1/btap/simulation/backends.py +364 -0
  178. canmet_btap-0.2.1/btap/simulation/engine.py +332 -0
  179. canmet_btap-0.2.1/btap/simulation/runner.py +284 -0
  180. canmet_btap-0.2.1/canmet_btap.egg-info/PKG-INFO +72 -0
  181. canmet_btap-0.2.1/canmet_btap.egg-info/SOURCES.txt +191 -0
  182. canmet_btap-0.2.1/canmet_btap.egg-info/dependency_links.txt +1 -0
  183. canmet_btap-0.2.1/canmet_btap.egg-info/entry_points.txt +2 -0
  184. canmet_btap-0.2.1/canmet_btap.egg-info/requires.txt +7 -0
  185. canmet_btap-0.2.1/canmet_btap.egg-info/top_level.txt +1 -0
  186. canmet_btap-0.2.1/pyproject.toml +112 -0
  187. canmet_btap-0.2.1/setup.cfg +4 -0
  188. canmet_btap-0.2.1/tests/test_compat.py +166 -0
  189. canmet_btap-0.2.1/tests/test_fixture_drift.py +178 -0
  190. canmet_btap-0.2.1/tests/test_inventory_validation.py +57 -0
  191. canmet_btap-0.2.1/tests/test_request_manifest.py +137 -0
  192. canmet_btap-0.2.1/tests/test_sdk_invariants.py +105 -0
  193. canmet_btap-0.2.1/tests/test_self_containment.py +368 -0
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: canmet-btap
3
+ Version: 0.2.1
4
+ Summary: NECB 2020/2025 Part 8 performance-path compliance toolchain (Python port of the btap gem family)
5
+ License: LGPL-3.0-or-later
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: openstudio~=3.11.0
9
+ Requires-Dist: canmet-energyplus==25.2.0.2; sys_platform == "win32" or (sys_platform == "linux" and platform_machine == "x86_64")
10
+ Provides-Extra: tbd
11
+ Requires-Dist: canmet-tbd==3.5.2; extra == "tbd"
12
+
13
+ # btap (Python)
14
+
15
+ The Python port of the btap gem family — one distribution, five subpackages
16
+ mirroring the gems (`btap.audit`, `btap.simulation`, `btap.modeling`,
17
+ `btap.costing`, `btap.necb`), same one-way dependency direction (D-77).
18
+ The port is COMPLETE (M0–M8, 2026-08-28; the record is `../PORT_STATUS.md`
19
+ and D-79), verified three ways: every Ruby suite ported, the Leg-C oracle
20
+ goldens consumed (frozen AND live), and the Leg-B corpus diff holding the
21
+ Ruby and Python `btap-compliance` CLIs equivalent at the `none`, `sizing`
22
+ and `annual --quick` tiers.
23
+
24
+ Since R3 (D-81) **this implementation is primary and canonical**, and
25
+ since the R4 handoff (D-82) it is the only one that changes: Ruby
26
+ backports have stopped. Behaviour changes land here with a re-frozen
27
+ scenario baseline in the same PR (`verification/scenarios/freeze.py`);
28
+ the frozen suite — not a live Ruby comparison — is the regression net.
29
+
30
+ ```bash
31
+ cd python && python3 -m unittest discover tests # stdlib-only, no install needed (serial)
32
+ cd python && .venv/bin/pytest -n auto tests/ # parallel (pytest-xdist worker
33
+ # processes; ~10x on the E+ suites)
34
+ ```
35
+
36
+ `btap/_compat.py` holds the cross-cutting Ruby-parity contracts (round-half-
37
+ away rounding, SDK Optional unwrapping, the NullAudit, determinism-critical
38
+ sorting, HTML escaping). New code uses those helpers, never the raw Python
39
+ equivalents — the differences they paper over are exactly the silent
40
+ Ruby-vs-Python drifts the census identified.
41
+
42
+ ## The CLI (M6)
43
+
44
+ The umbrella pipeline and its command line are ported:
45
+
46
+ ```bash
47
+ pip install . # installs the btap-compliance console script
48
+ btap-compliance model.osm --epw weather.epw # full 8.4.1.2 determination
49
+ python3 -m btap.necb.cli model.osm --simulate none # no-install equivalent
50
+ ```
51
+
52
+ Same seven exit codes as the Ruby CLI (0 compliant, 1 not compliant, 2
53
+ usage, 3 pre-flight, 4 simulation, 5 internal, 6 no determination), and the
54
+ same refusal to call a `--quick` week a determination. The Leg-B corpus gate
55
+ (`CLI_B=python bash verification/selftest.sh`) diffs this CLI's
56
+ `audit.json`/`report.json` against the Ruby CLI's over the whole corpus —
57
+ every pair equivalent at the `none`, `sizing`, and `annual` tiers.
58
+
59
+ ## Thermal bridging (M7)
60
+
61
+ NECB 3.1.1.7 runs through **py-tbd** (native Python, no Ruby subprocess),
62
+ pinned by commit SHA to its `tbd-3.5.2-compat` branch — the revision
63
+ verified against the family's frozen Ruby TBD 3.5.2 / OSut 0.8.2 oracle
64
+ baseline:
65
+
66
+ ```bash
67
+ pip install '.[tbd]'
68
+ ```
69
+
70
+ Without it the pipeline still works: `thermal_bridging=` degrades to the
71
+ same loud audited 3.1.1.7-not-accounted warning the Ruby gem emits without
72
+ its tbd gem — never a silent clear-field result.
@@ -0,0 +1,60 @@
1
+ # btap (Python)
2
+
3
+ The Python port of the btap gem family — one distribution, five subpackages
4
+ mirroring the gems (`btap.audit`, `btap.simulation`, `btap.modeling`,
5
+ `btap.costing`, `btap.necb`), same one-way dependency direction (D-77).
6
+ The port is COMPLETE (M0–M8, 2026-08-28; the record is `../PORT_STATUS.md`
7
+ and D-79), verified three ways: every Ruby suite ported, the Leg-C oracle
8
+ goldens consumed (frozen AND live), and the Leg-B corpus diff holding the
9
+ Ruby and Python `btap-compliance` CLIs equivalent at the `none`, `sizing`
10
+ and `annual --quick` tiers.
11
+
12
+ Since R3 (D-81) **this implementation is primary and canonical**, and
13
+ since the R4 handoff (D-82) it is the only one that changes: Ruby
14
+ backports have stopped. Behaviour changes land here with a re-frozen
15
+ scenario baseline in the same PR (`verification/scenarios/freeze.py`);
16
+ the frozen suite — not a live Ruby comparison — is the regression net.
17
+
18
+ ```bash
19
+ cd python && python3 -m unittest discover tests # stdlib-only, no install needed (serial)
20
+ cd python && .venv/bin/pytest -n auto tests/ # parallel (pytest-xdist worker
21
+ # processes; ~10x on the E+ suites)
22
+ ```
23
+
24
+ `btap/_compat.py` holds the cross-cutting Ruby-parity contracts (round-half-
25
+ away rounding, SDK Optional unwrapping, the NullAudit, determinism-critical
26
+ sorting, HTML escaping). New code uses those helpers, never the raw Python
27
+ equivalents — the differences they paper over are exactly the silent
28
+ Ruby-vs-Python drifts the census identified.
29
+
30
+ ## The CLI (M6)
31
+
32
+ The umbrella pipeline and its command line are ported:
33
+
34
+ ```bash
35
+ pip install . # installs the btap-compliance console script
36
+ btap-compliance model.osm --epw weather.epw # full 8.4.1.2 determination
37
+ python3 -m btap.necb.cli model.osm --simulate none # no-install equivalent
38
+ ```
39
+
40
+ Same seven exit codes as the Ruby CLI (0 compliant, 1 not compliant, 2
41
+ usage, 3 pre-flight, 4 simulation, 5 internal, 6 no determination), and the
42
+ same refusal to call a `--quick` week a determination. The Leg-B corpus gate
43
+ (`CLI_B=python bash verification/selftest.sh`) diffs this CLI's
44
+ `audit.json`/`report.json` against the Ruby CLI's over the whole corpus —
45
+ every pair equivalent at the `none`, `sizing`, and `annual` tiers.
46
+
47
+ ## Thermal bridging (M7)
48
+
49
+ NECB 3.1.1.7 runs through **py-tbd** (native Python, no Ruby subprocess),
50
+ pinned by commit SHA to its `tbd-3.5.2-compat` branch — the revision
51
+ verified against the family's frozen Ruby TBD 3.5.2 / OSut 0.8.2 oracle
52
+ baseline:
53
+
54
+ ```bash
55
+ pip install '.[tbd]'
56
+ ```
57
+
58
+ Without it the pipeline still works: `thermal_bridging=` degrades to the
59
+ same loud audited 3.1.1.7-not-accounted warning the Ruby gem emits without
60
+ its tbd gem — never a silent clear-field result.
@@ -0,0 +1,9 @@
1
+ """btap — the NECB 2020/2025 Part 8 performance path, one distribution.
2
+
3
+ Five subpackages mirror the Ruby gem family and keep its one-way dependency
4
+ direction (D-77): necb -> costing -> modeling -> audit, simulation beside.
5
+ Import subpackages directly (``from btap.audit import AuditLog``); this root
6
+ deliberately imports none of them, so ``import btap`` never pulls the SDK.
7
+ """
8
+
9
+ __version__ = "0.2.1"
@@ -0,0 +1,195 @@
1
+ """Cross-cutting Ruby-parity contracts for the btap port (D-79).
2
+
3
+ Every helper here papers over ONE specific Ruby-vs-Python semantic difference
4
+ the port-hazard census identified as a silent-drift hazard. Ported code MUST
5
+ use these instead of the raw Python equivalents:
6
+
7
+ - ``ruby_round`` — Ruby ``Float#round`` (half AWAY from zero, on the
8
+ shortest decimal representation); Python's ``round()`` is banker's.
9
+ - ``ruby_div`` — Ruby FLOAT division, which never raises: x/0.0 is
10
+ ±Infinity and 0.0/0.0 is NaN, where Python raises ZeroDivisionError.
11
+ - ``opt``/``opt_or`` — SDK ``Optional`` unwrapping; routes every call through
12
+ one greppable site and retires the missing-``()`` truthy-bound-method bug.
13
+ - ``NullAudit`` — null-object stand-in for AuditLog, replacing Ruby's 235
14
+ ``audit&.warn`` safe-navigation sites with plain ``audit.warn``.
15
+ - ``sorted_by_name`` — the determinism-critical ``sort_by(&:nameString)``.
16
+ - ``esc``/``Raw`` — the report's HTML escaping (exactly Ruby's four
17
+ substitutions; ``html.escape`` also escapes ``'`` and would diff).
18
+ - ``ruby_str`` — Ruby string interpolation of scalars (``true``/``false``/
19
+ ``nil``→empty, Ruby float rendering) for byte-parity narrative text.
20
+
21
+ Stdlib only. This module must never import openstudio.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ from contextlib import contextmanager
28
+ from dataclasses import dataclass
29
+ from decimal import ROUND_HALF_UP, Decimal
30
+
31
+
32
+ def ruby_round(x, ndigits: int = 0):
33
+ """Ruby ``Float#round(ndigits)``: half away from zero on the value's
34
+ shortest decimal representation (so ``ruby_round(2.675, 2) == 2.68`` even
35
+ though the double is 2.67499...). Returns int for ``ndigits <= 0`` and
36
+ float otherwise, exactly as Ruby returns Integer/Float.
37
+
38
+ Non-finite input follows Ruby exactly: with ndigits > 0 the value comes
39
+ back unchanged (``Infinity.round(2) == Infinity``, ``NaN.round(4) ==
40
+ NaN``), while ndigits <= 0 must produce an Integer and so raises, as
41
+ Ruby's FloatDomainError does. This matters because Ruby float division
42
+ never raises — see ``ruby_div`` — so NaN/Infinity reach rounding on
43
+ perfectly ordinary code paths."""
44
+ if isinstance(x, int) and ndigits >= 0:
45
+ return x
46
+ value = float(x)
47
+ if math.isnan(value) or math.isinf(value):
48
+ if ndigits > 0:
49
+ return value
50
+ raise ValueError(f"cannot round {value} to an integer (Ruby: FloatDomainError)")
51
+ quantum = Decimal(1).scaleb(-ndigits)
52
+ d = Decimal(repr(value)).quantize(quantum, rounding=ROUND_HALF_UP)
53
+ return int(d) if ndigits <= 0 else float(d)
54
+
55
+
56
+ def ruby_div(numerator, denominator) -> float:
57
+ """Ruby FLOAT division, which never raises: ``x/0.0`` is ±Infinity and
58
+ ``0.0/0.0`` is NaN, where Python raises ZeroDivisionError.
59
+
60
+ This is a family-wide porting hazard, not a curiosity. Ruby code
61
+ routinely divides by a possibly-zero area or count and then relies on the
62
+ result comparing false (``NaN <= 0.006`` is false), which is how several
63
+ NECB criteria skip an inapplicable half. Transliterating such a division
64
+ turns a silent skip into a crash, so every ported float division whose
65
+ denominator can be zero goes through here."""
66
+ n, d = float(numerator), float(denominator)
67
+ if d != 0.0:
68
+ return n / d
69
+ if n == 0.0 or math.isnan(n):
70
+ return math.nan
71
+ return math.inf if (n > 0) == (not math.copysign(1.0, d) < 0) else -math.inf
72
+
73
+
74
+ def opt(optional):
75
+ """Unwrap an SDK Optional: the value, or None when empty (Ruby's
76
+ ``x.is_initialized ? x.get : nil``). None passes through.
77
+
78
+ ALWAYS route through this rather than calling ``.get()`` directly. The
79
+ Python bindings do not fail the way Ruby does: ``OptionalModel.get()`` on
80
+ an EMPTY optional RETURNS AN EMPTY MODEL instead of raising (so a failed
81
+ ``Model.load`` flows onward silently), while ``OptionalDouble``/
82
+ ``OptionalString`` raise ``SystemError`` and leave the C-level error
83
+ indicator set, which can segfault the interpreter on a later unrelated
84
+ call. Ruby raises cleanly in all of these. See D-79.
85
+ """
86
+ if optional is None:
87
+ return None
88
+ return optional.get() if optional.is_initialized() else None
89
+
90
+
91
+ def opt_or(optional, default):
92
+ """Unwrap an SDK Optional with a default (Ruby's ``.empty? ? d : .get``)."""
93
+ value = opt(optional)
94
+ return default if value is None else value
95
+
96
+
97
+ def sorted_by_name(objects):
98
+ """``sort_by(&:nameString)`` — THE determinism idiom: every iteration whose
99
+ order reaches an output must pass through here (reference models, audit
100
+ entries, costing line items are reproducible because of it)."""
101
+ return sorted(objects, key=lambda o: o.nameString())
102
+
103
+
104
+ class NullAudit:
105
+ """Null-object AuditLog: absorbs every write, so callers take
106
+ ``audit=NullAudit()`` instead of Ruby's ``audit&.`` at 235 sites.
107
+ API-compatible with btap.audit.AuditLog; never import that here
108
+ (audit imports _compat, not the reverse)."""
109
+
110
+ building = None
111
+ entries: list = [] # always empty; writes are discarded, not stored
112
+
113
+ def decision(self, *args, **kwargs):
114
+ return self
115
+
116
+ def info(self, *args, **kwargs):
117
+ return self
118
+
119
+ def warn(self, *args, **kwargs):
120
+ return self
121
+
122
+ @property
123
+ def warnings(self):
124
+ return []
125
+
126
+ @contextmanager
127
+ def with_building(self, name):
128
+ yield
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class Raw:
133
+ """Marks a pre-built HTML fragment as safe to embed unescaped
134
+ (Ruby's ``Html::Raw`` struct)."""
135
+
136
+ html: str
137
+
138
+
139
+ def esc(value) -> str:
140
+ """HTML-escape any value — exactly Ruby ``Html.esc``'s four gsubs, in
141
+ order. Deliberately NOT ``html.escape``: that escapes ``'`` too and the
142
+ report goldens would diff."""
143
+ return (
144
+ ruby_str(value)
145
+ .replace("&", "&amp;")
146
+ .replace("<", "&lt;")
147
+ .replace(">", "&gt;")
148
+ .replace('"', "&quot;")
149
+ )
150
+
151
+
152
+ def ruby_str(value) -> str:
153
+ """Ruby string interpolation (``"#{value}"``) for the scalar types the
154
+ audit narrative and report text actually carry: None→'' (nil), booleans
155
+ lowercase, Ruby float rendering (``1.0e+16``, not ``1e+16``), lists in
156
+ Ruby ``Array#to_s`` style. Best-effort beyond those — audit.txt is
157
+ narrative, only audit.json/report.json are Leg-B-gated."""
158
+ if value is None:
159
+ return ""
160
+ if value is True:
161
+ return "true"
162
+ if value is False:
163
+ return "false"
164
+ if isinstance(value, float):
165
+ return ruby_float_str(value)
166
+ if isinstance(value, list):
167
+ return "[" + ", ".join(_ruby_inspect(v) for v in value) + "]"
168
+ return str(value)
169
+
170
+
171
+ def ruby_float_str(x: float) -> str:
172
+ """Ruby ``Float#to_s``: same shortest-repr digits and sci-notation
173
+ thresholds as Python's repr, but the mantissa always carries a decimal
174
+ point (Ruby ``1.0e+16`` vs Python ``1e+16``)."""
175
+ s = repr(x)
176
+ if "e" in s:
177
+ mantissa, exponent = s.split("e")
178
+ if "." not in mantissa:
179
+ mantissa += ".0"
180
+ return f"{mantissa}e{exponent}"
181
+ return s
182
+
183
+
184
+ def _ruby_inspect(value) -> str:
185
+ """Ruby ``#inspect`` for array elements: strings quoted, nil literal."""
186
+ if value is None:
187
+ return "nil"
188
+ if isinstance(value, str):
189
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
190
+ return ruby_str(value)
191
+
192
+
193
+ # NOTE: SDK-touching helpers (ensure_sdk_hashable) live in btap._sdk — this
194
+ # module stays stdlib-only so btap.audit can depend on it and still run on a
195
+ # runner with no OpenStudio. The import-linter contract enforces that.
@@ -0,0 +1,56 @@
1
+ """SDK-touching cross-cutting helpers.
2
+
3
+ Separate from ``_compat`` on purpose: ``_compat`` is stdlib-only so that
4
+ ``btap.audit`` — which the family contract keeps SDK-free, and which CI
5
+ exercises on a bare runner with no OpenStudio — can depend on it. Anything
6
+ that needs ``import openstudio`` lives here instead, and the import-linter
7
+ contract enforces the split.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ _sdk_hash_patched = False
13
+
14
+
15
+ def ensure_sdk_hashable() -> None:
16
+ """Make SDK model objects usable as dict keys (idempotent).
17
+
18
+ The openstudio wheel's ModelObject/IdfObject define __eq__ (handle
19
+ equality) WITHOUT __hash__, so Python marks them unhashable — but the
20
+ ported code keys dicts by SpaceType/BuildingStory/ThermalZone exactly as
21
+ the Ruby did. This patches a handle-based __hash__ consistent with the
22
+ wheel's __eq__. Call it at the top of any module that keys a dict or set
23
+ by SDK objects.
24
+ """
25
+ global _sdk_hash_patched
26
+ if _sdk_hash_patched:
27
+ return
28
+ import openstudio
29
+
30
+ def _handle_hash(self):
31
+ return hash(str(self.handle()))
32
+
33
+ openstudio.model.ModelObject.__hash__ = _handle_hash
34
+ openstudio.IdfObject.__hash__ = _handle_hash
35
+ _sdk_hash_patched = True
36
+
37
+
38
+ def load_model(path):
39
+ """Load an .osm, refusing an unreadable path LOUDLY.
40
+
41
+ THE reason this exists: ``OptionalModel.get()`` on an EMPTY optional
42
+ RETURNS AN EMPTY MODEL rather than raising (Ruby raises), so an
43
+ unreadable path flows onward as a model with no spaces and no zones and
44
+ surfaces later as dozens of meaningless downstream failures. Other empty
45
+ Optionals raise ``SystemError`` and leave the C-level error indicator
46
+ set, which can segfault the interpreter on a later unrelated call.
47
+
48
+ Never write ``Model.load(...).get()``. Use this. The invariant is
49
+ enforced by tests/test_sdk_invariants.py, not just documented (D-79).
50
+ """
51
+ import openstudio
52
+
53
+ loaded = openstudio.model.Model.load(openstudio.path(str(path)))
54
+ if not loaded.is_initialized():
55
+ raise ValueError(f"could not read an OpenStudio model at: {path}")
56
+ return loaded.get()
@@ -0,0 +1,11 @@
1
+ """btap.audit — the family's shared audit machinery (port of btap-audit).
2
+
3
+ One AuditLog class (entry schema, building stamping, article:/ruling:
4
+ citation axes, JSON + narrative rendering) and one article-coverage emitter.
5
+ No domain knowledge, no SDK — the bottom of the family.
6
+ """
7
+
8
+ from btap.audit.coverage import emit_coverage
9
+ from btap.audit.log import AuditLog
10
+
11
+ __all__ = ["AuditLog", "emit_coverage"]
@@ -0,0 +1,73 @@
1
+ """Article-coverage emission (port of btap-audit's coverage.rb): the ONE
2
+ implementation of the completeness accounting every family module performs at
3
+ the end of its happy path.
4
+
5
+ Each domain owns an `article_coverage` manifest (implemented / partial /
6
+ not_implemented / satisfied_by_clone / host_scope) and resolves it its own
7
+ way; what every domain then does with it is identical and lives here: every
8
+ declared article lands in the audit with its status and how many decisions
9
+ cited it this run, so a missed requirement is visible in every log rather
10
+ than discovered by review.
11
+
12
+ partial/not_implemented WARN — except entries flagged `gap_owner: "modeller"`,
13
+ whose remaining gaps are wholly the modeller's responsibility: those emit as
14
+ info scope notes instead (project decision D-09).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections import Counter
21
+
22
+ _ARTICLE_RE = re.compile(r"\d+\.\d+(?:\.\d+)*\.")
23
+ # Strip ' (slice label)' / '(N)' suffixes, but KEEP the trailing dot: the scan
24
+ # above only ever yields keys ending in '.', so the dot is what stops
25
+ # '8.4.4.1.' from prefix-matching '8.4.4.14.' and claiming its citations.
26
+ # (report/checklist.rb#covered? guards the same collision the same way.)
27
+ _SLICE_SUFFIX_RE = re.compile(r"\s*\(.*\Z")
28
+
29
+ _INFO_STATUSES = ("implemented", "satisfied_by_clone", "host_scope")
30
+
31
+
32
+ def emit_coverage(coverage, audit):
33
+ """`coverage` is the resolved article_coverage block — a dict with an
34
+ 'articles' list of {article, title, status, how, gaps, gap_owner, code}
35
+ records. None is a deliberate no-op (a ruleset with no manifest emits
36
+ nothing). Entries append to `audit`; their `article:` tags are what the
37
+ citation count reads."""
38
+ if coverage is None:
39
+ return
40
+
41
+ cited = Counter()
42
+ for e in audit.entries:
43
+ cited.update(_ARTICLE_RE.findall(str(e.get("article") or "")))
44
+ for art in coverage["articles"]:
45
+ prefix = _SLICE_SUFFIX_RE.sub("", str(art["article"]))
46
+ applied = sum(n for a, n in cited.items() if a.startswith(prefix))
47
+ inputs = {"status": art["status"], "decisions_citing": applied}
48
+ if art.get("gap_owner") is not None:
49
+ inputs["gap_owner"] = art["gap_owner"]
50
+ # "Where is this dealt with" — path#method refs, carried into the
51
+ # audit so the AHJ trail answers the question without the repo.
52
+ if art.get("code") is not None:
53
+ inputs["code"] = art["code"]
54
+ status_text = art["status"].replace("_", " ")
55
+ how = art.get("how")
56
+ gaps = art.get("gaps")
57
+ if art["status"] in _INFO_STATUSES:
58
+ audit.info("coverage",
59
+ f"{art['title']} — {status_text}{': ' + how if how else ''}",
60
+ inputs=inputs, article=art["article"])
61
+ elif art.get("gap_owner") == "modeller": # scope note, not a warning (D-09)
62
+ action = f"{art['title']} — {status_text}, modeller scope"
63
+ if how:
64
+ action += f". Applied: {how}"
65
+ if gaps:
66
+ action += f". Modeller's responsibility: {gaps}"
67
+ audit.info("coverage", action, inputs=inputs, article=art["article"])
68
+ else: # partial / not_implemented
69
+ audit.warn("coverage",
70
+ f"{art['title']} — {status_text}"
71
+ f"{'. Applied: ' + how if how else ''}"
72
+ f"{'. Gaps: ' + gaps if gaps else ''}",
73
+ inputs=inputs, article=art["article"])
@@ -0,0 +1,123 @@
1
+ """The family-wide decision/audit trail (port of btap-audit's log.rb).
2
+
3
+ Every consequential step records WHAT was decided, the INPUTS it was decided
4
+ from, the model EVIDENCE behind it, and (where applicable) the NECB ARTICLE or
5
+ data-table citation that mandates it — so QAQC can answer "why did zone X get
6
+ System 6?" from the log instead of diffing models.
7
+
8
+ Entry schema (all optional except step/action/level):
9
+ {step, target, action, inputs, value, article, ruling, evidence,
10
+ building, level} level: 'decision' | 'info' | 'warning'
11
+
12
+ ruling: WHICH adjudicated project decision(s) govern this code path — the D-XX
13
+ ids of the family's decision record. Where `article` cites the CODE that
14
+ mandates a value, `ruling` cites OUR judgement call about how that code was
15
+ read. Multiple ids are ONE space-separated string ('D-19 D-21'); consumers
16
+ scan r'\\bD-\\d{2}\\b'.
17
+
18
+ building: WHICH model the entry is about ('input model', 'proposed building',
19
+ 'reference building'), stamped from the current building context a pipeline
20
+ sets at phase boundaries. None = cross-building comparison or verdict.
21
+
22
+ Port notes (D-79): entries are str-keyed dicts throughout — Ruby's
23
+ symbol-keys-in-memory / string-keys-in-JSON dualism collapses to str, which
24
+ leaves the serialized audit.json IDENTICAL (the Leg-B contract). None-valued
25
+ fields are dropped at insert (Ruby's `.compact`): consumers use
26
+ ``e.get('article')`` truthiness, never key presence with a None fallback
27
+ difference. Contract: warnings are never silent.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from contextlib import contextmanager
34
+
35
+ from btap._compat import ruby_str
36
+
37
+
38
+ class AuditLog:
39
+ def __init__(self):
40
+ self.entries: list[dict] = []
41
+ self.building: str | None = None
42
+
43
+ @contextmanager
44
+ def with_building(self, name):
45
+ """Stamp every entry recorded inside the block with the given building
46
+ context; restores the previous context afterwards (nestable)."""
47
+ previous = self.building
48
+ self.building = name
49
+ try:
50
+ yield self
51
+ finally:
52
+ self.building = previous
53
+
54
+ def decision(self, step, action, *, target=None, inputs=None, value=None,
55
+ article=None, ruling=None, evidence=None):
56
+ return self._add("decision", step, action, target, inputs, value,
57
+ article, ruling, evidence)
58
+
59
+ def info(self, step, action, *, target=None, inputs=None, value=None,
60
+ article=None, ruling=None, evidence=None):
61
+ return self._add("info", step, action, target, inputs, value,
62
+ article, ruling, evidence)
63
+
64
+ def warn(self, step, action, *, target=None, inputs=None, value=None,
65
+ article=None, ruling=None, evidence=None):
66
+ return self._add("warning", step, action, target, inputs, value,
67
+ article, ruling, evidence)
68
+
69
+ @property
70
+ def warnings(self):
71
+ """Warning entries only. The recorded level is 'warning', not 'warn'."""
72
+ return [e for e in self.entries if e["level"] == "warning"]
73
+
74
+ def to_json(self) -> str:
75
+ return json.dumps(self.entries, indent=2, ensure_ascii=False)
76
+
77
+ def __str__(self):
78
+ """Human-readable narrative, one line per entry — same fixed-width
79
+ shape as Ruby's to_s (the umbrella's checklist classifier parses the
80
+ action text case-SENSITIVELY: violations SHOUTED, passes lowercase)."""
81
+ lines = []
82
+ for e in self.entries:
83
+ line = "[%-8s] %-13s %s" % (e["level"], e["step"], e["action"])
84
+ # Segment gates are RUBY truthiness (only nil/false falsy — '' and
85
+ # 0 print), not Python truthiness.
86
+ if _truthy(e.get("building")):
87
+ line += f" | building: {e['building']}"
88
+ if _truthy(e.get("target")):
89
+ line += f" | target: {e['target']}"
90
+ if _truthy(e.get("inputs")):
91
+ line += f" | inputs: {_compact_hash(e['inputs'])}"
92
+ if _truthy(e.get("value")):
93
+ line += f" | value: {ruby_str(e['value'])}"
94
+ if _truthy(e.get("evidence")):
95
+ line += f" | evidence: {e['evidence']}"
96
+ if _truthy(e.get("article")):
97
+ line += f" | per {e['article']}"
98
+ if _truthy(e.get("ruling")):
99
+ line += f" | ruling {e['ruling']}"
100
+ lines.append(line)
101
+ return "\n".join(lines)
102
+
103
+ def _add(self, level, step, action, target, inputs, value, article,
104
+ ruling, evidence):
105
+ entry = {"step": step, "target": target, "action": action,
106
+ "inputs": inputs, "value": value, "article": article,
107
+ "ruling": ruling, "evidence": evidence,
108
+ "building": self.building, "level": level}
109
+ self.entries.append({k: v for k, v in entry.items() if v is not None})
110
+ return self
111
+
112
+
113
+ def _truthy(value) -> bool:
114
+ """Ruby truthiness: everything except nil and false."""
115
+ return value is not None and value is not False
116
+
117
+
118
+ def _compact_hash(inputs: dict) -> str:
119
+ """Ruby's inputs rendering: 'k=v, k=v', arrays joined with '/'."""
120
+ return ", ".join(
121
+ f"{k}={'/'.join(ruby_str(x) for x in v) if isinstance(v, list) else ruby_str(v)}"
122
+ for k, v in inputs.items()
123
+ )
@@ -0,0 +1,14 @@
1
+ """btap.costing — pricing of model objects (port of the btap-costing gem).
2
+
3
+ HVAC equipment BOMs, envelope assemblies, lighting fixtures, and SHW — one
4
+ package owning the whole licensed-data seam. The vendored CSVs under
5
+ ``data/`` are PLACEHOLDER schema copies; real RS-Means values are injected
6
+ at runtime (costs_csv=/local_factors_csv= kwargs, or BTAP_COSTING_DIR /
7
+ OPENSTUDIO_COSTING_DIR) and are never committed or redistributed.
8
+
9
+ Dependency direction is the design (D-77): costing imports btap.modeling
10
+ and btap.audit, NEVER btap.necb. Where a costing rule needs NECB-owned
11
+ geometry (the daylighted-area sensors), the NECB layer passes a provider in.
12
+ Sub-namespaced (hvac/envelope/lighting + shw) because Database/Report exist
13
+ per domain; import the domain modules directly.
14
+ """