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.
- api581/__init__.py +80 -0
- api581/_amine_tables.py +105 -0
- api581/_h2sh2_tables.py +161 -0
- api581/_slabx_worker.py +174 -0
- api581/_sulfidic_tables.py +517 -0
- api581/_sulfuric_tables.py +328 -0
- api581/acid_sour_water.py +238 -0
- api581/acscc_screening.py +88 -0
- api581/alkaline_sour_water.py +267 -0
- api581/amine_corrosion.py +277 -0
- api581/amine_screening.py +103 -0
- api581/api/__init__.py +4 -0
- api581/api/api520.py +88 -0
- api581/api/bundle.py +121 -0
- api581/api/consequence.py +102 -0
- api581/api/damage.py +1831 -0
- api581/api/dispersion.py +121 -0
- api581/api/effects.py +318 -0
- api581/api/events.py +96 -0
- api581/api/financial.py +115 -0
- api581/api/frequency.py +40 -0
- api581/api/inspection.py +96 -0
- api581/api/level1.py +238 -0
- api581/api/level2.py +74 -0
- api581/api/planning.py +17 -0
- api581/api/prd.py +190 -0
- api581/api/properties.py +130 -0
- api581/api/release.py +252 -0
- api581/api/reliability.py +40 -0
- api581/api/risk.py +71 -0
- api581/api/tank.py +490 -0
- api581/api520.py +205 -0
- api581/area_contribution.py +72 -0
- api581/blast.py +232 -0
- api581/brittle.py +205 -0
- api581/bundle.py +300 -0
- api581/catalog.py +442 -0
- api581/caustic_screening.py +103 -0
- api581/clad_thinning.py +146 -0
- api581/clscc_screening.py +55 -0
- api581/co2_corrosion.py +313 -0
- api581/co2_screening.py +205 -0
- api581/consequences.py +250 -0
- api581/contracts.py +1038 -0
- api581/cooling_selection.py +204 -0
- api581/cooling_water.py +305 -0
- api581/core.py +126 -0
- api581/corrosion_rate.py +38 -0
- api581/cost_tables.py +175 -0
- api581/cracking.py +74 -0
- api581/data/coverage.json +21361 -0
- api581/data/digital-twin.schema.json +538 -0
- api581/data/direct-test-queue.json +7 -0
- api581/data/native-engine-evidence.json +44 -0
- api581/data/python-api.schema.json +92706 -0
- api581/data/reference-fixture-evidence.json +2956 -0
- api581/data/scope-boundaries.schema.json +58 -0
- api581/data/software-test-evidence.json +4278 -0
- api581/data/standard-metadata.json +1328 -0
- api581/data/standard.schema.json +233 -0
- api581/data/supporting-text-review.json +1319 -0
- api581/digital_twin.py +491 -0
- api581/dispersion.py +45 -0
- api581/effects.py +232 -0
- api581/evaporation.py +181 -0
- api581/evidence.py +81 -0
- api581/external_corrosion.py +148 -0
- api581/external_cracking.py +63 -0
- api581/fatigue.py +56 -0
- api581/fireball.py +163 -0
- api581/flash_pool.py +111 -0
- api581/flash_solver.py +122 -0
- api581/groundwater.py +101 -0
- api581/h2sh2_corrosion.py +128 -0
- api581/hic_screened.py +89 -0
- api581/htha.py +45 -0
- api581/hydrochloric_acid.py +272 -0
- api581/hydrofluoric_acid.py +236 -0
- api581/ignition.py +204 -0
- api581/inspection_effectiveness.py +601 -0
- api581/jet_handoff.py +60 -0
- api581/level1.py +311 -0
- api581/level1_summary.py +35 -0
- api581/level1_toxic.py +313 -0
- api581/level1_type1.py +91 -0
- api581/level2_summary.py +237 -0
- api581/lining.py +173 -0
- api581/native_toxic.py +67 -0
- api581/outputs.py +5672 -0
- api581/oxidation.py +145 -0
- api581/pascc_screening.py +78 -0
- api581/physical_explosion.py +190 -0
- api581/pof.py +207 -0
- api581/pool_fire.py +273 -0
- api581/portfolio.py +68 -0
- api581/prd.py +204 -0
- api581/prd_consequences.py +245 -0
- api581/prd_history.py +44 -0
- api581/py.typed +0 -0
- api581/release.py +307 -0
- api581/representative_fluids.py +172 -0
- api581/result.py +50 -0
- api581/risk.py +191 -0
- api581/saturation.py +66 -0
- api581/scc_mechanisms.py +242 -0
- api581/schema.py +45 -0
- api581/slabx_adapter.py +261 -0
- api581/soil_corrosion.py +191 -0
- api581/source_partition.py +169 -0
- api581/spatial.py +87 -0
- api581/ssc_screening.py +77 -0
- api581/standard.py +281 -0
- api581/storage.py +42 -0
- api581/sulfidic_corrosion.py +197 -0
- api581/sulfuric_acid.py +209 -0
- api581/tank.py +260 -0
- api581/tank_corrosion.py +256 -0
- api581/tank_environment.py +186 -0
- api581/tank_financial.py +202 -0
- api581/tank_integrated.py +51 -0
- api581/tank_shell.py +141 -0
- api581/tank_source.py +152 -0
- api581/tank_thinning.py +204 -0
- api581/temper.py +105 -0
- api581/thinning.py +172 -0
- api581/thinning_conditions.py +212 -0
- api581/thinning_history.py +118 -0
- api581/thinning_selection.py +81 -0
- api581/thinning_type.py +34 -0
- api581/toxic_criteria.py +111 -0
- api581/toxic_level2.py +233 -0
- api581/two_phase_events.py +120 -0
- api581/workflow.py +42 -0
- api581_engine-0.1.4.dist-info/METADATA +419 -0
- api581_engine-0.1.4.dist-info/RECORD +139 -0
- api581_engine-0.1.4.dist-info/WHEEL +5 -0
- api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
- api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
- api581_engine-0.1.4.dist-info/top_level.txt +1 -0
api581/co2_screening.py
ADDED
|
@@ -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
|
+
}
|