api581-engine 0.1.4__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 (139) hide show
  1. api581/__init__.py +80 -0
  2. api581/_amine_tables.py +105 -0
  3. api581/_h2sh2_tables.py +161 -0
  4. api581/_slabx_worker.py +174 -0
  5. api581/_sulfidic_tables.py +517 -0
  6. api581/_sulfuric_tables.py +328 -0
  7. api581/acid_sour_water.py +238 -0
  8. api581/acscc_screening.py +88 -0
  9. api581/alkaline_sour_water.py +267 -0
  10. api581/amine_corrosion.py +277 -0
  11. api581/amine_screening.py +103 -0
  12. api581/api/__init__.py +4 -0
  13. api581/api/api520.py +88 -0
  14. api581/api/bundle.py +121 -0
  15. api581/api/consequence.py +102 -0
  16. api581/api/damage.py +1831 -0
  17. api581/api/dispersion.py +121 -0
  18. api581/api/effects.py +318 -0
  19. api581/api/events.py +96 -0
  20. api581/api/financial.py +115 -0
  21. api581/api/frequency.py +40 -0
  22. api581/api/inspection.py +96 -0
  23. api581/api/level1.py +238 -0
  24. api581/api/level2.py +74 -0
  25. api581/api/planning.py +17 -0
  26. api581/api/prd.py +190 -0
  27. api581/api/properties.py +130 -0
  28. api581/api/release.py +252 -0
  29. api581/api/reliability.py +40 -0
  30. api581/api/risk.py +71 -0
  31. api581/api/tank.py +490 -0
  32. api581/api520.py +205 -0
  33. api581/area_contribution.py +72 -0
  34. api581/blast.py +232 -0
  35. api581/brittle.py +205 -0
  36. api581/bundle.py +300 -0
  37. api581/catalog.py +442 -0
  38. api581/caustic_screening.py +103 -0
  39. api581/clad_thinning.py +146 -0
  40. api581/clscc_screening.py +55 -0
  41. api581/co2_corrosion.py +313 -0
  42. api581/co2_screening.py +205 -0
  43. api581/consequences.py +250 -0
  44. api581/contracts.py +1038 -0
  45. api581/cooling_selection.py +204 -0
  46. api581/cooling_water.py +305 -0
  47. api581/core.py +126 -0
  48. api581/corrosion_rate.py +38 -0
  49. api581/cost_tables.py +175 -0
  50. api581/cracking.py +74 -0
  51. api581/data/coverage.json +21361 -0
  52. api581/data/digital-twin.schema.json +538 -0
  53. api581/data/direct-test-queue.json +7 -0
  54. api581/data/native-engine-evidence.json +44 -0
  55. api581/data/python-api.schema.json +92706 -0
  56. api581/data/reference-fixture-evidence.json +2956 -0
  57. api581/data/scope-boundaries.schema.json +58 -0
  58. api581/data/software-test-evidence.json +4278 -0
  59. api581/data/standard-metadata.json +1328 -0
  60. api581/data/standard.schema.json +233 -0
  61. api581/data/supporting-text-review.json +1319 -0
  62. api581/digital_twin.py +491 -0
  63. api581/dispersion.py +45 -0
  64. api581/effects.py +232 -0
  65. api581/evaporation.py +181 -0
  66. api581/evidence.py +81 -0
  67. api581/external_corrosion.py +148 -0
  68. api581/external_cracking.py +63 -0
  69. api581/fatigue.py +56 -0
  70. api581/fireball.py +163 -0
  71. api581/flash_pool.py +111 -0
  72. api581/flash_solver.py +122 -0
  73. api581/groundwater.py +101 -0
  74. api581/h2sh2_corrosion.py +128 -0
  75. api581/hic_screened.py +89 -0
  76. api581/htha.py +45 -0
  77. api581/hydrochloric_acid.py +272 -0
  78. api581/hydrofluoric_acid.py +236 -0
  79. api581/ignition.py +204 -0
  80. api581/inspection_effectiveness.py +601 -0
  81. api581/jet_handoff.py +60 -0
  82. api581/level1.py +311 -0
  83. api581/level1_summary.py +35 -0
  84. api581/level1_toxic.py +313 -0
  85. api581/level1_type1.py +91 -0
  86. api581/level2_summary.py +237 -0
  87. api581/lining.py +173 -0
  88. api581/native_toxic.py +67 -0
  89. api581/outputs.py +5672 -0
  90. api581/oxidation.py +145 -0
  91. api581/pascc_screening.py +78 -0
  92. api581/physical_explosion.py +190 -0
  93. api581/pof.py +207 -0
  94. api581/pool_fire.py +273 -0
  95. api581/portfolio.py +68 -0
  96. api581/prd.py +204 -0
  97. api581/prd_consequences.py +245 -0
  98. api581/prd_history.py +44 -0
  99. api581/py.typed +0 -0
  100. api581/release.py +307 -0
  101. api581/representative_fluids.py +172 -0
  102. api581/result.py +50 -0
  103. api581/risk.py +191 -0
  104. api581/saturation.py +66 -0
  105. api581/scc_mechanisms.py +242 -0
  106. api581/schema.py +45 -0
  107. api581/slabx_adapter.py +261 -0
  108. api581/soil_corrosion.py +191 -0
  109. api581/source_partition.py +169 -0
  110. api581/spatial.py +87 -0
  111. api581/ssc_screening.py +77 -0
  112. api581/standard.py +281 -0
  113. api581/storage.py +42 -0
  114. api581/sulfidic_corrosion.py +197 -0
  115. api581/sulfuric_acid.py +209 -0
  116. api581/tank.py +260 -0
  117. api581/tank_corrosion.py +256 -0
  118. api581/tank_environment.py +186 -0
  119. api581/tank_financial.py +202 -0
  120. api581/tank_integrated.py +51 -0
  121. api581/tank_shell.py +141 -0
  122. api581/tank_source.py +152 -0
  123. api581/tank_thinning.py +204 -0
  124. api581/temper.py +105 -0
  125. api581/thinning.py +172 -0
  126. api581/thinning_conditions.py +212 -0
  127. api581/thinning_history.py +118 -0
  128. api581/thinning_selection.py +81 -0
  129. api581/thinning_type.py +34 -0
  130. api581/toxic_criteria.py +111 -0
  131. api581/toxic_level2.py +233 -0
  132. api581/two_phase_events.py +120 -0
  133. api581/workflow.py +42 -0
  134. api581_engine-0.1.4.dist-info/METADATA +419 -0
  135. api581_engine-0.1.4.dist-info/RECORD +139 -0
  136. api581_engine-0.1.4.dist-info/WHEEL +5 -0
  137. api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
  138. api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
  139. api581_engine-0.1.4.dist-info/top_level.txt +1 -0
api581/level1.py ADDED
@@ -0,0 +1,311 @@
1
+ """Level 1 release controls and flammable-area tables, explicitly bounded by support."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
6
+ from .release import Gas, Liquid, gas, liquid, AvailableInventory, inventory, ReleaseType, release_type, ReleaseMagnitude, magnitude
7
+ from .consequences import Acid, acid, Steam, steam
8
+ from .level1_toxic import ToxicComponent, MitigationCredits, toxic_component
9
+
10
+ # Four (a,b) pairs in order: continuous AINL, continuous AIL, instantaneous AINL, instantaneous AIL.
11
+ # SI Tables 4.8M / 4.9M, PDF pp522,524. None represents a blank, never zero.
12
+ COEFFICIENTS = {
13
+ "C1-C2": {"gas": [(8.669,.98),(55.13,.95),(6.469,.67),(163.7,.62), (21.83,.96),(143.2,.92),(12.46,.67),(473.9,.63)]},
14
+ "C3-C4": {"gas": [(10.13,1),(64.23,1),(4.590,.72),(79.94,.63), (25.64,1),(171.4,1),(9.702,.75),(270.4,.63)]},
15
+ "C5": {"gas": [(5.115,.99),(62.41,1),(2.214,.73),(41.38,.61), (12.71,1),(166.1,1),(4.820,.76),(146.7,.63)],
16
+ "liquid": [(100.6,.89),None,(.271,.85),None, (290.1,.89),None,(.790,.85),None]},
17
+ "C6-C8": {"gas": [(5.846,.98),(63.98,1),(2.188,.66),(41.49,.61), (13.49,.96),(169.7,1),(4.216,.67),(147.2,.63)],
18
+ "liquid": [(34.17,.89),(103.4,.95),(.749,.78),(8.180,.55), (96.88,.89),(252.8,.92),(2.186,.78),(31.89,.54)]},
19
+ "C9-C12": {"gas": [(2.419,.98),(76.98,.95),(1.111,.66),(42.28,.61), (5.755,.96),(188.6,.92),(2.035,.66),(151.0,.63)],
20
+ "liquid": [(24.60,.90),(110.3,.95),(.559,.76),(.848,.53), (70.03,.89),(269.4,.92),(1.609,.76),(2.847,.54)]},
21
+ "C13-C16": {"liquid": [(12.11,.90),(196.7,.92),(.086,.88),(1.714,.88), (34.36,.89),(539.4,.90),(.242,.88),(4.843,.88)]},
22
+ "C17-C25": {"liquid": [(3.785,.90),(165.5,.92),(.021,.91),(1.068,.91), (10.70,.89),(458.0,.90),(.061,.91),(3.052,.91)]},
23
+ "C25+": {"liquid": [(2.098,.91),(103.0,.90),(.006,.99),(.284,.99), (6.196,.89),(303.6,.90),(.016,.99),(.833,.99)]},
24
+ "H2": {"gas": [(13.13,.992),(86.02,1),(9.605,.657),(216.5,.618), (32.05,.933),(228.8,1),(18.43,.652),(636.5,.621)]},
25
+ "H2S": {"gas": [(6.554,1),(38.11,.89),(22.63,.63),(53.72,.61), (10.65,1),(73.25,.94),(41.43,.63),(191.5,.63)]},
26
+ }
27
+ COEFFICIENTS["Pyrophoric"] = {phase:list(rows) for phase,rows in COEFFICIENTS["C9-C12"].items()}
28
+ COEFFICIENTS["HF"] = {"gas":[None]*8,"liquid":[None]*8}
29
+
30
+
31
+ class Coefficients(Model):
32
+ fluid: str
33
+ release_phase: Literal["gas", "liquid"]
34
+ effect: Literal["equipment", "personnel"]
35
+ release_type: Literal["continuous", "instantaneous"]
36
+ autoignition: Literal["not_likely", "likely"]
37
+
38
+
39
+ @calculation("level1/flammable-coefficients", "SI flammable consequence coefficients", Coefficients,
40
+ ["Part 3 Tables 4.8M, 4.9M; PDF pp522,524"],
41
+ limitations=["Type0 and Type1 table lookup only; blank cells are unsupported while printed zero coefficients are valid. Coefficients include original event weighting; do not multiply ignition probability again. Type1 areas must use the separate unblended calculation."])
42
+ def coefficients(x):
43
+ table = COEFFICIENTS
44
+ if x.fluid not in table:
45
+ from .level1_type1 import TYPE1_COEFFICIENTS
46
+ table = TYPE1_COEFFICIENTS
47
+ ensure(x.fluid in table and x.release_phase in table[x.fluid], "Unsupported reference fluid/phase")
48
+ offset = (4 if x.effect == "personnel" else 0) + (2 if x.release_type == "instantaneous" else 0) + (1 if x.autoignition == "likely" else 0)
49
+ cell = table[x.fluid][x.release_phase][offset]
50
+ ensure(cell is not None, "Selected coefficient cell is blank in the source table", "unsupported_table_cell")
51
+ return {"a": cell[0], "b": cell[1], "magnitude_unit": "kg/s" if x.release_type == "continuous" else "kg", "area_unit": "m2"}
52
+
53
+
54
+ class Detection(Model):
55
+ detection: Literal["A", "B", "C"]
56
+ isolation: Literal["A", "B", "C"]
57
+
58
+
59
+ @calculation("release/detection-reduction", "Detection/isolation reduction factor", Detection,
60
+ ["Part 3 Table 4.6; PDF p519"], limitations=["All nine A/B/C detection-isolation combinations are represented explicitly from Table 4.6; ratings themselves remain qualified inputs."])
61
+ def detection_reduction(x):
62
+ # Table 4.6 lists B/A with the same 10% reduction as B/C, and C with
63
+ # any isolation rating as no adjustment. Keep every printed cell
64
+ # explicit rather than rejecting valid combinations as "unlisted".
65
+ factors = {("A","A"):.25, ("A","B"):.20, ("A","C"):.10,
66
+ ("B","A"):.10, ("B","B"):.15, ("B","C"):.10,
67
+ ("C","A"):0., ("C","B"):0., ("C","C"):0.}
68
+ return {"reduction_fraction": factors[x.detection,x.isolation]}
69
+
70
+
71
+ class LeakDuration(Detection):
72
+ hole: Literal["small", "medium", "large"]
73
+
74
+
75
+ @calculation("release/detection-duration", "Detection/isolation maximum duration", LeakDuration,
76
+ ["Part 3 Table 4.7; PDF p520"], limitations=["No rupture-duration entry in this table; returned duration is seconds."])
77
+ def detection_duration(x):
78
+ if x.detection == "A":
79
+ values = {"A": (20,10,5), "B": (30,20,10), "C": (40,30,20)}[x.isolation]
80
+ elif x.detection == "B":
81
+ values = (60,30,20) if x.isolation == "C" else (40,30,20)
82
+ else:
83
+ values = (60,40,20)
84
+ return {"maximum_duration_s": values[("small","medium","large").index(x.hole)] * 60}
85
+
86
+
87
+ class FinalPhase(Model):
88
+ stored_phase: Literal["gas", "liquid"]
89
+ ambient_phase: Literal["gas", "liquid"]
90
+ normal_boiling_point_k: Positive
91
+
92
+
93
+ @calculation("level1/release-phase", "Level 1 final consequence phase", FinalPhase,
94
+ ["Part 3 Table 4.3; PDF p518"], limitations=["Level 1 approximation only; steam/acid overrides must be selected separately. No two-phase flash calculation."])
95
+ def final_phase(x):
96
+ phase = "gas" if x.stored_phase == "gas" else ("liquid" if x.ambient_phase == "liquid" or x.normal_boiling_point_k > (80-32)*5/9+273.15 else "gas")
97
+ return {"release_phase": phase}
98
+
99
+
100
+ class Energy(Model):
101
+ released_mass_kg: NonNegative
102
+
103
+
104
+ @calculation("level1/energy-efficiency", "Instantaneous energy efficiency correction", Energy,
105
+ ["Part 3 eq (3.18), Annex 3.B C4A"], limitations=["Applied to instantaneous-release area only; below 4536 kg the correction is 1."])
106
+ def energy(x):
107
+ return {"energy_efficiency_factor": 4 * math.log10(2.205 * x.released_mass_kg) - 15 if x.released_mass_kg > 4536 else 1}
108
+
109
+
110
+ class Blending(Model):
111
+ release_type: Literal["continuous", "instantaneous"]
112
+ adjusted_rate_kg_s: NonNegative
113
+ instantaneous_coefficients_available: bool
114
+ operating_temperature_k: Positive
115
+ autoignition_temperature_k: Positive
116
+
117
+
118
+ @calculation("level1/blending-factors", "Type 0 release/AIT blending factors", Blending,
119
+ ["Part 3 eqs (3.19)-(3.26), Annex 3.B C5,C6"], limitations=["Type 0 fluids only. Type 1 correlations already incorporate smoothing."])
120
+ def blending(x):
121
+ ic = (1 if x.release_type == "instantaneous" else min(x.adjusted_rate_kg_s / 25.2, 1)) if x.instantaneous_coefficients_available else 0
122
+ ait = min(1, max(0, (x.operating_temperature_k - x.autoignition_temperature_k + 55.6) / (2 * 55.6)))
123
+ return {"instantaneous_weight": ic, "autoignition_likely_weight": ait}
124
+
125
+
126
+ class Area(Model):
127
+ fluid: str
128
+ release_phase: Literal["gas", "liquid"]
129
+ release_type: Literal["continuous", "instantaneous"]
130
+ adjusted_rate_kg_s: NonNegative
131
+ released_mass_kg: NonNegative
132
+ operating_temperature_k: Positive
133
+ autoignition_temperature_k: Positive
134
+ mitigation_fraction: Fraction
135
+
136
+
137
+ @calculation("level1/flammable-area", "Type 0 per-hole blended flammable consequence", Area,
138
+ ["Part 3 §4.8.8 eqs (3.29)-(3.58), Tables 4.8M/4.9M"],
139
+ limitations=["Per-hole flammable consequence only; no toxic/NFNT/financial or hole-frequency weighting. Initial Type 0 set. Caller supplies final phase/AIT/mitigation; LH2 Level 2 behavior is not represented."])
140
+ def flammable_area(x):
141
+ ensure(x.fluid in COEFFICIENTS and x.release_phase in COEFFICIENTS[x.fluid], "Unsupported fluid/phase")
142
+ rows = COEFFICIENTS[x.fluid][x.release_phase]
143
+ factors = blending(Blending(**{k:getattr(x,k) for k in ["release_type","adjusted_rate_kg_s","operating_temperature_k","autoignition_temperature_k"]}, instantaneous_coefficients_available=rows[2] is not None))
144
+ ic, ait = factors["instantaneous_weight"], factors["autoignition_likely_weight"]
145
+ correction = energy(Energy(released_mass_kg=x.released_mass_kg))["energy_efficiency_factor"]
146
+ weights = [(1-ic)*(1-ait),(1-ic)*ait,ic*(1-ait),ic*ait]
147
+ outputs = {}
148
+ for effect, offset in [("equipment",0),("personnel",4)]:
149
+ terms = []
150
+ for i,w in enumerate(weights):
151
+ if w == 0:
152
+ terms.append(0)
153
+ continue
154
+ cell = rows[offset+i]
155
+ ensure(cell is not None, f"Required {effect} coefficient cell is blank", "unsupported_table_cell")
156
+ magnitude = x.adjusted_rate_kg_s if i < 2 else x.released_mass_kg
157
+ terms.append(w * cell[0] * magnitude ** cell[1] / (correction if i >= 2 else 1) * (1-x.mitigation_fraction))
158
+ outputs[effect+"_area_m2"] = math.fsum(terms)
159
+ return {**outputs, **factors, "energy_efficiency_factor": correction}
160
+
161
+
162
+ class ControlledRelease(Model):
163
+ source: Gas | Liquid
164
+ operating_temperature_k: Positive
165
+ component_mass_kg: NonNegative
166
+ inventory_group_mass_kg: NonNegative
167
+ detection: Literal["A", "B", "C"]
168
+ isolation: Literal["A", "B", "C"]
169
+ duration_hole: Literal["small", "medium", "large", "rupture"]
170
+ rupture_maximum_duration_s: Positive | None = None
171
+
172
+
173
+ @calculation("release/controlled-scenario", "Per-hole source, inventory, detection/isolation and release magnitude", ControlledRelease,
174
+ ["Part 3 §§4.3-4.7"],
175
+ limitations=["Single qualified representative hole. Constant ideal-gas/incompressible-liquid source; caller supplies controls and inventory. Rupture duration must be explicit. Adjusted flammable rate is not the toxic source rate."])
176
+ def controlled_release(x):
177
+ if x.duration_hole != "rupture":
178
+ sizes = {"small":(.00635,.0064),"medium":(.025,.0254),"large":(.1016,.102)}[x.duration_hole]
179
+ ensure(any(math.isclose(x.source.diameter_m,d,rel_tol=0,abs_tol=1e-9) for d in sizes),
180
+ "Duration table category must match a standard representative hole diameter")
181
+ if isinstance(x.source, Gas):
182
+ ensure(x.source.temperature_k == x.operating_temperature_k, "Gas source and operating temperatures must match")
183
+ calculator = gas if isinstance(x.source, Gas) else liquid
184
+ flow = calculator(x.source)
185
+ eight_inch = x.source.model_copy(update={"diameter_m": math.sqrt(4*.03245/math.pi)})
186
+ maximum_supply = calculator(eight_inch)["mass_rate_kg_s"]
187
+ mass = inventory(AvailableInventory(component_mass_kg=x.component_mass_kg,inventory_group_mass_kg=x.inventory_group_mass_kg,
188
+ release_rate_kg_s=flow["mass_rate_kg_s"],eight_inch_rate_kg_s=maximum_supply))
189
+ kind = release_type(ReleaseType(diameter_m=x.source.diameter_m,theoretical_rate_kg_s=flow["mass_rate_kg_s"],available_mass_kg=mass["available_mass_kg"]))
190
+ controls = detection_reduction(Detection(detection=x.detection,isolation=x.isolation))
191
+ if x.duration_hole == "rupture":
192
+ ensure(x.rupture_maximum_duration_s is not None,"Rupture maximum duration must be explicitly supplied")
193
+ duration = x.rupture_maximum_duration_s
194
+ else:
195
+ ensure(x.rupture_maximum_duration_s is None,"Rupture duration is only valid for rupture")
196
+ duration = detection_duration(LeakDuration(detection=x.detection,isolation=x.isolation,hole=x.duration_hole))["maximum_duration_s"]
197
+ released = magnitude(ReleaseMagnitude(theoretical_rate_kg_s=flow["mass_rate_kg_s"],reduction_fraction=controls["reduction_fraction"],
198
+ available_mass_kg=mass["available_mass_kg"],maximum_duration_s=duration))
199
+ return {"theoretical_rate_kg_s":flow["mass_rate_kg_s"],"available_mass_kg":mass["available_mass_kg"],
200
+ "release_type":kind["release_type"],"reduction_fraction":controls["reduction_fraction"],
201
+ "maximum_duration_s":duration,**released}
202
+
203
+
204
+ class ReleaseToFlammable(ControlledRelease):
205
+ fluid: str
206
+ final_release_phase: Literal["gas", "liquid"]
207
+ autoignition_temperature_k: Positive
208
+ mitigation_fraction: Fraction
209
+
210
+
211
+ @calculation("level1/release-to-flammable", "Compose source, inventory, controls and per-hole flammable area", ReleaseToFlammable,
212
+ ["Part 3 §§4.3-4.8"],
213
+ limitations=["Type0 only, single selected hole; caller selects fluid, phase, AIT and mitigation. No toxic/NFNT/cost assessment. Rupture duration must be explicit; Type1 uses release-to-type1-flammable."])
214
+ def release_to_flammable(x):
215
+ released = controlled_release(x)
216
+ consequence = flammable_area(Area(fluid=x.fluid,release_phase=x.final_release_phase,release_type=released["release_type"],
217
+ adjusted_rate_kg_s=released["adjusted_rate_kg_s"],released_mass_kg=released["released_mass_kg"],
218
+ operating_temperature_k=x.operating_temperature_k,autoignition_temperature_k=x.autoignition_temperature_k,
219
+ mitigation_fraction=x.mitigation_fraction))
220
+ return {**released,**consequence}
221
+
222
+
223
+ class ReleaseToAcid(ControlledRelease):
224
+ differential_pressure_pa: NonNegative
225
+
226
+
227
+ @calculation(
228
+ "level1/release-to-acid",
229
+ "Compose controlled release and ACID splash consequence",
230
+ ReleaseToAcid,
231
+ ["Part 3 §§4.1.3,4.3-4.7,4.10.3; PDF pp483-484,490,502"],
232
+ limitations=[
233
+ "ACID representative-fluid route only. The caller must qualify the nonflammable/nontoxic acid-or-caustic selection and the differential pressure. The splash correlation is valid only for the documented 103.4-413.7 kPa range; no toxic, flammable or Level 2 dispersion consequence is inferred."
234
+ ],
235
+ )
236
+ def release_to_acid(x):
237
+ released = controlled_release(x)
238
+ consequence = acid(
239
+ Acid(
240
+ adjusted_rate_kg_s=released["adjusted_rate_kg_s"],
241
+ differential_pressure_pa=x.differential_pressure_pa,
242
+ )
243
+ )
244
+ return {**released, **consequence, "representative_fluid": "Acid", "release_phase": "liquid"}
245
+
246
+
247
+ class ReleaseToSteam(ControlledRelease):
248
+ """Controlled steam release routed to the Level 1 steam correlation."""
249
+ pass
250
+
251
+
252
+ @calculation(
253
+ "level1/release-to-steam",
254
+ "Compose controlled release and Level 1 steam consequence",
255
+ ReleaseToSteam,
256
+ ["Part 3 §§4.3-4.7,4.10.2; PDF pp485-490,501-502"],
257
+ limitations=[
258
+ "Steam NFNT correlation only. Steam is treated as gas/vapor; the source must be qualified as steam and the returned area is personnel-only with equipment area zero. No condensation, jet dispersion, two-phase flash or Level 2 consequence is inferred."
259
+ ],
260
+ )
261
+ def release_to_steam(x):
262
+ released = controlled_release(x)
263
+ consequence = steam(
264
+ Steam(
265
+ adjusted_rate_kg_s=released["adjusted_rate_kg_s"],
266
+ released_mass_kg=released["released_mass_kg"],
267
+ )
268
+ )
269
+ return {**released, **consequence, "representative_fluid": "Steam", "release_phase": "gas"}
270
+
271
+
272
+ class ReleaseToToxic(ControlledRelease):
273
+ fluid: Literal["hf", "h2s", "ammonia", "chlorine", "alcl3", "co", "hcl", "nitric_acid", "no2", "phosgene", "tdi", "ee", "eo", "po"]
274
+ release_phase: Literal["gas", "liquid", "powder"]
275
+ toxic_mass_fraction: Fraction
276
+ nonignition_probability: Fraction
277
+ mitigation: MitigationCredits = Field(default_factory=MitigationCredits)
278
+ concentration_ppm_on_idlh_basis: NonNegative | None = None
279
+ hf_instantaneous_intercept: float | None = None
280
+ intercept_qualification: str | None = Field(default=None, min_length=1, max_length=1000)
281
+
282
+
283
+ @calculation(
284
+ "level1/release-to-toxic",
285
+ "Compose controlled release and Level 1 toxic consequence",
286
+ ReleaseToToxic,
287
+ ["Part 3 §§4.3-4.7,4.9; PDF pp485-500"],
288
+ limitations=[
289
+ "Uses the controlled source for one representative hole and passes the theoretical rate (not flammable-adjusted rate) to the toxic component calculation. Single toxic component only; mixture maximum, four-hole aggregation, dispersion and Level 2 source partition remain separate."
290
+ ],
291
+ )
292
+ def release_to_toxic(x):
293
+ released = controlled_release(x)
294
+ toxic = toxic_component(
295
+ ToxicComponent(
296
+ fluid=x.fluid,
297
+ release_phase=x.release_phase,
298
+ release_type=released["release_type"],
299
+ theoretical_rate_kg_s=released["theoretical_rate_kg_s"],
300
+ released_mass_kg=released["released_mass_kg"],
301
+ toxic_mass_fraction=x.toxic_mass_fraction,
302
+ available_mass_kg=released["available_mass_kg"],
303
+ detection_maximum_duration_s=released["maximum_duration_s"],
304
+ nonignition_probability=x.nonignition_probability,
305
+ mitigation=x.mitigation,
306
+ concentration_ppm_on_idlh_basis=x.concentration_ppm_on_idlh_basis,
307
+ hf_instantaneous_intercept=x.hf_instantaneous_intercept,
308
+ intercept_qualification=x.intercept_qualification,
309
+ )
310
+ )
311
+ return {**released, **toxic}
@@ -0,0 +1,35 @@
1
+ """Four-hole Level 1 aggregation with mechanism selection after frequency weighting."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, NonNegative, calculation, ensure
5
+ from .pof import Component, generic
6
+ from .consequences import HoleValue, Weighted, hole_weighted, FinalArea, final_area
7
+
8
+
9
+ class HoleAreas(Model):
10
+ hole: Literal["small","medium","large","rupture"]
11
+ flammable_equipment_area_m2: NonNegative
12
+ flammable_personnel_area_m2: NonNegative
13
+ toxic_personnel_area_m2: NonNegative
14
+ nfnt_personnel_area_m2: NonNegative
15
+
16
+
17
+ class Summary(Model):
18
+ component_type: str
19
+ holes: list[HoleAreas] = Field(min_length=4,max_length=4)
20
+
21
+
22
+ @calculation("level1/consequence-summary", "Four-hole mechanism-weighted final consequence areas", Summary,
23
+ ["Part 3 eqs (3.27)-(3.28),(3.68),(3.78),(3.81)-(3.84)"],
24
+ limitations=["Caller supplies complete per-hole mechanism consequences, already event-weighted. GFF table supports non-tank components. Mechanism areas are frequency-weighted before final maximum selection."])
25
+ def summary(x):
26
+ ensure({h.hole for h in x.holes}=={"small","medium","large","rupture"},"Exactly one consequence per hole is required")
27
+ frequencies=generic(Component(component_type=x.component_type))
28
+ values={}
29
+ for name in ["flammable_equipment_area_m2","flammable_personnel_area_m2","toxic_personnel_area_m2","nfnt_personnel_area_m2"]:
30
+ weighted=hole_weighted(Weighted(value_unit="m2",holes=[HoleValue(hole=h.hole,generic_frequency_per_year=frequencies["by_hole_per_year"][h.hole],value=getattr(h,name)) for h in x.holes]))
31
+ values[name]=weighted["weighted_value"]
32
+ result=final_area(FinalArea(flammable_equipment_area_m2=values["flammable_equipment_area_m2"],
33
+ flammable_personnel_area_m2=values["flammable_personnel_area_m2"],
34
+ toxic_personnel_areas_m2=[values["toxic_personnel_area_m2"]],nfnt_personnel_area_m2=values["nfnt_personnel_area_m2"]))
35
+ return {**values,**result,"generic_frequency_per_year":frequencies["total_per_year"]}
api581/level1_toxic.py ADDED
@@ -0,0 +1,313 @@
1
+ """Level1 HF/H2S and miscellaneous toxic tables, explicit units and mitigation."""
2
+ from bisect import bisect_left
3
+ import math
4
+ from typing import Annotated, Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
7
+ from .consequences import ToxicSource, toxic_source, ToxicDuration, toxic_duration, ToxicArea, toxic_area
8
+
9
+ Finite = Annotated[float,Field(strict=True,allow_inf_nan=False)]
10
+ LB_PER_KG = 2.205
11
+ M2_PER_FT2 = .0929
12
+ HF_H2S_TIMES = (300,600,1200,2400,3600)
13
+ HF_H2S = {
14
+ "hf":((1.1401,3.5683),(1.1031,3.8431),(1.0816,4.1040),(1.0942,4.3295),(1.1031,4.4576)),
15
+ "h2s":((1.2411,3.9686),(1.2410,4.0948),(1.2370,4.238),(1.2297,4.3626),(1.2266,4.4365)),
16
+ }
17
+
18
+
19
+ def _bracket(times,value):
20
+ for i,node in enumerate(times):
21
+ if math.isclose(value,node,rel_tol=0,abs_tol=1e-9):
22
+ return ((i,1.0),)
23
+ ensure(times[0] < value < times[-1],"Toxic exposure duration outside tabulated range; no extrapolation")
24
+ upper=bisect_left(times,value)
25
+ w=(value-times[upper-1])/(times[upper]-times[upper-1])
26
+ return ((upper-1,1-w),(upper,w))
27
+
28
+
29
+ class HfH2sCoefficients(Model):
30
+ fluid: Literal["hf","h2s"]
31
+ release_type: Literal["continuous","instantaneous"]
32
+ duration_s: NonNegative = 0
33
+ hf_instantaneous_intercept: Finite | None = None
34
+ intercept_qualification: str | None = Field(default=None,min_length=1,max_length=1000)
35
+
36
+
37
+ def _check_override(x):
38
+ ensure((x.hf_instantaneous_intercept is None) == (x.intercept_qualification is None),"HF intercept override and qualification must be supplied together")
39
+ ensure(x.hf_instantaneous_intercept is None or (x.fluid == "hf" and x.release_type == "instantaneous"),
40
+ "Intercept override is only for instantaneous HF")
41
+ if x.intercept_qualification is not None:
42
+ ensure(bool(x.intercept_qualification.strip()),"Intercept qualification must not be blank")
43
+
44
+
45
+ @calculation("level1/hf-h2s-toxic-coefficients", "HF/H2S native toxic log-correlation coefficients", HfH2sCoefficients,
46
+ ["Part3 Table4.11; eqs(3.63)-(3.64); PDF pp497-498,525"],
47
+ limitations=["Native log10 correlation uses lb/s or lb and ft2; conversion factors2.205 and.0929 returned. Continuous lookup requires a printed duration node. HF instantaneous d is printed33,606: returned as33606 with ambiguous_print status, never silently changed to3.3606. Qualified caller override is explicit and traceable."])
48
+ def hf_h2s_coefficients(x):
49
+ _check_override(x)
50
+ status="clear"
51
+ if x.release_type == "instantaneous":
52
+ c,d=(.9674,2.7840) if x.fluid == "h2s" else (1.4056,33606)
53
+ if x.fluid == "hf":
54
+ status="ambiguous_print"
55
+ if x.hf_instantaneous_intercept is not None:
56
+ d,status=x.hf_instantaneous_intercept,"qualified_override"
57
+ else:
58
+ bracket=_bracket(HF_H2S_TIMES,x.duration_s)
59
+ ensure(len(bracket)==1,"Coefficient lookup requires printed duration; area function interpolates curves", "interpolation_required")
60
+ c,d=HF_H2S[x.fluid][bracket[0][0]]
61
+ return {"c":c,"d":d,"native_magnitude_unit":"lb/s" if x.release_type == "continuous" else "lb",
62
+ "native_area_unit":"ft2","si_mass_multiplier":LB_PER_KG,"si_area_multiplier":M2_PER_FT2,
63
+ "source_cell_status":status}
64
+
65
+
66
+ class HfH2sArea(HfH2sCoefficients):
67
+ toxic_rate_kg_s: NonNegative
68
+ toxic_mass_kg: NonNegative
69
+ nonignition_probability: Fraction
70
+
71
+
72
+ @calculation("level1/hf-h2s-toxic-area", "HF/H2S duration-dependent toxic consequence area", HfH2sArea,
73
+ ["Part3 §4.9.6,§4.9.11; eqs(3.63)-(3.64); Table4.11; PDF pp497-499,525"],
74
+ origin="api581_equations_with_explicit_qualified_override_support",
75
+ limitations=["Gas/vapor toxic consequence assumption. Continuous 5-60minute curves interpolate areas, not coefficients. Nonignition weighting applies once. Positive instantaneous HF mass requires qualified intercept override because printed33,606 is unresolved. No extrapolation or automatic spray/IDLH credit."])
76
+ def hf_h2s_area(x):
77
+ _check_override(x)
78
+ value=x.toxic_rate_kg_s if x.release_type == "continuous" else x.toxic_mass_kg
79
+ if value == 0 or (x.release_type == "continuous" and x.duration_s == 0):
80
+ return {"unweighted_personnel_area_m2":0,"personnel_area_m2":0}
81
+ if x.release_type == "instantaneous":
82
+ cell=hf_h2s_coefficients(x)
83
+ ensure(cell["source_cell_status"] != "ambiguous_print","Instantaneous HF source intercept unresolved; qualified override required", "unsupported_table_cell")
84
+ pairs=((cell["c"],cell["d"],1.0),)
85
+ else:
86
+ pairs=tuple((*HF_H2S[x.fluid][i],w) for i,w in _bracket(HF_H2S_TIMES,x.duration_s))
87
+ logmag=math.log10(LB_PER_KG*value)
88
+ area=math.fsum(w*M2_PER_FT2*10**(c*logmag+d) for c,d,w in pairs)
89
+ return {"unweighted_personnel_area_m2":area,"personnel_area_m2":area*x.nonignition_probability}
90
+
91
+
92
+ TIMES=(180,300,600,1200,2400,3600)
93
+ LONG_TIMES=(90,*TIMES)
94
+ # Printed SI Table4.13M, kg/s and m2. None preserves N/A cells.
95
+ MISC = {
96
+ "alcl3":{"powder":((),((3.4531,.9411),))},
97
+ "co":{"gas":(TIMES,((9.55,1.15),(60.09,1.06),(189.42,1.13),(651.49,1.11),(1252.67,1.17),(1521.89,1.21)))},
98
+ "hcl":{"gas":(TIMES,((47.39,1.09),(123.67,1.15),(531.45,1.10),(950.02,1.18),(1851.8,1.20),(2118.87,1.23)))},
99
+ "nitric_acid":{
100
+ "gas":(TIMES,((13230.9,1.25),(17146,1.25),(23851.3,1.24),(31185,1.23),(35813.7,1.22),(38105.8,1.22))),
101
+ "liquid":(TIMES,((1114.96,1.08),(2006.1,1.02),(2674.47,1.06),(4112.65,1.06),(6688.99,1.06),(9458.29,1.12)))},
102
+ "no2":{
103
+ "gas":(TIMES,((1071.74,.70),(1466.57,.68),(1902.9,.68),(2338.76,.72),(3621.1,.70),(4070.48,.71))),
104
+ "liquid":(TIMES,((430,.98),(610.31,1.04),(1340.93,1.07),(3020.54,1.08),(6110.67,1.12),(9455.68,1.13)))},
105
+ "phosgene":{
106
+ "gas":(TIMES,((3095.33,1.20),(5918.49,1.29),(12129.3,1.24),(27459.6,1.27),(63526.4,1.30),(96274.2,1.31))),
107
+ "liquid":(TIMES,((733.39,1.06),(1520.02,1.10),(4777.72,1.12),(14727.5,1.16),(42905,1.20),(77287.7,1.23)))},
108
+ "tdi":{"liquid":(TIMES,((793.04,1.06),(846.54,1.09),(1011.9,1.10),(1026.06,1.06),(1063.8,1.06),(1252.57,1.03)))},
109
+ "ee":{
110
+ "gas":(LONG_TIMES,((.8954,1.171),(1.7578,1.181),(4.0002,1.122),(7.5400,1.111),(24.56,.971),(31.22,.995),(59.67,.899))),
111
+ "liquid":(LONG_TIMES,(None,None,None,(.6857,1.105),(3.6389,1.065),(9.8422,1.132),(23.513,1.104)))},
112
+ "eo":{"gas":(LONG_TIMES,((.5085,1.222),(2.9720,1.207),(7.9931,1.271),(47.69,1.2909),(237.57,1.2849),(1088.4,1.1927),(1767.5,1.203)))},
113
+ "po":{
114
+ "gas":(TIMES,((.0008,1.913),(.0864,1.217),(.1768,1.2203),(.4172,1.2164),(.9537,1.2097),(1.2289,1.2522))),
115
+ "liquid":(TIMES,(None,(2.4084,1.198),(9.0397,1.111),(17.425,1.114),(34.255,1.118),(367.06,.9855)))},
116
+ }
117
+ MiscFluid=Literal["alcl3","co","hcl","nitric_acid","no2","phosgene","tdi","ee","eo","po"]
118
+
119
+
120
+ class MiscCoefficients(Model):
121
+ fluid: MiscFluid
122
+ release_phase: Literal["gas","liquid","powder"]
123
+ duration_s: NonNegative
124
+
125
+
126
+ def _misc_rows(x):
127
+ ensure(x.release_phase in MISC[x.fluid],"Unsupported toxic fluid/phase", "unsupported_table_cell")
128
+ return MISC[x.fluid][x.release_phase]
129
+
130
+
131
+ @calculation("level1/misc-toxic-coefficients", "Miscellaneous continuous toxic SI coefficients", MiscCoefficients,
132
+ ["Part3 §4.9.8; Table4.13M; PDF pp498-499,529-530"],
133
+ limitations=["Ten chemicals, printed SI kg/s and m2 coefficients. Exact printed duration nodes only. AlCl3 powder uses gas-column coefficients for all positive durations per §4.9.8. N/A cells never become zero. Uses SI table directly rather than guessing inconsistent US punctuation/exponents or reconverting US coefficients."])
134
+ def misc_coefficients(x):
135
+ times,rows=_misc_rows(x)
136
+ bracket=((0,1),) if not times else _bracket(times,x.duration_s)
137
+ ensure(len(bracket)==1,"Coefficient lookup requires a printed duration node", "interpolation_required")
138
+ pair=rows[bracket[0][0]]
139
+ ensure(pair is not None,"Source toxic coefficient cell is N/A or unresolved", "unsupported_table_cell")
140
+ return {"e":pair[0],"f":pair[1],"native_rate_unit":"kg/s","native_area_unit":"m2",
141
+ "si_mass_multiplier":1,"si_area_multiplier":1}
142
+
143
+
144
+ class MiscArea(MiscCoefficients):
145
+ release_type: Literal["continuous"] = "continuous"
146
+ toxic_rate_kg_s: NonNegative
147
+ nonignition_probability: Fraction
148
+
149
+
150
+ @calculation("level1/misc-toxic-area", "Ten additional chemical continuous toxic consequence areas", MiscArea,
151
+ ["Part3 §4.9.8,§4.9.11; eq(3.65); Table4.13M; PDF pp498-499,529-530"],
152
+ limitations=["Continuous only; no invented instantaneous correlation. Printed SI coefficients used directly; no additional unit conversion. N/A cells block interpolation that touches them. AlCl3 powder is duration-independent for positive duration. Caller qualifies phase and nonignition probability; no automatic spray credit."])
153
+ def misc_area(x):
154
+ times,rows=_misc_rows(x)
155
+ if x.toxic_rate_kg_s == 0 or x.duration_s == 0:
156
+ return {"unweighted_personnel_area_m2":0,"personnel_area_m2":0}
157
+ bracket=((0,1),) if not times else _bracket(times,x.duration_s)
158
+ area=0.0
159
+ for i,w in bracket:
160
+ ensure(rows[i] is not None,"Toxic interpolation touches N/A or unresolved coefficient", "unsupported_table_cell")
161
+ e,f=rows[i]
162
+ area+=w*e*x.toxic_rate_kg_s**f
163
+ return {"unweighted_personnel_area_m2":area,"personnel_area_m2":area*x.nonignition_probability}
164
+
165
+
166
+ class ToxicCutoff(Model):
167
+ fluid: Literal["hf","h2s","ammonia","chlorine"]
168
+ concentration_ppm_on_idlh_basis: NonNegative
169
+
170
+
171
+ @calculation("level1/toxic-cutoff", "Historical Level1 toxic concentration screening", ToxicCutoff,
172
+ ["Part3 §4.9.9; PDF p499"],
173
+ limitations=["2016 printed IDLH values: HF30,H2S100,NH3 300,Cl10ppm. Stored-fluid concentration must be on matching IDLH ppm basis, not silently inferred from mass fraction. At or below cutoff evaluation is not required by this source rule. Not a current occupational exposure limit or an atmospheric dispersion safety claim."])
174
+ def toxic_cutoff(x):
175
+ value={"hf":30,"h2s":100,"ammonia":300,"chlorine":10}[x.fluid]
176
+ return {"idlh_ppm":value,"requires_toxic_evaluation":x.concentration_ppm_on_idlh_basis > value}
177
+
178
+
179
+ class MitigationCredits(Model):
180
+ rate_reduction_fraction: Fraction = 0
181
+ duration_reduction_fraction: Fraction = 0
182
+ mass_reduction_fraction: Fraction = 0
183
+
184
+
185
+ class ToxicMitigation(MitigationCredits):
186
+ toxic_rate_kg_s: NonNegative
187
+ toxic_mass_kg: NonNegative
188
+ duration_s: NonNegative
189
+
190
+
191
+ @calculation("level1/toxic-mitigation", "Apply explicit toxic source mitigation credits", ToxicMitigation,
192
+ ["Part3 §4.9.13; PDF pp499-500"],
193
+ limitations=["Owner-qualified reductions only; no fixed default water-spray efficiency. Rate/duration apply to continuous release, mass to instantaneous. These are not the flammable Table4.6 detection magnitude credit. The caller controls the explicit credit basis and avoids applying the same measure twice."])
194
+ def toxic_mitigation(x):
195
+ return {"toxic_rate_kg_s":x.toxic_rate_kg_s*(1-x.rate_reduction_fraction),
196
+ "toxic_mass_kg":x.toxic_mass_kg*(1-x.mass_reduction_fraction),
197
+ "duration_s":x.duration_s*(1-x.duration_reduction_fraction)}
198
+
199
+
200
+ class ToxicComponent(Model):
201
+ fluid: Literal["hf","h2s","ammonia","chlorine","alcl3","co","hcl","nitric_acid","no2","phosgene","tdi","ee","eo","po"]
202
+ release_phase: Literal["gas","liquid","powder"]
203
+ release_type: Literal["continuous","instantaneous"]
204
+ theoretical_rate_kg_s: NonNegative
205
+ released_mass_kg: NonNegative
206
+ toxic_mass_fraction: Fraction
207
+ available_mass_kg: NonNegative
208
+ detection_maximum_duration_s: NonNegative
209
+ nonignition_probability: Fraction
210
+ mitigation: MitigationCredits = Field(default_factory=MitigationCredits)
211
+ concentration_ppm_on_idlh_basis: NonNegative | None = None
212
+ hf_instantaneous_intercept: Finite | None = None
213
+ intercept_qualification: str | None = Field(default=None,min_length=1,max_length=1000)
214
+
215
+
216
+ class ToxicMixtureComponentArea(Model):
217
+ component_id: str = Field(min_length=1, max_length=100)
218
+ personnel_area_m2: NonNegative
219
+
220
+
221
+ class ToxicMixtureAreas(Model):
222
+ components: list[ToxicMixtureComponentArea] = Field(min_length=1, max_length=100)
223
+
224
+
225
+ @calculation("level1/toxic-component-consequence", "Compose toxic source/duration/mitigation and per-component area", ToxicComponent,
226
+ ["Part3 §4.9; eqs(3.61)-(3.67); Tables4.11-4.13"],
227
+ origin="api581_equations_with_explicit_qualified_mitigation",
228
+ limitations=["Single toxic component; mixture maximum and four-hole aggregation are separate. Uses theoretical total leak rate without flammable magnitude reduction. HF/H2S/NH3/Cl use gas/vapor exposure assumptions; miscellaneous phases follow Table4.13. Optional printed IDLH screening only supports four refinery toxins. All interpolation domains and HF ambiguity still apply; mitigation may shorten duration below a supported table range and then evaluation is rejected."])
229
+ def toxic_component(x):
230
+ common=x.fluid in ("hf","h2s","ammonia","chlorine")
231
+ ensure(not common or x.release_phase == "gas","Common refinery toxic correlations assume gas/vapor consequence phase")
232
+ ensure(x.fluid in ("hf","h2s") or x.hf_instantaneous_intercept is None and x.intercept_qualification is None,
233
+ "HF intercept parameters apply only to HF instantaneous releases")
234
+ if x.fluid in ("hf","h2s"):
235
+ _check_override(x)
236
+ if not common:
237
+ ensure(x.release_type == "continuous","Miscellaneous toxic correlations support continuous releases only")
238
+ _misc_rows(x)
239
+ screening=x.concentration_ppm_on_idlh_basis is not None
240
+ if screening:
241
+ ensure(common,"Built-in IDLH cutoff is not provided for this chemical")
242
+ required=toxic_cutoff(ToxicCutoff(fluid=x.fluid,concentration_ppm_on_idlh_basis=x.concentration_ppm_on_idlh_basis))["requires_toxic_evaluation"]
243
+ else:
244
+ required=True
245
+ source=toxic_source(ToxicSource(theoretical_rate_kg_s=x.theoretical_rate_kg_s,released_mass_kg=x.released_mass_kg,toxic_mass_fraction=x.toxic_mass_fraction))
246
+ duration=toxic_duration(ToxicDuration(available_mass_kg=x.available_mass_kg,theoretical_rate_kg_s=x.theoretical_rate_kg_s,
247
+ detection_maximum_duration_s=x.detection_maximum_duration_s))["duration_s"]
248
+ mitigated=toxic_mitigation(ToxicMitigation(**source,duration_s=duration,**x.mitigation.model_dump()))
249
+ if not required:
250
+ area={"unweighted_personnel_area_m2":0,"personnel_area_m2":0}
251
+ elif x.fluid in ("hf","h2s"):
252
+ area=hf_h2s_area(HfH2sArea(fluid=x.fluid,release_type=x.release_type,**mitigated,
253
+ nonignition_probability=x.nonignition_probability,hf_instantaneous_intercept=x.hf_instantaneous_intercept,
254
+ intercept_qualification=x.intercept_qualification))
255
+ elif common:
256
+ area=toxic_area(ToxicArea(fluid=x.fluid,release_type=x.release_type,**mitigated,nonignition_probability=x.nonignition_probability))
257
+ else:
258
+ area=misc_area(MiscArea(fluid=x.fluid,release_phase=x.release_phase,duration_s=mitigated["duration_s"],
259
+ toxic_rate_kg_s=mitigated["toxic_rate_kg_s"],nonignition_probability=x.nonignition_probability))
260
+ return {**mitigated,**area,"unmitigated_duration_s":duration,"screening_applied":screening,
261
+ "requires_toxic_evaluation":required}
262
+
263
+
264
+ @calculation(
265
+ "level1/toxic-mixture-consequence",
266
+ "Largest individual Level 1 toxic consequence area for a mixture",
267
+ ToxicMixtureAreas,
268
+ ["Part 3 §4.9.12, §4.9.15(f), eq (3.68); PDF pp500-501"],
269
+ limitations=[
270
+ "Takes already calculated individual toxic component areas and returns the largest; it does not add overlapping clouds, recalculate species release rates, or perform dispersion. For four-hole weighting use the separate hole-weighted/Level 2 composition API."
271
+ ],
272
+ )
273
+ def toxic_mixture_consequence(x):
274
+ ensure(len({c.component_id for c in x.components}) == len(x.components), "Duplicate toxic component id")
275
+ critical = max(x.components, key=lambda c: c.personnel_area_m2)
276
+ return {
277
+ "personnel_area_m2": critical.personnel_area_m2,
278
+ "critical_component_id": critical.component_id,
279
+ "component_areas_m2": {c.component_id: c.personnel_area_m2 for c in x.components},
280
+ "component_count": len(x.components),
281
+ }
282
+
283
+
284
+ class ToxicHoleCase(Model):
285
+ hole: Literal["small", "medium", "large", "rupture"]
286
+ generic_frequency_per_year: NonNegative
287
+ components: list[ToxicMixtureComponentArea] = Field(min_length=1, max_length=100)
288
+
289
+
290
+ class ToxicHoleSummary(Model):
291
+ cases: list[ToxicHoleCase] = Field(min_length=4, max_length=4)
292
+
293
+
294
+ @calculation(
295
+ "level1/toxic-hole-summary",
296
+ "Four-hole GFF-weighted Level 1 toxic consequence",
297
+ ToxicHoleSummary,
298
+ ["Part 3 §4.9.14-§4.9.15(f), eq (3.68); PDF pp500-501"],
299
+ limitations=[
300
+ "Requires one case for each canonical hole. Within each hole the largest individual toxic-component area is used, then areas are weighted by the supplied generic failure frequencies. Detection/DF-adjusted frequencies, dispersion and financial risk are outside this consequence-area API."
301
+ ],
302
+ )
303
+ def toxic_hole_summary(x):
304
+ ensure({c.hole for c in x.cases} == {"small", "medium", "large", "rupture"}, "Require four canonical hole cases")
305
+ areas = {}
306
+ for case in x.cases:
307
+ ensure(len({c.component_id for c in case.components}) == len(case.components), "Duplicate toxic component id in hole case")
308
+ areas[case.hole] = max(c.personnel_area_m2 for c in case.components)
309
+ total = math.fsum(c.generic_frequency_per_year for c in x.cases)
310
+ ensure(math.isfinite(total) and total > 0, "Total generic frequency must be finite and positive")
311
+ weighted = math.fsum(c.generic_frequency_per_year / total * areas[c.hole] for c in x.cases)
312
+ return {"personnel_area_m2": weighted, "total_generic_frequency_per_year": total,
313
+ "by_hole_personnel_area_m2": areas}