comcheck-api 1.0.0__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 (54) hide show
  1. comcheck_api/DISCLAIMER.md +24 -0
  2. comcheck_api/__init__.py +99 -0
  3. comcheck_api/ai/__init__.py +30 -0
  4. comcheck_api/ai/skill/SKILL.md +285 -0
  5. comcheck_api/ai/skill/__init__.py +5 -0
  6. comcheck_api/ai/skill/reference/operations.md +101 -0
  7. comcheck_api/ai/skill/reference/simulation.md +99 -0
  8. comcheck_api/ai/skill/reference/types.md +90 -0
  9. comcheck_api/ai/skill/scripts/__init__.py +1 -0
  10. comcheck_api/ai/skill/scripts/validate_code.py +210 -0
  11. comcheck_api/api/__init__.py +1 -0
  12. comcheck_api/api/api_services.py +273 -0
  13. comcheck_api/cli.py +136 -0
  14. comcheck_api/client/__init__.py +1 -0
  15. comcheck_api/client/comcheck_client.py +335 -0
  16. comcheck_api/constants/__init__.py +0 -0
  17. comcheck_api/constants/building_area_constants.py +35 -0
  18. comcheck_api/constants/common_constants.py +116 -0
  19. comcheck_api/constants/envelope_constants.py +250 -0
  20. comcheck_api/defaults.py +150 -0
  21. comcheck_api/exceptions.py +54 -0
  22. comcheck_api/introspection.py +188 -0
  23. comcheck_api/managers/__init__.py +0 -0
  24. comcheck_api/managers/components/__init__.py +0 -0
  25. comcheck_api/managers/components/building_area.py +11 -0
  26. comcheck_api/managers/components/envelope/__init__.py +0 -0
  27. comcheck_api/managers/components/envelope/ag_wall.py +97 -0
  28. comcheck_api/managers/components/envelope/bg_wall.py +39 -0
  29. comcheck_api/managers/components/envelope/door.py +11 -0
  30. comcheck_api/managers/components/envelope/floor.py +11 -0
  31. comcheck_api/managers/components/envelope/roof.py +30 -0
  32. comcheck_api/managers/components/envelope/skylight.py +11 -0
  33. comcheck_api/managers/components/envelope/window.py +11 -0
  34. comcheck_api/managers/data_manager.py +369 -0
  35. comcheck_api/project_operations/__init__.py +8 -0
  36. comcheck_api/project_operations/project_building_area_operations.py +107 -0
  37. comcheck_api/project_operations/project_envelope_operations.py +899 -0
  38. comcheck_api/schemas/comCheck.schema.json +6463 -0
  39. comcheck_api/types/__init__.py +49 -0
  40. comcheck_api/types/api_types.py +127 -0
  41. comcheck_api/types/common_types.py +32 -0
  42. comcheck_api/types/core_types.py +4198 -0
  43. comcheck_api/types/custom_base_model.py +314 -0
  44. comcheck_api/utilities/__init__.py +5 -0
  45. comcheck_api/utilities/common.py +50 -0
  46. comcheck_api/utilities/envelope_utilities.py +46 -0
  47. comcheck_api/utilities/id_registry.py +79 -0
  48. comcheck_api/utilities/project_utilities.py +60 -0
  49. comcheck_api/validation.py +64 -0
  50. comcheck_api-1.0.0.dist-info/METADATA +244 -0
  51. comcheck_api-1.0.0.dist-info/RECORD +54 -0
  52. comcheck_api-1.0.0.dist-info/WHEEL +4 -0
  53. comcheck_api-1.0.0.dist-info/entry_points.txt +2 -0
  54. comcheck_api-1.0.0.dist-info/licenses/LICENSE +24 -0
@@ -0,0 +1,899 @@
1
+ """Project Envelope Operations."""
2
+
3
+ from typing import Any, Union
4
+
5
+ from comcheck_api.managers.components.envelope.ag_wall import AgWallListManager
6
+ from comcheck_api.types.core_types import (
7
+ AgWall,
8
+ BgWall,
9
+ ComBuilding,
10
+ Door,
11
+ Floor,
12
+ ProjectTypeOptions,
13
+ Roof,
14
+ Skylight,
15
+ ThermalBridgeCategoryOptions,
16
+ ThermalBridgeComplianceTypeOptions,
17
+ ThermalBridgeTypeOptions,
18
+ Window,
19
+ )
20
+ from comcheck_api.utilities.project_utilities import _require_building_area
21
+
22
+ # *********** Roof, agWall, bgWall and floor add/update operations ***********
23
+
24
+
25
+ def add_roof_to_project(
26
+ project: ComBuilding, building_area_key: str, new_roof: Roof
27
+ ) -> ComBuilding:
28
+ """Add a new roof to the envelope in any project object using RoofListManager.
29
+
30
+ Args:
31
+ project: The project object to modify
32
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
33
+ new_roof: The roof object to add
34
+
35
+ Returns:
36
+ Updated project object with the roof added
37
+
38
+ Raises:
39
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
40
+ """
41
+ # Deep clone to avoid mutating the original project
42
+ _require_building_area(project, building_area_key)
43
+
44
+ updated_project = project.model_copy(deep=True)
45
+
46
+ new_roof.bldgUseKey = building_area_key
47
+ updated_project.envelope.append_subcomponent(new_roof)
48
+
49
+ return updated_project
50
+
51
+
52
+ def update_roof_in_project(
53
+ project: ComBuilding, roof_assembly_type: str, updates: dict[str, Any] | Roof
54
+ ) -> ComBuilding:
55
+ """Update a roof in the project's envelope.
56
+
57
+ Args:
58
+ project: The project object to modify
59
+ roof_assembly_type: The assemblyType of the roof to update in project.envelope.roof list
60
+ updates: Partial updates (dict) or full Roof object to apply
61
+
62
+ Returns:
63
+ Updated project object with the roof added
64
+ """
65
+ updated_project = project.model_copy(deep=True)
66
+
67
+ updated_project.envelope.update_subcomponent_list(
68
+ subcomponent_updates=updates,
69
+ subcomponent_id=roof_assembly_type,
70
+ subcomponent_name="roof",
71
+ )
72
+
73
+ return updated_project
74
+
75
+
76
+ def remove_roof_from_project(
77
+ project: ComBuilding, roof_assembly_type: str
78
+ ) -> ComBuilding:
79
+ """Remove a roof from the envelope in any project object.
80
+
81
+ Args:
82
+ project: The project object to modify
83
+ roof_assembly_type: The assemblyType of the roof to update in project.envelope.roof list
84
+
85
+ Returns:
86
+ Project object with the roof removed
87
+
88
+ Raises:
89
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
90
+ """
91
+ updated_project = project.model_copy(deep=True)
92
+
93
+ updated_project.envelope.remove_from_subcomponent_list(
94
+ subcomponent_id=roof_assembly_type, subcomponent_name="roof"
95
+ )
96
+
97
+ return updated_project
98
+
99
+
100
+ def add_ag_wall_to_project(
101
+ project: ComBuilding, building_area_key: str, new_ag_wall: AgWall
102
+ ) -> ComBuilding:
103
+ """Add a new agWall to the envelope in any project object using AgWallListManager.
104
+
105
+ Args:
106
+ project: The project object to modify
107
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
108
+ new_ag_wall: The agWall object to add
109
+
110
+ Returns:
111
+ Updated project object with the agWall added
112
+
113
+ Raises:
114
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
115
+ """
116
+
117
+ _require_building_area(project, building_area_key)
118
+
119
+ updated_project = project.model_copy(deep=True)
120
+
121
+ new_ag_wall.bldgUseKey = building_area_key
122
+ updated_project.envelope.append_subcomponent(new_ag_wall)
123
+
124
+ return updated_project
125
+
126
+
127
+ def update_ag_wall_in_project(
128
+ project: ComBuilding, ag_wall_assembly_type: str, updates: dict[str, Any] | AgWall
129
+ ) -> ComBuilding:
130
+ """Update an agWall in the project's envelope.
131
+
132
+ Args:
133
+ project: The project object to modify
134
+ ag_wall_assembly_type: The assemblyType of the agWall to update in project.envelope.agWall list
135
+ updates: Partial updates (dict) or full AgWall object to apply
136
+
137
+ Returns:
138
+ Updated project object with the agWall updated
139
+ """
140
+ updated_project = project.model_copy(deep=True)
141
+
142
+ updated_project.envelope.update_subcomponent_list(
143
+ subcomponent_updates=updates,
144
+ subcomponent_id=ag_wall_assembly_type,
145
+ subcomponent_name="agWall",
146
+ )
147
+
148
+ return updated_project
149
+
150
+
151
+ def remove_ag_wall_from_project(
152
+ project: ComBuilding, ag_wall_assembly_type: str
153
+ ) -> ComBuilding:
154
+ """Remove an agWall from the envelope in any project object.
155
+
156
+ Args:
157
+ project: The project object to modify
158
+ ag_wall_assembly_type: The assemblyType of the agWall to update in project.envelope.agWall list
159
+
160
+ Returns:
161
+ Project object with the agWall removed
162
+
163
+ Raises:
164
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
165
+ """
166
+ updated_project = project.model_copy(deep=True)
167
+
168
+ updated_project.envelope.remove_from_subcomponent_list(
169
+ subcomponent_id=ag_wall_assembly_type, subcomponent_name="agWall"
170
+ )
171
+
172
+ return updated_project
173
+
174
+
175
+ def add_bg_wall_to_project(
176
+ project: ComBuilding, building_area_key: str, new_bg_wall: BgWall
177
+ ) -> ComBuilding:
178
+ """Add a new bgWall to the envelope in any project object using BgWallListManager.
179
+
180
+ Args:
181
+ project: The project object to modify
182
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
183
+ new_bg_wall: The bgWall object to add
184
+
185
+ Returns:
186
+ Updated project object with the bgWall added
187
+
188
+ Raises:
189
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
190
+ """
191
+ _require_building_area(project, building_area_key)
192
+
193
+ updated_project = project.model_copy(deep=True)
194
+
195
+ new_bg_wall.bldgUseKey = building_area_key
196
+ updated_project.envelope.append_subcomponent(new_bg_wall)
197
+
198
+ return updated_project
199
+
200
+
201
+ def update_bg_wall_in_project(
202
+ project: ComBuilding, bg_wall_assembly_type: str, updates: dict[str, Any] | BgWall
203
+ ) -> ComBuilding:
204
+ """Update an bgWall in the project's envelope.
205
+
206
+ Args:
207
+ project: The project object to modify
208
+ bg_wall_assembly_type: The assemblyType of the bgWall to update in project.envelope.bgWall list
209
+ updates: Partial updates (dict) or full BgWall object to apply
210
+
211
+ Returns:
212
+ Updated project object with the bgWall updated
213
+ """
214
+ updated_project = project.model_copy(deep=True)
215
+
216
+ updated_project.envelope.update_subcomponent_list(
217
+ subcomponent_updates=updates,
218
+ subcomponent_id=bg_wall_assembly_type,
219
+ subcomponent_name="bgWall",
220
+ )
221
+
222
+ return updated_project
223
+
224
+
225
+ def remove_bg_wall_from_project(
226
+ project: ComBuilding, bg_wall_assembly_type: str
227
+ ) -> ComBuilding:
228
+ """Remove an bgWall from the envelope in any project object.
229
+
230
+ Args:
231
+ project: The project object to modify
232
+ bg_wall_assembly_type: The assemblyType of the bgWall to update in project.envelope.bgWall list
233
+
234
+ Returns:
235
+ Project object with the bgWall removed
236
+
237
+ Raises:
238
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
239
+ """
240
+ updated_project = project.model_copy(deep=True)
241
+
242
+ updated_project.envelope.remove_from_subcomponent_list(
243
+ subcomponent_id=bg_wall_assembly_type, subcomponent_name="bgWall"
244
+ )
245
+
246
+ return updated_project
247
+
248
+
249
+ def add_floor_to_project(
250
+ project: ComBuilding, building_area_key: str, new_floor: Floor
251
+ ) -> ComBuilding:
252
+ """Add a new floor to the envelope in any project object using FloorListManager.
253
+
254
+ Args:
255
+ project: The project object to modify
256
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
257
+ new_floor: The floor object to add
258
+
259
+ Returns:
260
+ Updated project object with the floor added
261
+
262
+ Raises:
263
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
264
+ """
265
+ _require_building_area(project, building_area_key)
266
+
267
+ updated_project = project.model_copy(deep=True)
268
+
269
+ new_floor.bldgUseKey = building_area_key
270
+ updated_project.envelope.append_subcomponent(new_floor)
271
+
272
+ return updated_project
273
+
274
+
275
+ def update_floor_in_project(
276
+ project: ComBuilding, floor_assembly_type: str, updates: dict[str, Any] | Floor
277
+ ) -> ComBuilding:
278
+ """Update an floor in the project's envelope.
279
+
280
+ Args:
281
+ project: The project object to modify
282
+ floor_assembly_type: The assemblyType of the floor to update in project.envelope.floor list
283
+ updates: Partial updates (dict) or full Floor object to apply
284
+
285
+ Returns:
286
+ Updated project object with the floor updated
287
+ """
288
+ updated_project = project.model_copy(deep=True)
289
+
290
+ updated_project.envelope.update_subcomponent_list(
291
+ subcomponent_updates=updates,
292
+ subcomponent_id=floor_assembly_type,
293
+ subcomponent_name="floor",
294
+ )
295
+
296
+ return updated_project
297
+
298
+
299
+ def remove_floor_from_project(
300
+ project: ComBuilding, floor_assembly_type: str
301
+ ) -> ComBuilding:
302
+ """Remove an bgWall from the envelope in any project object.
303
+
304
+ Args:
305
+ project: The project object to modify
306
+ floor_assembly_type: The assemblyType of the floor to remove in project.envelope.floor list
307
+
308
+ Returns:
309
+ Project object with the floor removed
310
+
311
+ Raises:
312
+ ValueError: If buildingAreaKey is not found in lighting.wholeBldgUse list
313
+ """
314
+ updated_project = project.model_copy(deep=True)
315
+
316
+ updated_project.envelope.remove_from_subcomponent_list(
317
+ subcomponent_id=floor_assembly_type, subcomponent_name="floor"
318
+ )
319
+
320
+ return updated_project
321
+
322
+
323
+ # *********** Assemblies or Components attached to wall or roof ***********
324
+
325
+
326
+ def add_skylight_to_project(
327
+ project: ComBuilding,
328
+ building_area_key: str,
329
+ new_skylight: Skylight,
330
+ roof: Roof | None = None,
331
+ ) -> ComBuilding:
332
+ """Add a new skylight to a specific roof in the envelope.
333
+
334
+ Args:
335
+ project: The project object to modify
336
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
337
+ new_skylight: The skylight object to add
338
+ roof: The roof object to which the skylight will be added (required for non-alteration projects)
339
+
340
+ Returns:
341
+ Updated project object with the skylight added to the specified roof
342
+
343
+ Raises:
344
+ ValueError: If buildingAreaKey is not found or if roof's bldgUseKey doesn't match buildingAreaKey
345
+ """
346
+ updated_project = project.model_copy(deep=True)
347
+
348
+ _require_building_area(project, building_area_key)
349
+ updated_project.require_attribute("envelope")
350
+
351
+ new_skylight.bldgUseKey = building_area_key
352
+
353
+ if updated_project.projectType != ProjectTypeOptions.ALTERATION:
354
+ # Add to existing roof for non-alteration projects
355
+ if roof is None:
356
+ raise ValueError("Roof must be specified for non-alteration projects.")
357
+
358
+ if (roof_use_key := getattr(roof, "bldgUseKey")) != building_area_key:
359
+ raise ValueError(
360
+ f"Roof's bldgUseKey '{roof_use_key}' does not match buildingAreaKey '{building_area_key}'."
361
+ )
362
+
363
+ roof.append_subcomponent(new_skylight, "skylight")
364
+ updated_project.envelope.update_subcomponent_list(
365
+ subcomponent_updates=roof,
366
+ subcomponent_id=getattr(roof, "assemblyType"),
367
+ subcomponent_name=roof.json_key(),
368
+ )
369
+
370
+ return updated_project
371
+ else:
372
+ # Alteration projects: add orphaned skylight directly
373
+ updated_project.envelope.append_subcomponent(new_skylight, "skylight")
374
+ return updated_project
375
+
376
+
377
+ def remove_skylight_from_project(
378
+ project: ComBuilding,
379
+ skylight_assembly_type: str,
380
+ ) -> ComBuilding:
381
+ """Add a new skylight to a specific roof in the envelope.
382
+
383
+ Args:
384
+ project: ComBuilding,
385
+ skylight_assembly_type: str
386
+
387
+ Returns:
388
+ Updated project object with the skylight added to the specified roof
389
+
390
+ Raises:
391
+ ValueError: If buildingAreaKey is not found or if roof's bldgUseKey doesn't match buildingAreaKey
392
+ """
393
+ updated_project = project.model_copy(deep=True)
394
+
395
+ # Find where the skylight is located
396
+ location_type, roof_index, _ = _find_component_location(
397
+ project, "skylight", skylight_assembly_type
398
+ )
399
+
400
+ # Update skylight based on its location
401
+ if location_type == "orphaned":
402
+ parent_obj = updated_project.envelope
403
+ elif location_type == "roof":
404
+ parent_obj = updated_project.envelope.roof[roof_index] # type: ignore
405
+ parent_obj.remove_from_subcomponent_list(
406
+ subcomponent_id=skylight_assembly_type, subcomponent_name="skylight"
407
+ )
408
+
409
+ return updated_project
410
+
411
+
412
+ def update_skylight_in_project(
413
+ project: ComBuilding,
414
+ skylight_assembly_type: str,
415
+ updates: dict[str, Any] | Skylight,
416
+ ) -> ComBuilding:
417
+ """Update a skylight in the project's envelope.
418
+
419
+ Skylights can exist in two locations:
420
+ 1. Orphaned skylights in envelope.skylight (ALTERATION projects)
421
+ 2. Skylights nested in roof list
422
+
423
+ Args:
424
+ project: The project object to modify
425
+ skylight_assembly_type: The assemblyType of the skylight to update
426
+ updates: Partial updates (dict) or full Skylight object to apply
427
+
428
+ Returns:
429
+ Updated project object with the skylight updated
430
+
431
+ Raises:
432
+ ValueError: If the skylight is not found in any location
433
+ """
434
+ updated_project = project.model_copy(deep=True)
435
+
436
+ # Find where the skylight is located
437
+ location_type, roof_index, _ = _find_component_location(
438
+ project, "skylight", skylight_assembly_type
439
+ )
440
+
441
+ # Update skylight based on its location
442
+ if location_type == "orphaned":
443
+ parent_obj = updated_project.envelope
444
+ elif location_type == "roof":
445
+ parent_obj = updated_project.envelope.roof[roof_index] # type: ignore
446
+ parent_obj.update_subcomponent_list(
447
+ subcomponent_updates=updates,
448
+ subcomponent_id=skylight_assembly_type,
449
+ subcomponent_name="skylight",
450
+ )
451
+
452
+ return updated_project
453
+
454
+
455
+ def add_window_to_project(
456
+ project: ComBuilding,
457
+ building_area_key: str,
458
+ new_window: Window,
459
+ wall: AgWall | BgWall | None = None,
460
+ ) -> ComBuilding:
461
+ """Add a new window to a wall (AgWall or BgWall) in the envelope.
462
+
463
+ Args:
464
+ project: The project object to modify
465
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
466
+ new_window: The window object to add
467
+ wall: The AgWall or BgWall object to which the window will be added - required for non-alteration projects
468
+
469
+ Returns:
470
+ Updated project object with the window added to the specified wall
471
+
472
+ Raises:
473
+ ValueError: If buildingAreaKey is not found or if wall's bldgUseKey doesn't match buildingAreaKey
474
+ """
475
+ updated_project = project.model_copy(deep=True)
476
+
477
+ _require_building_area(project, building_area_key)
478
+ updated_project.require_attribute("envelope")
479
+
480
+ new_window.bldgUseKey = building_area_key
481
+
482
+ if updated_project.projectType != ProjectTypeOptions.ALTERATION:
483
+ if wall is None:
484
+ raise ValueError(
485
+ "Wall (AgWall or BgWall) must be specified for non-alteration projects."
486
+ )
487
+
488
+ if (wall_use_key := getattr(wall, "bldgUseKey")) != building_area_key:
489
+ raise ValueError(
490
+ f"Wall's bldgUseKey '{wall_use_key}' does not match buildingAreaKey '{building_area_key}'."
491
+ )
492
+
493
+ wall.append_subcomponent(new_window, "window")
494
+ updated_project.envelope.update_subcomponent_list(
495
+ subcomponent_updates=wall,
496
+ subcomponent_id=getattr(wall, "assemblyType"),
497
+ subcomponent_name=wall.json_key(),
498
+ )
499
+ return updated_project
500
+ else:
501
+ # Alteration projects: orphaned windows
502
+ updated_project.envelope.append_subcomponent(new_window, "window")
503
+ return updated_project
504
+
505
+
506
+ def remove_window_from_project(
507
+ project: ComBuilding,
508
+ window_assembly_type: str,
509
+ ) -> ComBuilding:
510
+ """Remove a window from the project.
511
+
512
+ Args:
513
+ project: ComBuilding,
514
+ window_assembly_type: str
515
+
516
+ Returns:
517
+ Updated project object with the window removed
518
+ """
519
+ updated_project = project.model_copy(deep=True)
520
+
521
+ # Find where the window is located
522
+ location_type, wall_index, _ = _find_component_location(
523
+ project, "window", window_assembly_type
524
+ )
525
+
526
+ # Update window based on its location
527
+ if location_type == "orphaned":
528
+ parent_obj = updated_project.envelope
529
+ elif location_type in ["agWall", "bgWall"]:
530
+ parent_obj = updated_project.envelope.get_by_path(f"{location_type}[{wall_index}]") # type: ignore[assignment]
531
+ parent_obj.remove_from_subcomponent_list(
532
+ subcomponent_id=window_assembly_type, subcomponent_name="window"
533
+ )
534
+
535
+ return updated_project
536
+
537
+
538
+ def update_window_in_project(
539
+ project: ComBuilding, window_assembly_type: str, updates: dict[str, Any] | Window
540
+ ) -> ComBuilding:
541
+ """Update a window in the project's envelope.
542
+
543
+ Windows can exist in three locations:
544
+ 1. Orphaned windows in envelope.window (ALTERATION projects)
545
+ 2. Windows nested in agWall list
546
+ 3. Windows nested in bgWall list
547
+
548
+ Args:
549
+ project: The project object to modify
550
+ window_assembly_type: The assemblyType of the window to update
551
+ updates: Partial updates (dict) or full Window object to apply
552
+
553
+ Returns:
554
+ Updated project object with the window updated
555
+
556
+ Raises:
557
+ ValueError: If the window is not found in any location
558
+ """
559
+ updated_project = project.model_copy(deep=True)
560
+
561
+ # Find where the window is located
562
+ location_type, wall_index, _ = _find_component_location(
563
+ project, "window", window_assembly_type
564
+ )
565
+
566
+ # Update window based on its location
567
+ if location_type == "orphaned":
568
+ parent_obj = updated_project.envelope
569
+ elif location_type in ["agWall", "bgWall"]:
570
+ parent_obj = updated_project.envelope.get_by_path(f"{location_type}[{wall_index}]") # type: ignore[assignment]
571
+ parent_obj.update_subcomponent_list(
572
+ subcomponent_updates=updates,
573
+ subcomponent_id=window_assembly_type,
574
+ subcomponent_name="window",
575
+ )
576
+
577
+ return updated_project
578
+
579
+
580
+ def add_door_to_project(
581
+ project: ComBuilding,
582
+ building_area_key: str,
583
+ new_door: Door,
584
+ wall: AgWall | BgWall | None = None,
585
+ ) -> ComBuilding:
586
+ """Add a new door to a wall (AgWall or BgWall) in the envelope.
587
+
588
+ Args:
589
+ project: The project object to modify
590
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
591
+ new_door: The door object to add
592
+ wall: The AgWall or BgWall object to which the door will be added - required for non-alteration projects
593
+
594
+ Returns:
595
+ Updated project object with the door added to the specified wall
596
+
597
+ Raises:
598
+ ValueError: If buildingAreaKey is not found or if wall's bldgUseKey doesn't match buildingAreaKey
599
+ """
600
+
601
+ updated_project = project.model_copy(deep=True)
602
+
603
+ _require_building_area(project, building_area_key)
604
+ updated_project.require_attribute("envelope")
605
+
606
+ new_door.bldgUseKey = building_area_key
607
+
608
+ if updated_project.projectType != ProjectTypeOptions.ALTERATION:
609
+ # Add to existing wall for non-alteration projects
610
+ if not wall:
611
+ raise ValueError(
612
+ "Wall (AgWall or BgWall) must be specified for non-alteration projects."
613
+ )
614
+
615
+ if (wall_use_key := getattr(wall, "bldgUseKey")) != building_area_key:
616
+ raise ValueError(
617
+ f"Wall's bldgUseKey '{wall_use_key}' does not match buildingAreaKey '{building_area_key}'."
618
+ )
619
+ wall.append_subcomponent(new_door, "door")
620
+ updated_project.envelope.update_subcomponent_list(
621
+ subcomponent_updates=wall,
622
+ subcomponent_id=getattr(wall, "assemblyType"),
623
+ subcomponent_name=wall.json_key(),
624
+ )
625
+ return updated_project
626
+ else:
627
+ updated_project.envelope.append_subcomponent(new_door)
628
+
629
+ return updated_project
630
+
631
+
632
+ def remove_door_from_project(
633
+ project: ComBuilding,
634
+ door_assembly_type: str,
635
+ ) -> ComBuilding:
636
+ """Remove a door from the project.
637
+
638
+ Args:
639
+ project: ComBuilding,
640
+ door_assembly_type: str
641
+
642
+ Returns:
643
+ Updated project object with the door removed
644
+ """
645
+ updated_project = project.model_copy(deep=True)
646
+
647
+ # Find where the door is located
648
+ location_type, wall_index, _ = _find_component_location(
649
+ project, "door", door_assembly_type
650
+ )
651
+
652
+ # Remove door based on its location
653
+ if location_type == "orphaned":
654
+ parent_obj = updated_project.envelope
655
+ elif location_type in ["agWall", "bgWall"]:
656
+ parent_obj = updated_project.envelope.get_by_path(f"{location_type}[{wall_index}]") # type: ignore[assignment]
657
+ parent_obj.remove_from_subcomponent_list(
658
+ subcomponent_id=door_assembly_type, subcomponent_name="door"
659
+ )
660
+
661
+ return updated_project
662
+
663
+
664
+ def update_door_in_project(
665
+ project: ComBuilding, door_assembly_type: str, updates: dict[str, Any] | Door
666
+ ) -> ComBuilding:
667
+ """Update a door in the project's envelope.
668
+
669
+ Doors can exist in three locations:
670
+ 1. Orphaned doors in envelope.door (ALTERATION projects)
671
+ 2. Doors nested in agWall list
672
+ 3. Doors nested in bgWall list
673
+
674
+ Args:
675
+ project: The project object to modify
676
+ door_assembly_type: The assemblyType of the door to update
677
+ updates: Partial updates (dict) or full Door object to apply
678
+
679
+ Returns:
680
+ Updated project object with the door updated
681
+
682
+ Raises:
683
+ ValueError: If the door is not found in any location
684
+ """
685
+ updated_project = project.model_copy(deep=True)
686
+
687
+ # Find where the door is located
688
+ location_type, wall_index, _ = _find_component_location(
689
+ project, "door", door_assembly_type
690
+ )
691
+
692
+ # Update door based on its location
693
+ if location_type == "orphaned":
694
+ parent_obj = updated_project.envelope
695
+ elif location_type in ["agWall", "bgWall"]:
696
+ parent_obj = updated_project.envelope.get_by_path(f"{location_type}[{wall_index}]") # type: ignore[assignment]
697
+ parent_obj.update_subcomponent_list(
698
+ subcomponent_updates=updates,
699
+ subcomponent_id=door_assembly_type,
700
+ subcomponent_name="door",
701
+ )
702
+
703
+ return updated_project
704
+
705
+
706
+ # TODO: verify when thermal bridges are needed
707
+ def add_thermal_bridge_to_project(
708
+ project: ComBuilding,
709
+ building_area_key: str,
710
+ ag_wall: AgWall,
711
+ thermal_bridge_type: (
712
+ ThermalBridgeTypeOptions | None
713
+ ) = ThermalBridgeTypeOptions.THERMAL_BRIDGE_OTHER,
714
+ thermal_bridge_category: Union[
715
+ ThermalBridgeCategoryOptions | None
716
+ ] = ThermalBridgeCategoryOptions.THERMAL_BRIDGE_UNCATEGORIZED,
717
+ thermal_bridge_compliance_type: Union[
718
+ ThermalBridgeComplianceTypeOptions | None
719
+ ] = ThermalBridgeComplianceTypeOptions.THERMAL_BRIDGE_NON_PRESCRIPTIVE,
720
+ psi_factor: float = 0.0,
721
+ chi_factor: float = 0.0,
722
+ thermal_bridge_length: float = 0.0,
723
+ number_of_points: int = 0,
724
+ ) -> ComBuilding:
725
+ """Add a new thermal bridge to an AgWall in the envelope.
726
+
727
+ Note: Thermal bridges can only be added to AgWalls and cannot be orphaned.
728
+
729
+ Args:
730
+ project: The project object to modify
731
+ building_area_key: The building area key to validate against lighting.wholeBldgUse list
732
+ ag_wall: The AgWall object to which the thermal bridge will be added (required)
733
+ thermal_bridge_type: Type of thermal bridge (defaults to 'THERMAL_BRIDGE_OTHER')
734
+ thermal_bridge_category: Category of thermal bridge (defaults to 'THERMAL_BRIDGE_UNCATEGORIZED')
735
+ thermal_bridge_compliance_type: Compliance type (defaults to 'THERMAL_BRIDGE_NON_PRESCRIPTIVE')
736
+ psi_factor: Psi factor (defaults to 0.0)
737
+ chi_factor: Chi factor (defaults to 0.0)
738
+ thermal_bridge_length: Length of thermal bridge (defaults to 0.0)
739
+ number_of_points: Number of points for thermal bridge (defaults to 0)
740
+
741
+ Returns:
742
+ Updated project object with the thermal bridge added to the specified AgWall
743
+
744
+ Raises:
745
+ ValueError: If buildingAreaKey is not found, agWall is not provided, or wall's bldgUseKey doesn't match buildingAreaKey
746
+ """
747
+ updated_project = project.model_copy(deep=True)
748
+
749
+ _require_building_area(
750
+ project, building_area_key
751
+ ) # assuming this works with models
752
+
753
+ if ag_wall is None:
754
+ raise ValueError("AgWall must be specified for thermal bridges.")
755
+
756
+ if getattr(ag_wall, "bldgUseKey", None) != building_area_key:
757
+ raise ValueError(
758
+ f"AgWall's bldgUseKey '{getattr(ag_wall, 'bldgUseKey')}' does not match buildingAreaKey '{building_area_key}'."
759
+ )
760
+
761
+ envelope = getattr(updated_project, "envelope", None)
762
+ if envelope is None:
763
+ raise ValueError("Envelope not found in project.")
764
+
765
+ ag_wall_list = getattr(envelope, "agWall", None)
766
+ if not isinstance(ag_wall_list, list):
767
+ raise ValueError("agWall list not found or invalid.")
768
+
769
+ # Find index of matching agWall by assemblyType (attribute access)
770
+ ag_wall_index = next(
771
+ (
772
+ i
773
+ for i, w in enumerate(ag_wall_list)
774
+ if getattr(w, "assemblyType", None)
775
+ == getattr(ag_wall, "assemblyType", None)
776
+ ),
777
+ -1,
778
+ )
779
+
780
+ if ag_wall_index == -1:
781
+ raise ValueError("Specified agWall not found in project.")
782
+
783
+ # Use your manager on Pydantic list
784
+ manager = AgWallListManager(ag_wall_list)
785
+ updated_wall = manager.add_new_thermal_bridge(
786
+ ag_wall_list[ag_wall_index],
787
+ thermal_bridge_type,
788
+ thermal_bridge_category,
789
+ thermal_bridge_compliance_type,
790
+ psi_factor,
791
+ chi_factor,
792
+ thermal_bridge_length,
793
+ number_of_points,
794
+ )
795
+
796
+ # Replace the agWall in the list
797
+ ag_wall_list[ag_wall_index] = updated_wall
798
+
799
+ # If envelope.agWall is a Pydantic field with validation, reassign updated list
800
+ envelope.agWall = ag_wall_list
801
+
802
+ # If updated_project.envelope is frozen or uses validators, reassign as needed
803
+ updated_project.envelope = envelope
804
+
805
+ return updated_project
806
+
807
+
808
+ # *********** Helper Functions ***********
809
+
810
+
811
+ def _find_component_location(
812
+ project: ComBuilding, component_type: str, assembly_type: str
813
+ ) -> tuple[str, int | None, int | None]:
814
+ """Find the location of a window, door, or skylight component.
815
+
816
+ Components can exist in different locations:
817
+ - Windows/Doors: orphaned (envelope.window/door) or nested in agWall/bgWall
818
+ - Skylights: orphaned (envelope.skylight) or nested in roof
819
+
820
+ Args:
821
+ project: The project to search in
822
+ component_type: Either "window", "door", or "skylight"
823
+ assembly_type: The assemblyType to search for
824
+
825
+ Returns:
826
+ Tuple of (location_type, parent_index, component_index) where:
827
+ - location_type is "orphaned", "agWall", "bgWall", or "roof"
828
+ - parent_index is the index in agWall/bgWall/roof list (None for orphaned)
829
+ - component_index is the index in the component list
830
+
831
+ Raises:
832
+ ValueError: If component is not found anywhere
833
+ """
834
+ # Check orphaned components first
835
+
836
+ if project.projectType == ProjectTypeOptions.ALTERATION:
837
+ orphaned_list = getattr(project.envelope, component_type, [])
838
+ component_index = next(
839
+ (
840
+ i
841
+ for i, c in enumerate(orphaned_list)
842
+ if getattr(c, "assemblyType", None) == assembly_type
843
+ ),
844
+ -1,
845
+ )
846
+ if component_index != -1:
847
+ return ("orphaned", None, component_index)
848
+ else:
849
+ # For windows/doors: check agWall and bgWall components
850
+ if component_type in ("window", "door"):
851
+ # Check agWall components
852
+ for ag_wall_index, ag_wall in enumerate(project.envelope.agWall):
853
+ wall_components = getattr(ag_wall, component_type, [])
854
+ component_index = next(
855
+ (
856
+ i
857
+ for i, c in enumerate(wall_components)
858
+ if getattr(c, "assemblyType", None) == assembly_type
859
+ ),
860
+ -1,
861
+ )
862
+ if component_index != -1:
863
+ return ("agWall", ag_wall_index, component_index)
864
+
865
+ # Check bgWall components
866
+ for bg_wall_index, bg_wall in enumerate(project.envelope.bgWall):
867
+ wall_components = getattr(bg_wall, component_type, [])
868
+ component_index = next(
869
+ (
870
+ i
871
+ for i, c in enumerate(wall_components)
872
+ if getattr(c, "assemblyType", None) == assembly_type
873
+ ),
874
+ -1,
875
+ )
876
+ if component_index != -1:
877
+ return ("bgWall", bg_wall_index, component_index)
878
+
879
+ # For skylights: check roof components
880
+ elif component_type == "skylight":
881
+ for roof_index, roof in enumerate(project.envelope.roof or []):
882
+ roof_skylights = getattr(roof, "skylight", [])
883
+ skylight_index = next(
884
+ (
885
+ i
886
+ for i, s in enumerate(roof_skylights)
887
+ if getattr(s, "assemblyType", None) == assembly_type
888
+ ),
889
+ -1,
890
+ )
891
+ if skylight_index != -1:
892
+ return ("roof", roof_index, skylight_index)
893
+
894
+ raise ValueError(
895
+ f"{component_type.capitalize()} with assemblyType '{assembly_type}' not found in project"
896
+ )
897
+
898
+
899
+ # *********** End of Project Envelope Operations ***********