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
@@ -0,0 +1,205 @@
1
+ """CO2 Figure13.1 screening, source defaults and SI input preparation."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure, finite_tree
5
+ from .co2_corrosion import CO2Assessment, CO2WetModel, assessment
6
+ from .thinning import Posterior, WallLoss
7
+ from .clad_thinning import PressureStrength, CodeStrength
8
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
9
+
10
+
11
+ class CO2PartialPressure(Model):
12
+ total_pressure_pa_abs: Positive
13
+ co2_mole_fraction: Fraction
14
+
15
+
16
+ @calculation("properties/co2-partial-pressure", "CO2 partial pressure from absolute total pressure and mole fraction", CO2PartialPressure,
17
+ ["Part2 Table2.B.13.1; PDF p427"],limitations=[
18
+ "pCO2=mole fraction*absolute total pressure. Input is fraction0..1, not mole percent or gauge pressure. No fugacity/EOS calculation or clamping to4MPa integrated model limit. Pure input conversion does not establish free-water or material applicability."])
19
+ def partial_pressure(x):
20
+ value = x.total_pressure_pa_abs*x.co2_mole_fraction
21
+ ensure(value > 0 or x.co2_mole_fraction == 0,"Partial pressure underflow","numerical_failure")
22
+ return {"co2_partial_pressure_pa_abs":value}
23
+
24
+
25
+ class CO2MassFlowReynolds(Model):
26
+ diameter_m: Positive
27
+ cross_sectional_area_m2: Positive
28
+ mass_flow_kg_s: NonNegative
29
+ mixture_viscosity_pa_s: Positive
30
+
31
+
32
+ @calculation("damage/co2-reynolds-mass-flow", "CO2 mixture Reynolds number from mass flow and area", CO2MassFlowReynolds,
33
+ ["Part2 eq(2.B.35); PDF p425"],limitations=[
34
+ "Re=mass_flow*diameter/(area*viscosity); mass flux=mass_flow/area. Source wording calls dottedm mass flux although printed formula divides byarea; API names dimensional quantities explicitly rather than dividing an already-area-normalized flux again. Area/hydraulic diameter and effective viscosity externally qualified; no mixture-property model or laminar correlation."])
35
+ def mass_flow_reynolds(x):
36
+ flux = x.mass_flow_kg_s/x.cross_sectional_area_m2
37
+ re = flux*x.diameter_m/x.mixture_viscosity_pa_s
38
+ finite_tree([flux,re])
39
+ ensure((flux > 0 and re > 0) or x.mass_flow_kg_s == 0,"Mass-flow Reynolds underflow","numerical_failure")
40
+ return {"mass_flux_kg_m2_s":flux,"reynolds_number":re}
41
+
42
+
43
+ class CO2MaterialScreen(Model):
44
+ material_category: Literal["carbon_steel","low_alloy_steel","stainless_steel","copper_alloy","other"]
45
+ interpretation: Literal["figure_13cr_ferrous","table_resistant_alloys"]
46
+ chromium_mass_fraction: Fraction | None = None
47
+ qualification_basis: str = Field(min_length=1)
48
+
49
+
50
+ @calculation("damage/co2-material-screen", "CO2 figure chromium or table material screening", CO2MaterialScreen,
51
+ ["Part2 Figure2.B.13.1,Table2.B.13.1; PDF pp427,429"],limitations=[
52
+ "Figure screens carbon steel orCr<13%; equality13% is source-assumed resistant. Chromium threshold applied only to qualified ferrous categories, not copper with nominalCr0. Table interpretation lists stainless/copper assumed resistant and carbon/low-alloy evaluated. Other material remains not_covered, never automatic zero. Explicit interpretation preserves differences between named-alloy table and elemental figure. Source-assumed resistance is CO2-specific, not immunity to other corrosion/SCC or a verified material-performance conclusion."])
53
+ def material_screen(x):
54
+ ensure(bool(x.qualification_basis.strip()),"Material identification/screening qualification required")
55
+ category = x.material_category
56
+ if x.interpretation == "table_resistant_alloys":
57
+ ensure(x.chromium_mass_fraction is None,"Chromium fraction is unused in table-only classification")
58
+ decision = "evaluate_co2" if category in ("carbon_steel","low_alloy_steel") else "source_assumed_resistant" if category in ("stainless_steel","copper_alloy") else "not_covered"
59
+ elif category == "carbon_steel":
60
+ ensure(x.chromium_mass_fraction is None,"Carbon-steel figure branch does not require competing chromium input")
61
+ decision = "evaluate_co2"
62
+ elif category in ("low_alloy_steel","stainless_steel"):
63
+ ensure(x.chromium_mass_fraction is not None,"Ferrous figure screen requires known chromium fraction")
64
+ decision = "evaluate_co2" if x.chromium_mass_fraction < .13 else "source_assumed_resistant"
65
+ else:
66
+ ensure(x.chromium_mass_fraction is None,"Figure chromium screen not generalized to non-ferrous/unknown material")
67
+ decision = "not_covered"
68
+ return {"material_screen_decision":decision,"source_material_resistance_assumed":decision == "source_assumed_resistant"}
69
+
70
+
71
+ class CO2HydrocarbonWetting(Model):
72
+ liquid_hydrocarbons_present: bool
73
+ water_cut_policy: Literal["explicit_qualified_percent","source_default_30_percent"] | None = None
74
+ water_content_percent: float | None = Field(default=None,ge=0,le=100,strict=True,allow_inf_nan=False)
75
+ water_content_basis: str | None = None
76
+ mixture_velocity_m_s: NonNegative | None = None
77
+ qualification_basis: str = Field(min_length=1)
78
+
79
+
80
+ @calculation("damage/co2-hydrocarbon-wetting-screen", "CO2 low-water high-velocity hydrocarbon figure screen", CO2HydrocarbonWetting,
81
+ ["Part2 Figure2.B.13.1,Table2.B.13.1; PDF pp427,429"],limitations=[
82
+ "LiquidHC AND water<20% AND velocity>1m/s gives source figure exemption. Equality20%/1m/s proceeds to moisture/water check. Percentage basis is not identified precisely in figure: explicit qualified water-cut percent/basis required; do not substitute gas water mass fraction or glycol composition. Missing liquid-system water cut can use explicit source default30%, which never grants exemption. NoHC proceeds to moisture check. This is a qualified legacy figure rule, not proof metal is oil-wet under actual multiphase flow."])
83
+ def hydrocarbon_screen(x):
84
+ ensure(bool(x.qualification_basis.strip()),"Hydrocarbon/wetting applicability basis required")
85
+ if not x.liquid_hydrocarbons_present:
86
+ ensure(x.water_cut_policy is None and x.water_content_percent is None and x.water_content_basis is None and x.mixture_velocity_m_s is None,
87
+ "No-liquid-HC branch cannot contain unused liquid-system water/velocity fields")
88
+ water,default,exempt = None,False,False
89
+ else:
90
+ ensure(x.water_cut_policy is not None,"Liquid-HC water-cut policy required")
91
+ default = x.water_cut_policy == "source_default_30_percent"
92
+ if default:
93
+ ensure(x.water_content_percent is None and x.water_content_basis is None,"Default30% cannot have competing explicit water cut")
94
+ water = 30.
95
+ else:
96
+ ensure(x.water_content_percent is not None and x.water_content_basis is not None and bool(x.water_content_basis.strip()),"Explicit liquid water cut requires value and qualified percentage basis")
97
+ water = x.water_content_percent
98
+ if water < 20:
99
+ ensure(x.mixture_velocity_m_s is not None,"Low water cut requires velocity to resolve figure branch")
100
+ exempt = water < 20 and x.mixture_velocity_m_s > 1
101
+ return {"wetting_screen_decision":"figure_low_water_high_velocity_exemption" if exempt else "proceed_to_water_check",
102
+ "water_content_percent_used":water,"source_water_default_used":default}
103
+
104
+
105
+ class CO2MoistureScreen(Model):
106
+ temperature_k: Positive
107
+ evidence: Literal["qualified_dew_point","qualified_free_water_present","qualified_no_free_water","source_missing_moisture_assume_wet"]
108
+ dew_point_temperature_k: Positive | None = None
109
+ dew_point_uncertainty_k: NonNegative | None = None
110
+ equality_policy: Literal["figure_strict_less","indeterminate_at_saturation"] | None = None
111
+ qualification_basis: str = Field(min_length=1)
112
+
113
+
114
+ @calculation("damage/co2-moisture-screen", "CO2 free-water or dew-point screening with explicit uncertainty", CO2MoistureScreen,
115
+ ["Part2 §13.3.2,Table2.B.13.1,Figure2.B.13.1; PDF pp423-424,427,429"],limitations=[
116
+ "Known free-water presence/absence can be supplied with basis. Missing gas moisture can explicitly assume wet per table, flagged as source assumption. Qualified dew point requires owner uncertainty and equality policy: outside uncertainty interval classify; inside interval indeterminate. At zero uncertainty and equalT, figure_strict_less uses absent, alternative leaves indeterminate. Approximate eq2.B.25 is not assumed accurate within±10F: no automatic zero-uncertainty adoption of its output. Free-water states concern CO2 only, not all corrosion."])
117
+ def moisture_screen(x):
118
+ ensure(bool(x.qualification_basis.strip()),"Moisture/free-water evidence basis required")
119
+ assumed = x.evidence == "source_missing_moisture_assume_wet"
120
+ if x.evidence == "qualified_dew_point":
121
+ ensure(x.dew_point_temperature_k is not None and x.dew_point_uncertainty_k is not None and x.equality_policy is not None,"Dew-point screen requires value, uncertainty and equality policy")
122
+ delta = x.temperature_k-x.dew_point_temperature_k
123
+ u = x.dew_point_uncertainty_k
124
+ state = "present" if delta < -u else "absent" if delta > u else "absent" if u == 0 and delta == 0 and x.equality_policy == "figure_strict_less" else "indeterminate"
125
+ else:
126
+ ensure(x.dew_point_temperature_k is None and x.dew_point_uncertainty_k is None and x.equality_policy is None,"Direct/default water evidence cannot carry competing dew-point inputs")
127
+ state = "absent" if x.evidence == "qualified_no_free_water" else "present"
128
+ return {"free_water_screen_state":state,"free_water_assumed_from_missing_data":assumed}
129
+
130
+
131
+ class CO2ScreenedAssessment(Model):
132
+ temperature_k: Positive
133
+ pressure: CO2PartialPressure
134
+ material: CO2MaterialScreen
135
+ hydrocarbon_wetting: CO2HydrocarbonWetting | None = None
136
+ moisture: CO2MoistureScreen | None = None
137
+ wet_model: CO2WetModel | None = None
138
+ qualification_basis: str = Field(min_length=1)
139
+
140
+
141
+ def _zero():
142
+ return {"corrosion_rate_m_year":0.,"base_corrosion_rate_m_year":0.,"model_ph":None,"temperature_ph_factor":None,
143
+ "fugacity_pa":None,"requested_shear_stress_pa":None,"applied_shear_stress_pa":None,"shear_cap_applied":False,
144
+ "glycol_factor":1.,"inhibitor_factor":1.,"applied_reduction_factor":1.,"sharp_temperature_interval_interpolated":False}
145
+
146
+
147
+ @calculation("damage/co2-screened-assessment", "CO2 material hydrocarbon moisture screening through corrosion model", CO2ScreenedAssessment,
148
+ ["Part2 Annex2.B §13,Figure2.B.13.1,Table2.B.13.1; PDF pp423-429"],origin="api581_composition_with_explicit_figure_policies",limitations=[
149
+ "Explicit 2016 material/HC-water-velocity/dew-point policies, source defaults and qualified wet model; no autonomous metallurgy or actual wetting/EOS judgment. Source resistance/exemption yields CO2-specific0 with flags, not measured immunity or zero total DF. Indeterminate moisture errors rather than asserts dry. Unused downstream inputs on screened branches rejected; no hidden model fallback. Low-Cr stainless figure evaluation is not silently reclassified low-alloy: current quantitative model supports only named carbon/low-alloy steel. Same pressure/temperature/known velocity drives screens and wet kernel. Explicit free-water evidence or source wet assumption supersedes approximate dew-point generation."])
150
+ def screened_assessment(x):
151
+ ensure(bool(x.qualification_basis.strip()),"Screened model applicability basis required")
152
+ pressure = partial_pressure(x.pressure)
153
+ p = pressure["co2_partial_pressure_pa_abs"]
154
+ material = material_screen(x.material)
155
+ state = material["material_screen_decision"]
156
+ ensure(state != "not_covered","Material not covered by selected source screening interpretation")
157
+ water = {"wetting_screen_decision":None,"water_content_percent_used":None,"source_water_default_used":False}
158
+ moisture = {"free_water_screen_state":None,"free_water_assumed_from_missing_data":False}
159
+ if state == "source_assumed_resistant" or p == 0:
160
+ ensure(x.hydrocarbon_wetting is None and x.moisture is None and x.wet_model is None,"Material/no-CO2 branch cannot carry unused downstream model inputs")
161
+ result,branch = _zero(),"source_material_resistance" if state == "source_assumed_resistant" else "no_co2"
162
+ else:
163
+ ensure(x.hydrocarbon_wetting is not None,"Susceptible nonzero-CO2 case requires qualified HC/water screen")
164
+ water = hydrocarbon_screen(x.hydrocarbon_wetting)
165
+ if water["wetting_screen_decision"] == "figure_low_water_high_velocity_exemption":
166
+ ensure(x.moisture is None and x.wet_model is None,"Figure HC exemption cannot carry unused moisture/wet model")
167
+ result,branch = _zero(),"figure_hydrocarbon_exemption"
168
+ else:
169
+ ensure(x.moisture is not None,"Water check requires direct evidence, dew point or explicit source assumption")
170
+ ensure(x.moisture.temperature_k == x.temperature_k,"Moisture screen and model temperatures must agree")
171
+ moisture = moisture_screen(x.moisture)
172
+ ensure(moisture["free_water_screen_state"] != "indeterminate","Moisture uncertainty interval unresolved; no automatic zero corrosion","co2_moisture_indeterminate")
173
+ if moisture["free_water_screen_state"] == "absent":
174
+ ensure(x.wet_model is None,"Dry branch cannot contain unused wet-model inputs")
175
+ result,branch = _zero(),"qualified_no_free_water"
176
+ else:
177
+ ensure(x.material.material_category in ("carbon_steel","low_alloy_steel"),"Quantitative CO2 kernel does not reclassify low-Cr stainless as low-alloy steel")
178
+ if x.wet_model is not None and x.wet_model.flow.method == "turbulent_flow" and x.hydrocarbon_wetting.mixture_velocity_m_s is not None:
179
+ ensure(x.wet_model.flow.mixture_velocity_m_s == x.hydrocarbon_wetting.mixture_velocity_m_s,"HC screen and wet flow velocities must agree")
180
+ result = assessment(CO2Assessment(material=x.material.material_category,temperature_k=x.temperature_k,co2_partial_pressure_pa_abs=p,
181
+ free_liquid_water_present=True,applicability_basis=x.qualification_basis,wet_model=x.wet_model))
182
+ result.pop("calculation_branch")
183
+ branch = "wet_carbonic_acid_model"
184
+ return {**result,**pressure,**material,**water,**moisture,"screened_calculation_branch":branch}
185
+
186
+
187
+ class CO2ScreenedThinning(Model):
188
+ corrosion: CO2ScreenedAssessment
189
+ starting_thickness_m: Positive
190
+ elapsed_years: NonNegative
191
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
192
+ inspections: Posterior
193
+ conditions: ThinningConditions
194
+ qualification_basis: str = Field(min_length=1)
195
+
196
+
197
+ @calculation("damage/co2-screened-thinning", "Screened CO2 rate through non-AST unclad thinning DF", CO2ScreenedThinning,
198
+ ["Part2 Annex2.B §13,Figure2.B.13.1; Part2 §4.5.7"],origin="api581_composition_with_explicit_figure_policies",limitations=[
199
+ "Unclad non-AST component and owner-qualified strength/inspection baseline. Retains material/HC exemption, moisture uncertainty/default flags and source-specific rate through DF. Zero CO2 rate is not total DF0 or omission of other mechanisms. No independent confirmation of legacy material/wetting rules, oil-water phase distribution, chromium chemistry, actual moisture or field conditions."])
200
+ def screened_thinning(x):
201
+ rate = screened_assessment(x.corrosion)
202
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
203
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=rate["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
204
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
205
+ return {**rate,**result}
api581/consequences.py ADDED
@@ -0,0 +1,250 @@
1
+ """Level 1 toxic, NFNT, final area and financial consequence calculations."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+
7
+
8
+ class Mitigation(Model):
9
+ system: Literal["inventory_blowdown", "deluge_and_monitors", "monitors", "foam", "none"]
10
+ isolation_class: Literal["A", "B", "C"]
11
+
12
+
13
+ @calculation("level1/mitigation", "Flammable consequence mitigation factor", Mitigation,
14
+ ["Part 3 Table 4.10; PDF p525"], limitations=["Select one applicable measure; no unsupported combination/stacking of credits."])
15
+ def mitigation(x):
16
+ ensure(x.system != "inventory_blowdown" or x.isolation_class in ("A","B"), "Blowdown credit requires isolation class A or B")
17
+ return {"mitigation_fraction": {"inventory_blowdown":.25,"deluge_and_monitors":.2,"monitors":.05,"foam":.15,"none":0}[x.system]}
18
+
19
+
20
+ class ToxicSource(Model):
21
+ theoretical_rate_kg_s: NonNegative
22
+ released_mass_kg: NonNegative
23
+ toxic_mass_fraction: Fraction
24
+
25
+
26
+ @calculation("level1/toxic-source", "Toxic component rate and mass", ToxicSource,
27
+ ["Part 3 eqs (3.61)-(3.62); PDF p497"], limitations=["Use theoretical rate, without flammable detection magnitude reduction. Explicit toxic mitigation, if justified, is a separate input adjustment."])
28
+ def toxic_source(x):
29
+ return {"toxic_rate_kg_s": x.theoretical_rate_kg_s*x.toxic_mass_fraction,"toxic_mass_kg": x.released_mass_kg*x.toxic_mass_fraction}
30
+
31
+
32
+ class ToxicDuration(Model):
33
+ available_mass_kg: NonNegative
34
+ theoretical_rate_kg_s: NonNegative
35
+ detection_maximum_duration_s: NonNegative
36
+
37
+
38
+ @calculation("level1/toxic-duration", "Toxic release exposure duration", ToxicDuration,
39
+ ["Part 3 §4.9.10 eq (3.67)"], limitations=["One-hour cap; detection magnitude reduction does not apply to toxic source rate."])
40
+ def toxic_duration(x):
41
+ duration = min(3600,x.detection_maximum_duration_s,x.available_mass_kg/x.theoretical_rate_kg_s) if x.theoretical_rate_kg_s else 0
42
+ return {"duration_s":duration}
43
+
44
+
45
+ AMMONIA = [(636.7,1.183),(846.3,1.181),(1053,1.180),(1256,1.178),(1455,1.176),(1650,1.174),
46
+ (1842,1.172),(2029,1.169),(2213,1.166),(2389,1.161),(2558,1.155),(2714,1.145)]
47
+ CHLORINE = [(3350,1.097),(3518,1.095),(3798,1.092),(4191,1.089),(4694,1.085),(5312,1.082),
48
+ (6032,1.077),(6860,1.072),(7788,1.066),(8798,1.057),(9890,1.046),(10994,1.026)]
49
+
50
+
51
+ class ToxicArea(Model):
52
+ fluid: Literal["ammonia","chlorine"]
53
+ release_type: Literal["continuous","instantaneous"]
54
+ toxic_rate_kg_s: NonNegative
55
+ toxic_mass_kg: NonNegative
56
+ duration_s: NonNegative
57
+ nonignition_probability: Fraction
58
+
59
+
60
+ @calculation("level1/toxic-area", "Ammonia/chlorine toxic consequence area", ToxicArea,
61
+ ["Part 3 eqs (3.65)-(3.66), §4.9.11, Table 4.12M; PDF pp498,526"],
62
+ limitations=["Continuous curves support 5-60 minutes only; interpolate areas between duration curves, never extrapolate. Explicit nonignition probability supplied; no IDLH screening or spray credit selection."])
63
+ def toxic_area(x):
64
+ if (x.release_type=="continuous" and (x.toxic_rate_kg_s==0 or x.duration_s==0)) or (x.release_type=="instantaneous" and x.toxic_mass_kg==0):
65
+ return {"unweighted_personnel_area_m2":0,"personnel_area_m2":0}
66
+ if x.release_type=="instantaneous":
67
+ a,b = (2.684,.9011) if x.fluid=="ammonia" else (3.528,1.177)
68
+ area=a*x.toxic_mass_kg**b
69
+ else:
70
+ ensure(300<=x.duration_s<=3600,"Continuous toxic duration must be within table range 300-3600 s")
71
+ rows=AMMONIA if x.fluid=="ammonia" else CHLORINE
72
+ position=x.duration_s/300-1
73
+ lo,hi=math.floor(position),math.ceil(position)
74
+ a,b=rows[lo]; lower=a*x.toxic_rate_kg_s**b
75
+ a,b=rows[hi]; upper=a*x.toxic_rate_kg_s**b
76
+ area=lower+(upper-lower)*(position-lo)
77
+ return {"unweighted_personnel_area_m2":area,"personnel_area_m2":area*x.nonignition_probability}
78
+
79
+
80
+ class Steam(Model):
81
+ adjusted_rate_kg_s: NonNegative
82
+ released_mass_kg: NonNegative
83
+
84
+
85
+ @calculation("level1/steam-area", "Steam leak personnel consequence area", Steam,
86
+ ["Part 3 eqs (3.69)-(3.71),(3.76); Annex 3.B C9,C10; PDF pp502,647"], limitations=["Level 1 steam burn correlation; no equipment damage area."])
87
+ def steam(x):
88
+ continuous=.123*x.adjusted_rate_kg_s
89
+ instantaneous=9.744*x.released_mass_kg**.6384
90
+ weight=min(x.adjusted_rate_kg_s/25.2,1)
91
+ return {"continuous_area_m2":continuous,"instantaneous_area_m2":instantaneous,"instantaneous_weight":weight,
92
+ "personnel_area_m2":instantaneous*weight+continuous*(1-weight),"equipment_area_m2":0}
93
+
94
+
95
+ class Acid(Model):
96
+ adjusted_rate_kg_s: NonNegative
97
+ differential_pressure_pa: NonNegative
98
+
99
+
100
+ @calculation("level1/acid-caustic-area", "Acid/caustic splash personnel consequence", Acid,
101
+ ["Part 3 eqs (3.72)-(3.75); Annex 3.B C4B,C8,C11; PDF pp502,647"],
102
+ limitations=["Empirical correlation developed at approximately 15,30,60 psig. Reject extrapolation outside 103.4-413.7 kPa differential. Source has inconsistent C4/C4B naming; uses lb/s conversion 2.205."])
103
+ def acid(x):
104
+ ensure(103400<=x.differential_pressure_pa<=413700,"Acid splash pressure outside documented 103.4-413.7 kPa range")
105
+ pressure=.145*x.differential_pressure_pa/1000
106
+ g=2696-21.9*pressure+1.474*pressure**2
107
+ h=.31-.00032*(pressure-40)**2
108
+ area=.2*.0929*g*(2.205*x.adjusted_rate_kg_s)**h if x.adjusted_rate_kg_s else 0
109
+ return {"personnel_area_m2":area,"equipment_area_m2":0,"coefficient_g":g,"exponent_h":h}
110
+
111
+
112
+ class FinalArea(Model):
113
+ flammable_equipment_area_m2: NonNegative
114
+ flammable_personnel_area_m2: NonNegative
115
+ toxic_personnel_areas_m2: list[NonNegative] = Field(max_length=100)
116
+ nfnt_personnel_area_m2: NonNegative
117
+
118
+
119
+ @calculation("consequence/final-area", "Final Level 1 consequence areas", FinalArea,
120
+ ["Part 3 §§4.9.12,4.11 eqs (3.81)-(3.84)"], limitations=["Inputs must be evaluated on the same basis; multiple toxic species use maximum, not area addition."])
121
+ def final_area(x):
122
+ toxic=max(x.toxic_personnel_areas_m2,default=0)
123
+ injury=max(x.flammable_personnel_area_m2,toxic,x.nfnt_personnel_area_m2)
124
+ return {"equipment_area_m2":x.flammable_equipment_area_m2,"personnel_area_m2":injury,
125
+ "consequence_area_m2":max(x.flammable_equipment_area_m2,injury)}
126
+
127
+
128
+ class HoleValue(Model):
129
+ hole: Literal["small","medium","large","rupture"]
130
+ generic_frequency_per_year: NonNegative
131
+ value: NonNegative
132
+
133
+
134
+ class Weighted(Model):
135
+ holes: list[HoleValue] = Field(min_length=4,max_length=4)
136
+ value_unit: str = Field(min_length=1,max_length=50)
137
+
138
+
139
+ @calculation("consequence/hole-weighted", "Generic-frequency weighted four-hole consequence", Weighted,
140
+ ["Part 3 eqs (3.27)-(3.28),(3.68),(3.78),(3.86),(3.88),(3.94)"],
141
+ limitations=["All four holes required; value may be area, repair cost, outage or spill volume. Already conditional consequences; no second event weighting."])
142
+ def hole_weighted(x):
143
+ ensure({h.hole for h in x.holes}=={"small","medium","large","rupture"},"Exactly one value per hole is required")
144
+ total=math.fsum(h.generic_frequency_per_year for h in x.holes)
145
+ ensure(total>0,"Total generic frequency must be positive")
146
+ return {"weighted_value":math.fsum(h.generic_frequency_per_year*h.value for h in x.holes)/total,"value_unit":x.value_unit}
147
+
148
+
149
+ class SurroundingCost(Model):
150
+ equipment_area_m2: NonNegative
151
+ replacement_cost_per_m2: NonNegative
152
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
153
+
154
+
155
+ @calculation("financial/surrounding-damage", "Surrounding equipment damage cost", SurroundingCost,["Part 3 eq (3.87)"])
156
+ def surrounding_cost(x):
157
+ return {"cost":x.equipment_area_m2*x.replacement_cost_per_m2,"currency":x.currency}
158
+
159
+
160
+ class ProductionCost(Model):
161
+ component_outage_days: NonNegative
162
+ surrounding_outage_days: NonNegative
163
+ production_cost_per_day: NonNegative
164
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
165
+
166
+
167
+ @calculation("financial/production-loss", "Business interruption cost", ProductionCost,["Part 3 eq (3.90)"])
168
+ def production_cost(x):
169
+ return {"cost":(x.component_outage_days+x.surrounding_outage_days)*x.production_cost_per_day,"currency":x.currency}
170
+
171
+
172
+ class InjuryCost(Model):
173
+ personnel_area_m2: NonNegative
174
+ population_density_per_m2: NonNegative
175
+ cost_per_injury: NonNegative
176
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
177
+
178
+
179
+ @calculation("financial/injury", "Potential injury cost", InjuryCost,["Part 3 eq (3.91)"])
180
+ def injury_cost(x):
181
+ return {"cost":x.personnel_area_m2*x.population_density_per_m2*x.cost_per_injury,"currency":x.currency}
182
+
183
+
184
+ class Spill(Model):
185
+ released_mass_kg: NonNegative
186
+ liquid_density_kg_m3: Positive
187
+ evaporated_fraction: Fraction
188
+ cleanup_cost_per_m3: NonNegative
189
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
190
+
191
+
192
+ @calculation("financial/spill-cleanup", "Liquid spill remediation volume and cost", Spill,
193
+ ["Part 3 eqs (3.93)-(3.94), expressed in m3"], limitations=["Caller must screen liquid phase, boiling point and ignition eligibility; evaporation fraction supplied. Cost basis is per m3, not per barrel."])
194
+ def spill(x):
195
+ volume=x.released_mass_kg/x.liquid_density_kg_m3*(1-x.evaporated_fraction)
196
+ return {"remediation_volume_m3":volume,"cost":volume*x.cleanup_cost_per_m3,"currency":x.currency}
197
+
198
+
199
+ class HoleFinancial(Model):
200
+ """Financial consequence components for one API 581 release hole."""
201
+ hole: Literal["small", "medium", "large", "rupture"]
202
+ generic_frequency_per_year: NonNegative
203
+ component_repair_cost: NonNegative
204
+ surrounding_equipment_cost: NonNegative
205
+ business_interruption_cost: NonNegative
206
+ injury_cost: NonNegative
207
+ environmental_cost: NonNegative
208
+
209
+
210
+ class FinancialByHole(Model):
211
+ """Four-hole financial consequence roll-up on one declared basis."""
212
+ holes: list[HoleFinancial] = Field(min_length=4, max_length=4)
213
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
214
+ cost_basis_year: int = Field(ge=1900, le=2200)
215
+
216
+
217
+ @calculation(
218
+ "financial/hole-total",
219
+ "Four-hole financial consequence and annual risk roll-up",
220
+ FinancialByHole,
221
+ ["Part 3 §§4.11-4.12; eqs (3.86)-(3.94)"],
222
+ limitations=[
223
+ "All five cost categories are caller-qualified on one currency and cost basis year.",
224
+ "Per-hole generic frequencies must be disjoint and already include the applicable GFF basis; no second ignition or weather weighting is applied.",
225
+ "The API does not infer damage applicability, release hydraulics, cleanup unit costs, or currency escalation.",
226
+ ],
227
+ )
228
+ def financial_by_hole(x: FinancialByHole):
229
+ ensure(
230
+ {item.hole for item in x.holes} == {"small", "medium", "large", "rupture"},
231
+ "Exactly one financial row for each of the four holes is required",
232
+ )
233
+ total_frequency = math.fsum(item.generic_frequency_per_year for item in x.holes)
234
+ ensure(total_frequency > 0, "Total generic frequency must be positive")
235
+ rows = []
236
+ for item in x.holes:
237
+ total = math.fsum((item.component_repair_cost, item.surrounding_equipment_cost,
238
+ item.business_interruption_cost, item.injury_cost,
239
+ item.environmental_cost))
240
+ rows.append({"hole": item.hole, "generic_frequency_per_year": item.generic_frequency_per_year,
241
+ "financial_consequence": total})
242
+ annual = math.fsum(row["generic_frequency_per_year"] * row["financial_consequence"] for row in rows)
243
+ return {
244
+ "currency": x.currency,
245
+ "cost_basis_year": x.cost_basis_year,
246
+ "total_generic_frequency_per_year": total_frequency,
247
+ "per_hole": rows,
248
+ "frequency_weighted_financial_consequence": annual / total_frequency,
249
+ "annual_financial_risk": annual,
250
+ }