typehaus 0.1.0a0__py3-none-any.whl

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 (239) hide show
  1. typehaus/__init__.py +15 -0
  2. typehaus/_meta.py +28 -0
  3. typehaus/analysis.py +149 -0
  4. typehaus/checks/__init__.py +30 -0
  5. typehaus/checks/advisory/__init__.py +3 -0
  6. typehaus/checks/advisory/checks.py +297 -0
  7. typehaus/checks/building_science/__init__.py +5 -0
  8. typehaus/checks/building_science/condensation.py +221 -0
  9. typehaus/checks/building_science/glaser.py +403 -0
  10. typehaus/checks/building_science/wwr.py +112 -0
  11. typehaus/checks/code/__init__.py +0 -0
  12. typehaus/checks/code/mn_energy.py +186 -0
  13. typehaus/checks/code/mn_residential/__init__.py +6 -0
  14. typehaus/checks/code/mn_residential/profile.py +34 -0
  15. typehaus/checks/code/mn_residential/rules.py +372 -0
  16. typehaus/checks/code/site.py +65 -0
  17. typehaus/checks/integrity/__init__.py +3 -0
  18. typehaus/checks/integrity/checks.py +120 -0
  19. typehaus/checks/mep/__init__.py +7 -0
  20. typehaus/checks/mep/electrical.py +266 -0
  21. typehaus/checks/mep/hvac.py +76 -0
  22. typehaus/checks/mep/plumbing.py +272 -0
  23. typehaus/checks/mep/vent_path.py +107 -0
  24. typehaus/checks/permit.py +96 -0
  25. typehaus/checks/pytest_plugin.py +56 -0
  26. typehaus/checks/registry.py +163 -0
  27. typehaus/checks/run.py +97 -0
  28. typehaus/checks/structural/__init__.py +6 -0
  29. typehaus/checks/structural/checks.py +272 -0
  30. typehaus/checks/structural/deck.py +451 -0
  31. typehaus/checks/structural/deck_tables.py +139 -0
  32. typehaus/checks/structural/interference.py +401 -0
  33. typehaus/checks/structural/stairs.py +173 -0
  34. typehaus/cli/__init__.py +7 -0
  35. typehaus/cli/app.py +904 -0
  36. typehaus/cli/digest.py +33 -0
  37. typehaus/cli/furniture_import.py +88 -0
  38. typehaus/cli/prices.py +190 -0
  39. typehaus/cli/scaffold.py +276 -0
  40. typehaus/cli/variants.py +209 -0
  41. typehaus/diff/__init__.py +39 -0
  42. typehaus/diff/assembly_compare.py +116 -0
  43. typehaus/diff/compare.py +301 -0
  44. typehaus/diff/equivalence.py +283 -0
  45. typehaus/diff/ifc_adapter.py +299 -0
  46. typehaus/diff/matcher.py +118 -0
  47. typehaus/diff/model.py +29 -0
  48. typehaus/diff/report.py +171 -0
  49. typehaus/diff/semantic.py +408 -0
  50. typehaus/diff/variants.py +167 -0
  51. typehaus/emit/__init__.py +0 -0
  52. typehaus/emit/draw/__init__.py +38 -0
  53. typehaus/emit/draw/_shared.py +151 -0
  54. typehaus/emit/draw/card.py +135 -0
  55. typehaus/emit/draw/detail_components/__init__.py +215 -0
  56. typehaus/emit/draw/detail_components/below_grade.py +162 -0
  57. typehaus/emit/draw/detail_components/breezeway.py +226 -0
  58. typehaus/emit/draw/detail_components/chrome.py +175 -0
  59. typehaus/emit/draw/detail_components/config.py +365 -0
  60. typehaus/emit/draw/detail_components/dispatch.py +187 -0
  61. typehaus/emit/draw/detail_components/eave.py +122 -0
  62. typehaus/emit/draw/detail_components/geometry.py +288 -0
  63. typehaus/emit/draw/detail_components/opening.py +164 -0
  64. typehaus/emit/draw/detail_components/ridge.py +102 -0
  65. typehaus/emit/draw/detail_components/sauna.py +247 -0
  66. typehaus/emit/draw/detail_components/shower.py +215 -0
  67. typehaus/emit/draw/detail_components/stack.py +152 -0
  68. typehaus/emit/draw/detail_components/wall_base.py +216 -0
  69. typehaus/emit/draw/details.py +772 -0
  70. typehaus/emit/draw/door_symbols.py +320 -0
  71. typehaus/emit/draw/dxf_writer.py +266 -0
  72. typehaus/emit/draw/electricalplan.py +77 -0
  73. typehaus/emit/draw/elevation.py +283 -0
  74. typehaus/emit/draw/floorplan.py +216 -0
  75. typehaus/emit/draw/foundation_schedule.py +391 -0
  76. typehaus/emit/draw/foundationplan.py +312 -0
  77. typehaus/emit/draw/framing_schedule.py +295 -0
  78. typehaus/emit/draw/framingplan.py +196 -0
  79. typehaus/emit/draw/hvacplan.py +89 -0
  80. typehaus/emit/draw/ir.py +123 -0
  81. typehaus/emit/draw/joints.py +193 -0
  82. typehaus/emit/draw/palette.py +190 -0
  83. typehaus/emit/draw/pdf_writer.py +366 -0
  84. typehaus/emit/draw/plumbingplan.py +69 -0
  85. typehaus/emit/draw/render.py +78 -0
  86. typehaus/emit/draw/roofframingplan.py +251 -0
  87. typehaus/emit/draw/roofplan.py +31 -0
  88. typehaus/emit/draw/scene.py +160 -0
  89. typehaus/emit/draw/schedule_block.py +174 -0
  90. typehaus/emit/draw/section.py +755 -0
  91. typehaus/emit/draw/sheets.py +493 -0
  92. typehaus/emit/draw/siteplan.py +304 -0
  93. typehaus/emit/draw/structural_common.py +100 -0
  94. typehaus/emit/gltf/__init__.py +13 -0
  95. typehaus/emit/gltf/buffers.py +90 -0
  96. typehaus/emit/gltf/canvas_objects.py +119 -0
  97. typehaus/emit/gltf/emitter.py +211 -0
  98. typehaus/emit/gltf/geometry.py +136 -0
  99. typehaus/emit/gltf/members.py +48 -0
  100. typehaus/emit/gltf/mesh.py +250 -0
  101. typehaus/emit/gltf/openings.py +106 -0
  102. typehaus/emit/gltf/palette.py +218 -0
  103. typehaus/emit/gltf/roofs.py +236 -0
  104. typehaus/emit/gltf/scene.py +120 -0
  105. typehaus/emit/gltf/walls.py +98 -0
  106. typehaus/emit/ifc/__init__.py +16 -0
  107. typehaus/emit/ifc/electrical.py +88 -0
  108. typehaus/emit/ifc/emitter.py +1040 -0
  109. typehaus/emit/ifc/lowlevel.py +348 -0
  110. typehaus/emit/ifc/roof.py +325 -0
  111. typehaus/energy.py +289 -0
  112. typehaus/findings.py +67 -0
  113. typehaus/model/__init__.py +239 -0
  114. typehaus/model/assembly.py +173 -0
  115. typehaus/model/base.py +48 -0
  116. typehaus/model/canvas.py +194 -0
  117. typehaus/model/electrical.py +36 -0
  118. typehaus/model/elements.py +98 -0
  119. typehaus/model/enums.py +249 -0
  120. typehaus/model/floors.py +146 -0
  121. typehaus/model/ids.py +41 -0
  122. typehaus/model/materials.py +57 -0
  123. typehaus/model/mep.py +185 -0
  124. typehaus/model/patterns.py +14 -0
  125. typehaus/model/placeable_symbols/__init__.py +99 -0
  126. typehaus/model/placeable_symbols/_families.py +456 -0
  127. typehaus/model/placeable_symbols/_frame.py +146 -0
  128. typehaus/model/placeable_symbols/appliances.py +154 -0
  129. typehaus/model/placeable_symbols/furniture.py +67 -0
  130. typehaus/model/placeable_symbols/plumbing.py +243 -0
  131. typehaus/model/placeables.py +98 -0
  132. typehaus/model/plan.py +114 -0
  133. typehaus/model/project.py +85 -0
  134. typehaus/model/refs.py +95 -0
  135. typehaus/model/registry.py +90 -0
  136. typehaus/model/remap.py +158 -0
  137. typehaus/model/site.py +149 -0
  138. typehaus/model/spatial.py +172 -0
  139. typehaus/model/structure.py +275 -0
  140. typehaus/model/trim.py +146 -0
  141. typehaus/model/types.py +129 -0
  142. typehaus/model/views.py +108 -0
  143. typehaus/quantities/__init__.py +32 -0
  144. typehaus/quantities/_base.py +77 -0
  145. typehaus/quantities/angle.py +133 -0
  146. typehaus/quantities/area.py +84 -0
  147. typehaus/quantities/length.py +184 -0
  148. typehaus/quantities/point.py +72 -0
  149. typehaus/quantities/thermal.py +188 -0
  150. typehaus/resolve/__init__.py +26 -0
  151. typehaus/resolve/accessories.py +337 -0
  152. typehaus/resolve/construction.py +390 -0
  153. typehaus/resolve/envelope.py +446 -0
  154. typehaus/resolve/floor_heat.py +45 -0
  155. typehaus/resolve/floors.py +307 -0
  156. typehaus/resolve/framing/__init__.py +39 -0
  157. typehaus/resolve/framing/backing.py +86 -0
  158. typehaus/resolve/framing/corners.py +125 -0
  159. typehaus/resolve/framing/footprint.py +66 -0
  160. typehaus/resolve/framing/openings.py +282 -0
  161. typehaus/resolve/framing/profiles.py +157 -0
  162. typehaus/resolve/framing/roof.py +441 -0
  163. typehaus/resolve/framing/roof_gable.py +253 -0
  164. typehaus/resolve/framing/solver.py +409 -0
  165. typehaus/resolve/framing/stud_module.py +98 -0
  166. typehaus/resolve/framing/tables.py +172 -0
  167. typehaus/resolve/geometry.py +84 -0
  168. typehaus/resolve/mep.py +263 -0
  169. typehaus/resolve/model.py +538 -0
  170. typehaus/resolve/orientation.py +274 -0
  171. typehaus/resolve/pipeline.py +270 -0
  172. typehaus/resolve/placeable_clear_floor_obstruction.py +129 -0
  173. typehaus/resolve/placeable_groups.py +94 -0
  174. typehaus/resolve/placeables.py +312 -0
  175. typehaus/resolve/platform.py +103 -0
  176. typehaus/resolve/roof_edge.py +195 -0
  177. typehaus/resolve/roof_edge_geometry.py +286 -0
  178. typehaus/resolve/roof_geometry.py +184 -0
  179. typehaus/resolve/roof_layer_setbacks.py +175 -0
  180. typehaus/resolve/roof_trim.py +379 -0
  181. typehaus/resolve/rooms.py +84 -0
  182. typehaus/resolve/site_earth.py +66 -0
  183. typehaus/resolve/solar.py +91 -0
  184. typehaus/resolve/stacking.py +118 -0
  185. typehaus/resolve/stairs/__init__.py +23 -0
  186. typehaus/resolve/stairs/bearing.py +265 -0
  187. typehaus/resolve/stairs/common.py +83 -0
  188. typehaus/resolve/stairs/dispatch.py +197 -0
  189. typehaus/resolve/stairs/straight.py +53 -0
  190. typehaus/resolve/stairs/u_split.py +150 -0
  191. typehaus/resolve/stairs/winder.py +144 -0
  192. typehaus/resolve/topology.py +697 -0
  193. typehaus/resolve/vent_termination.py +62 -0
  194. typehaus/server/__init__.py +0 -0
  195. typehaus/server/app.py +376 -0
  196. typehaus/server/building_height_summary.py +76 -0
  197. typehaus/server/events.py +32 -0
  198. typehaus/server/macros_api.py +205 -0
  199. typehaus/server/model_json.py +562 -0
  200. typehaus/server/space_summary.py +53 -0
  201. typehaus/server/state.py +370 -0
  202. typehaus/source/__init__.py +43 -0
  203. typehaus/source/assembly_ops.py +133 -0
  204. typehaus/source/bundle.py +328 -0
  205. typehaus/source/coordinator.py +240 -0
  206. typehaus/source/detail_ops.py +79 -0
  207. typehaus/source/dialect.py +219 -0
  208. typehaus/source/fmt.py +69 -0
  209. typehaus/source/imported_furniture.py +86 -0
  210. typehaus/source/imports.py +141 -0
  211. typehaus/source/imports_py.py +105 -0
  212. typehaus/source/inmemory.py +178 -0
  213. typehaus/source/journal.py +60 -0
  214. typehaus/source/loader.py +397 -0
  215. typehaus/source/macros.py +871 -0
  216. typehaus/source/ops.py +194 -0
  217. typehaus/source/placeable_import.py +397 -0
  218. typehaus/source/placeables.py +166 -0
  219. typehaus/source/provenance.py +79 -0
  220. typehaus/source/serialize.py +105 -0
  221. typehaus/source/writeback.py +387 -0
  222. typehaus/source/writeback_py.py +364 -0
  223. typehaus/takeoff/__init__.py +64 -0
  224. typehaus/takeoff/anchors.py +258 -0
  225. typehaus/takeoff/bom.py +71 -0
  226. typehaus/takeoff/electrical.py +216 -0
  227. typehaus/takeoff/fasteners.py +171 -0
  228. typehaus/takeoff/framing.py +234 -0
  229. typehaus/takeoff/glazing.py +152 -0
  230. typehaus/takeoff/hangers.py +167 -0
  231. typehaus/takeoff/hardware.py +36 -0
  232. typehaus/takeoff/hardware_catalog.py +146 -0
  233. typehaus/takeoff/hardware_config.py +130 -0
  234. typehaus/takeoff/placeables.py +53 -0
  235. typehaus/takeoff/plan_geometry.py +57 -0
  236. typehaus-0.1.0a0.dist-info/METADATA +93 -0
  237. typehaus-0.1.0a0.dist-info/RECORD +239 -0
  238. typehaus-0.1.0a0.dist-info/WHEEL +4 -0
  239. typehaus-0.1.0a0.dist-info/entry_points.txt +5 -0
typehaus/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Type:Haus engine — typed declarative residential house modeling.
2
+
3
+ The public authoring surface (everything the plan dialect may import) is re-exported
4
+ here from ``typehaus.model``. Plan files import from ``typehaus`` and ``library``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typehaus._meta import PROJECT_NAME, engine_version
10
+ from typehaus.model import * # noqa: F401,F403 — re-export the authoring surface
11
+ from typehaus.model import __all__ as _model_all
12
+
13
+ __version__ = engine_version()
14
+
15
+ __all__ = ["PROJECT_NAME", "engine_version", "__version__", *_model_all]
typehaus/_meta.py ADDED
@@ -0,0 +1,28 @@
1
+ """Brand-name and build-identity constants, centralized for cheap rename (→ 01 §Naming).
2
+
3
+ A rename touches only this file, the ``pyproject.toml`` ``name``, the ``src/typehaus``
4
+ directory, and the UI ``branding.ts`` — see ``docs/RENAME.md``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ PROJECT_NAME = "Type:Haus"
12
+ PROJECT_SLUG = "typehaus"
13
+ PROJECT_URL = "https://type-haus.com"
14
+ CLI_NAME = "haus"
15
+
16
+ # IFC header application name and pset prefix. The prefix is kept short and
17
+ # brand-agnostic so the emitted IFC does not churn on a rename (→ 01 §Naming).
18
+ IFC_APP_NAME = "Type:Haus"
19
+ PSET_PREFIX = "Pset_TH"
20
+ PSET_SOURCE = f"{PSET_PREFIX}_Source"
21
+
22
+
23
+ def engine_version() -> str:
24
+ """Installed engine version, or a dev sentinel when running from a source tree."""
25
+ try:
26
+ return version(PROJECT_SLUG)
27
+ except PackageNotFoundError:
28
+ return "0.0.0+dev"
typehaus/analysis.py ADDED
@@ -0,0 +1,149 @@
1
+ """Model-level analysis helpers shared by the card and the checks (R-value, etc.).
2
+
3
+ Tri-state honest (#32): a computation missing an input reports UNKNOWN with the material
4
+ named, never a silent zero or a fabricated pass.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ from typehaus.model.assembly import Assembly
12
+ from typehaus.model.enums import LayerFunction
13
+ from typehaus.model.plan import Library
14
+ from typehaus.quantities import RValue, rsi
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RValueResult:
19
+ value: RValue | None # None => UNKNOWN
20
+ unknown_materials: tuple[str, ...] # layer/material names lacking r_per_inch
21
+
22
+ @property
23
+ def known(self) -> bool:
24
+ return self.value is not None
25
+
26
+ def fmt(self) -> str:
27
+ if self.value is None:
28
+ missing = ", ".join(self.unknown_materials)
29
+ return f"UNKNOWN (missing r_per_inch: {missing})"
30
+ return self.value.fmt()
31
+
32
+
33
+ # Still-air surface film resistances (RSI), interior + exterior, ASHRAE nominal.
34
+ _FILM_RSI = 0.12 + 0.03
35
+
36
+
37
+ def _layer_rsi(layer, library: Library, unknown: list[str]) -> float:
38
+ """Series RSI of one layer, parallel-pathed across its cavity fill if it has one.
39
+
40
+ A batt between studs is *not* in series with the studs — it is the other path through
41
+ the same depth. Summing both (which a sibling batt layer used to do) overstates a 2x6
42
+ wall by roughly the full cavity R. Area-weighted U per ASHRAE parallel-path:
43
+ ``U = ff*U_framing + (1-ff)*U_fill``.
44
+ """
45
+ from typehaus.quantities import r_us
46
+
47
+ mat = library.material(layer.material_ref)
48
+ if mat is None or mat.r_per_inch is None:
49
+ unknown.append(f"{layer.name}({layer.material_ref})")
50
+ return 0.0
51
+ framing_rsi = r_us(mat.r_per_inch * layer.thickness.inches).rsi
52
+
53
+ fill = getattr(layer, "cavity", None)
54
+ if fill is None:
55
+ return framing_rsi
56
+
57
+ fill_mat = library.material(fill.material_ref)
58
+ if fill_mat is None or fill_mat.r_per_inch is None:
59
+ unknown.append(f"{layer.name}-cavity({fill.material_ref})")
60
+ return 0.0
61
+ fill_thickness = fill.thickness if fill.thickness is not None else layer.thickness
62
+ fill_rsi = r_us(fill_mat.r_per_inch * fill_thickness.inches).rsi
63
+ # A shallower fill leaves still air in the rest of the bay; count only the fill so the
64
+ # result stays conservative rather than crediting an unsealed void.
65
+ if framing_rsi <= 0.0 or fill_rsi <= 0.0:
66
+ return max(framing_rsi, 0.0)
67
+ ff = min(max(fill.framing_factor, 0.0), 1.0)
68
+ u_eff = ff / framing_rsi + (1.0 - ff) / fill_rsi
69
+ return 1.0 / u_eff if u_eff > 0.0 else framing_rsi
70
+
71
+
72
+ def assembly_r_value(asm: Assembly, library: Library, include_lining: bool = True,
73
+ include_films: bool = True) -> RValueResult:
74
+ """Compute core (+ default_lining) R-value from material r_per_inch (#34)."""
75
+ total_rsi = _FILM_RSI if include_films else 0.0
76
+ unknown: list[str] = []
77
+ layers = list(asm.layers)
78
+ if include_lining:
79
+ layers = list(asm.default_lining) + layers
80
+ for layer in layers:
81
+ total_rsi += _layer_rsi(layer, library, unknown)
82
+ if unknown:
83
+ return RValueResult(value=None, unknown_materials=tuple(unknown))
84
+ return RValueResult(value=rsi(total_rsi), unknown_materials=())
85
+
86
+
87
+ # The framing solver's o.c. default when a STRUCTURE layer's FramingSpec leaves it open;
88
+ # reported so the delta compare never shows a blank spacing for an authored-by-omission wall.
89
+ DEFAULT_FRAMING_SPACING_IN = 16.0
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class AssemblyMetrics:
94
+ """The comparable facts of one assembly — the delta-compare row's data (#53, → 21b).
95
+
96
+ Model-free like the section card itself: everything here reads off the authored
97
+ ``Assembly``, so two candidate assemblies can be weighed against each other before
98
+ either is placed on a wall. ``stc`` stays None unless the assembly carries a lab test
99
+ (#50) — an acoustic number is never computed.
100
+ """
101
+
102
+ tag: str
103
+ r_value: RValueResult
104
+ thickness_in: float
105
+ layer_count: int
106
+ structure_member: str | None # nominal lumber size of the STRUCTURE layer, e.g. "2x6"
107
+ framing_spacing_in: float | None # o.c. of that layer's framing
108
+ stc: int | None
109
+ variant_of: str | None
110
+
111
+ def as_dict(self) -> dict:
112
+ return {
113
+ "tag": self.tag,
114
+ "r_value": (round(self.r_value.value.r_us, 3)
115
+ if self.r_value.value is not None else None),
116
+ "r_value_unknown_materials": list(self.r_value.unknown_materials),
117
+ "thickness_in": round(self.thickness_in, 4),
118
+ "layer_count": self.layer_count,
119
+ "structure_member": self.structure_member,
120
+ "framing_spacing_in": self.framing_spacing_in,
121
+ "stc": self.stc,
122
+ "variant_of": self.variant_of,
123
+ }
124
+
125
+
126
+ def assembly_metrics(asm: Assembly, library: Library) -> AssemblyMetrics:
127
+ """Roll one assembly up to the numbers a designer compares alternatives on.
128
+
129
+ Thickness sums the whole built stack (default lining + core) because that is what the
130
+ hallway loses; cavity fill is deliberately excluded — it lives *inside* its host layer's
131
+ depth (→ :class:`CavityFill`) and adding it would double-count the wall.
132
+ """
133
+ stack = list(asm.default_lining) + list(asm.layers)
134
+ structure = next((ly for ly in stack if ly.function is LayerFunction.STRUCTURE), None)
135
+ spec = structure.framing if structure is not None else None
136
+ spacing_in = None
137
+ if spec is not None:
138
+ spacing_in = (spec.spacing.inches if spec.spacing is not None
139
+ else DEFAULT_FRAMING_SPACING_IN)
140
+ return AssemblyMetrics(
141
+ tag=asm.tag,
142
+ r_value=assembly_r_value(asm, library),
143
+ thickness_in=sum(layer.thickness.inches for layer in stack),
144
+ layer_count=len(stack),
145
+ structure_member=spec.member if spec is not None else None,
146
+ framing_spacing_in=spacing_in,
147
+ stc=asm.stc,
148
+ variant_of=asm.variant_of,
149
+ )
@@ -0,0 +1,30 @@
1
+ """Checks framework — one registry, two invokers (pytest + `haus check`) (→ 12).
2
+
3
+ Importing this package imports every tier module, registering all checks.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typehaus.checks import advisory, building_science, integrity, mep, structural # noqa: F401 - register
9
+ from typehaus.checks.code import mn_energy, mn_residential, site # noqa: F401 - register
10
+ from typehaus.checks.registry import (
11
+ CheckContext,
12
+ CheckReport,
13
+ FramingPreferences,
14
+ PlumbingPreferences,
15
+ ReferenceUnderlay,
16
+ JurisdictionProfile,
17
+ Preferences,
18
+ Tier,
19
+ check,
20
+ registered,
21
+ run_checks,
22
+ )
23
+ from typehaus.checks.run import build_context, load_preferences, run, run_from_model
24
+ from typehaus.checks.permit import PermitChecklist, PermitChecklistItem, evaluate_permit_checklist
25
+
26
+ __all__ = [
27
+ "run", "run_from_model", "build_context", "load_preferences", "run_checks", "registered", "check",
28
+ "CheckContext", "CheckReport", "Preferences", "FramingPreferences", "PlumbingPreferences", "ReferenceUnderlay", "JurisdictionProfile", "Tier",
29
+ "PermitChecklist", "PermitChecklistItem", "evaluate_permit_checklist",
30
+ ]
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ from typehaus.checks.advisory import checks # noqa: F401 - registers checks
@@ -0,0 +1,297 @@
1
+ """Advisory checks — design intelligence, warn-only, reasoning shown (→ 12 §checks/advisory).
2
+
3
+ Opinions with arithmetic behind them, never authority. Each finding states *why* and is
4
+ individually suppressible in preferences.toml.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typehaus.checks.registry import CheckContext, Tier, check
10
+ from typehaus.findings import Finding, Result, Severity
11
+
12
+
13
+ def _warn(cid: str, msg: str, tags: tuple[str, ...] = ()) -> Finding:
14
+ return Finding(severity=Severity.WARN, check_id=cid, message=msg, element_tags=tags,
15
+ result=Result.FAIL)
16
+
17
+
18
+ @check(Tier.ADVISORY, "advisory.habitable_window")
19
+ def habitable_room_window(ctx: CheckContext) -> list[Finding]:
20
+ """Habitable rooms should have an exterior window (natural light, R303)."""
21
+ from typehaus.model.enums import Occupancy
22
+
23
+ habitable = {Occupancy.BEDROOM, Occupancy.LIVING, Occupancy.KITCHEN,
24
+ Occupancy.DINING, Occupancy.OFFICE}
25
+ out: list[Finding] = []
26
+ has_window = any(not op.is_door for op in ctx.model.openings)
27
+ for room in (e for e in ctx.plan.all_elements() if e.element_kind == "Room"):
28
+ if room.occupancy in habitable and not has_window:
29
+ out.append(_warn("advisory.habitable_window",
30
+ f"habitable room {room.tag} appears to have no window — "
31
+ "consider natural light/ventilation", (room.tag,)))
32
+ return out
33
+
34
+
35
+ @check(Tier.ADVISORY, "advisory.window_size_variety")
36
+ def window_size_variety(ctx: CheckContext) -> list[Finding]:
37
+ """Fewer unique window sizes = cheaper ordering (reported as a fact with a histogram)."""
38
+ sizes: dict[tuple[float, float], int] = {}
39
+ for op in ctx.model.openings:
40
+ if not op.is_door:
41
+ key = (round(op.width_m, 3), round(op.height_m, 3))
42
+ sizes[key] = sizes.get(key, 0) + 1
43
+ if len(sizes) <= 1:
44
+ return []
45
+ hist = ", ".join(f"{w*39.37:.0f}x{h*39.37:.0f}\"×{n}" for (w, h), n in sizes.items())
46
+ return [_warn("advisory.window_size_variety",
47
+ f"{len(sizes)} unique window sizes — fewer eases ordering ({hist})")]
48
+
49
+
50
+ @check(Tier.ADVISORY, "advisory.control_continuity")
51
+ def control_layer_continuity(ctx: CheckContext) -> list[Finding]:
52
+ """Warn where a tagged control layer dead-ends at a condition whose transition does
53
+ not declare continuity (walks junctions and stack edges, → 11b)."""
54
+ out: list[Finding] = []
55
+ transitions = ctx.plan.library.transitions
56
+ for cond in ctx.model.conditions:
57
+ if cond.kind.value not in ("storey_stack", "assembly_change"):
58
+ continue
59
+ declared = any(
60
+ _matches(t.condition_pattern, cond.key) and t.continuity for t in transitions
61
+ )
62
+ if not declared:
63
+ out.append(_warn("advisory.control_continuity",
64
+ f"control-layer continuity not declared across {cond.key}",
65
+ cond.element_tags))
66
+ return out
67
+
68
+
69
+ @check(Tier.ADVISORY, "advisory.wet_wall_depth")
70
+ def wet_wall_depth(ctx: CheckContext) -> list[Finding]:
71
+ """Drain fixtures need a real service chase, not an assumed 2x4-wall stack."""
72
+ from typehaus.model.enums import Service
73
+
74
+ types = {item.tag: item for item in ctx.plan.library.fixture_types}
75
+ required = ctx.preferences.plumbing.drain_stack_required_structure_in * 0.0254
76
+ out: list[Finding] = []
77
+ for fixture in (element for element in ctx.plan.all_elements()
78
+ if element.element_kind == "Fixture"):
79
+ fixture_type = types.get(fixture.type_ref)
80
+ if fixture_type is None or Service.DRAIN not in fixture_type.needs:
81
+ continue
82
+ if fixture.wall_ref is None:
83
+ out.append(_warn("advisory.wet_wall_depth",
84
+ f"drain fixture {fixture.tag} has no wall_ref for a service chase",
85
+ (fixture.tag,)))
86
+ continue
87
+ wall = ctx.model.wall(fixture.wall_ref)
88
+ if wall is None:
89
+ out.append(_warn("advisory.wet_wall_depth",
90
+ f"drain fixture {fixture.tag} references missing wall {fixture.wall_ref}",
91
+ (fixture.tag, fixture.wall_ref)))
92
+ continue
93
+ structure = next((layer for layer in wall.layers if layer.function == "structure"), None)
94
+ if structure is None or structure.thickness_m + 1e-9 < required:
95
+ actual = structure.thickness_m / 0.0254 if structure is not None else 0.0
96
+ out.append(_warn(
97
+ "advisory.wet_wall_depth",
98
+ f"drain fixture {fixture.tag} uses {fixture.wall_ref} with {actual:.1f}\" structure; "
99
+ f"planning allowance requires {required / 0.0254:.1f}\" for its drain stack",
100
+ (fixture.tag, fixture.wall_ref),
101
+ ))
102
+ return out
103
+
104
+
105
+ @check(Tier.ADVISORY, "advisory.fixture_room_unassigned")
106
+ def fixture_room_unassigned(ctx: CheckContext) -> list[Finding]:
107
+ """A permit fixture schedule lists a room per fixture, so an unassignable one is worth
108
+ saying out loud. This is the finding that replaced ``Fixture.room`` being a required
109
+ string: a drag that lands a fixture outside every resolvable room stays loadable and
110
+ keeps its authored position, and the gap is reported here instead of at import time."""
111
+ return [_warn("advisory.fixture_room_unassigned",
112
+ f"fixture {obj.tag} is in no room — the fixture schedule needs one; move it "
113
+ "inside a room or extend the room boundary", (obj.tag,))
114
+ for obj in ctx.model.canvas_objects
115
+ if obj.kind == "Fixture" and obj.room is None]
116
+
117
+
118
+ @check(Tier.ADVISORY, "advisory.floor_heat_fixture_keepout")
119
+ def floor_heat_fixture_keepout(ctx: CheckContext) -> list[Finding]:
120
+ """Radiant wire zones must not run beneath a fixture's authored footprint."""
121
+ from shapely.geometry import Polygon, box
122
+
123
+ types = {item.tag: item for item in ctx.plan.library.fixture_types}
124
+ fixtures = [
125
+ (storey.tag, element)
126
+ for storey in ctx.plan.storeys
127
+ for element in ctx.plan.storey_elements(storey.tag)
128
+ if element.element_kind == "Fixture"
129
+ ]
130
+ out: list[Finding] = []
131
+ for zone in ctx.model.floor_heat:
132
+ zone_polygon = Polygon(zone.zone)
133
+ for storey, fixture in fixtures:
134
+ if storey != zone.storey or fixture.type_ref not in types:
135
+ continue
136
+ width, depth = (value.meters for value in types[fixture.type_ref].footprint)
137
+ x, y = fixture.position.xy_m
138
+ footprint = box(x - width / 2, y - depth / 2, x + width / 2, y + depth / 2)
139
+ if zone_polygon.intersects(footprint):
140
+ out.append(_warn(
141
+ "advisory.floor_heat_fixture_keepout",
142
+ f"floor-heat zone {zone.tag} overlaps fixture {fixture.tag}; "
143
+ "exclude the fixture footprint from the heating loop",
144
+ (zone.tag, fixture.tag),
145
+ ))
146
+ return out
147
+
148
+
149
+ @check(Tier.ADVISORY, "advisory.clearance_overlap")
150
+ def clearance_overlap(ctx: CheckContext) -> list[Finding]:
151
+ """Surface authored fixture/furniture use-zone conflicts in both CLI and canvas.
152
+
153
+ Clearances are intentionally a planning envelope, not a code-compliance calculation:
154
+ rotations and wall-side semantics remain authored decisions. A bounding envelope catches
155
+ the high-value case—another placed object occupies a declared use zone—without quietly
156
+ asserting that every item has a universal code clearance.
157
+ """
158
+ from shapely.geometry import box
159
+
160
+ fixtures = {item.tag: item for item in ctx.plan.library.fixture_types}
161
+ furniture = {item.tag: item for item in ctx.plan.library.furniture_types}
162
+ placed: list[tuple[str, object, tuple[float, float], tuple[float, float, float, float] | None]] = []
163
+ for storey in ctx.plan.storeys:
164
+ for element in ctx.plan.storey_elements(storey.tag):
165
+ if element.element_kind == "Fixture":
166
+ item = fixtures.get(element.type_ref)
167
+ elif element.element_kind == "Furniture":
168
+ item = furniture.get(element.type_ref)
169
+ else:
170
+ continue
171
+ if item is None:
172
+ continue
173
+ footprint = tuple(value.meters for value in item.footprint)
174
+ clearance = (tuple(value.meters for value in item.clearance)
175
+ if item.clearance is not None else None)
176
+ placed.append((storey.tag, element, footprint, clearance))
177
+
178
+ out: list[Finding] = []
179
+ reported: set[tuple[str, str]] = set()
180
+ for storey, source, footprint, clearance in placed:
181
+ if clearance is None:
182
+ continue
183
+ front, back, left, right = clearance
184
+ x, y = source.position.xy_m
185
+ zone = box(x - footprint[0] / 2 - left, y - footprint[1] / 2 - back,
186
+ x + footprint[0] / 2 + right, y + footprint[1] / 2 + front)
187
+ for other_storey, other, other_footprint, _ in placed:
188
+ if other_storey != storey or other.uid == source.uid:
189
+ continue
190
+ ox, oy = other.position.xy_m
191
+ other_box = box(ox - other_footprint[0] / 2, oy - other_footprint[1] / 2,
192
+ ox + other_footprint[0] / 2, oy + other_footprint[1] / 2)
193
+ key = tuple(sorted((source.tag, other.tag)))
194
+ if zone.intersects(other_box) and key not in reported:
195
+ reported.add(key)
196
+ out.append(_warn(
197
+ "advisory.clearance_overlap",
198
+ f"declared clearance for {source.tag} overlaps {other.tag}; review the use zone",
199
+ key,
200
+ ))
201
+ return out
202
+
203
+
204
+ def _polygon_centroid(polygon) -> tuple[float, float] | None:
205
+ """Vertex mean — enough to locate a layer's thin rectangle relative to its neighbours."""
206
+ if not polygon:
207
+ return None
208
+ return (sum(p[0] for p in polygon) / len(polygon),
209
+ sum(p[1] for p in polygon) / len(polygon))
210
+
211
+
212
+ def _cladding_offset(wall) -> tuple[float, float] | None:
213
+ """Innermost layer → cladding layer, i.e. the direction this wall calls "outdoors".
214
+
215
+ ``None`` for walls with nothing to orient: a single-layer or uncladded assembly is
216
+ symmetric, so no winding can put it on backwards.
217
+ """
218
+ layers = wall.depth_layers()
219
+ cladding = next((layer for layer in reversed(layers) if layer.function == "cladding"), None)
220
+ if cladding is None or len(layers) < 2 or cladding is layers[0]:
221
+ return None
222
+ inner = _polygon_centroid(layers[0].polygon)
223
+ outer = _polygon_centroid(cladding.polygon)
224
+ if inner is None or outer is None:
225
+ return None
226
+ return (outer[0] - inner[0], outer[1] - inner[1])
227
+
228
+
229
+ @check(Tier.ADVISORY, "advisory.cladding_side_mismatch")
230
+ def cladding_side_mismatch(ctx: CheckContext) -> list[Finding]:
231
+ """Adjacent clad walls must put their cladding on the same side of the run they form.
232
+
233
+ A wall's layers are laid out interior→exterior along its authored start→end direction
234
+ (times the storey's outward sign, → resolve/orientation), so authoring one wall of a run
235
+ backwards silently turns its stack inside out — face brick pointing at the porch, stucco
236
+ at the weather. Nothing downstream notices, because each wall is individually well-formed
237
+ and the storey still has a single outward sign. Comparing neighbours across a shared node
238
+ catches it without having to decide which side of the run is outdoors: walk node ``a→n``
239
+ then ``n→b`` and the cladding must sit on the same hand of both legs.
240
+ """
241
+ import math
242
+
243
+ out: list[Finding] = []
244
+ for storey in ctx.plan.storeys:
245
+ elements = ctx.plan.storey_elements(storey.tag)
246
+ positions = {e.tag: e.position.xy_m for e in elements if e.element_kind == "Node"}
247
+ # node tag -> [(wall tag, far-end node tag, cladding offset)]
248
+ incident: dict[str, list[tuple[str, str, tuple[float, float]]]] = {}
249
+ for element in elements:
250
+ if element.element_kind not in ("Wall", "FoundationWall"):
251
+ continue
252
+ resolved = ctx.model.wall(element.tag)
253
+ if resolved is None:
254
+ continue
255
+ offset = _cladding_offset(resolved)
256
+ if offset is None:
257
+ continue
258
+ a, b = element.start_node, element.end_node
259
+ if a not in positions or b not in positions or a == b:
260
+ continue
261
+ incident.setdefault(a, []).append((element.tag, b, offset))
262
+ incident.setdefault(b, []).append((element.tag, a, offset))
263
+
264
+ for node, legs in sorted(incident.items()):
265
+ # Only an unambiguous two-wall corner/joint has one traversal; at a branch the
266
+ # "same side" question has no single answer, so say nothing rather than guess.
267
+ if len(legs) != 2:
268
+ continue
269
+ (tag_a, far_a, offset_a), (tag_b, far_b, offset_b) = legs
270
+ here = positions[node]
271
+ # Traverse the run far_a -> node -> far_b, then take each leg's cladding hand.
272
+ hands = []
273
+ for (far, offset, incoming) in ((far_a, offset_a, True), (far_b, offset_b, False)):
274
+ direction = ((here[0] - positions[far][0], here[1] - positions[far][1])
275
+ if incoming
276
+ else (positions[far][0] - here[0], positions[far][1] - here[1]))
277
+ length = math.hypot(*direction)
278
+ if length < 1e-9:
279
+ break
280
+ hands.append((direction[0] * offset[1] - direction[1] * offset[0]) / length)
281
+ if len(hands) != 2 or min(abs(h) for h in hands) < 1e-9:
282
+ continue
283
+ if hands[0] * hands[1] < 0.0:
284
+ out.append(_warn(
285
+ "advisory.cladding_side_mismatch",
286
+ f"{tag_a} and {tag_b} meet at {node} but their cladding faces opposite "
287
+ "sides of the run — one of them is authored end-to-start, so its layer "
288
+ "stack is inside out; swap that wall's start_node/end_node",
289
+ (tag_a, tag_b, node),
290
+ ))
291
+ return out
292
+
293
+
294
+ def _matches(pattern: str, key: str) -> bool:
295
+ import fnmatch
296
+
297
+ return fnmatch.fnmatch(key, pattern)
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ """Building-science checks — advisory physics analyses introduced in M5."""
4
+
5
+ from typehaus.checks.building_science import condensation, wwr # noqa: F401 - register