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/api520.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""API RP 520 (7th ed., Jan 2000) pressure-relief sizing primitives.
|
|
2
|
+
|
|
3
|
+
The public functions intentionally expose the individual sizing steps so they can
|
|
4
|
+
be used by API 581 PRD calculations or by a digital-twin caller. Inputs use SI
|
|
5
|
+
units unless a field name explicitly says ``psig``/``psia``. This is a sizing
|
|
6
|
+
calculation aid; valve selection, certified capacity, two-phase DIERS methods,
|
|
7
|
+
and installation hydraulics remain separate engineering checks.
|
|
8
|
+
"""
|
|
9
|
+
from math import sqrt
|
|
10
|
+
from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ATM_KPA = 101.325
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CriticalRatio(Model):
|
|
17
|
+
heat_capacity_ratio: Positive
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@calculation("api520/critical-pressure-ratio", "API 520 critical-flow pressure ratio", CriticalRatio,
|
|
21
|
+
["API RP 520 7th ed. §3.6.1.4, Eq. 3.1"],
|
|
22
|
+
origin="engineering_utility",
|
|
23
|
+
limitations=["Ideal-gas relation; use qualified property data for real-gas/high-pressure service."])
|
|
24
|
+
def critical_pressure_ratio(x):
|
|
25
|
+
k = x.heat_capacity_ratio
|
|
26
|
+
ensure(k > 1.0, "Heat-capacity ratio must exceed one")
|
|
27
|
+
return {"critical_pressure_ratio": (2.0 / (k + 1.0)) ** (k / (k - 1.0))}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FlowCoefficient(Model):
|
|
31
|
+
heat_capacity_ratio: Positive
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@calculation("api520/flow-coefficient-c", "API 520 gas/vapor flow coefficient C", FlowCoefficient,
|
|
35
|
+
["API RP 520 7th ed. §3.6.2, Eq. 3.2-3.4; Fig. 32"],
|
|
36
|
+
origin="engineering_utility",
|
|
37
|
+
limitations=["Ideal-gas coefficient; API 520 recommends qualified data or C=315 when k is unavailable."])
|
|
38
|
+
def flow_coefficient_c(x):
|
|
39
|
+
k = x.heat_capacity_ratio
|
|
40
|
+
ensure(k > 1.0, "Heat-capacity ratio must exceed one")
|
|
41
|
+
return {"flow_coefficient_c": 520.0 * sqrt(k * (2.0 / (k + 1.0)) ** ((k + 1.0) / (k - 1.0)))}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RelievingPressure(Model):
|
|
45
|
+
set_pressure_psig: Positive
|
|
46
|
+
allowable_overpressure_fraction: Fraction
|
|
47
|
+
atmospheric_pressure_kpa: Positive = ATM_KPA
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@calculation("api520/relieving-pressure", "API 520 relieving pressure", RelievingPressure,
|
|
51
|
+
["API RP 520 7th ed. §3.5, Tables 1-6"], origin="engineering_utility",
|
|
52
|
+
limitations=["Overpressure fraction is supplied by the contingency/code basis; no automatic ASME scenario selection."])
|
|
53
|
+
def relieving_pressure(x):
|
|
54
|
+
gauge = x.set_pressure_psig * (1.0 + x.allowable_overpressure_fraction)
|
|
55
|
+
return {"relieving_pressure_psig": gauge,
|
|
56
|
+
"relieving_pressure_kpaa": gauge * 6.894757293168 + x.atmospheric_pressure_kpa}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GasCriticalArea(Model):
|
|
60
|
+
required_mass_flow_kg_h: Positive
|
|
61
|
+
relieving_temperature_k: Positive
|
|
62
|
+
compressibility_factor: Positive = 1.0
|
|
63
|
+
molecular_weight_kg_kmol: Positive
|
|
64
|
+
relief_pressure_kpaa: Positive
|
|
65
|
+
heat_capacity_ratio: Positive
|
|
66
|
+
discharge_coefficient: Fraction = 0.975
|
|
67
|
+
back_pressure_factor: Positive = 1.0
|
|
68
|
+
combination_factor: Positive = 1.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@calculation("api520/gas-critical-area", "API 520 critical gas/vapor effective area", GasCriticalArea,
|
|
72
|
+
["API RP 520 7th ed. §3.6.2, Eqs. 3.2-3.4"], origin="engineering_utility",
|
|
73
|
+
limitations=["SI mass-flow form; assumes critical ideal-gas/nozzle behavior and supplied correction factors."])
|
|
74
|
+
def gas_critical_area(x):
|
|
75
|
+
ensure(x.compressibility_factor > 0 and x.molecular_weight_kg_kmol > 0, "Invalid gas property")
|
|
76
|
+
k = x.heat_capacity_ratio
|
|
77
|
+
ensure(k > 1.0, "Heat-capacity ratio must exceed one")
|
|
78
|
+
c = 520.0 * sqrt(k * (2.0 / (k + 1.0)) ** ((k + 1.0) / (k - 1.0)))
|
|
79
|
+
return {"required_area_mm2": 13160.0 * x.required_mass_flow_kg_h * sqrt(x.relieving_temperature_k * x.compressibility_factor / x.molecular_weight_kg_kmol) / (c * x.discharge_coefficient * x.relief_pressure_kpaa * x.back_pressure_factor * x.combination_factor)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GasSubcriticalArea(GasCriticalArea):
|
|
83
|
+
downstream_pressure_kpaa: Positive
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@calculation("api520/gas-subcritical-area", "API 520 subcritical gas/vapor effective area", GasSubcriticalArea,
|
|
87
|
+
["API RP 520 7th ed. §3.6.3, Eqs. 3.5-3.7"], origin="engineering_utility",
|
|
88
|
+
limitations=["SI mass-flow form; conventional/pilot valve route only; use balanced-bellows manufacturer factors where applicable."])
|
|
89
|
+
def gas_subcritical_area(x):
|
|
90
|
+
k = x.heat_capacity_ratio
|
|
91
|
+
p1, p2 = x.relief_pressure_kpaa, x.downstream_pressure_kpaa
|
|
92
|
+
ensure(k > 1.0 and 0 < p2 < p1, "Subcritical flow requires 0 < P2 < P1 and k > 1")
|
|
93
|
+
r = p2 / p1
|
|
94
|
+
f2 = sqrt((k / (k - 1.0)) * r ** (2.0 / k) * (1.0 - r ** ((k - 1.0) / k)) / (1.0 - r))
|
|
95
|
+
area = 17.9 * x.required_mass_flow_kg_h * sqrt(x.relieving_temperature_k * x.compressibility_factor / x.molecular_weight_kg_kmol) / (f2 * x.discharge_coefficient * x.combination_factor * sqrt(p1 * (p1 - p2)))
|
|
96
|
+
return {"required_area_mm2": area, "back_pressure_ratio": r, "subcritical_flow_factor": f2}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class LiquidArea(Model):
|
|
100
|
+
required_flow_l_min: Positive
|
|
101
|
+
specific_gravity: Positive
|
|
102
|
+
set_pressure_kpag: Positive
|
|
103
|
+
back_pressure_kpag: NonNegative = 0.0
|
|
104
|
+
discharge_coefficient: Positive = 0.65
|
|
105
|
+
back_pressure_factor: Positive = 1.0
|
|
106
|
+
combination_factor: Positive = 1.0
|
|
107
|
+
viscosity_factor: Positive = 1.0
|
|
108
|
+
overpressure_factor: Positive = 1.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@calculation("api520/liquid-area", "API 520 liquid relief effective area", LiquidArea,
|
|
112
|
+
["API RP 520 7th ed. §3.8-3.9, Eqs. 3.9 and 3.12"], origin="engineering_utility",
|
|
113
|
+
limitations=["SI form; Kp/Kv/Kw are supplied or separately calculated; certified capacity and two-phase flashing are separate routes."])
|
|
114
|
+
def liquid_area(x):
|
|
115
|
+
ensure(x.set_pressure_kpag > x.back_pressure_kpag, "Set pressure must exceed back pressure")
|
|
116
|
+
dp = x.set_pressure_kpag - x.back_pressure_kpag
|
|
117
|
+
area = 11.78 * x.required_flow_l_min * sqrt(x.specific_gravity) / (x.discharge_coefficient * x.back_pressure_factor * x.combination_factor * x.viscosity_factor * x.overpressure_factor * sqrt(dp))
|
|
118
|
+
return {"required_area_mm2": area}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ViscosityFactor(Model):
|
|
122
|
+
reynolds_number: Positive
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@calculation("api520/liquid-viscosity-factor", "API 520 liquid viscosity correction Kv", ViscosityFactor,
|
|
126
|
+
["API RP 520 7th ed. §3.8.1.3, Eqs. 3.10-3.11; Fig. 36"], origin="engineering_utility",
|
|
127
|
+
limitations=["Correlation is not recommended below 100 SSU when using the alternate viscosity basis."])
|
|
128
|
+
def liquid_viscosity_factor(x):
|
|
129
|
+
r = x.reynolds_number
|
|
130
|
+
return {"viscosity_factor": 1.0 / (0.9935 + 2.878 / sqrt(r) + 342.75 / (r ** 1.5))}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GasCriticalCapacity(Model):
|
|
134
|
+
effective_area_mm2: Positive
|
|
135
|
+
relieving_temperature_k: Positive
|
|
136
|
+
compressibility_factor: Positive = 1.0
|
|
137
|
+
molecular_weight_kg_kmol: Positive
|
|
138
|
+
relief_pressure_kpaa: Positive
|
|
139
|
+
heat_capacity_ratio: Positive
|
|
140
|
+
discharge_coefficient: Fraction = 0.975
|
|
141
|
+
back_pressure_factor: Positive = 1.0
|
|
142
|
+
combination_factor: Positive = 1.0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@calculation("api520/gas-critical-capacity", "API 520 critical gas/vapor mass capacity", GasCriticalCapacity,
|
|
146
|
+
["API RP 520 7th ed. §3.6.2, Eqs. 3.2-3.4 (inverse sizing)"], origin="engineering_utility",
|
|
147
|
+
limitations=["Inverse of the SI effective-area relation; use certified valve capacity and qualified real-gas data for selection."])
|
|
148
|
+
def gas_critical_capacity(x):
|
|
149
|
+
ensure(x.compressibility_factor > 0 and x.molecular_weight_kg_kmol > 0, "Invalid gas property")
|
|
150
|
+
k = x.heat_capacity_ratio
|
|
151
|
+
ensure(k > 1.0, "Heat-capacity ratio must exceed one")
|
|
152
|
+
c = 520.0 * sqrt(k * (2.0 / (k + 1.0)) ** ((k + 1.0) / (k - 1.0)))
|
|
153
|
+
rate = x.effective_area_mm2 * c * x.discharge_coefficient * x.relief_pressure_kpaa * x.back_pressure_factor * x.combination_factor
|
|
154
|
+
rate /= 13160.0 * sqrt(x.relieving_temperature_k * x.compressibility_factor / x.molecular_weight_kg_kmol)
|
|
155
|
+
return {"mass_capacity_kg_h": rate}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class GasSubcriticalCapacity(Model):
|
|
159
|
+
effective_area_mm2: Positive
|
|
160
|
+
relieving_temperature_k: Positive
|
|
161
|
+
compressibility_factor: Positive = 1.0
|
|
162
|
+
molecular_weight_kg_kmol: Positive
|
|
163
|
+
relief_pressure_kpaa: Positive
|
|
164
|
+
heat_capacity_ratio: Positive
|
|
165
|
+
discharge_coefficient: Fraction = 0.975
|
|
166
|
+
combination_factor: Positive = 1.0
|
|
167
|
+
downstream_pressure_kpaa: Positive
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@calculation("api520/gas-subcritical-capacity", "API 520 subcritical gas/vapor mass capacity", GasSubcriticalCapacity,
|
|
171
|
+
["API RP 520 7th ed. §3.6.3, Eqs. 3.5-3.7 (inverse sizing)"], origin="engineering_utility",
|
|
172
|
+
limitations=["Inverse of the SI effective-area relation; downstream pressure and manufacturer correction factors remain caller-qualified."])
|
|
173
|
+
def gas_subcritical_capacity(x):
|
|
174
|
+
k = x.heat_capacity_ratio
|
|
175
|
+
p1, p2 = x.relief_pressure_kpaa, x.downstream_pressure_kpaa
|
|
176
|
+
ensure(k > 1.0 and 0 < p2 < p1, "Subcritical flow requires 0 < P2 < P1 and k > 1")
|
|
177
|
+
r = p2 / p1
|
|
178
|
+
f2 = sqrt((k / (k - 1.0)) * r ** (2.0 / k) * (1.0 - r ** ((k - 1.0) / k)) / (1.0 - r))
|
|
179
|
+
rate = x.effective_area_mm2 * f2 * x.discharge_coefficient * x.combination_factor * sqrt(p1 * (p1 - p2))
|
|
180
|
+
rate /= 17.9 * sqrt(x.relieving_temperature_k * x.compressibility_factor / x.molecular_weight_kg_kmol)
|
|
181
|
+
return {"mass_capacity_kg_h": rate, "back_pressure_ratio": r, "subcritical_flow_factor": f2}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class LiquidCapacity(Model):
|
|
185
|
+
effective_area_mm2: Positive
|
|
186
|
+
specific_gravity: Positive
|
|
187
|
+
set_pressure_kpag: Positive
|
|
188
|
+
back_pressure_kpag: NonNegative = 0.0
|
|
189
|
+
discharge_coefficient: Positive = 0.65
|
|
190
|
+
back_pressure_factor: Positive = 1.0
|
|
191
|
+
combination_factor: Positive = 1.0
|
|
192
|
+
viscosity_factor: Positive = 1.0
|
|
193
|
+
overpressure_factor: Positive = 1.0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@calculation("api520/liquid-capacity", "API 520 liquid relief volumetric capacity", LiquidCapacity,
|
|
197
|
+
["API RP 520 7th ed. §3.8-3.9, Eqs. 3.9 and 3.12 (inverse sizing)"], origin="engineering_utility",
|
|
198
|
+
limitations=["Inverse of the SI effective-area relation; flashing/two-phase capacity and certified valve coefficients remain separate routes."])
|
|
199
|
+
def liquid_capacity(x):
|
|
200
|
+
ensure(x.set_pressure_kpag > x.back_pressure_kpag, "Set pressure must exceed back pressure")
|
|
201
|
+
dp = x.set_pressure_kpag - x.back_pressure_kpag
|
|
202
|
+
rate = x.effective_area_mm2 * x.discharge_coefficient * x.back_pressure_factor * x.combination_factor * x.viscosity_factor * x.overpressure_factor * sqrt(dp)
|
|
203
|
+
rate /= 11.78 * sqrt(x.specific_gravity)
|
|
204
|
+
return {"flow_capacity_l_min": rate}
|
|
205
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Selected event/weather area-risk contributions, distinct from full assessment."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, NonNegative, Fraction, calculation, ensure, REGISTRY
|
|
5
|
+
from .pof import Component, generic, Management, management, Frequency, adjusted
|
|
6
|
+
from .flash_pool import FlashedBoilingPoolFlashFire, flashed_boiling_pool_flash_fire
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AreaEventContribution(Model):
|
|
10
|
+
annual_release_frequency_per_year: NonNegative
|
|
11
|
+
weather_probability_given_release: Fraction
|
|
12
|
+
event_probability_given_release_weather: Fraction
|
|
13
|
+
equipment_area_m2: NonNegative
|
|
14
|
+
personnel_area_m2: NonNegative
|
|
15
|
+
probability_source: str = Field(min_length=1,max_length=1000)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@calculation('risk/area-event-contribution','Selected conditional event/weather area-risk contribution',AreaEventContribution,
|
|
19
|
+
['Part1 §4.3.1 eqs(1.6)-(1.8); Part3 §5.8.7'],
|
|
20
|
+
origin='explicit_selected_event_weather_risk_contribution',limitations=[
|
|
21
|
+
'Partial contribution only; does not require or imply complete terminal outcomes/weather/holes. Event probability is conditional on the supplied release AND weather, so multiplying by weather probability is the chain rule, not an independence assertion. Raw event areas must not already be event/weather weighted. Release frequency must not already contain these probabilities. Each probability and annual release frequency is applied once. No financial, individual or societal risk; no zero-consequence assertion for omitted events.'])
|
|
22
|
+
def area_event_contribution(x):
|
|
23
|
+
ensure(bool(x.probability_source.strip()),'Conditional event/weather probability source required')
|
|
24
|
+
joint=x.weather_probability_given_release*x.event_probability_given_release_weather
|
|
25
|
+
equipment=joint*x.equipment_area_m2
|
|
26
|
+
personnel=joint*x.personnel_area_m2
|
|
27
|
+
equipment_risk=x.annual_release_frequency_per_year*equipment
|
|
28
|
+
personnel_risk=x.annual_release_frequency_per_year*personnel
|
|
29
|
+
return dict(annual_release_frequency_per_year=x.annual_release_frequency_per_year,
|
|
30
|
+
conditional_joint_probability=joint,annual_selected_event_frequency_per_year=x.annual_release_frequency_per_year*joint,
|
|
31
|
+
event_weighted_equipment_area_m2=equipment,event_weighted_personnel_area_m2=personnel,
|
|
32
|
+
equipment_area_risk_contribution_m2_per_year=equipment_risk,
|
|
33
|
+
personnel_area_risk_contribution_m2_per_year=personnel_risk,
|
|
34
|
+
area_risk_contribution_m2_per_year=max(equipment_risk,personnel_risk),
|
|
35
|
+
contribution_scope='selected_event_weather_case')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FlashedPoolRiskContribution(Model):
|
|
39
|
+
fire: FlashedBoilingPoolFlashFire
|
|
40
|
+
component_type: str = Field(min_length=1)
|
|
41
|
+
hole: Literal['small','medium','large','rupture']
|
|
42
|
+
management_score: NonNegative = Field(le=1000)
|
|
43
|
+
total_damage_factor: NonNegative
|
|
44
|
+
damage_factor_source: str = Field(min_length=1,max_length=1000)
|
|
45
|
+
hole_release_basis: str = Field(min_length=1,max_length=1000)
|
|
46
|
+
weather_probability_given_release: Fraction
|
|
47
|
+
event_probability_given_release_weather: Fraction
|
|
48
|
+
probability_source: str = Field(min_length=1,max_length=1000)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@calculation('risk/flashed-pool-flash-fire-contribution','Selected hole/weather EOS-to-flash-fire annual area-risk contribution',FlashedPoolRiskContribution,
|
|
52
|
+
['Part1 §4.3.1; Part2 Table3.1,eqs(2.1),(2.9)-(2.10); Part3 §§5.7.4,5.8.6-5.8.7'],
|
|
53
|
+
origin='selected_hole_eos_native_fire_risk_composition',limitations=[
|
|
54
|
+
*REGISTRY['risk/area-event-contribution'].limitations,
|
|
55
|
+
'Uses only the selected non-tank GFF hole, supplied governing DF and management score. Hole-specific adjusted rate/mass and applicability must be qualified externally; this API does not derive hydraulic hole size, isolation/inventory or damage applicability. Probability is for this selected steady pool flash-fire scenario, not total ignition probability.',
|
|
56
|
+
'EOS/native source scope, constant-property qualification, ground LFL, sampled-grid censoring and LH2 premise restrictions are inherited. No startup/jet/post-release pool or other outcome contribution. Omitted scenarios remain unassessed; this result is not a whole-component or four-hole risk assessment.'])
|
|
57
|
+
def flashed_pool_risk_contribution(x):
|
|
58
|
+
ensure(all(s.strip() for s in (x.damage_factor_source,x.hole_release_basis,x.probability_source)),
|
|
59
|
+
'Damage, selected-hole release and probability bases must be nonblank')
|
|
60
|
+
frequencies=generic(Component(component_type=x.component_type))
|
|
61
|
+
gff=frequencies['by_hole_per_year'][x.hole]
|
|
62
|
+
fms=management(Management(score=x.management_score))['management_factor']
|
|
63
|
+
frequency=adjusted(Frequency(generic_frequency_per_year=gff,total_damage_factor=x.total_damage_factor,
|
|
64
|
+
management_factor=fms))['annual_failure_frequency']
|
|
65
|
+
fire=flashed_boiling_pool_flash_fire(x.fire)
|
|
66
|
+
contribution=area_event_contribution(AreaEventContribution(annual_release_frequency_per_year=frequency,
|
|
67
|
+
weather_probability_given_release=x.weather_probability_given_release,event_probability_given_release_weather=x.event_probability_given_release_weather,
|
|
68
|
+
equipment_area_m2=fire['equipment_area_m2'],personnel_area_m2=fire['personnel_area_m2'],probability_source=x.probability_source))
|
|
69
|
+
return {**fire,**contribution,'generic_hole_frequency_per_year':gff,'management_factor':fms,
|
|
70
|
+
'total_damage_factor':x.total_damage_factor,'selected_hole':x.hole,
|
|
71
|
+
'contribution_scope':'selected_hole_weather_steady_pool_flash_fire',
|
|
72
|
+
'integration_scope':'selected_hole_weather_steady_pool_flash_fire_risk_contribution'}
|
api581/blast.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""API581 TNT blast fit, bounded inversion and building-collapse probit.
|
|
2
|
+
|
|
3
|
+
The source provides no fit validity interval. Caller-supplied intervals are
|
|
4
|
+
checked for mathematical admissibility, which is not engineering validation.
|
|
5
|
+
"""
|
|
6
|
+
import math
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from statistics import NormalDist
|
|
9
|
+
from typing import Literal
|
|
10
|
+
from pydantic import Field
|
|
11
|
+
from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
|
|
12
|
+
from .effects import TntEquivalence, tnt_equivalence
|
|
13
|
+
|
|
14
|
+
_A=(-.059965896,1.1288697,-7.9625216,25.106738,-30.396707,
|
|
15
|
+
19.399862,-6.8853477,1.2825511,-.097705789)
|
|
16
|
+
_Q=tuple(i*_A[i] for i in range(1,len(_A)))
|
|
17
|
+
_FIT_LIMIT=("Printed inverse-log polynomial, not an exponential of the polynomial. "
|
|
18
|
+
"C26=100 gives kPa; public pressure is Pa. Source specifies no fit validity "
|
|
19
|
+
"interval. Caller must supply bounds and engineering basis; positive pressure "
|
|
20
|
+
"and strictly decreasing behavior are checked over that interval. These checks "
|
|
21
|
+
"do not establish experimental fit validity or LH2 applicability.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BlastFitDomain(Model):
|
|
25
|
+
minimum_scaled_distance_m_kg13: Positive
|
|
26
|
+
maximum_scaled_distance_m_kg13: Positive
|
|
27
|
+
engineering_basis: str = Field(min_length=1,max_length=1000)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _polynomial(coefficients,u):
|
|
31
|
+
value=coefficients[-1]
|
|
32
|
+
for coefficient in reversed(coefficients[:-1]):
|
|
33
|
+
value=value*u+coefficient
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pressure(scaled):
|
|
38
|
+
return 100000*_polynomial(_A,1/math.log(scaled))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _derivative_interval(a,b):
|
|
42
|
+
"""Outward-rounded interval Horner enclosure of dP_bar/d(1/ln R)."""
|
|
43
|
+
# Also enclose rounding of integer*A[i] when derivative coefficients form.
|
|
44
|
+
low=math.nextafter(_Q[-1],-math.inf)
|
|
45
|
+
high=math.nextafter(_Q[-1],math.inf)
|
|
46
|
+
for coefficient in reversed(_Q[:-1]):
|
|
47
|
+
products=(low*a,low*b,high*a,high*b)
|
|
48
|
+
low=math.nextafter(math.nextafter(min(products),-math.inf)+math.nextafter(coefficient,-math.inf),-math.inf)
|
|
49
|
+
high=math.nextafter(math.nextafter(max(products),math.inf)+math.nextafter(coefficient,math.inf),math.inf)
|
|
50
|
+
return low,high
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@lru_cache(maxsize=128)
|
|
54
|
+
def _certify_decreasing(first,last):
|
|
55
|
+
# R>1 -> u>0 and du/dR<0: Q(u)>0 proves pressure decreases with R.
|
|
56
|
+
a=math.nextafter(1/math.log(last),-math.inf)
|
|
57
|
+
b=math.nextafter(1/math.log(first),math.inf)
|
|
58
|
+
pending=[(a,b,0)]
|
|
59
|
+
visits=0
|
|
60
|
+
while pending:
|
|
61
|
+
a,b,depth=pending.pop()
|
|
62
|
+
visits+=1
|
|
63
|
+
low,high=_derivative_interval(a,b)
|
|
64
|
+
if low > 0:
|
|
65
|
+
continue
|
|
66
|
+
ensure(high > 0 and depth < 24 and visits <= 8192,
|
|
67
|
+
"Cannot certify strictly decreasing blast pressure over supplied interval","invalid_blast_fit_domain")
|
|
68
|
+
mid=a+(b-a)/2
|
|
69
|
+
ensure(a < mid < b,"Blast interval certification reached numerical resolution","invalid_blast_fit_domain")
|
|
70
|
+
pending.extend(((a,mid,depth+1),(mid,b,depth+1)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _domain(x):
|
|
74
|
+
first,last=x.minimum_scaled_distance_m_kg13,x.maximum_scaled_distance_m_kg13
|
|
75
|
+
ensure(first > 1 and last > first and bool(x.engineering_basis.strip()),
|
|
76
|
+
"Blast fit requires ordered scaled bounds above1 and nonblank engineering basis","invalid_blast_fit_domain")
|
|
77
|
+
pfirst,plast=_pressure(first),_pressure(last)
|
|
78
|
+
ensure(math.isfinite(pfirst) and math.isfinite(plast) and pfirst > plast > 0,
|
|
79
|
+
"Blast fit must have finite positive decreasing endpoint pressures","invalid_blast_fit_domain")
|
|
80
|
+
_certify_decreasing(first,last)
|
|
81
|
+
return first,last,pfirst,plast
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ScaledDistance(Model):
|
|
85
|
+
distance_m: Positive
|
|
86
|
+
tnt_equivalent_mass_kg: Positive
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@calculation("effects/vce-scaled-distance", "Hopkinson TNT scaled distance", ScaledDistance,
|
|
90
|
+
["Part3 eq(3.171), Annex3.B C27=1; PDF pp558,648"],
|
|
91
|
+
limitations=["SI distance m and TNT equivalent mass kg; output units m/kg^(1/3). Scaling alone does not establish blast-curve applicability. Zero TNT mass has no defined scaled distance."])
|
|
92
|
+
def scaled_distance(x):
|
|
93
|
+
result=x.distance_m/math.cbrt(x.tnt_equivalent_mass_kg)
|
|
94
|
+
ensure(math.isfinite(result) and result > 0,"Scaled distance outside finite positive numerical domain","numerical_failure")
|
|
95
|
+
return {"scaled_distance_m_kg13":result}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ScaledOverpressure(Model):
|
|
99
|
+
scaled_distance_m_kg13: Positive
|
|
100
|
+
fit_domain: BlastFitDomain
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@calculation("effects/vce-scaled-overpressure", "Hopkinson-scaled blast overpressure fit", ScaledOverpressure,
|
|
104
|
+
["Part3 eq(3.170), Annex3.B C26=100; PDF pp558,648"],limitations=[_FIT_LIMIT])
|
|
105
|
+
def scaled_overpressure(x):
|
|
106
|
+
first,last,_,_=_domain(x.fit_domain)
|
|
107
|
+
scaled=x.scaled_distance_m_kg13
|
|
108
|
+
ensure(first <= scaled <= last,"Scaled distance outside caller blast fit interval","outside_blast_fit_domain")
|
|
109
|
+
pressure=_pressure(scaled)
|
|
110
|
+
ensure(math.isfinite(pressure) and pressure > 0,"Blast pressure outside numerical domain","numerical_failure")
|
|
111
|
+
u=1/math.log(scaled)
|
|
112
|
+
slope=-100000*u*u/scaled*_polynomial(_Q,u)
|
|
113
|
+
return {"side_on_overpressure_pa":pressure,"slope_pa_per_scaled_distance":slope}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class BlastOverpressure(ScaledDistance):
|
|
117
|
+
fit_domain: BlastFitDomain
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@calculation("effects/vce-blast-overpressure", "TNT blast pressure at physical distance", BlastOverpressure,
|
|
121
|
+
["Part3 eqs(3.170)-(3.171); PDF p558"],limitations=[_FIT_LIMIT,
|
|
122
|
+
"Side-on gauge overpressure, not reflected pressure or absolute pressure. Point-source TNT equivalence has near-field limitations."])
|
|
123
|
+
def overpressure(x):
|
|
124
|
+
scaled=scaled_distance(ScaledDistance(distance_m=x.distance_m,tnt_equivalent_mass_kg=x.tnt_equivalent_mass_kg))
|
|
125
|
+
result=scaled_overpressure(ScaledOverpressure(**scaled,fit_domain=x.fit_domain))
|
|
126
|
+
return {**scaled,**result}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class BuildingProbit(Model):
|
|
130
|
+
side_on_overpressure_pa: Positive
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@calculation("effects/blast-building-probit", "Blast-induced process-building collapse probit", BuildingProbit,
|
|
134
|
+
["Part3 eq(3.172), Annex3.B C28=1000/kPa; PDF pp558,648"],
|
|
135
|
+
limitations=["Probit=-23.8+2.92*ln(overpressure in Pa); probability=Phi(probit-5). Building-collapse mechanism used as a serious-injury proxy, not open-air lung hemorrhage, direct fatality, or a pressure-duration/structural-response model."])
|
|
136
|
+
def building_probit(x):
|
|
137
|
+
probit=-23.8+2.92*math.log(x.side_on_overpressure_pa)
|
|
138
|
+
probability=.5*math.erfc(-(probit-5)/math.sqrt(2))
|
|
139
|
+
return {"probit":probit,"building_collapse_probability":probability}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class BuildingPressureThreshold(Model):
|
|
143
|
+
building_collapse_probability: Fraction
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@calculation("effects/blast-building-pressure-threshold", "Pressure for target building-collapse probability", BuildingPressureThreshold,
|
|
147
|
+
["Part3 eq(3.172) inverse, STEP8.14; PDF pp558,564"],
|
|
148
|
+
origin="api581_probit_inverse",
|
|
149
|
+
limitations=["Target probability must be strictly between0 and1. This is the building-collapse proxy, not a universal personnel injury threshold. Part3 STEP8.14 uses probit5 (50%); standalone inverse also supports explicitly selected alternative targets."])
|
|
150
|
+
def building_threshold(x):
|
|
151
|
+
ensure(0 < x.building_collapse_probability < 1,"Building-collapse target must be strictly between0 and1")
|
|
152
|
+
probit=5+NormalDist().inv_cdf(x.building_collapse_probability)
|
|
153
|
+
pressure=math.exp((probit+23.8)/2.92)
|
|
154
|
+
return {"probit":probit,"side_on_overpressure_pa":pressure}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class BlastDistance(Model):
|
|
158
|
+
tnt_equivalent_mass_kg: NonNegative
|
|
159
|
+
threshold_overpressure_pa: Positive
|
|
160
|
+
fit_domain: BlastFitDomain
|
|
161
|
+
distance_tolerance_m: Positive = 1e-5
|
|
162
|
+
maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@calculation("effects/vce-blast-distance", "TNT blast threshold distance and consequence area", BlastDistance,
|
|
166
|
+
["Part3 eqs(3.170)-(3.171),(3.173)-(3.174); PDF p558"],
|
|
167
|
+
origin="api581_bounded_blast_threshold_iteration",limitations=[_FIT_LIMIT,
|
|
168
|
+
"Requires threshold bracket within supplied scaled interval; does not extrapolate to source or beyond bounds. Returns safe upper distance bracket with error bounded by distance tolerance. Zero TNT mass returns no_explosion and zero area. Area=pi*distance^2."])
|
|
169
|
+
def distance(x):
|
|
170
|
+
first,last,pfirst,plast=_domain(x.fit_domain)
|
|
171
|
+
if x.tnt_equivalent_mass_kg == 0:
|
|
172
|
+
return {"safe_distance_m":0,"consequence_area_m2":0,"scaled_distance_m_kg13":None,
|
|
173
|
+
"status":"no_explosion","iterations":0,"pressure_residual_pa":None}
|
|
174
|
+
threshold=x.threshold_overpressure_pa
|
|
175
|
+
ensure(plast <= threshold <= pfirst,"Threshold not bracketed in supplied blast fit interval","unbracketed_threshold")
|
|
176
|
+
root=math.cbrt(x.tnt_equivalent_mass_kg)
|
|
177
|
+
low,high=first*root,last*root
|
|
178
|
+
ensure(math.isfinite(high) and low > 0,"Blast distance bracket outside numerical domain","numerical_failure")
|
|
179
|
+
iterations=0
|
|
180
|
+
if threshold == pfirst:
|
|
181
|
+
high=low
|
|
182
|
+
elif threshold != plast:
|
|
183
|
+
while high-low > x.distance_tolerance_m and iterations < x.maximum_iterations:
|
|
184
|
+
mid=low+(high-low)/2
|
|
185
|
+
ensure(low < mid < high,"Blast iteration reached floating point resolution","numerical_failure")
|
|
186
|
+
if _pressure(mid/root) > threshold:
|
|
187
|
+
low=mid
|
|
188
|
+
else:
|
|
189
|
+
high=mid
|
|
190
|
+
iterations+=1
|
|
191
|
+
ensure(high-low <= x.distance_tolerance_m,"Blast threshold iteration did not converge","nonconvergent_threshold")
|
|
192
|
+
scaled=high/root
|
|
193
|
+
return {"safe_distance_m":high,"consequence_area_m2":math.pi*high**2,
|
|
194
|
+
"scaled_distance_m_kg13":scaled,"status":"threshold_solved","iterations":iterations,
|
|
195
|
+
"pressure_residual_pa":_pressure(scaled)-threshold}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class BlastConsequence(TntEquivalence):
|
|
199
|
+
mass_basis: Literal["between_flammability_limits", "total_released_flammable"]
|
|
200
|
+
yield_engineering_basis: str = Field(min_length=1,max_length=1000)
|
|
201
|
+
personnel_building_collapse_probability: Fraction = .5
|
|
202
|
+
equipment_threshold_overpressure_pa: Positive = 34500
|
|
203
|
+
fit_domain: BlastFitDomain
|
|
204
|
+
distance_tolerance_m: Positive = 1e-5
|
|
205
|
+
maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@calculation("effects/vce-blast-consequence", "Cloud mass to TNT blast personnel/equipment areas", BlastConsequence,
|
|
209
|
+
["Part3 §§5.8.5.3-5.8.5.6 eqs(3.169)-(3.174), STEP8.14; PDF pp557-558,564"],
|
|
210
|
+
origin="api581_tnt_blast_composition",limitations=[_FIT_LIMIT,
|
|
211
|
+
"Caller supplies cloud mass at ignition or total released flammable mass and a consistent qualified yield. Source recommends yield1 for mass between LFL/UFL; typical total-release yields.03-.19 are not automatically assigned by material. Dispersion/ignition timing not solved here.",
|
|
212
|
+
"TNT heat is mandatory because printed4648J/kg conflicts with2000Btu/lb; no silent correction. Personnel target defaults to50% building collapse per Part3 STEP8.14 (probit5), with explicit alternative targets supported. This is not direct lethality. No ignition/event weighting, congestion correction or TNO multi-energy model."])
|
|
213
|
+
def consequence(x):
|
|
214
|
+
ensure(bool(x.yield_engineering_basis.strip()) and bool(x.tnt_heat_source.strip()),
|
|
215
|
+
"TNT heat and explosion yield require nonblank engineering source/basis")
|
|
216
|
+
_domain(x.fit_domain)
|
|
217
|
+
tnt=tnt_equivalence(TntEquivalence(**x.model_dump(include=set(TntEquivalence.model_fields))))["tnt_equivalent_mass_kg"]
|
|
218
|
+
ensure(math.isfinite(tnt),"TNT equivalent mass outside numerical domain","numerical_failure")
|
|
219
|
+
ensure(tnt > 0 or x.flammable_cloud_mass_kg == 0 or x.explosion_yield == 0,
|
|
220
|
+
"TNT equivalent mass underflow","numerical_failure")
|
|
221
|
+
personnel_pressure=building_threshold(BuildingPressureThreshold(
|
|
222
|
+
building_collapse_probability=x.personnel_building_collapse_probability))["side_on_overpressure_pa"]
|
|
223
|
+
setup=dict(tnt_equivalent_mass_kg=tnt,fit_domain=x.fit_domain,
|
|
224
|
+
distance_tolerance_m=x.distance_tolerance_m,maximum_iterations=x.maximum_iterations)
|
|
225
|
+
personnel=distance(BlastDistance(**setup,threshold_overpressure_pa=personnel_pressure))
|
|
226
|
+
equipment=distance(BlastDistance(**setup,threshold_overpressure_pa=x.equipment_threshold_overpressure_pa))
|
|
227
|
+
return {"tnt_equivalent_mass_kg":tnt,"personnel_threshold_overpressure_pa":personnel_pressure,
|
|
228
|
+
"personnel_building_collapse_probability":x.personnel_building_collapse_probability,
|
|
229
|
+
"equipment_threshold_overpressure_pa":x.equipment_threshold_overpressure_pa,
|
|
230
|
+
"personnel_safe_distance_m":personnel["safe_distance_m"],"equipment_safe_distance_m":equipment["safe_distance_m"],
|
|
231
|
+
"personnel_area_m2":personnel["consequence_area_m2"],"equipment_area_m2":equipment["consequence_area_m2"],
|
|
232
|
+
"personnel_status":personnel["status"],"equipment_status":equipment["status"],"mass_basis":x.mass_basis}
|