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_type1.py ADDED
@@ -0,0 +1,91 @@
1
+ """Type1 SI flammable tables; no Type0 continuous/instantaneous or AIT blending."""
2
+ from typing import Literal
3
+ from .core import Model, NonNegative, Fraction, calculation, ensure
4
+ from .level1 import Coefficients, coefficients, Energy, energy, ControlledRelease, controlled_release
5
+
6
+ Type1Fluid = Literal["Aromatics","Styrene","CO","DEE","Methanol","PO","EEA","EE","EG","EO"]
7
+
8
+ # (continuous AINL, continuous AIL, instantaneous AINL, instantaneous AIL),
9
+ # first equipment then personnel. None is a blank; (0,b) is a real zero.
10
+ TYPE1_COEFFICIENTS = {
11
+ "Aromatics":{
12
+ "gas":[(3.952,1.097),(80.11,1.055),(1.804,.667),(83.68,.713), (12.76,.963),(261.9,.937),(2.889,.686),(83.68,.713)],
13
+ "liquid":[(21.10,1),None,(14.36,1),(143.6,1), (66.01,.883),(56,.268),(.027,.935),(.273,.935)]},
14
+ "CO":{"gas":[(.040,1.752),None,(10.97,.667),None, (5.491,.991),None,(16.91,.692),None]},
15
+ "DEE":{
16
+ "gas":[(9.072,1.134),(67.42,1.033),(24.51,.667),None, (26.76,1.025),(241.5,.997),(31.71,.682),(128.3,.657)],
17
+ "liquid":[(164.2,1.106),(976,.649),(.981,.919),(1.090,.919), (236.7,1.219),(488.9,.864),(8.333,.814),(9.258,.814)]},
18
+ "Methanol":{
19
+ "gas":[(.005,.909),None,(4.425,.667),None, (0,1.008),None,(6.035,.688),None],
20
+ "liquid":[(340.4,.934),None,(.363,.900),None, (849.9,.902),None,(1.157,.871),None]},
21
+ "PO":{
22
+ "gas":[(3.277,1.114),None,(10.32,.667),None, (8.239,1.047),None,(13.33,.682),None],
23
+ "liquid":[(257,.960),None,(.629,.869),None, (352.8,.840),None,(2.732,.834),None]},
24
+ "EEA":{
25
+ "gas":[(0,1.035),None,(1.261,.667),None, (0,.946),None,(1.825,.687),None],
26
+ "liquid":[(23.96,1),None,(14.13,1),None, (79.66,.835),None,(.030,.924),None]},
27
+ "EE":{
28
+ "gas":[(2.595,1.005),None,(6.119,.667),None, (7.107,.969),None,(25.36,.660),None],
29
+ "liquid":[(35.45,1),None,(14.79,1),None, (8.142,.800),None,(.029,.927),None]},
30
+ "EG":{
31
+ "gas":[(1.548,.973),None,(1.027,.667),None, (5.042,.947),None,(1.435,.687),None],
32
+ "liquid":[(22.12,1),None,(14.13,1),None, (59.96,.869),None,(.027,.922),None]},
33
+ "EO":{"gas":[(6.712,1.069),None,(21.46,.667),None, (11,1.105),None,(34.70,.665),None]},
34
+ }
35
+ TYPE1_COEFFICIENTS["Styrene"] = {phase:list(rows) for phase,rows in TYPE1_COEFFICIENTS["Aromatics"].items()}
36
+
37
+
38
+ class Type1Area(Coefficients):
39
+ fluid: Type1Fluid
40
+ adjusted_rate_kg_s: NonNegative
41
+ released_mass_kg: NonNegative
42
+ mitigation_fraction: Fraction
43
+
44
+
45
+ @calculation("level1/type1-flammable-area", "Type1 single-effect flammable consequence area", Type1Area,
46
+ ["Part 3 §4.1.5(b),§4.8.4,§4.8.8; Tables4.8M,4.9M; PDF pp484,492,522,524"],
47
+ limitations=["Ten Type1 flammable fluids. Caller selects phase, release type and AINL/AIL branch; no Type0 IC or AIT blending. Instantaneous energy correction follows STEP8.2 and §4.8.4. Blank cells are rejected, printed zero retained. Equipment/personnel are callable separately when only one source cell exists."])
48
+ def type1_area(x):
49
+ ensure(x.fluid in TYPE1_COEFFICIENTS,"Not a supported Type1 flammable reference fluid")
50
+ cell = coefficients(Coefficients(**{k:getattr(x,k) for k in ("fluid","release_phase","effect","release_type","autoignition")}))
51
+ correction = energy(Energy(released_mass_kg=x.released_mass_kg))["energy_efficiency_factor"] if x.release_type == "instantaneous" else 1
52
+ magnitude = x.adjusted_rate_kg_s if x.release_type == "continuous" else x.released_mass_kg
53
+ return {"area_m2":cell["a"]*magnitude**cell["b"]*(1-x.mitigation_fraction)/correction,
54
+ "coefficient_a":cell["a"],"coefficient_b":cell["b"],"energy_efficiency_factor":correction}
55
+
56
+
57
+ class Type1Consequence(Model):
58
+ fluid: Type1Fluid
59
+ release_phase: Literal["gas","liquid"]
60
+ release_type: Literal["continuous","instantaneous"]
61
+ autoignition: Literal["not_likely","likely"]
62
+ adjusted_rate_kg_s: NonNegative
63
+ released_mass_kg: NonNegative
64
+ mitigation_fraction: Fraction
65
+
66
+
67
+ @calculation("level1/type1-flammable-consequence", "Type1 equipment and personnel flammable areas", Type1Consequence,
68
+ ["Part 3 §4.1.5(b),§4.8.8; Tables4.8M,4.9M"],
69
+ limitations=["Unblended Type1 branch selected explicitly by caller. Requires populated equipment and personnel cells; use type1-flammable-area to query just one effect. Energy/mitigation applied once; coefficients already incorporate event probabilities."])
70
+ def type1_consequence(x):
71
+ values = {effect:type1_area(Type1Area(effect=effect,**x.model_dump())) for effect in ("equipment","personnel")}
72
+ return {"equipment_area_m2":values["equipment"]["area_m2"],"personnel_area_m2":values["personnel"]["area_m2"],
73
+ "energy_efficiency_factor":values["equipment"]["energy_efficiency_factor"],"fluid_type":1}
74
+
75
+
76
+ class ReleaseToType1(ControlledRelease):
77
+ fluid: Type1Fluid
78
+ final_release_phase: Literal["gas","liquid"]
79
+ autoignition: Literal["not_likely","likely"]
80
+ mitigation_fraction: Fraction
81
+
82
+
83
+ @calculation("level1/release-to-type1-flammable", "Compose controlled release and Type1 flammable consequence", ReleaseToType1,
84
+ ["Part 3 §§4.3-4.8; §4.1.5(b)"],
85
+ limitations=["Single qualified hole. Uses gas/liquid source, inventory and detection/isolation duration; Type1 phase/AIT branch is caller-qualified. No Type0 blending. Toxic/financial/other-hole aggregation remain separate composable functions."])
86
+ def release_to_type1(x):
87
+ released = controlled_release(x)
88
+ consequence = type1_consequence(Type1Consequence(fluid=x.fluid,release_phase=x.final_release_phase,
89
+ release_type=released["release_type"],autoignition=x.autoignition,adjusted_rate_kg_s=released["adjusted_rate_kg_s"],
90
+ released_mass_kg=released["released_mass_kg"],mitigation_fraction=x.mitigation_fraction))
91
+ return {**released,**consequence}
@@ -0,0 +1,237 @@
1
+ """Level2 event weighting, non-ignition maxima, GFF averages and assessment.
2
+
3
+ Unlike Level1, eqs3.202/203 ADD flammable areas to the non-ignition branch.
4
+ """
5
+ import math
6
+ from typing import Literal
7
+ from pydantic import Field
8
+ from .core import Model, NonNegative, Fraction, calculation, ensure
9
+ from .physical_explosion import NfntCase, nfnt_case
10
+ from .toxic_level2 import ToxicCloudArea, ToxicCase, toxic_case
11
+ from .pof import Component, generic, Management, management, Frequency, adjusted
12
+ from .cost_tables import RepairCost, ComponentOutage, FinancialConsequence, financial_consequence
13
+ from .risk import Risk, RiskScenario, aggregate
14
+
15
+ Hole=Literal["small","medium","large","rupture"]
16
+ Event=Literal["pool_fire","jet_fire","fireball","vce","flash_fire"]
17
+ _EVENTS={"pool_fire","jet_fire","fireball","vce","flash_fire"}
18
+ _MECHANISMS=("flammable_equipment_area_m2","flammable_personnel_area_m2","toxic_personnel_area_m2",
19
+ "nfnt_equipment_area_m2","nfnt_personnel_area_m2")
20
+
21
+
22
+ class FlammableOutcome(Model):
23
+ event: Event
24
+ probability: Fraction
25
+ equipment_area_m2: NonNegative
26
+ personnel_area_m2: NonNegative
27
+
28
+
29
+ class CorrelatedJetPool(Model):
30
+ probability: Fraction
31
+ jet_equipment_area_m2: NonNegative
32
+ jet_personnel_area_m2: NonNegative
33
+ pool_equipment_area_m2: NonNegative
34
+ pool_personnel_area_m2: NonNegative
35
+ consequence_source: str = Field(min_length=1,max_length=1000)
36
+
37
+
38
+ @calculation("level2/correlated-jet-pool", "One immediate-ignition branch with max(jet,pool) area", CorrelatedJetPool,
39
+ ["Part3 Figure5.3 two-phase continuous immediate branch; PDF p586; eqs(3.176)-(3.177)"],
40
+ origin="api581_correlated_two_phase_immediate_area",
41
+ limitations=["One branch probability applies to maximum jet/pool area, separately for equipment and personnel. Do not treat jet and pool as two independent terminal probabilities or sum their areas. Source footprints supplied and qualified externally; no geometric union or simultaneous-fire radiation solver."])
42
+ def correlated_jet_pool(x):
43
+ ensure(x.consequence_source.strip(),"Correlated branch consequence source must be nonblank")
44
+ equipment=max(x.jet_equipment_area_m2,x.pool_equipment_area_m2)
45
+ personnel=max(x.jet_personnel_area_m2,x.pool_personnel_area_m2)
46
+ def governing(jet,pool):
47
+ return "equal" if jet == pool else "jet" if jet > pool else "pool"
48
+ return {"equipment_area_m2":x.probability*equipment,"personnel_area_m2":x.probability*personnel,
49
+ "maximum_equipment_area_m2":equipment,"maximum_personnel_area_m2":personnel,
50
+ "governing_equipment":governing(x.jet_equipment_area_m2,x.pool_equipment_area_m2),
51
+ "governing_personnel":governing(x.jet_personnel_area_m2,x.pool_personnel_area_m2)}
52
+
53
+
54
+ class FlammableCase(Model):
55
+ hole: Hole
56
+ release_type: Literal["continuous","instantaneous"]
57
+ outcomes: list[FlammableOutcome] = Field(min_length=5,max_length=5)
58
+ non_ignition_probability: Fraction
59
+ event_tree_source: str = Field(min_length=1,max_length=1000)
60
+ consequence_source: str = Field(min_length=1,max_length=1000)
61
+ fireball_applicability_basis: Literal["rupture_only_text","figure53_instantaneous"] = "rupture_only_text"
62
+ correlated_jet_pool: CorrelatedJetPool | None = None
63
+
64
+
65
+ @calculation("level2/flammable-case", "Flammable areas with optional correlated immediate branch", FlammableCase,
66
+ ["Part3 eqs(3.176)-(3.177),Figure5.3; PDF pp559,586"],
67
+ limitations=["Requires pool,jet,fireball,VCE,flash outcomes exactly once, including explicit zero branches. Together with optional correlated_jet_pool and non-ignition probabilities they sum to1. Raw areas weighted once. Correlated max(jet,pool) only for continuous leakage; separate jet/pool branches must be zero. Fireball defaults to instantaneous rupture-only text; explicit figure53_instantaneous also permits figure5.3 instantaneous leakage. Caller qualifies tree/area sources."])
68
+ def flammable_case(x):
69
+ ensure({o.event for o in x.outcomes} == _EVENTS,"Require five distinct canonical flammable outcomes")
70
+ ensure(x.event_tree_source.strip() and x.consequence_source.strip(),"Event-tree and consequence sources must be nonblank")
71
+ correlated={"equipment_area_m2":0,"personnel_area_m2":0}
72
+ if x.correlated_jet_pool is not None:
73
+ ensure(x.release_type == "continuous" and x.hole != "rupture","Correlated jet/pool branch only for continuous leakage")
74
+ ensure(all(o.probability == o.equipment_area_m2 == o.personnel_area_m2 == 0 for o in x.outcomes if o.event in {"jet_fire","pool_fire"}),
75
+ "Correlated branch cannot coexist with separate jet/pool probability or area")
76
+ correlated=correlated_jet_pool(x.correlated_jet_pool)
77
+ total=math.fsum([o.probability for o in x.outcomes]+[x.non_ignition_probability,
78
+ x.correlated_jet_pool.probability if x.correlated_jet_pool is not None else 0])
79
+ ensure(math.isclose(total,1,rel_tol=0,abs_tol=1e-10),"Flammable outcomes plus non-ignition probability must sum to1")
80
+ for outcome in x.outcomes:
81
+ inactive=(outcome.event == "fireball" and (x.release_type != "instantaneous" or (
82
+ x.hole != "rupture" and x.fireball_applicability_basis != "figure53_instantaneous"))) or (
83
+ outcome.event == "jet_fire" and x.release_type != "continuous")
84
+ ensure(not inactive or outcome.probability == outcome.equipment_area_m2 == outcome.personnel_area_m2 == 0,
85
+ "Inapplicable fireball/jet branch must have zero probability and area")
86
+ equipment=math.fsum([o.probability*o.equipment_area_m2 for o in x.outcomes]+[correlated["equipment_area_m2"]])
87
+ personnel=math.fsum([o.probability*o.personnel_area_m2 for o in x.outcomes]+[correlated["personnel_area_m2"]])
88
+ return {"flammable_equipment_area_m2":equipment,"flammable_personnel_area_m2":personnel,
89
+ "non_ignition_probability":x.non_ignition_probability,"terminal_probability_sum":total}
90
+
91
+
92
+ class FinalArea(Model):
93
+ flammable_equipment_area_m2: NonNegative
94
+ flammable_personnel_area_m2: NonNegative
95
+ toxic_personnel_area_m2: NonNegative
96
+ nfnt_equipment_area_m2: NonNegative
97
+ nfnt_personnel_area_m2: NonNegative
98
+
99
+
100
+ @calculation("level2/final-area", "Final Level2 component/personnel consequence areas", FinalArea,
101
+ ["Part3 eqs(3.202)-(3.204); PDF pp572-573"],
102
+ limitations=["Inputs must already be event- and hole-weighted on a common basis. Equipment=flammable+NFNT; personnel=flammable+max(toxic,NFNT); final=max(equipment,personnel). Safe-event areas are0. Do not replace with Level1 maximum over all mechanisms or apply non-ignition probability twice."])
103
+ def final_area(x):
104
+ equipment=x.flammable_equipment_area_m2+x.nfnt_equipment_area_m2
105
+ nonignition=max(x.toxic_personnel_area_m2,x.nfnt_personnel_area_m2)
106
+ personnel=x.flammable_personnel_area_m2+nonignition
107
+ ensure(math.isfinite(equipment) and math.isfinite(personnel),"Final Level2 area exceeds numerical domain","numerical_failure")
108
+ return {"equipment_area_m2":equipment,"personnel_area_m2":personnel,"consequence_area_m2":max(equipment,personnel),
109
+ "non_ignition_personnel_area_m2":nonignition,
110
+ "governing_non_ignition_personnel_mechanism":"equal" if x.toxic_personnel_area_m2 == x.nfnt_personnel_area_m2 else (
111
+ "toxic" if x.toxic_personnel_area_m2 > x.nfnt_personnel_area_m2 else "nfnt")}
112
+
113
+
114
+ class MechanismHole(FinalArea):
115
+ hole: Hole
116
+ generic_frequency_per_year: NonNegative
117
+
118
+
119
+ class ConsequenceSummary(Model):
120
+ holes: list[MechanismHole] = Field(min_length=4,max_length=4)
121
+
122
+
123
+ def _holes(cases):
124
+ ensure({c.hole for c in cases} == {"small","medium","large","rupture"},"Require four distinct canonical holes")
125
+
126
+
127
+ @calculation("level2/consequence-summary", "GFF mechanism averages followed by Level2 final selection", ConsequenceSummary,
128
+ ["Part3 eqs(3.178)-(3.179),(3.191),(3.200)-(3.204); PDF pp560,567,570,572-573"],
129
+ origin="api581_level2_mechanism_composition",
130
+ limitations=["All four holes required. Input mechanism areas already include event/non-ignition probabilities. GFF averages are formed separately before final non-ignition maximum. Averaging per-hole maxima generally gives a different result. No DF-adjusted frequencies as GFF weights."])
131
+ def consequence_summary(x):
132
+ _holes(x.holes)
133
+ total=math.fsum(h.generic_frequency_per_year for h in x.holes)
134
+ ensure(math.isfinite(total) and total > 0,"Total generic frequency must be finite and positive")
135
+ values={name:math.fsum(h.generic_frequency_per_year/total*getattr(h,name) for h in x.holes) for name in _MECHANISMS}
136
+ return {**values,**final_area(FinalArea(**values)),"generic_frequency_per_year":total}
137
+
138
+
139
+ class NfntAreas(Model):
140
+ physical_equipment_area_m2: NonNegative = 0
141
+ physical_personnel_area_m2: NonNegative = 0
142
+ bleve_equipment_area_m2: NonNegative = 0
143
+ bleve_personnel_area_m2: NonNegative = 0
144
+ steam_chemical_personnel_area_m2: NonNegative = 0
145
+ consequence_source: str = Field(min_length=1,max_length=1000)
146
+
147
+
148
+ class ScenarioCase(FlammableCase):
149
+ toxic_components: list[ToxicCloudArea] = Field(max_length=100)
150
+ toxic_applicability: Literal["evaluated","not_applicable"]
151
+ toxic_applicability_source: str = Field(min_length=1,max_length=1000)
152
+ nfnt: NfntAreas
153
+
154
+
155
+ @calculation("level2/scenario-consequence", "One-hole flammable/toxic/NFNT mechanism consequences", ScenarioCase,
156
+ ["Part3 eqs(3.176)-(3.177),(3.188),(3.198)-(3.199),Figure5.3; PDF pp559,567,570,586"],
157
+ origin="api581_level2_conditional_scenario_composition",
158
+ limitations=["Supplied terminal probabilities and raw event/footprint areas; no release or dispersion calculation is inferred. One shared non-ignition probability drives toxic and NFNT. Empty toxic list requires explicit not_applicable with basis. Outputs remain separate mechanisms; final selection is after hole weighting."])
159
+ def scenario_consequence(x):
160
+ ensure(x.toxic_applicability_source.strip() and x.nfnt.consequence_source.strip(),"Toxic applicability and NFNT sources must be nonblank")
161
+ ensure(bool(x.toxic_components) == (x.toxic_applicability == "evaluated"),"Toxic components must match explicit applicability")
162
+ flammable=flammable_case(FlammableCase(**x.model_dump(include=set(FlammableCase.model_fields))))
163
+ toxic=0
164
+ if x.toxic_components:
165
+ toxic=toxic_case(ToxicCase(components=x.toxic_components,non_ignition_probability=x.non_ignition_probability))["personnel_area_m2"]
166
+ nfnt=nfnt_case(NfntCase(**x.nfnt.model_dump(exclude={"consequence_source"}),hole=x.hole,
167
+ non_ignition_probability=x.non_ignition_probability))
168
+ return {**flammable,"toxic_personnel_area_m2":toxic,"nfnt_equipment_area_m2":nfnt["equipment_area_m2"],
169
+ "nfnt_personnel_area_m2":nfnt["personnel_area_m2"],"hole":x.hole}
170
+
171
+
172
+ class FrequencyScenario(ScenarioCase):
173
+ generic_frequency_per_year: NonNegative
174
+
175
+
176
+ class ScenarioSummary(Model):
177
+ cases: list[FrequencyScenario] = Field(min_length=4,max_length=4)
178
+
179
+
180
+ @calculation("level2/scenario-summary", "Four raw event scenarios to final Level2 consequence areas", ScenarioSummary,
181
+ ["Part3 §§5.8.7-5.11 eqs(3.176)-(3.204); PDF pp559-573"],
182
+ origin="api581_level2_event_to_final_area_composition",
183
+ limitations=["Caller-qualified event probabilities and raw event/dispersion areas for all four holes. Composes conditional mechanisms, separate GFF averages, flammable addition and non-ignition maximum. Does not run thermodynamics, dispersion or applicability inference."])
184
+ def scenario_summary(x):
185
+ _holes(x.cases)
186
+ holes=[]
187
+ for case in x.cases:
188
+ result=scenario_consequence(ScenarioCase(**case.model_dump(include=set(ScenarioCase.model_fields))))
189
+ holes.append(MechanismHole(hole=case.hole,generic_frequency_per_year=case.generic_frequency_per_year,
190
+ **{name:result[name] for name in _MECHANISMS}))
191
+ return consequence_summary(ConsequenceSummary(holes=holes))
192
+
193
+
194
+ class FinancialBasis(Model):
195
+ repair: RepairCost
196
+ outage: ComponentOutage
197
+ equipment_cost_per_m2: NonNegative
198
+ production_cost_per_day: NonNegative
199
+ population_density_per_m2: NonNegative
200
+ cost_per_injury: NonNegative
201
+ weighted_cleanup_volume_m3: NonNegative
202
+ cleanup_cost_per_m3: NonNegative
203
+
204
+
205
+ class Assessment(Model):
206
+ component_type: str = Field(min_length=1,max_length=100)
207
+ management_score: NonNegative = Field(le=1000)
208
+ total_damage_factor: NonNegative
209
+ damage_factor_source: str = Field(min_length=1,max_length=1000)
210
+ cases: list[ScenarioCase] = Field(min_length=4,max_length=4)
211
+ financial: FinancialBasis
212
+
213
+
214
+ @calculation("level2/assessment", "Supplied Level2 event consequences to financial/annual risk", Assessment,
215
+ ["Part3 §§5.8.7-5.12 eqs(3.176)-(3.204); Part1 eqs(1.1),(1.6)-(1.8)"],
216
+ origin="api581_level2_supplied_event_area_and_damage_assessment",
217
+ limitations=["Explicit integration boundary: owner-qualified terminal probabilities, raw event/dispersion areas, applicability and total DF supplied. Computes GFF/Fms, Level2 area composition, five financial categories and component annual risk. Non-tank GFF only. Common component DF/Fms; no hole-specific DF substitution or automatic future damage model.",
218
+ "Repair/outage component types must match assessment. Areas are computed here; cleanup volume/pricing/screening remain supplied. Financial equations reuse §4.12 per §5.12. Not a complete asset-data-to-risk workflow or independent certification."])
219
+ def assessment(x):
220
+ ensure(x.damage_factor_source.strip(),"Damage factor source must be nonblank")
221
+ ensure(x.component_type == x.financial.repair.component_type == x.financial.outage.component_type,
222
+ "Assessment, repair and outage component types must match")
223
+ frequencies=generic(Component(component_type=x.component_type))
224
+ cases=[FrequencyScenario(**c.model_dump(),generic_frequency_per_year=frequencies["by_hole_per_year"][c.hole]) for c in x.cases]
225
+ area=scenario_summary(ScenarioSummary(cases=cases))
226
+ finance=financial_consequence(FinancialConsequence(**x.financial.model_dump(),
227
+ equipment_area_m2=area["equipment_area_m2"],personnel_area_m2=area["personnel_area_m2"]))
228
+ fms=management(Management(score=x.management_score))["management_factor"]
229
+ occurrence=adjusted(Frequency(generic_frequency_per_year=frequencies["total_per_year"],total_damage_factor=x.total_damage_factor,
230
+ management_factor=fms))["annual_failure_frequency"]
231
+ ensure(math.isfinite(occurrence) and (occurrence > 0 or x.total_damage_factor == 0),
232
+ "Adjusted frequency outside finite positive numerical domain","numerical_failure")
233
+ risk=aggregate(Risk(currency=finance["currency"],scenarios=[RiskScenario(scenario_id="level2_component",
234
+ annual_failure_frequency=occurrence,equipment_area_m2=area["equipment_area_m2"],personnel_area_m2=area["personnel_area_m2"],
235
+ financial_consequence=finance["financial_consequence"])]))
236
+ return {"integration_scope":"supplied_level2_event_probabilities_areas_and_damage",
237
+ "management_factor":fms,**area,**finance,**risk}
api581/lining.py ADDED
@@ -0,0 +1,173 @@
1
+ """2016 lining tables, qualified inspection-age policy and governing thinning."""
2
+ from datetime import date
3
+ import math
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, calculation, ensure, finite_tree
7
+ from .cracking import LiningAdjustment, lining_adjust
8
+
9
+ LiningType = Literal["strip_lined_resistant_alloy","castable_refractory","castable_refractory_severe",
10
+ "glass_lined","acid_brick","fiberglass","organic_low_quality","organic_medium_quality","organic_high_quality"]
11
+ Lookup = Literal["exact_rows_only","linear_between_rows","conservative_upper_row"]
12
+ FirstYear = Literal["reject_below_first_year","qualified_first_year_value"]
13
+ _INORGANIC = (
14
+ (.3,.5,9,3,.01,1),(.5,1,40,4,.03,1),(.7,2,146,6,.05,1),(1,4,428,7,.15,1),
15
+ (1,9,1017,9,1,1),(2,16,1978,11,1,1),(3,30,3000,13,1,2),(4,53,3000,16,1,3),
16
+ (6,89,3000,20,2,7),(9,146,3000,25,3,13),(12,230,3000,30,4,26),(16,351,3000,36,5,47),
17
+ (22,518,3000,44,7,82),(30,738,3000,53,9,139),(40,1017,3000,63,11,228),
18
+ (53,1358,3000,75,15,359),(69,1758,3000,89,19,548),(89,2209,3000,105,25,808),
19
+ (115,2697,3000,124,31,1151),(146,3000,3000,146,40,1587),(184,3000,3000,170,50,2119),
20
+ (230,3000,3000,199,63,2743),(286,3000,3000,230,78,3000),(351,3000,3000,266,97,3000),
21
+ (428,3000,3000,306,119,3000))
22
+ _ORGANIC = ((30,1,.1),(89,4,.13),(230,16,.15),(518,53,.17),(1017,146,.2),(1758,351,1),
23
+ (2697,738,4),(3000,1358,16),(3000,2209,53),(3000,3000,146),(3000,3000,351),
24
+ (3000,3000,738),(3000,3000,1358),(3000,3000,2209))+((3000,3000,3000),)*11
25
+
26
+
27
+ class LiningInspection(Model):
28
+ inspection_id: str = Field(min_length=1)
29
+ inspected_on: date
30
+ effectiveness: Literal["a","b","c","d","e"]
31
+ qualified_for_age_reset: bool
32
+ qualification_basis: str | None = None
33
+
34
+
35
+ class LiningAge(Model):
36
+ lining_family: Literal["inorganic","organic"]
37
+ installed_on: date
38
+ evaluated_on: date
39
+ inspections: list[LiningInspection] = Field(default_factory=list,max_length=10000)
40
+ age_basis_policy: Literal["step1_installation_or_last_ab","figure51_organic_service_only"]
41
+ year_length_days: Literal[365,365.25]
42
+
43
+
44
+ @calculation("damage/lining-effective-age", "Lining age from installation or qualified A/B inspection", LiningAge,
45
+ ["Part2 §§5.5.2-5.5.3 STEP1,Table5.3,Figure5.1; PDF pp166,168,171"],limitations=[
46
+ "STEP1 permits installation or last A/B inspection baseline; organic table/figure also say years in service. Explicit policy chooses STEP1 reset or organic service-only interpretation, not an asserted erratum. No inspection-count/Bayesian credit. Owner qualifies A/B reset applicability under Annex2.C.6; C/D/E cannot reset. Date-only deterministic elapsed days divided by explicit365 or365.25; no inferred current clock, time zone or service downtime subtraction."])
47
+ def effective_age(x):
48
+ ensure(x.evaluated_on >= x.installed_on,"Evaluation date precedes installation")
49
+ ensure(x.age_basis_policy != "figure51_organic_service_only" or x.lining_family == "organic","Service-only figure interpretation applies only to organic lining")
50
+ ensure(len({i.inspection_id for i in x.inspections}) == len(x.inspections),"Duplicate lining inspection identifier")
51
+ eligible = []
52
+ for i in x.inspections:
53
+ ensure(bool(i.inspection_id.strip()),"Inspection identifier must not be blank")
54
+ ensure(x.installed_on <= i.inspected_on <= x.evaluated_on,"Inspection date outside lining service/evaluation period")
55
+ if i.qualified_for_age_reset:
56
+ ensure(i.effectiveness in ("a","b") and i.qualification_basis is not None and bool(i.qualification_basis.strip()),"Age reset requires qualified A/B inspection and basis")
57
+ eligible.append(i)
58
+ else:
59
+ ensure(i.qualification_basis is None,"Unqualified reset cannot carry qualification basis")
60
+ latest = max(eligible,key=lambda i:(i.inspected_on,i.inspection_id),default=None)
61
+ reset = latest if x.age_basis_policy == "step1_installation_or_last_ab" else None
62
+ baseline = reset.inspected_on if reset is not None else x.installed_on
63
+ return {"effective_age_years":(x.evaluated_on-baseline).days/x.year_length_days,
64
+ "elapsed_service_years":(x.evaluated_on-x.installed_on).days/x.year_length_days,
65
+ "age_baseline_date":baseline.isoformat(),"age_baseline_source":"qualified_ab_inspection" if reset else "installation",
66
+ "reset_inspection_id":reset.inspection_id if reset else None,"qualified_ab_inspection_count":len(eligible),
67
+ "age_basis_policy":x.age_basis_policy}
68
+
69
+
70
+ class LiningBase(Model):
71
+ lining_type: LiningType
72
+ age_years: NonNegative
73
+ lookup_policy: Lookup
74
+ first_year_policy: FirstYear
75
+ first_year_qualification_basis: str | None = None
76
+
77
+
78
+ @calculation("damage/lining-base-factor", "Inorganic and organic lining base damage factor tables", LiningBase,
79
+ ["Part2 Tables5.4-5.5; PDF pp169-170"],limitations=[
80
+ "All225 cells:6 inorganic/3 organic columns over1..25years. Fiberglass follows source Table5.4 grouping without inferring chemical classification. Organic quality includes stated application/reinforcement thickness, not material name alone. No >25year extrapolation even if selected column plateaus. Between-year numerical policy explicit; source gives no interpolation instruction. Below1year requires rejection or qualified first-year-value policy, never silently zero damage or invented age0 row."])
81
+ def base_factor(x):
82
+ qualified = x.first_year_policy == "qualified_first_year_value"
83
+ ensure((x.first_year_qualification_basis is not None and bool(x.first_year_qualification_basis.strip())) if qualified else x.first_year_qualification_basis is None,
84
+ "First-year policy and qualification basis must agree")
85
+ ensure(x.age_years <= 25,"Lining age exceeds25year source table; no extrapolation","lining_table_age_exceeded")
86
+ ensure(x.age_years >= 1 or qualified,"Lining age below1year requires qualified first-year policy","lining_age_below_first_row")
87
+ age = max(x.age_years,1.)
88
+ col = LiningType.__args__.index(x.lining_type)
89
+ table, col = (_INORGANIC,col) if col < 6 else (_ORGANIC,col-6)
90
+ low, high = math.floor(age),math.ceil(age)
91
+ weight = age-low
92
+ ensure(low == high or x.lookup_policy != "exact_rows_only","Age lies between table rows; numerical policy required","lining_age_between_rows")
93
+ value = float(table[high-1][col]) if x.lookup_policy == "conservative_upper_row" else table[low-1][col]+weight*(table[high-1][col]-table[low-1][col])
94
+ return {"base_damage_factor":value,"lower_age_row_years":float(low),"upper_age_row_years":float(high),
95
+ "age_interpolation_weight":weight,"first_year_value_used":x.age_years < 1,"source_table":"5.4" if table is _INORGANIC else "5.5"}
96
+
97
+
98
+ class LiningCondition(Model):
99
+ condition: Literal["good","average","poor"]
100
+
101
+
102
+ @calculation("damage/lining-condition-factor", "Qualitative lining condition factor", LiningCondition,
103
+ ["Part2 Table5.6; PDF p171"],limitations=[
104
+ "Owner classifies good: like new/no attack/no repairs; average: no excessive attack with successful good-quality local repairs; poor: prior failures/imminent failure conditions or unsuccessful/poor repairs. No automatic classification from a single damage flag or image."])
105
+ def condition_factor(x):
106
+ return {"condition_factor":{"good":1.,"average":2.,"poor":10.}[x.condition]}
107
+
108
+
109
+ class LiningMonitoring(Model):
110
+ monitoring_used: bool
111
+ known_effective_for_lining_deterioration: bool
112
+ effectiveness_basis: str | None = None
113
+
114
+
115
+ @calculation("damage/lining-monitoring-factor", "Lining early-detection monitoring credit", LiningMonitoring,
116
+ ["Part2 §5.5.3 STEP3(2); PDF p167"],limitations=[
117
+ "Factor.1 only when monitoring is used and known effective for detecting lining deterioration early enough for orderly shutdown; otherwise1. Owner supplies effectiveness basis. Merely installed sensors do not prove efficacy. Unlike thinning FOM, lining factor multiplies DF, not divides; no stacked methods."])
118
+ def monitoring_factor(x):
119
+ if x.known_effective_for_lining_deterioration:
120
+ ensure(x.monitoring_used and x.effectiveness_basis is not None and bool(x.effectiveness_basis.strip()),"Effective lining monitoring requires use and qualification basis")
121
+ else:
122
+ ensure(x.effectiveness_basis is None,"Unverified monitoring cannot claim effectiveness basis")
123
+ return {"monitoring_factor":.1 if x.known_effective_for_lining_deterioration else 1.}
124
+
125
+
126
+ class GoverningLining(Model):
127
+ base_material_thinning_damage_factor: NonNegative
128
+ lining_damage_factor: NonNegative
129
+
130
+
131
+ @calculation("damage/lining-governing-thinning", "Minimum of base-material thinning and lining damage factors", GoverningLining,
132
+ ["Part2 §5.5.1 and eq(2.2); PDF pp142,166"],limitations=[
133
+ "Compares lining DF to base-material thinning DF as source instructs. Not total DF: other applicable damage mechanisms require separate combination. No automatic zero/thinning applicability assumption or additional.1 floor on lining DF."])
134
+ def governing_thinning(x):
135
+ return {"governing_thinning_damage_factor":min(x.base_material_thinning_damage_factor,x.lining_damage_factor),
136
+ "governing_source":"equal" if x.base_material_thinning_damage_factor == x.lining_damage_factor else "lining" if x.lining_damage_factor < x.base_material_thinning_damage_factor else "base_material_thinning"}
137
+
138
+
139
+ class LiningAssessment(Model):
140
+ lining_type: LiningType
141
+ applicability_basis: str = Field(min_length=1)
142
+ organic_compatible_with_environment: bool | None = None
143
+ organic_within_design_temperature_including_steamout: bool | None = None
144
+ organic_properly_applied_and_cured: bool | None = None
145
+ age: LiningAge
146
+ lookup_policy: Lookup
147
+ first_year_policy: FirstYear
148
+ first_year_qualification_basis: str | None = None
149
+ condition: LiningCondition
150
+ monitoring: LiningMonitoring
151
+ base_material_thinning_damage_factor: NonNegative
152
+ base_material_thinning_basis: str = Field(min_length=1)
153
+
154
+
155
+ @calculation("damage/lining-assessment", "Compose lining age base factor condition monitoring and governing thinning", LiningAssessment,
156
+ ["Part2 §§5.1-5.5,eq(2.24),Tables5.4-5.6,Figure5.1; PDF pp166-171"],origin="api581_composition_with_explicit_age_policies",limitations=[
157
+ "Compatible protective lining must be owner-qualified. Organic compatibility, temperature including steamout and proper application/curing must all be affirmed; false/unknown rejects source-assumption credit. Date history only resets per explicit source-interpretation policy and qualified A/B inspection. No automatic inspection-method classification or actual lining degradation-rate model. Base tables1..25years; interpolation/first-year policies explicit. LiningDF=base*condition*monitoring, then governing thinning=min(base-metal thinningDF,liningDF), not sum or product of both DFs. No.1 floor or automatic all-mechanism DF. Cladding/overlay sequential wall-loss calculations are distinct."])
158
+ def assessment(x):
159
+ ensure(bool(x.applicability_basis.strip()) and bool(x.base_material_thinning_basis.strip()),"Applicability and base-material thinning basis must be nonblank")
160
+ organic = x.lining_type.startswith("organic_")
161
+ ensure(x.age.lining_family == ("organic" if organic else "inorganic"),"Lining type and age family must agree with source table grouping")
162
+ flags = (x.organic_compatible_with_environment,x.organic_within_design_temperature_including_steamout,x.organic_properly_applied_and_cured)
163
+ ensure(all(v is True for v in flags) if organic else all(v is None for v in flags),"Organic assumptions must all be affirmed for organic lining and omitted for inorganic table types")
164
+ age = effective_age(x.age)
165
+ base = base_factor(LiningBase(lining_type=x.lining_type,age_years=age["effective_age_years"],lookup_policy=x.lookup_policy,
166
+ first_year_policy=x.first_year_policy,first_year_qualification_basis=x.first_year_qualification_basis))
167
+ condition, monitoring = condition_factor(x.condition),monitoring_factor(x.monitoring)
168
+ # Reuse the established eq2.24 calculation after the stronger qualification gates.
169
+ result = lining_adjust(LiningAdjustment(base_damage_factor=base["base_damage_factor"],condition=x.condition.condition,
170
+ effective_online_monitoring=monitoring["monitoring_factor"] == .1))
171
+ finite_tree(result)
172
+ governing = governing_thinning(GoverningLining(base_material_thinning_damage_factor=x.base_material_thinning_damage_factor,lining_damage_factor=result["lining_damage_factor"]))
173
+ return {**age,**base,**condition,**monitoring,**result,**governing,"base_material_thinning_damage_factor":x.base_material_thinning_damage_factor}
api581/native_toxic.py ADDED
@@ -0,0 +1,67 @@
1
+ """Qualified historical toxic criterion -> native long-duration pure-species pool."""
2
+ from typing import Literal
3
+ from pydantic import Field, model_validator
4
+ from .core import Model, Positive, Fraction, REGISTRY, calculation, ensure, finite_tree
5
+ from .slabx_adapter import NativeSubstance, NativeAtmosphere, NativePoolFootprint, native_pool_footprint
6
+ from .toxic_criteria import ToxicCriterion, select_criterion
7
+ from .toxic_level2 import ToxicDuration, toxic_duration, ToxicCloudArea, ToxicCase, toxic_case
8
+
9
+
10
+ class ToxicPoolSource(Model):
11
+ substance: NativeSubstance
12
+ atmosphere: NativeAtmosphere
13
+ vapor_source_rate_kg_s: Positive
14
+ source_area_m2: Positive
15
+ available_mass_kg: Positive
16
+ detection_maximum_duration_s: Positive
17
+ receptor_height_m: Literal[0.0]
18
+ plume_distance_limit_m: Positive = Field(le=1e6)
19
+ puff_time_limit_s: Positive | None = None
20
+ puff_steps: int = Field(default=40,ge=10,le=1000,strict=True)
21
+ crosswind_half_span_m: Positive
22
+ crosswind_points_per_side: int = Field(default=65,ge=3,le=501,strict=True)
23
+ source_qualification: str = Field(min_length=1,max_length=1000)
24
+ meander_closure: Literal["ermak","sigma_theta"]
25
+ timeout_s: Positive = Field(default=120,le=600)
26
+
27
+ @model_validator(mode="after")
28
+ def qualification(self):
29
+ if not self.source_qualification.strip() or not self.substance.property_source.strip():
30
+ raise ValueError("Source and property qualifications must be nonblank")
31
+ return self
32
+
33
+
34
+ class NativeToxicPool(Model):
35
+ criterion: ToxicCriterion
36
+ source: ToxicPoolSource
37
+ non_ignition_probability: Fraction
38
+ exposure_model_basis: Literal["constant_exposure_equivalent_native_time_average"]
39
+
40
+
41
+ @calculation("dispersion/native-pool-toxic-consequence", "Historical toxic criterion through native pure-pool footprint and conditional toxic consequence", NativeToxicPool,
42
+ ["Part3 §§5.9.2-5.9.6,eqs(3.186)-(3.189); PDF pp565-567", "SLABx1.0.8 native time-averaged planar field"],
43
+ origin="api581_criterion_to_native_time_average_toxic_screen",
44
+ limitations=[*REGISTRY["consequence/select-toxic-criterion"].limitations,
45
+ "Pure species only: selected fluid_id must equal native substance.fluid_id and toxic mole fraction must be1. Mixture criteria remain callable independently but mixture thermodynamics/species-resolved source mapping is not inferred. Uses native slabx1.0.8 ground-level constant vapor pool with explicit properties/atmosphere/spatial grid; no automatic LH2 toxicity or asphyxiation model.",
46
+ "Eq3.186 bounds source duration by3600s,inventory/rate,detection duration. Selected exposure window must not exceed this source duration; native averaging equals that exposure. Short pulses fail explicitly, not zero consequence. Constant-exposure-equivalent TWA is a declared engineering screening assumption: nonlinear probit dose from C(t) is not generally equal to dose from average C. Native cloud passage does not prove time-above-threshold at every receptor.",
47
+ "Ground-level receptor height0 is required by §5.9.6. Censored/premise-invalid spatial footprints fail before toxic consequence. Valid sampled area is multiplied by non-ignition probability exactly once, with toxic equipment area0 per eq3.188. No mixture maximum, additional event/GFF weighting or actual exposure-history/dose integration beyond this single pure component. Historical criteria are not current exposure advice or model/site certification."])
48
+ def native_toxic_pool(x):
49
+ ensure(x.criterion.toxic_mole_fraction == 1,"Native pure-pool toxic bridge requires mole fraction1","unsupported_mixture_source")
50
+ ensure(x.criterion.fluid_id == x.source.substance.fluid_id,"Toxic species and native substance id must match","toxic_species_mismatch")
51
+ selected = select_criterion(x.criterion)
52
+ finite_tree(selected)
53
+ ensure(selected["component_present"] and selected["attainable_in_mixture"],"Selected toxic criterion is not physically attainable","toxic_criterion_unattainable")
54
+ duration = toxic_duration(ToxicDuration(available_mass_kg=x.source.available_mass_kg,
55
+ mixture_release_rate_kg_s=x.source.vapor_source_rate_kg_s,detection_maximum_duration_s=x.source.detection_maximum_duration_s))
56
+ ensure(duration["release_duration_s"] >= x.criterion.exposure_duration_s,
57
+ "Source duration shorter than selected exposure window; transient exposure model required","toxic_exposure_duration_unsupported")
58
+ values = x.source.model_dump(exclude={"available_mass_kg","detection_maximum_duration_s"})
59
+ native = native_pool_footprint(NativePoolFootprint(**values,engine="slabx",lh2_plume_width_coupling=False,
60
+ release_duration_s=duration["release_duration_s"],averaging_time_s=x.criterion.exposure_duration_s,
61
+ threshold_volume_fraction=selected["threshold_volume_fraction"],threshold_source=selected["criterion_source"]))
62
+ ensure(native["threshold_area_m2"] is not None,"Native toxic footprint unusable: "+native["footprint_status"],"dispersion_footprint_unusable")
63
+ toxic = toxic_case(ToxicCase(components=[ToxicCloudArea(component_id=x.criterion.fluid_id,
64
+ selected_criterion_source=selected["criterion_source"],cloud_area_m2=native["threshold_area_m2"])],
65
+ non_ignition_probability=x.non_ignition_probability))
66
+ return {**native,**selected,**toxic,"release_duration_s":duration["release_duration_s"],
67
+ "release_duration_governing_limit":duration["governing_limit"],"exposure_model_basis":x.exposure_model_basis}