epinterface 1.2.0__tar.gz → 1.3.0__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 (133) hide show
  1. {epinterface-1.2.0 → epinterface-1.3.0}/.cursor/Dockerfile.agent +3 -1
  2. epinterface-1.3.0/.cursor/rules/uv-dependencies.mdc +44 -0
  3. {epinterface-1.2.0 → epinterface-1.3.0}/.github/workflows/main.yml +2 -2
  4. {epinterface-1.2.0 → epinterface-1.3.0}/PKG-INFO +1 -1
  5. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/geometry.py +120 -0
  6. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/builder.py +82 -8
  7. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/composer.py +3 -0
  8. {epinterface-1.2.0 → epinterface-1.3.0}/pyproject.toml +1 -1
  9. epinterface-1.3.0/tests/test_construct_zone_def.py +68 -0
  10. epinterface-1.3.0/tests/test_neighbor_shading_geometry.py +151 -0
  11. {epinterface-1.2.0 → epinterface-1.3.0}/uv.lock +1 -1
  12. {epinterface-1.2.0 → epinterface-1.3.0}/.cursor/environment.json +0 -0
  13. {epinterface-1.2.0 → epinterface-1.3.0}/.dockerignore +0 -0
  14. {epinterface-1.2.0 → epinterface-1.3.0}/.editorconfig +0 -0
  15. {epinterface-1.2.0 → epinterface-1.3.0}/.github/actions/setup-python-env/action.yml +0 -0
  16. {epinterface-1.2.0 → epinterface-1.3.0}/.github/workflows/on-release-main.yml +0 -0
  17. {epinterface-1.2.0 → epinterface-1.3.0}/.github/workflows/validate-codecov-config.yml +0 -0
  18. {epinterface-1.2.0 → epinterface-1.3.0}/.gitignore +0 -0
  19. {epinterface-1.2.0 → epinterface-1.3.0}/.pre-commit-config.yaml +0 -0
  20. {epinterface-1.2.0 → epinterface-1.3.0}/.prettierignore +0 -0
  21. {epinterface-1.2.0 → epinterface-1.3.0}/.python-version +0 -0
  22. {epinterface-1.2.0 → epinterface-1.3.0}/.vscode/settings.json +0 -0
  23. {epinterface-1.2.0 → epinterface-1.3.0}/CONTRIBUTING.md +0 -0
  24. {epinterface-1.2.0 → epinterface-1.3.0}/LICENSE +0 -0
  25. {epinterface-1.2.0 → epinterface-1.3.0}/Makefile +0 -0
  26. {epinterface-1.2.0 → epinterface-1.3.0}/README.md +0 -0
  27. {epinterface-1.2.0 → epinterface-1.3.0}/benchmarking/benchmark.ipynb +0 -0
  28. {epinterface-1.2.0 → epinterface-1.3.0}/benchmarking/benchmark.py +0 -0
  29. {epinterface-1.2.0 → epinterface-1.3.0}/codecov.yaml +0 -0
  30. {epinterface-1.2.0 → epinterface-1.3.0}/docs/cli.md +0 -0
  31. {epinterface-1.2.0 → epinterface-1.3.0}/docs/index.md +0 -0
  32. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/actions.md +0 -0
  33. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/builder.md +0 -0
  34. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/climate-studio.md +0 -0
  35. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/geometry.md +0 -0
  36. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/interface.md +0 -0
  37. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/sbem/components.md +0 -0
  38. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/sbem/composer.md +0 -0
  39. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/sbem/index.md +0 -0
  40. {epinterface-1.2.0 → epinterface-1.3.0}/docs/modules/weather.md +0 -0
  41. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/__init__.py +0 -0
  42. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/actions.py +0 -0
  43. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/analysis/__init__.py +0 -0
  44. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/analysis/energy_and_peak.py +0 -0
  45. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/analysis/overheating.py +0 -0
  46. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/builder.py +0 -0
  47. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/cli.py +0 -0
  48. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/climate_studio/__init__.py +0 -0
  49. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/climate_studio/builder.py +0 -0
  50. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/climate_studio/interface.py +0 -0
  51. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/constants/__init__.py +0 -0
  52. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/Minimal.idf +0 -0
  53. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/USA_MA_Boston-Logan.Intl.AP.725090_TMYx.2009-2023.ddy +0 -0
  54. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/USA_MA_Boston-Logan.Intl.AP.725090_TMYx.2009-2023.epw +0 -0
  55. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/USA_MA_Boston-Logan.Intl.AP.725090_TMYx.2009-2023.zip +0 -0
  56. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/__init__.py +0 -0
  57. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/data/res_schedules.parquet +0 -0
  58. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/ddy_injector_bayes.py +0 -0
  59. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/ddy_interface_bayes.py +0 -0
  60. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/interface.py +0 -0
  61. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/py.typed +0 -0
  62. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/__init__.py +0 -0
  63. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/annotations.py +0 -0
  64. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/common.py +0 -0
  65. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/__init__.py +0 -0
  66. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/envelope.py +0 -0
  67. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/materials.py +0 -0
  68. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/operations.py +0 -0
  69. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/schedules.py +0 -0
  70. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/space_use.py +0 -0
  71. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/systems.py +0 -0
  72. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/components/zones.py +0 -0
  73. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/exceptions.py +0 -0
  74. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/fields/__init__.py +0 -0
  75. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/fields/spec.py +0 -0
  76. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/flat_model.py +0 -0
  77. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/interface.py +0 -0
  78. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/model_graph_structure.md +0 -0
  79. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/client.py +0 -0
  80. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250309133033_create_initial_schema/migration.sql +0 -0
  81. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250310162045_switch_to_using_one_week_per_month/migration.sql +0 -0
  82. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250317202733_change_names_ventilation/migration.sql +0 -0
  83. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250325185158_add_mutually_exclusive_ventilation_techtypes/migration.sql +0 -0
  84. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250326141941_reduce_naming_complexity_ventilation/migration.sql +0 -0
  85. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250331141910_add_support_for_attic_and_basement_constructions/migration.sql +0 -0
  86. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/20250919152559_decouple_basement_infiltration/migration.sql +0 -0
  87. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/migrations/migration_lock.toml +0 -0
  88. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/partial_types.py +0 -0
  89. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/schema.prisma +0 -0
  90. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/prisma/seed_fns.py +0 -0
  91. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/sbem/utils.py +0 -0
  92. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/settings.py +0 -0
  93. {epinterface-1.2.0 → epinterface-1.3.0}/epinterface/weather.py +0 -0
  94. {epinterface-1.2.0 → epinterface-1.3.0}/mkdocs.yml +0 -0
  95. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/everett_lib.json +0 -0
  96. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/everett_lib_v2.ipynb +0 -0
  97. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/everett_retrofit_actions.ipynb +0 -0
  98. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/everett_retrofits.yaml +0 -0
  99. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/idf-demo.ipynb +0 -0
  100. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/idf-demo.py +0 -0
  101. {epinterface-1.2.0 → epinterface-1.3.0}/notebooks/view-geo.ipynb +0 -0
  102. {epinterface-1.2.0 → epinterface-1.3.0}/scripts/gridsim.py +0 -0
  103. {epinterface-1.2.0 → epinterface-1.3.0}/tests/conftest.py +0 -0
  104. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/20251001_Template_MAWebApp.xlsx +0 -0
  105. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/USA_MA_Chicopee-Westover.Metro.AP.744910_TMYx.2009-2023.zip +0 -0
  106. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/component-map-ma.yml +0 -0
  107. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/components-ma.db +0 -0
  108. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/0318_Template_MAWebApp.xlsx +0 -0
  109. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/Template_MAWebApp.xlsx +0 -0
  110. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/component-map-ma.yml +0 -0
  111. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/components-ma-old.db +0 -0
  112. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/components-ma.db +0 -0
  113. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/components-ma.db Kopie +0 -0
  114. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/components-ma.db Kopie 2 +0 -0
  115. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/components-ma.db Kopie 3 +0 -0
  116. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/old/semantic-fields-ma.yml +0 -0
  117. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/semantic-fields-ma.yml +0 -0
  118. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/test_lib.json +0 -0
  119. {epinterface-1.2.0 → epinterface-1.3.0}/tests/data/tester_lib.xlsx +0 -0
  120. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_actions.py +0 -0
  121. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_analysis/__init__.py +0 -0
  122. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_analysis/test_overheating.py +0 -0
  123. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_components/test_builder.py +0 -0
  124. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_components/test_component_lib.py +0 -0
  125. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_components/test_envelope_comps.py +0 -0
  126. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_components/test_operations.py +0 -0
  127. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_components/test_space_use_comps.py +0 -0
  128. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_composer.py +0 -0
  129. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_db.py +0 -0
  130. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_example.py +0 -0
  131. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_geometry.py +0 -0
  132. {epinterface-1.2.0 → epinterface-1.3.0}/tests/test_weather.py +0 -0
  133. {epinterface-1.2.0 → epinterface-1.3.0}/tox.ini +0 -0
@@ -1,5 +1,6 @@
1
1
  ARG EP_VERSION=24.2.0
2
2
  ARG PYTHON_VERSION=3.12
3
+ ARG UV_VERSION=0.9.30
3
4
  FROM nrel/energyplus:${EP_VERSION} AS energyplus
4
5
 
5
6
  # Remove unnecessary datasets to reduce image size
@@ -13,8 +14,9 @@ RUN mkdir -p /energyplus-local && \
13
14
  # ep is installed by the nrel image
14
15
  cp -r * /energyplus-local/
15
16
 
17
+ FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv
16
18
  FROM python:${PYTHON_VERSION}-slim-bookworm AS main
17
- COPY --from=ghcr.io/astral-sh/uv:0.9.30 /uv /uvx /bin/
19
+ COPY --from=uv /uv /uvx /bin/
18
20
 
19
21
 
20
22
  # Install dependencies for running energy models
@@ -0,0 +1,44 @@
1
+ ---
2
+ description: How to add packages with uv and when to use optional-dependencies vs dependency-groups
3
+ globs: pyproject.toml
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Adding Dependencies with uv
8
+
9
+ Use `uv add` to add packages. Where they go depends on who needs them.
10
+
11
+ ## `uv add` usage
12
+
13
+ - **Core runtime dependency** (always required by the library):
14
+ ```bash
15
+ uv add <package>
16
+ ```
17
+ Adds to `[project] dependencies` in `pyproject.toml`.
18
+
19
+ - **Optional extra** (for downstream installers):
20
+ ```bash
21
+ uv add --optional <extra-name> <package>
22
+ ```
23
+ Adds to `[project.optional-dependencies]` under the given extra (e.g. `uv add --optional plot pandas-plot`).
24
+
25
+ - **Local-only group** (dev/docs/tooling, not published as installable extras):
26
+ ```bash
27
+ uv add --group <group-name> <package>
28
+ ```
29
+ Adds to `[dependency-groups]` (e.g. `uv add --group dev pytest`).
30
+
31
+ ## When to use which
32
+
33
+ | Place | Who gets it | Use for |
34
+ |-------|-------------|--------|
35
+ | **`project.dependencies`** | Everyone who installs `epinterface` | Required runtime deps only. |
36
+ | **`project.optional-dependencies`** | Only people who install e.g. `uv add epinterface[extra-name]` or `pip install epinterface[extra-name]` | Optional features other projects may want when using epinterface as a library. |
37
+ | **`dependency-groups`** | Only this repo (local dev, CI, docs) | Testing, linting, docs, pre-commit, etc. Not exposed as pip/uv extras. |
38
+
39
+ **Rule of thumb:** If another repo would ever do `pip install epinterface[something]` to get optional functionality, put that in **optional-dependencies**. If it’s only for developing or building this repo, put it in **dependency-groups**.
40
+
41
+ ## Examples (this project)
42
+
43
+ - **optional-dependencies:** e.g. a `viz` extra for plotting only used by some downstream users → `[project.optional-dependencies] viz = ["matplotlib", ...]`.
44
+ - **dependency-groups:** `dev` (pytest, pyright, pre-commit), `docs` (mkdocs, mkdocstrings) — local tooling only, not installable as `epinterface[dev]`.
@@ -34,8 +34,8 @@ jobs:
34
34
  # EPINTERFACE_ENERGYPLUS_VERSION: ${{ matrix.energyplus-version }}
35
35
  strategy:
36
36
  matrix:
37
- python-version: ["3.10", "3.11", "3.12"]
38
- energyplus-version: [22.2.0, 23.2.0, 24.2.0, 25.2.0] # later, add 9.4.0
37
+ python-version: ["3.12"]
38
+ energyplus-version: [22.2.0, 24.2.0, 25.2.0] # later, add 9.4.0
39
39
  energyplus-platform: [Linux-Ubuntu22.04]
40
40
  include:
41
41
  - runs-on: ubuntu-latest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: epinterface
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: This is a repository for dynamically generating energy models within Python, relying on Archetypal and Eppy for most of its functionality.
5
5
  Project-URL: Homepage, https://github.com/szvsw/epinterface
6
6
  Project-URL: Documentation, https://szvsw.github.io/epinterface
@@ -7,6 +7,7 @@ import numpy as np
7
7
  from archetypal.idfclass import IDF
8
8
  from geomeppy.geom.polygons import Polygon3D
9
9
  from geomeppy.geom.vectors import Vector2D
10
+ from numpy.typing import ArrayLike
10
11
  from pydantic import BaseModel, Field
11
12
  from shapely import LineString, Polygon, from_wkt
12
13
  from shapely.affinity import translate
@@ -192,6 +193,125 @@ def compute_shading_mask(
192
193
  return shading_mask
193
194
 
194
195
 
196
+ def shading_fence_closed_ring(
197
+ elevations: ArrayLike,
198
+ d: float,
199
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
200
+ """Construct N vertical 'shading fence' rectangles whose bases are tangent segments forming a closed regular polygon around a circle of radius d.
201
+
202
+ Inputs:
203
+ elevations: (N,) elevation angles theta_k [radians]
204
+ d: radius to tangency points (base midpoints)
205
+
206
+ Outputs:
207
+ azimuths: (N,) inferred azimuths alpha_k = 2πk/N
208
+ p0: (N, 2) base endpoint A (x,y)
209
+ p1: (N, 2) base endpoint B (x,y)
210
+ h: (N,) heights h_k = d * tan(theta_k)
211
+ w: scalar side length / segment width = 2 d tan(π/N)
212
+
213
+ Notes:
214
+ - With this construction, the segments intersect/meet: p1[k] == p0[k+1] (cyclic),
215
+ up to floating point tolerance.
216
+ - N must be >= 3 for a closed polygon.
217
+ """
218
+ theta = np.asarray(elevations, dtype=np.float64)
219
+ if theta.ndim != 1:
220
+ msg = f"elevations must be 1D, got shape {theta.shape}"
221
+ raise ValueError(msg)
222
+
223
+ N = theta.shape[0]
224
+ if N < 3:
225
+ msg = "Need at least 3 elevations (N >= 3) to form a closed ring."
226
+ raise ValueError(msg)
227
+
228
+ # Inferred equally spaced azimuths
229
+ k = np.arange(N, dtype=np.float64)
230
+ azimuths = 2.0 * np.pi * k / N
231
+
232
+ ca, sa = np.cos(azimuths), np.sin(azimuths)
233
+
234
+ # Tangency points (base midpoints) on circle radius d
235
+ px = d * ca
236
+ py = d * sa
237
+
238
+ # Unit tangent direction (perpendicular to radius)
239
+ tx = -sa
240
+ ty = ca
241
+
242
+ # Choose width so adjacent tangent segments meet (circumscribed regular N-gon)
243
+ half_w = d * np.tan(np.pi / N)
244
+ w = 2.0 * half_w
245
+
246
+ # Endpoints in xy: p ± half_w * t
247
+ p0 = np.stack([px - half_w * tx, py - half_w * ty], axis=-1)
248
+ p1 = np.stack([px + half_w * tx, py + half_w * ty], axis=-1)
249
+
250
+ # Heights from elevation angles
251
+ h = d * np.tan(theta)
252
+
253
+ return azimuths, p0, p1, h, w
254
+
255
+
256
+ def prepare_neighbor_shading_for_idf(
257
+ building: Polygon | str,
258
+ neighbors: Sequence[Polygon | str | None],
259
+ neighbor_heights: Sequence[float | int | None],
260
+ *,
261
+ azimuthal_angle: float = 2 * np.pi / 48,
262
+ fence_radius: float = 100,
263
+ outward_offset: float = 2,
264
+ f2f_height: float,
265
+ ) -> tuple[list[Polygon], list[int]]:
266
+ """Prepare neighbor polygons and floor counts for match_idf_to_building_and_neighbors.
267
+
268
+ Computes a shading mask from the building and neighbors, converts it to shading fence
269
+ quadrilaterals, and returns polygons plus floor counts suitable for IDF shading blocks.
270
+
271
+ Args:
272
+ building: The building polygon (or WKT string).
273
+ neighbors: Neighbor polygons (or WKT strings).
274
+ neighbor_heights: Heights of each neighbor [m].
275
+ azimuthal_angle: Angular spacing for shading rays (default 2π/48).
276
+ fence_radius: Radius for shading fence tangency points.
277
+ outward_offset: Distance to extend fence quadrilaterals outward.
278
+ f2f_height: Floor-to-floor height for converting heights to floor counts.
279
+
280
+ Returns:
281
+ mask_polys: List of Shapely Polygons (quadrilaterals) for each shading fence.
282
+ neighbor_floors: List of floor counts (int) for each fence.
283
+ """
284
+ shading_mask = compute_shading_mask(
285
+ building, neighbors, neighbor_heights, azimuthal_angle
286
+ )
287
+ az, p0, p1, h, _w = shading_fence_closed_ring(
288
+ elevations=shading_mask, d=fence_radius
289
+ )
290
+
291
+ building_geom = building if isinstance(building, Polygon) else from_wkt(building)
292
+ centroid = building_geom.centroid
293
+ cx, cy = centroid.x, centroid.y
294
+
295
+ angles = 2 * np.pi * np.arange(len(az)) / len(az)
296
+ outward = np.stack([np.cos(angles), np.sin(angles)], axis=-1)
297
+ p2 = p1 + outward_offset * outward
298
+ p3 = p0 + outward_offset * outward
299
+
300
+ mask_polys = [
301
+ Polygon([
302
+ (p0[i, 0] + cx, p0[i, 1] + cy),
303
+ (p1[i, 0] + cx, p1[i, 1] + cy),
304
+ (p2[i, 0] + cx, p2[i, 1] + cy),
305
+ (p3[i, 0] + cx, p3[i, 1] + cy),
306
+ (p0[i, 0] + cx, p0[i, 1] + cy),
307
+ ])
308
+ for i in range(len(az))
309
+ ]
310
+ neighbor_floors = [int(h[i] // f2f_height) for i in range(len(h))]
311
+
312
+ return mask_polys, neighbor_floors
313
+
314
+
195
315
  ZoningType = Literal["core/perim", "by_storey"]
196
316
 
197
317
 
@@ -1,17 +1,22 @@
1
1
  """A module for building the energy model using the SBEM template library approach."""
2
2
 
3
3
  import gc
4
+ import io
4
5
  import logging
6
+ import os
5
7
  import shutil
8
+ import sys
6
9
  import tempfile
7
10
  from collections.abc import Callable
8
11
  from dataclasses import dataclass
12
+ from datetime import datetime
9
13
  from pathlib import Path
10
14
  from typing import Literal, cast, get_args
11
15
  from uuid import uuid4
12
16
 
13
17
  import numpy as np
14
18
  import pandas as pd
19
+ import yaml
15
20
  from archetypal.idfclass import IDF
16
21
  from archetypal.idfclass.sql import Sql
17
22
  from ladybug.epw import EPW
@@ -38,6 +43,10 @@ from epinterface.interface import (
38
43
  add_default_schedules,
39
44
  add_default_sim_controls,
40
45
  )
46
+ from epinterface.sbem.components.composer import (
47
+ construct_composer_model,
48
+ construct_graph,
49
+ )
41
50
  from epinterface.sbem.components.envelope import (
42
51
  ConstructionAssemblyComponent,
43
52
  EnvelopeAssemblyComponent,
@@ -46,6 +55,7 @@ from epinterface.sbem.components.envelope import (
46
55
  from epinterface.sbem.components.systems import DHWFuelType, FuelType
47
56
  from epinterface.sbem.components.zones import ZoneComponent
48
57
  from epinterface.sbem.exceptions import NotImplementedParameter
58
+ from epinterface.sbem.prisma.client import PrismaSettings
49
59
  from epinterface.settings import energyplus_settings
50
60
  from epinterface.weather import BaseWeather
51
61
 
@@ -1206,6 +1216,78 @@ class Model(BaseWeather, validate_assignment=True):
1206
1216
  return zone_weights, zone_names
1207
1217
 
1208
1218
 
1219
+ def construct_zone_def(
1220
+ component_map_path: Path,
1221
+ db_path: Path,
1222
+ semantic_field_context: dict[str, float | str | int],
1223
+ ) -> ZoneComponent:
1224
+ """Construct the zone definition from a component map and database.
1225
+
1226
+ Args:
1227
+ component_map_path: Path to the component map YAML file.
1228
+ db_path: Path to the component database file.
1229
+ semantic_field_context: The semantic field values used to compile the zone definition.
1230
+
1231
+ Returns:
1232
+ zone_def: The zone definition for the simulation.
1233
+ """
1234
+ g = construct_graph(ZoneComponent)
1235
+ SelectorModel = construct_composer_model(
1236
+ g,
1237
+ ZoneComponent,
1238
+ use_children=False,
1239
+ )
1240
+
1241
+ with open(component_map_path) as f:
1242
+ component_map_yaml = yaml.safe_load(f)
1243
+ selector = SelectorModel.model_validate(component_map_yaml)
1244
+
1245
+ if db_path.exists():
1246
+ mtime = os.path.getmtime(db_path)
1247
+ mtime_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
1248
+ logger.info(
1249
+ f"Loading database: {db_path} "
1250
+ f"(modified: {mtime_str}, size: {db_path.stat().st_size} bytes)"
1251
+ )
1252
+ else:
1253
+ logger.error(f"Database file not found: {db_path}")
1254
+
1255
+ settings = PrismaSettings.New(
1256
+ database_path=db_path, if_exists="ignore", auto_register=False
1257
+ )
1258
+ db = settings.db
1259
+
1260
+ def _stdout_has_fileno() -> bool:
1261
+ """True if sys.stdout has a real OS file descriptor (required by Prisma on Windows in Jupyter)."""
1262
+ try:
1263
+ sys.stdout.fileno()
1264
+ except (AttributeError, io.UnsupportedOperation, OSError, ValueError):
1265
+ return False
1266
+ else:
1267
+ return True
1268
+
1269
+ if not _stdout_has_fileno():
1270
+ with open(os.devnull, "w") as _devnull:
1271
+ _old_stdout, _old_stderr = sys.stdout, sys.stderr
1272
+ sys.stdout = sys.stderr = _devnull
1273
+ try:
1274
+ with db:
1275
+ zone = cast(
1276
+ ZoneComponent,
1277
+ selector.get_component(context=semantic_field_context, db=db),
1278
+ )
1279
+ finally:
1280
+ sys.stdout, sys.stderr = _old_stdout, _old_stderr
1281
+ _devnull.close()
1282
+ else:
1283
+ with db:
1284
+ zone = cast(
1285
+ ZoneComponent,
1286
+ selector.get_component(context=semantic_field_context, db=db),
1287
+ )
1288
+ return zone
1289
+
1290
+
1209
1291
  @dataclass
1210
1292
  class ModelRunResults:
1211
1293
  """The results of a model run."""
@@ -1219,14 +1301,6 @@ class ModelRunResults:
1219
1301
 
1220
1302
 
1221
1303
  if __name__ == "__main__":
1222
- import yaml
1223
-
1224
- from epinterface.sbem.components.composer import (
1225
- construct_composer_model,
1226
- construct_graph,
1227
- )
1228
- from epinterface.sbem.prisma.client import PrismaSettings
1229
-
1230
1304
  with tempfile.TemporaryDirectory() as temp_dir:
1231
1305
  # database_path = Path("/Users/daryaguettler/globi/data/Brazil/components-lib.db")
1232
1306
  # component_map_path = Path(
@@ -1,5 +1,6 @@
1
1
  """A module for automatically fetching and composing SBEM objects."""
2
2
 
3
+ import logging
3
4
  import uuid
4
5
  from typing import Any, ClassVar, Literal, TypeVar, get_origin
5
6
 
@@ -9,6 +10,8 @@ from pydantic import BaseModel, Field, create_model
9
10
 
10
11
  from epinterface.sbem.common import NamedObject
11
12
 
13
+ logger = logging.getLogger(__name__)
14
+
12
15
  """Tree construction:
13
16
 
14
17
  Every node in the tree may or may not have a selector action specified.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "epinterface"
3
- version = "v1.2.0"
3
+ version = "v1.3.0"
4
4
  description = "This is a repository for dynamically generating energy models within Python, relying on Archetypal and Eppy for most of its functionality."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,68 @@
1
+ """Tests for construct_zone_def."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from epinterface.sbem.builder import construct_zone_def
9
+ from epinterface.sbem.components.zones import ZoneComponent
10
+ from epinterface.sbem.prisma.client import PrismaSettings
11
+ from epinterface.sbem.prisma.seed_fns import (
12
+ create_dhw_systems,
13
+ create_envelope,
14
+ create_hvac_systems,
15
+ create_operations,
16
+ create_schedules,
17
+ create_space_use_children,
18
+ create_zone,
19
+ )
20
+
21
+
22
+ @pytest.fixture(scope="module")
23
+ def preseeded_db_path_and_component_map():
24
+ """Create a preseeded database and minimal component map for construct_zone_def tests."""
25
+ with tempfile.TemporaryDirectory() as temp_dir:
26
+ db_path = Path(temp_dir) / "test.db"
27
+ component_map_path = Path(temp_dir) / "component-map.yaml"
28
+
29
+ settings = PrismaSettings.New(
30
+ database_path=db_path, if_exists="raise", auto_register=False
31
+ )
32
+ with settings.db:
33
+ create_schedules(settings.db)
34
+ last_space_use_name = create_space_use_children(settings.db)
35
+ last_hvac_name = create_hvac_systems(settings.db)
36
+ last_dhw_name = create_dhw_systems(settings.db)
37
+ _last_ops_name = create_operations(
38
+ settings.db, last_space_use_name, last_hvac_name, last_dhw_name
39
+ )
40
+ create_envelope(settings.db)
41
+ create_zone(settings.db)
42
+
43
+ component_map_path.write_text(
44
+ "selector:\n source_fields: [basic]\n",
45
+ encoding="utf-8",
46
+ )
47
+
48
+ yield db_path, component_map_path
49
+
50
+
51
+ def test_construct_zone_def_returns_zone_component(
52
+ preseeded_db_path_and_component_map: tuple[Path, Path],
53
+ ):
54
+ """Test that construct_zone_def returns a valid ZoneComponent from the preseeded db."""
55
+ db_path, component_map_path = preseeded_db_path_and_component_map
56
+
57
+ zone = construct_zone_def(
58
+ component_map_path=component_map_path,
59
+ db_path=db_path,
60
+ semantic_field_context={"basic": "default_zone"},
61
+ )
62
+
63
+ assert isinstance(zone, ZoneComponent)
64
+ assert zone.Name == "default_zone"
65
+ assert zone.Envelope is not None
66
+ assert zone.Operations is not None
67
+ assert zone.Envelope.Name == "default_env"
68
+ assert zone.Operations.Name == "default_ops"
@@ -0,0 +1,151 @@
1
+ """Tests for neighbor shading geometry (shading_fence_closed_ring, prepare_neighbor_shading_for_idf)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+ from shapely import Polygon
6
+
7
+ from epinterface.geometry import (
8
+ compute_shading_mask,
9
+ prepare_neighbor_shading_for_idf,
10
+ shading_fence_closed_ring,
11
+ )
12
+
13
+
14
+ def test_shading_fence_closed_ring():
15
+ """Test shading_fence_closed_ring output shapes and cyclic closure."""
16
+ elevations = np.array([0.1, 0.2, 0.3])
17
+ d = 100.0
18
+
19
+ az, p0, p1, h, w = shading_fence_closed_ring(elevations=elevations, d=d)
20
+
21
+ assert az.shape == (3,)
22
+ assert p0.shape == (3, 2)
23
+ assert p1.shape == (3, 2)
24
+ assert h.shape == (3,)
25
+ assert isinstance(w, float)
26
+
27
+ # Cyclic closure: p1[k] == p0[(k+1) % N] (within floating point tolerance)
28
+ for k in range(3):
29
+ np.testing.assert_allclose(p1[k], p0[(k + 1) % 3], atol=1e-12)
30
+
31
+ # Heights: h[k] == d * tan(elevations[k])
32
+ expected_h = d * np.tan(elevations)
33
+ np.testing.assert_allclose(h, expected_h, rtol=1e-10)
34
+
35
+
36
+ def test_shading_fence_closed_ring_raises_for_n_less_than_3():
37
+ """Test that shading_fence_closed_ring raises ValueError for N < 3."""
38
+ elevations = np.array([0.1, 0.2])
39
+
40
+ with pytest.raises(ValueError, match="at least 3 elevations"):
41
+ shading_fence_closed_ring(elevations=elevations, d=100)
42
+
43
+
44
+ def test_prepare_neighbor_shading_for_idf_no_neighbors():
45
+ """Test prepare_neighbor_shading_for_idf with no neighbors (default 48 azimuths)."""
46
+ building = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
47
+
48
+ mask_polys, neighbor_floors = prepare_neighbor_shading_for_idf(
49
+ building=building,
50
+ neighbors=[],
51
+ neighbor_heights=[],
52
+ f2f_height=3.0,
53
+ )
54
+
55
+ assert len(mask_polys) == 48
56
+ assert len(neighbor_floors) == 48
57
+
58
+ for poly in mask_polys:
59
+ assert poly.is_valid
60
+ assert not poly.is_empty
61
+ assert len(poly.exterior.coords) >= 4 # closed ring has repeated first point
62
+
63
+
64
+ def test_prepare_neighbor_shading_for_idf_with_neighbors():
65
+ """Test prepare_neighbor_shading_for_idf with one neighbor."""
66
+ building = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
67
+ neighbor = Polygon([(20, 5), (25, 5), (25, 15), (20, 15)])
68
+ neighbor_height = 10.0
69
+
70
+ mask_polys, neighbor_floors = prepare_neighbor_shading_for_idf(
71
+ building=building,
72
+ neighbors=[neighbor],
73
+ neighbor_heights=[neighbor_height],
74
+ f2f_height=3.0,
75
+ )
76
+
77
+ assert len(mask_polys) == 48
78
+ assert len(neighbor_floors) == 48
79
+
80
+ for poly in mask_polys:
81
+ assert poly.is_valid
82
+ assert not poly.is_empty
83
+
84
+
85
+ def test_shading_mask_from_real_geometry_matches_shading_mask_from_fake_ring():
86
+ """Shading mask from real neighbor geometry equals shading mask from the fake ring.
87
+
88
+ Manually construct a main building with neighbors scattered around it. Compute the
89
+ shading mask from these real polygons. Then build the fake ring (mask_polys) from
90
+ that shading mask and compute the shading mask again from the fake ring. The two
91
+ masks should match, since the fake ring is designed to reproduce the same elevation
92
+ angles when rays are cast from the building centroid.
93
+ """
94
+ azimuthal_angle = 2 * np.pi / 48
95
+ fence_radius = 100.0
96
+
97
+ # Main building: 10x10 square centered at (5, 5) - centroid at (5, 5)
98
+ building = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
99
+
100
+ # Neighbors: rectangles to the east, north, and northwest with different heights.
101
+ # Building centroid is (5, 5). Rays will intersect these at various distances.
102
+ neighbor_east = Polygon([(15, 2), (25, 2), (25, 8), (15, 8)]) # east, height 12m
103
+ neighbor_north = Polygon([(2, 15), (8, 15), (8, 22), (2, 22)]) # north, height 9m
104
+ neighbor_nw = Polygon([
105
+ (-5, 8),
106
+ (-2, 8),
107
+ (-2, 12),
108
+ (-5, 12),
109
+ ]) # northwest, height 6m
110
+
111
+ neighbors = [neighbor_east, neighbor_north, neighbor_nw]
112
+ neighbor_heights = [12.0, 9.0, 6.0]
113
+
114
+ # Shading mask from real geometry
115
+ mask_from_real = compute_shading_mask(
116
+ building=building,
117
+ neighbors=neighbors,
118
+ neighbor_heights=neighbor_heights,
119
+ azimuthal_angle=azimuthal_angle,
120
+ )
121
+
122
+ # Build the fake ring from this mask
123
+ mask_polys, _neighbor_floors = prepare_neighbor_shading_for_idf(
124
+ building=building,
125
+ neighbors=neighbors,
126
+ neighbor_heights=neighbor_heights,
127
+ azimuthal_angle=azimuthal_angle,
128
+ fence_radius=fence_radius,
129
+ f2f_height=3.0,
130
+ )
131
+
132
+ # Heights of the fake ring fences: h = d * tan(elevation) for each azimuth.
133
+ # We need these exact heights (not the floor-discretized ones) for an exact match.
134
+ _, _, _, fence_heights, _ = shading_fence_closed_ring(
135
+ elevations=mask_from_real, d=fence_radius
136
+ )
137
+
138
+ # Shading mask from the fake ring (using exact fence heights)
139
+ mask_from_fake_ring = compute_shading_mask(
140
+ building=building,
141
+ neighbors=mask_polys,
142
+ neighbor_heights=fence_heights.tolist(),
143
+ azimuthal_angle=azimuthal_angle,
144
+ )
145
+
146
+ np.testing.assert_allclose(
147
+ mask_from_real,
148
+ mask_from_fake_ring,
149
+ atol=1e-10,
150
+ err_msg="Shading mask from real geometry should match shading mask from fake ring",
151
+ )
@@ -573,7 +573,7 @@ wheels = [
573
573
 
574
574
  [[package]]
575
575
  name = "epinterface"
576
- version = "1.2.0"
576
+ version = "1.3.0"
577
577
  source = { editable = "." }
578
578
  dependencies = [
579
579
  { name = "archetypal" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes