openepd 4.13.1__py3-none-any.whl → 5.1.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 (60) hide show
  1. openepd/__version__.py +1 -1
  2. openepd/model/common.py +40 -1
  3. openepd/model/declaration.py +7 -2
  4. openepd/model/geography.py +1 -1
  5. openepd/model/lcia.py +191 -19
  6. openepd/model/pcr.py +2 -2
  7. openepd/model/specs/README.md +34 -8
  8. openepd/model/specs/asphalt.py +2 -2
  9. openepd/model/specs/base.py +15 -5
  10. openepd/model/specs/generated/__init__.py +80 -0
  11. openepd/model/specs/generated/cladding.py +4 -4
  12. openepd/model/specs/generated/concrete.py +8 -7
  13. openepd/model/specs/generated/electrical.py +2 -2
  14. openepd/model/specs/generated/finishes.py +10 -6
  15. openepd/model/specs/generated/masonry.py +6 -2
  16. openepd/model/specs/generated/network_infrastructure.py +7 -2
  17. openepd/model/specs/generated/openings.py +10 -6
  18. openepd/model/specs/generated/sheathing.py +8 -4
  19. openepd/model/specs/generated/steel.py +10 -5
  20. openepd/model/specs/range/__init__.py +101 -0
  21. openepd/model/specs/range/accessories.py +97 -0
  22. openepd/model/specs/range/aggregates.py +57 -0
  23. openepd/model/specs/range/aluminium.py +92 -0
  24. openepd/model/specs/range/asphalt.py +61 -0
  25. openepd/model/specs/range/bulk_materials.py +31 -0
  26. openepd/model/specs/range/cast_decks_and_underlayment.py +34 -0
  27. openepd/model/specs/range/cladding.py +275 -0
  28. openepd/model/specs/range/cmu.py +44 -0
  29. openepd/model/specs/range/concrete.py +179 -0
  30. openepd/model/specs/range/conveying_equipment.py +86 -0
  31. openepd/model/specs/range/electrical.py +422 -0
  32. openepd/model/specs/range/electrical_transmission_and_distribution_equipment.py +96 -0
  33. openepd/model/specs/range/electricity.py +31 -0
  34. openepd/model/specs/range/finishes.py +585 -0
  35. openepd/model/specs/range/fire_and_smoke_protection.py +108 -0
  36. openepd/model/specs/range/furnishings.py +137 -0
  37. openepd/model/specs/range/grouting.py +34 -0
  38. openepd/model/specs/range/manufacturing_inputs.py +190 -0
  39. openepd/model/specs/range/masonry.py +87 -0
  40. openepd/model/specs/range/material_handling.py +50 -0
  41. openepd/model/specs/range/mechanical.py +307 -0
  42. openepd/model/specs/range/mechanical_insulation.py +42 -0
  43. openepd/model/specs/range/network_infrastructure.py +208 -0
  44. openepd/model/specs/range/openings.py +512 -0
  45. openepd/model/specs/range/other_electrical_equipment.py +31 -0
  46. openepd/model/specs/range/other_materials.py +194 -0
  47. openepd/model/specs/range/plumbing.py +200 -0
  48. openepd/model/specs/range/precast_concrete.py +115 -0
  49. openepd/model/specs/range/sheathing.py +86 -0
  50. openepd/model/specs/range/steel.py +332 -0
  51. openepd/model/specs/range/thermal_moisture_protection.py +336 -0
  52. openepd/model/specs/range/utility_piping.py +75 -0
  53. openepd/model/specs/range/wood.py +228 -0
  54. openepd/model/specs/range/wood_joists.py +44 -0
  55. openepd/model/validation/numbers.py +11 -5
  56. openepd/model/validation/quantity.py +469 -58
  57. {openepd-4.13.1.dist-info → openepd-5.1.0.dist-info}/METADATA +6 -1
  58. {openepd-4.13.1.dist-info → openepd-5.1.0.dist-info}/RECORD +60 -25
  59. {openepd-4.13.1.dist-info → openepd-5.1.0.dist-info}/LICENSE +0 -0
  60. {openepd-4.13.1.dist-info → openepd-5.1.0.dist-info}/WHEEL +0 -0
openepd/__version__.py CHANGED
@@ -13,4 +13,4 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
  #
16
- VERSION = "4.13.1"
16
+ VERSION = "5.1.0"
openepd/model/common.py CHANGED
@@ -42,7 +42,7 @@ class Measurement(BaseOpenEpdSchema):
42
42
  """A scientific value with units and uncertainty."""
43
43
 
44
44
  mean: float = pyd.Field(description="Mean (expected) value of the measurement")
45
- unit: str = pyd.Field(description="Measurement unit")
45
+ unit: str | None = pyd.Field(description="Measurement unit")
46
46
  rsd: pyd.PositiveFloat | None = pyd.Field(
47
47
  description="Relative standard deviation, i.e. standard_deviation/mean", default=None
48
48
  )
@@ -150,3 +150,42 @@ class OpenEPDUnit(StrEnum):
150
150
  degree_c = "°C"
151
151
  kg_co2 = "kgCO2e"
152
152
  hour = "hour"
153
+
154
+
155
+ class RangeBase(BaseOpenEpdSchema):
156
+ """Base class for range types having min and max and order between them."""
157
+
158
+ @pyd.root_validator
159
+ def _validate_range_bounds(cls, values: dict[str, Any]) -> dict[str, Any]:
160
+ min_boundary = values.get("min")
161
+ max_boundary = values.get("max")
162
+ if min_boundary is not None and max_boundary is not None and min_boundary > max_boundary:
163
+ raise ValueError("Max should be greater than min")
164
+ return values
165
+
166
+
167
+ class RangeFloat(RangeBase):
168
+ """Structure representing a range of floats."""
169
+
170
+ min: float | None = pyd.Field(default=None, example=3.1)
171
+ max: float | None = pyd.Field(default=None, example=5.8)
172
+
173
+
174
+ class RangeInt(RangeBase):
175
+ """Structure representing a range of ints1."""
176
+
177
+ min: int | None = pyd.Field(default=None, example=2)
178
+ max: int | None = pyd.Field(default=None, example=3)
179
+
180
+
181
+ class RangeRatioFloat(RangeFloat):
182
+ """Range of ratios (0-1 to 0-10)."""
183
+
184
+ min: float | None = pyd.Field(default=None, example=0.2, ge=0, le=1)
185
+ max: float | None = pyd.Field(default=None, example=0.65, ge=0, le=1)
186
+
187
+
188
+ class RangeAmount(RangeFloat):
189
+ """Structure representing a range of quantities."""
190
+
191
+ unit: str | None = pyd.Field(default=None, description="Unit of the range.")
@@ -22,6 +22,7 @@ from openepd.model.common import Amount
22
22
  from openepd.model.geography import Geography
23
23
  from openepd.model.org import Org
24
24
  from openepd.model.pcr import Pcr
25
+ from openepd.model.specs.range import SpecsRange
25
26
  from openepd.model.standard import Standard
26
27
  from openepd.model.validation.common import ReferenceStr
27
28
  from openepd.model.validation.quantity import AmountGWP, AmountMass
@@ -141,14 +142,14 @@ class BaseDeclaration(RootDocument, abc.ABC):
141
142
  description="Mass of elemental carbon, per declared unit, contained in the product itself at the manufacturing "
142
143
  "facility gate. Used (among other things) to check a carbon balance or calculate incineration "
143
144
  "emissions. The source of carbon (e.g. biogenic) is not relevant in this field.",
144
- example=Amount(qty=8.76, unit="kgCO2e"),
145
+ example=Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True),
145
146
  )
146
147
  kg_C_biogenic_per_declared_unit: AmountGWP | None = pyd.Field(
147
148
  default=None,
148
149
  description="Mass of elemental carbon from biogenic sources, per declared unit, contained in the product "
149
150
  "itself at the manufacturing facility gate. It may be presumed that any biogenic carbon content "
150
151
  "has been accounted for as -44/12 kgCO2e per kg C in stages A1-A3, per EN15804 and ISO 21930.",
151
- example=Amount(qty=8.76, unit="kgCO2e"),
152
+ example=Amount(qty=8.76, unit="kgCO2e").to_serializable(exclude_unset=True),
152
153
  )
153
154
  product_service_life_years: float | None = pyd.Field(
154
155
  gt=0.0009,
@@ -173,6 +174,10 @@ class AverageDatasetMixin(pyd.BaseModel, title="Average Dataset"):
173
174
  "implies global applicability.",
174
175
  )
175
176
 
177
+ specs: SpecsRange | None = pyd.Field(
178
+ default=None, description="Average dataset material performance specifications."
179
+ )
180
+
176
181
 
177
182
  class WithProgramOperatorMixin(pyd.BaseModel):
178
183
  """Object which has a connection to ProgramOperator."""
@@ -279,7 +279,7 @@ class Geography(StrEnum):
279
279
  * ZM: Zambia
280
280
  * ZW: Zimbabwe
281
281
 
282
- USA and Canada subdivisions, see https://en.wikipedia.org/wiki/ISO_3166-1:
282
+ USA states and Canada provinces, see https://en.wikipedia.org/wiki/ISO_3166-1:
283
283
 
284
284
  * CA-AB: Alberta, Canada
285
285
  * CA-BC: British Columbia, Canada
openepd/model/lcia.py CHANGED
@@ -14,10 +14,12 @@
14
14
  # limitations under the License.
15
15
  #
16
16
  from enum import StrEnum
17
+ from typing import Any, ClassVar
17
18
 
18
19
  from openepd.compat.pydantic import pyd
19
20
  from openepd.model.base import BaseOpenEpdSchema
20
21
  from openepd.model.common import Measurement
22
+ from openepd.model.validation.quantity import ExternalValidationConfig
21
23
 
22
24
 
23
25
  class EolScenario(BaseOpenEpdSchema):
@@ -70,6 +72,8 @@ class ScopeSet(BaseOpenEpdSchema):
70
72
  The 'unit' field must be consistent across all scopes in a single scopeset.
71
73
  """
72
74
 
75
+ allowed_units: ClassVar[str | tuple[str, ...] | None] = None
76
+
73
77
  A1A2A3: Measurement | None = pyd.Field(
74
78
  description="Sum of A1..A3",
75
79
  default=None,
@@ -194,17 +198,56 @@ class ScopeSet(BaseOpenEpdSchema):
194
198
  description="Potential net benefits from reuse, recycling, and/or energy recovery beyond the system boundary.",
195
199
  )
196
200
 
201
+ @pyd.root_validator
202
+ def _unit_validator(cls, values: dict[str, Any]) -> dict[str, Any]:
203
+ all_units = set()
204
+
205
+ for k, v in values.items():
206
+ if isinstance(v, Measurement):
207
+ all_units.add(v.unit)
208
+
209
+ # units should be the same across all measurements (textually)
210
+ if len(all_units) > 1:
211
+ raise ValueError("All scopes and measurements should be expressed in the same unit.")
212
+
213
+ # can correctly validate unit
214
+ if cls.allowed_units is not None and len(all_units) == 1 and ExternalValidationConfig.QUANTITY_VALIDATOR:
215
+ unit = next(iter(all_units))
216
+ allowed_units = cls.allowed_units if isinstance(cls.allowed_units, tuple) else (cls.allowed_units,)
197
217
 
198
- class ScopesetByNameBase(BaseOpenEpdSchema):
218
+ matched_unit = False
219
+ for allowed_unit in allowed_units:
220
+ try:
221
+ ExternalValidationConfig.QUANTITY_VALIDATOR.validate_same_dimensionality(unit, allowed_unit)
222
+ matched_unit = True
223
+ except ValueError:
224
+ ...
225
+ if not matched_unit:
226
+ raise ValueError(
227
+ f"'{', '.join(allowed_units)}' is only allowed unit for this scopeset. Provided '{unit}'"
228
+ )
229
+
230
+ return values
231
+
232
+
233
+ class ScopesetByNameBase(BaseOpenEpdSchema, extra="allow"):
199
234
  """Base class for the data structures presented as typed name:scopeset mapping ."""
200
235
 
201
236
  def get_scopeset_names(self) -> list[str]:
202
237
  """
203
238
  Get the names of scopesets which have been set by model (not defaults).
204
239
 
205
- :return: set of names, for example ['gwp', 'odp]
240
+ :return: set of names, for example ['gwp', 'odp']
206
241
  """
207
- return [self.__fields__[f].alias or f for f in self.__fields_set__ if f not in ("ext",)]
242
+ result = []
243
+ for f in self.__fields_set__:
244
+ if f in ("ext",):
245
+ continue
246
+ field = self.__fields__.get(f)
247
+ # field can be explicitly specified, or can be an unknown impact covered by extra='allow'
248
+ result.append(field.alias if field and field.alias else f)
249
+
250
+ return result
208
251
 
209
252
  def get_scopeset_by_name(self, name: str) -> ScopeSet | None:
210
253
  """
@@ -213,39 +256,133 @@ class ScopesetByNameBase(BaseOpenEpdSchema):
213
256
  :param name: The name of the scopeset.
214
257
  :return: A scopeset if found, None otherwise
215
258
  """
259
+ # check known impacts first
216
260
  for f_name, f in self.__fields__.items():
217
261
  if f.alias == name:
218
262
  return getattr(self, f_name)
219
263
  if f_name == name:
220
264
  return getattr(self, f_name)
265
+ # probably unknown impact, coming from 'extra' fields
266
+ return getattr(self, name, None)
267
+
268
+ @pyd.root_validator(skip_on_failure=True)
269
+ def _extra_scopeset_validator(cls, values: dict[str, Any]) -> dict[str, Any]:
270
+ for f in values:
271
+ # only interested in validating the extra fields
272
+ if f in cls.__fields__:
273
+ continue
274
+
275
+ # extra impact of an unknown type - engage validation of ScopeSet
276
+ extra_scopeset = values.get(f)
277
+ match extra_scopeset:
278
+ case ScopeSet():
279
+ continue
280
+ case dict():
281
+ values[f] = ScopeSet(**extra_scopeset)
282
+ case _:
283
+ raise ValueError(f"{f} must be a ScopeSet schema")
284
+
285
+ return values
286
+
287
+
288
+ class ScopeSetGwp(ScopeSet):
289
+ """ScopeSet measured in kgCO2e."""
290
+
291
+ allowed_units = "kgCO2e"
292
+
293
+
294
+ class ScopeSetOdp(ScopeSet):
295
+ """ScopeSet measured in kgCFC11e."""
296
+
297
+ allowed_units = "kgCFC11e"
298
+
299
+
300
+ class ScopeSetAp(ScopeSet):
301
+ """ScopeSet measured in kgSO2e."""
302
+
303
+ allowed_units = ("kgSO2e", "molHe")
304
+
305
+
306
+ class ScopeSetEpNe(ScopeSet):
307
+ """ScopeSet measured in kgNe."""
308
+
309
+ allowed_units = "kgNe"
310
+
311
+
312
+ class ScopeSetPocp(ScopeSet):
313
+ """ScopeSet measured in kgO3e."""
314
+
315
+ allowed_units = ("kgO3e", "kgNMVOCe")
316
+
317
+
318
+ class ScopeSetEpFresh(ScopeSet):
319
+ """ScopeSet measured in kgPO4e."""
320
+
321
+ allowed_units = "kgPO4e"
322
+
323
+
324
+ class ScopeSetEpTerr(ScopeSet):
325
+ """ScopeSet measured in molNe."""
221
326
 
222
- return None
327
+ allowed_units = "molNe"
328
+
329
+
330
+ class ScopeSetIrp(ScopeSet):
331
+ """ScopeSet measured in kilo Becquerel equivalent of u235."""
332
+
333
+ allowed_units = "kBqU235e"
334
+
335
+
336
+ class ScopeSetCTUh(ScopeSet):
337
+ """ScopeSet measured in CTUh."""
338
+
339
+ allowed_units = "CTUh"
340
+
341
+
342
+ class ScopeSetM3Aware(ScopeSet):
343
+ """ScopeSet measured in m3AWARE Water consumption by AWARE method."""
344
+
345
+ allowed_units = "m3AWARE"
346
+
347
+
348
+ class ScopeSetCTUe(ScopeSet):
349
+ """ScopeSet measured in CTUe."""
350
+
351
+ allowed_units = "CTUe"
352
+
353
+
354
+ class ScopeSetDiseaseIncidence(ScopeSet):
355
+ """ScopeSet measuring disease incidence measured in AnnualPerCapita (cases)."""
356
+
357
+ allowed_units = "AnnualPerCapita"
223
358
 
224
359
 
225
360
  class ImpactSet(ScopesetByNameBase):
226
361
  """A set of impacts, such as GWP, ODP, AP, EP, POCP, EP-marine, EP-terrestrial, EP-freshwater, etc."""
227
362
 
228
- gwp: ScopeSet | None = pyd.Field(
363
+ gwp: ScopeSetGwp | None = pyd.Field(
229
364
  default=None,
230
365
  description="GWP100, calculated per IPCC guidelines. If any CO2 removals are "
231
366
  "part of this figure, the gwp-fossil, gwp-bioganic, gwp-luluc, an "
232
367
  "gwp-nonCO2 fields are required, as is "
233
368
  "kg_C_biogenic_per_declared_unit.",
234
369
  )
235
- odp: ScopeSet | None = pyd.Field(default=None, description="Ozone Depletion Potential")
236
- ap: ScopeSet | None = pyd.Field(default=None, description="Acidification Potential")
237
- ep: ScopeSet | None = pyd.Field(
370
+ odp: ScopeSetOdp | None = pyd.Field(default=None, description="Ozone Depletion Potential")
371
+ ap: ScopeSetAp | None = pyd.Field(default=None, description="Acidification Potential")
372
+ ep: ScopeSetEpNe | None = pyd.Field(
238
373
  default=None, description="Eutrophication Potential in Marine Ecosystems. Has the same meaning as ep-marine."
239
374
  )
240
- pocp: ScopeSet | None = pyd.Field(default=None, description="Photochemical Smog (Ozone) creation potential")
241
- ep_marine: ScopeSet | None = pyd.Field(alias="ep-marine", default=None, description="Has the same meaning as 'ep'")
242
- ep_fresh: ScopeSet | None = pyd.Field(
375
+ pocp: ScopeSetPocp | None = pyd.Field(default=None, description="Photochemical Smog (Ozone) creation potential")
376
+ ep_marine: ScopeSetEpNe | None = pyd.Field(
377
+ alias="ep-marine", default=None, description="Has the same meaning as 'ep'"
378
+ )
379
+ ep_fresh: ScopeSetEpFresh | None = pyd.Field(
243
380
  alias="ep-fresh", default=None, description="Eutrophication Potential in Freshwater Ecosystems"
244
381
  )
245
- ep_terr: ScopeSet | None = pyd.Field(
382
+ ep_terr: ScopeSetEpTerr | None = pyd.Field(
246
383
  alias="ep-terr", default=None, description="Eutrophication Potential in Terrestrial Ecosystems"
247
384
  )
248
- gwp_biogenic: ScopeSet | None = pyd.Field(
385
+ gwp_biogenic: ScopeSetGwp | None = pyd.Field(
249
386
  alias="gwp-biogenic",
250
387
  default=None,
251
388
  description="Net GWP from removals of atmospheric CO2 into biomass and emissions of CO2 from biomass sources. "
@@ -254,7 +391,7 @@ class ImpactSet(ScopesetByNameBase):
254
391
  "space (similar biome). They must not have been sold, committed, or credited to any other "
255
392
  "product. Harvesting from native forests is handled under GWP_luluc for EN15804.",
256
393
  )
257
- gwp_luluc: ScopeSet | None = pyd.Field(
394
+ gwp_luluc: ScopeSetGwp | None = pyd.Field(
258
395
  alias="gwp-luluc",
259
396
  default=None,
260
397
  description="Climate change effects related to land use and land use change, for example biogenic carbon "
@@ -262,17 +399,49 @@ class ImpactSet(ScopesetByNameBase):
262
399
  "emissions). All related emissions for native forests are included under this category. "
263
400
  "Uptake for native forests is set to 0 kgCO2 for EN15804.",
264
401
  )
265
- gwp_nonCO2: ScopeSet | None = pyd.Field(
402
+ gwp_nonCO2: ScopeSetGwp | None = pyd.Field(
266
403
  alias="gwp-nonCO2",
267
404
  default=None,
268
405
  description="GWP from non-CO2, non-fossil sources, such as livestock-sourced CH4 and agricultural N2O.",
269
406
  )
270
- gwp_fossil: ScopeSet | None = pyd.Field(
407
+ gwp_fossil: ScopeSetGwp | None = pyd.Field(
271
408
  alias="gwp-fossil",
272
409
  default=None,
273
410
  description="Climate change effects due to greenhouse gas emissions originating from the oxidation or "
274
411
  "reduction of fossil fuels or materials containing fossil carbon. [Source: EN15804]",
275
412
  )
413
+ WDP: ScopeSetM3Aware | None = pyd.Field(
414
+ default=None,
415
+ description="Deprivation-weighted water consumption, calculated by the AWARE method "
416
+ "(https://wulca-waterlca.org/aware/what-is-aware)",
417
+ )
418
+ PM: ScopeSetDiseaseIncidence | None = pyd.Field(
419
+ default=None,
420
+ description="Potential incidence of disease due to particulate matter emissions.",
421
+ )
422
+ IRP: ScopeSetIrp | None = pyd.Field(
423
+ default=None,
424
+ description="Potential ionizing radiation effect on human health, relative to U235.",
425
+ )
426
+ ETP_fw: ScopeSetCTUe | None = pyd.Field(
427
+ alias="ETP-fw",
428
+ default=None,
429
+ description="Ecotoxicity in freshwater, in potential Comparative Toxic Unit for ecosystems.",
430
+ )
431
+ HTP_c: ScopeSetCTUh | None = pyd.Field(
432
+ alias="HTP-c",
433
+ default=None,
434
+ description="Human toxicity, cancer effects in potential Comparative Toxic Units for humans.",
435
+ )
436
+ HTP_nc: ScopeSetCTUh | None = pyd.Field(
437
+ alias="HTP-nc",
438
+ default=None,
439
+ description="Human toxicity, noncancer effects in potential Comparative Toxic Units for humans.",
440
+ )
441
+ SQP: ScopeSet | None = pyd.Field(
442
+ default=None,
443
+ description="Land use related impacts / Soil quality, in potential soil quality parameters.",
444
+ )
276
445
 
277
446
 
278
447
  class LCIAMethod(StrEnum):
@@ -512,12 +681,15 @@ class WithLciaMixin(BaseOpenEpdSchema):
512
681
  """Mixin for LCIA data."""
513
682
 
514
683
  impacts: Impacts | None = pyd.Field(
515
- description="List of environmental impacts, compiled per one of the standard Impact Assessment methods"
684
+ description="List of environmental impacts, compiled per one of the standard Impact Assessment methods",
685
+ example={"TRACI 2.1": {"gwp": {"A1A2A3": {"mean": 22.4, "unit": "kgCO2e"}}}},
516
686
  )
517
687
  resource_uses: ResourceUseSet | None = pyd.Field(
518
- description="Set of Resource Use Indicators, over various LCA scopes"
688
+ description="Set of Resource Use Indicators, over various LCA scopes",
689
+ example={"RPRe": {"A1A2A3": {"mean": 12, "unit": "MJ", "rsd": 0.12}}},
519
690
  )
520
691
  output_flows: OutputFlowSet | None = pyd.Field(
521
692
  description="Set of Waste and Output Flow indicators which describe the waste categories "
522
- "and other material output flows derived from the LCI."
693
+ "and other material output flows derived from the LCI.",
694
+ example={"hwd": {"A1A2A3": {"mean": 2300, "unit": "kg", "rsd": 0.22}}},
523
695
  )
openepd/model/pcr.py CHANGED
@@ -91,12 +91,12 @@ class Pcr(WithAttachmentsMixin, WithAltIdsMixin, BaseOpenEpdSchema):
91
91
  default=None,
92
92
  )
93
93
  date_of_issue: datetime.datetime | None = pyd.Field(
94
- example=datetime.date(day=11, month=2, year=2022),
94
+ example=datetime.datetime(day=11, month=9, year=2019, tzinfo=datetime.timezone.utc),
95
95
  default=None,
96
96
  description="First day on which the document is valid",
97
97
  )
98
98
  valid_until: datetime.datetime | None = pyd.Field(
99
- example=datetime.date(day=11, month=2, year=2024),
99
+ example=datetime.datetime(day=11, month=9, year=2019, tzinfo=datetime.timezone.utc),
100
100
  default=None,
101
101
  description="Last day on which the document is valid",
102
102
  )
@@ -1,19 +1,45 @@
1
1
  # Material Extensions
2
2
 
3
- This package contains openEPD material extensions. They are used to represent the material properties of the openEPD
4
- materials, and are a more dynamic, frequently changing part of the standard.
3
+ This package contains openEPD material extensions. They are used to represent the material properties of the openEPD
4
+ materials, and are a more dynamic, frequently changing part of the standard.
5
5
 
6
6
  ## Versioning
7
7
 
8
- Extensions are versioned separately from the openEPD standard or openEPD API.
8
+ Extensions are versioned separately from the openEPD standard or openEPD API.
9
9
 
10
- Each material extension is named after the corresponding EC3 product class, and is located in the relevant place
11
- in the specs tree. For example, `RebarSteel` is nested under `Steel`.
10
+ Each material extension is named after the corresponding EC3 product class, and is located in the relevant place
11
+ in the specs tree. For example, `RebarSteel` is nested under `Steel`.
12
12
 
13
- Extensions are versioned as Major.Minor, for example "2.4".
13
+ Extensions are versioned as Major.Minor, for example "2.4".
14
+
15
+ Rules:
14
16
 
15
- Rules:
16
17
  1. Minor versions for the same major version should be backwards compatible.
17
- 2. Major versions are not compatible between themselves.
18
+ 2. Major versions are not compatible between themselves.
18
19
  3. Pydantic models representing versions are named in a pattern SpecNameV1, where 1 is major version and SpecName is
19
20
  the name of the material extension.
21
+
22
+ ## Range specs
23
+
24
+ Normal EPDs get singular specs (e.g. `SteelV1`). Single specs can express performance parameters of one concrete
25
+ material/EPD. However, the IndustryEDPs and Generic Estimates often cover a specific segment of the market, and
26
+ include a range of products under the hood, thus demanding for ranges. For example, a single EPD has one `strength_28d`
27
+ of `4000 psi`, but an industry EPD can be applicable to certain concretes in the range of `4000 psi` to `5000 psi`.
28
+
29
+ Range specs are used to express that. Range specs are located in `specs.range` package, and are auto-generated from the
30
+ single specs, please see `make codegen` command and `tools/openepd/codegen/generate_range_spec_models.py`
31
+
32
+ Range specs are created by following general rules:
33
+
34
+ 1. A QuantityStr (such as `QuantityMassKg`) becomes an `AmountRange` of certain type - `AmountRangeMass`
35
+ 2. Float -> RangeFloat, Ratio -> RatioRange, int -> IntRange
36
+ 3. Enums become lists of enums, for example: `cable_trays_material: enums.CableTrayMaterial` in normal spec becomes a
37
+ `cable_trays_material: list[enums.CabeTrayMaterial]` in the range spec.
38
+ 4. Complex objects, strings remain unchanged.
39
+
40
+ This is, however, not always desired. For example, `recarbonation: float` and `recarbonation_z: float` property of CMU
41
+ should not be converted to ranges as these do not make sense.
42
+
43
+ The default rule-base behaviour can be overridden with the
44
+ `CodeGenSpec` class annotation like this: `recarbonation: Annotated[float, CodeGenSpec(override_type=float)]` in single
45
+ spec to make RangeSpec have normal `float` type.
@@ -19,7 +19,7 @@ from openepd.compat.pydantic import pyd
19
19
  from openepd.model.common import OpenEPDUnit
20
20
  from openepd.model.specs.base import BaseOpenEpdHierarchicalSpec
21
21
  from openepd.model.validation.numbers import RatioFloat
22
- from openepd.model.validation.quantity import LengthMmStr, TemperatureCStr, validate_unit_factory
22
+ from openepd.model.validation.quantity import LengthMmStr, TemperatureCStr, validate_quantity_unit_factory
23
23
 
24
24
 
25
25
  class AsphaltMixType(StrEnum):
@@ -79,5 +79,5 @@ class AsphaltV1(BaseOpenEpdHierarchicalSpec):
79
79
  asphalt_pmb: bool | None = pyd.Field(default=None, description="Polymer modified bitumen (PMB)")
80
80
 
81
81
  _aggregate_size_max_validator = pyd.validator("asphalt_aggregate_size_max", allow_reuse=True)(
82
- validate_unit_factory(OpenEPDUnit.m)
82
+ validate_quantity_unit_factory(OpenEPDUnit.m)
83
83
  )
@@ -13,12 +13,13 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
  #
16
+ import dataclasses
16
17
  from typing import Any
17
18
 
18
19
  from openepd.compat.pydantic import pyd
19
20
  from openepd.model.base import BaseOpenEpdSchema, Version
20
21
  from openepd.model.validation.common import validate_version_compatibility, validate_version_format
21
- from openepd.model.validation.quantity import QuantityValidator
22
+ from openepd.model.validation.quantity import ExternalValidationConfig, QuantityValidator
22
23
  from openepd.model.versioning import WithExtVersionMixin
23
24
 
24
25
 
@@ -32,9 +33,6 @@ class BaseOpenEpdSpec(BaseOpenEpdSchema):
32
33
  class BaseOpenEpdHierarchicalSpec(BaseOpenEpdSpec, WithExtVersionMixin):
33
34
  """Base class for new specs (hierarchical, versioned)."""
34
35
 
35
- # external validator for quantities (e.g. length, mass, etc.) which should be setup by the user of the library.
36
- _QUANTITY_VALIDATOR: QuantityValidator | None = None
37
-
38
36
  def __init__(self, **data: Any) -> None:
39
37
  # ensure that all the concrete spec objects fail on creations if they dont have _EXT_VERSION declared to
40
38
  # something meaningful
@@ -53,4 +51,16 @@ class BaseOpenEpdHierarchicalSpec(BaseOpenEpdSpec, WithExtVersionMixin):
53
51
 
54
52
  def setup_external_validators(quantity_validator: QuantityValidator):
55
53
  """Set the implementation unit validator for specs."""
56
- BaseOpenEpdHierarchicalSpec._QUANTITY_VALIDATOR = quantity_validator
54
+ ExternalValidationConfig.QUANTITY_VALIDATOR = quantity_validator
55
+
56
+
57
+ @dataclasses.dataclass(kw_only=True)
58
+ class CodegenSpec:
59
+ """
60
+ Specification for codegen when generating RangeSpecs from normal specs.
61
+
62
+ See openepd.mode.specs.README.md for details.
63
+ """
64
+
65
+ exclude_from_codegen: bool = False
66
+ override_type: type
@@ -13,3 +13,83 @@
13
13
  # See the License for the specific language governing permissions and
14
14
  # limitations under the License.
15
15
  #
16
+
17
+ from openepd.model.specs.base import BaseOpenEpdHierarchicalSpec
18
+ from openepd.model.specs.generated.accessories import AccessoriesV1
19
+ from openepd.model.specs.generated.aggregates import AggregatesV1
20
+ from openepd.model.specs.generated.aluminium import AluminiumV1
21
+ from openepd.model.specs.generated.asphalt import AsphaltV1
22
+ from openepd.model.specs.generated.bulk_materials import BulkMaterialsV1
23
+ from openepd.model.specs.generated.cast_decks_and_underlayment import CastDecksAndUnderlaymentV1
24
+ from openepd.model.specs.generated.cladding import CladdingV1
25
+ from openepd.model.specs.generated.cmu import CMUV1
26
+ from openepd.model.specs.generated.concrete import ConcreteV1
27
+ from openepd.model.specs.generated.conveying_equipment import ConveyingEquipmentV1
28
+ from openepd.model.specs.generated.electrical import ElectricalV1
29
+ from openepd.model.specs.generated.electrical_transmission_and_distribution_equipment import (
30
+ ElectricalTransmissionAndDistributionEquipmentV1,
31
+ )
32
+ from openepd.model.specs.generated.electricity import ElectricityV1
33
+ from openepd.model.specs.generated.finishes import FinishesV1
34
+ from openepd.model.specs.generated.fire_and_smoke_protection import FireAndSmokeProtectionV1
35
+ from openepd.model.specs.generated.furnishings import FurnishingsV1
36
+ from openepd.model.specs.generated.grouting import GroutingV1
37
+ from openepd.model.specs.generated.manufacturing_inputs import ManufacturingInputsV1
38
+ from openepd.model.specs.generated.masonry import MasonryV1
39
+ from openepd.model.specs.generated.material_handling import MaterialHandlingV1
40
+ from openepd.model.specs.generated.mechanical import MechanicalV1
41
+ from openepd.model.specs.generated.mechanical_insulation import MechanicalInsulationV1
42
+ from openepd.model.specs.generated.network_infrastructure import NetworkInfrastructureV1
43
+ from openepd.model.specs.generated.openings import OpeningsV1
44
+ from openepd.model.specs.generated.other_electrical_equipment import OtherElectricalEquipmentV1
45
+ from openepd.model.specs.generated.other_materials import OtherMaterialsV1
46
+ from openepd.model.specs.generated.plumbing import PlumbingV1
47
+ from openepd.model.specs.generated.precast_concrete import PrecastConcreteV1
48
+ from openepd.model.specs.generated.sheathing import SheathingV1
49
+ from openepd.model.specs.generated.steel import SteelV1
50
+ from openepd.model.specs.generated.thermal_moisture_protection import ThermalMoistureProtectionV1
51
+ from openepd.model.specs.generated.utility_piping import UtilityPipingV1
52
+ from openepd.model.specs.generated.wood import WoodV1
53
+ from openepd.model.specs.generated.wood_joists import WoodJoistsV1
54
+
55
+
56
+ class Specs(BaseOpenEpdHierarchicalSpec):
57
+ """Material specific specs."""
58
+
59
+ _EXT_VERSION = "1.0"
60
+
61
+ # Nested specs:
62
+ CMU: CMUV1 | None = None
63
+ Masonry: MasonryV1 | None = None
64
+ Steel: SteelV1 | None = None
65
+ NetworkInfrastructure: NetworkInfrastructureV1 | None = None
66
+ Finishes: FinishesV1 | None = None
67
+ ManufacturingInputs: ManufacturingInputsV1 | None = None
68
+ Accessories: AccessoriesV1 | None = None
69
+ ElectricalTransmissionAndDistributionEquipment: ElectricalTransmissionAndDistributionEquipmentV1 | None = None
70
+ Aggregates: AggregatesV1 | None = None
71
+ ThermalMoistureProtection: ThermalMoistureProtectionV1 | None = None
72
+ Mechanical: MechanicalV1 | None = None
73
+ Aluminium: AluminiumV1 | None = None
74
+ Cladding: CladdingV1 | None = None
75
+ FireAndSmokeProtection: FireAndSmokeProtectionV1 | None = None
76
+ PrecastConcrete: PrecastConcreteV1 | None = None
77
+ Asphalt: AsphaltV1 | None = None
78
+ OtherMaterials: OtherMaterialsV1 | None = None
79
+ Plumbing: PlumbingV1 | None = None
80
+ Electrical: ElectricalV1 | None = None
81
+ UtilityPiping: UtilityPipingV1 | None = None
82
+ BulkMaterials: BulkMaterialsV1 | None = None
83
+ CastDecksAndUnderlayment: CastDecksAndUnderlaymentV1 | None = None
84
+ Concrete: ConcreteV1 | None = None
85
+ Sheathing: SheathingV1 | None = None
86
+ Furnishings: FurnishingsV1 | None = None
87
+ Wood: WoodV1 | None = None
88
+ ConveyingEquipment: ConveyingEquipmentV1 | None = None
89
+ MaterialHandling: MaterialHandlingV1 | None = None
90
+ Openings: OpeningsV1 | None = None
91
+ Electricity: ElectricityV1 | None = None
92
+ Grouting: GroutingV1 | None = None
93
+ MechanicalInsulation: MechanicalInsulationV1 | None = None
94
+ OtherElectricalEquipment: OtherElectricalEquipmentV1 | None = None
95
+ WoodJoists: WoodJoistsV1 | None = None
@@ -16,7 +16,7 @@
16
16
  from openepd.compat.pydantic import pyd
17
17
  from openepd.model.specs.base import BaseOpenEpdHierarchicalSpec
18
18
  from openepd.model.specs.generated.enums import CladdingFacingMaterial, CladdingInsulatingMaterial, SidingFormFactor
19
- from openepd.model.validation.quantity import LengthMmStr, LengthMStr, RValueStr, validate_unit_factory
19
+ from openepd.model.validation.quantity import LengthMmStr, LengthMStr, RValueStr, validate_quantity_unit_factory
20
20
 
21
21
 
22
22
  class AluminiumSidingV1(BaseOpenEpdHierarchicalSpec):
@@ -75,7 +75,7 @@ class InsulatedVinylSidingV1(BaseOpenEpdHierarchicalSpec):
75
75
  thickness: LengthMmStr | None = pyd.Field(default=None, description="", example="1 mm")
76
76
 
77
77
  _vinyl_siding_thickness_is_quantity_validator = pyd.validator("thickness", allow_reuse=True)(
78
- validate_unit_factory("m")
78
+ validate_quantity_unit_factory("m")
79
79
  )
80
80
 
81
81
 
@@ -109,7 +109,7 @@ class VinylSidingV1(BaseOpenEpdHierarchicalSpec):
109
109
  thickness: LengthMmStr | None = pyd.Field(default=None, description="", example="5 mm")
110
110
 
111
111
  _vinyl_siding_thickness_is_quantity_validator = pyd.validator("thickness", allow_reuse=True)(
112
- validate_unit_factory("m")
112
+ validate_quantity_unit_factory("m")
113
113
  )
114
114
 
115
115
 
@@ -191,7 +191,7 @@ class CladdingV1(BaseOpenEpdHierarchicalSpec):
191
191
  thickness: LengthMStr | None = pyd.Field(default=None, description="", example="10 mm")
192
192
  facing_material: CladdingFacingMaterial | None = pyd.Field(default=None, description="", example="Steel")
193
193
 
194
- _thickness_is_quantity_validator = pyd.validator("thickness", allow_reuse=True)(validate_unit_factory("m"))
194
+ _thickness_is_quantity_validator = pyd.validator("thickness", allow_reuse=True)(validate_quantity_unit_factory("m"))
195
195
 
196
196
  # Nested specs:
197
197
  Siding: SidingV1 | None = None