api581-engine 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. api581/__init__.py +80 -0
  2. api581/_amine_tables.py +105 -0
  3. api581/_h2sh2_tables.py +161 -0
  4. api581/_slabx_worker.py +174 -0
  5. api581/_sulfidic_tables.py +517 -0
  6. api581/_sulfuric_tables.py +328 -0
  7. api581/acid_sour_water.py +238 -0
  8. api581/acscc_screening.py +88 -0
  9. api581/alkaline_sour_water.py +267 -0
  10. api581/amine_corrosion.py +277 -0
  11. api581/amine_screening.py +103 -0
  12. api581/api/__init__.py +4 -0
  13. api581/api/api520.py +88 -0
  14. api581/api/bundle.py +121 -0
  15. api581/api/consequence.py +102 -0
  16. api581/api/damage.py +1831 -0
  17. api581/api/dispersion.py +121 -0
  18. api581/api/effects.py +318 -0
  19. api581/api/events.py +96 -0
  20. api581/api/financial.py +115 -0
  21. api581/api/frequency.py +40 -0
  22. api581/api/inspection.py +96 -0
  23. api581/api/level1.py +238 -0
  24. api581/api/level2.py +74 -0
  25. api581/api/planning.py +17 -0
  26. api581/api/prd.py +190 -0
  27. api581/api/properties.py +130 -0
  28. api581/api/release.py +252 -0
  29. api581/api/reliability.py +40 -0
  30. api581/api/risk.py +71 -0
  31. api581/api/tank.py +490 -0
  32. api581/api520.py +205 -0
  33. api581/area_contribution.py +72 -0
  34. api581/blast.py +232 -0
  35. api581/brittle.py +205 -0
  36. api581/bundle.py +300 -0
  37. api581/catalog.py +442 -0
  38. api581/caustic_screening.py +103 -0
  39. api581/clad_thinning.py +146 -0
  40. api581/clscc_screening.py +55 -0
  41. api581/co2_corrosion.py +313 -0
  42. api581/co2_screening.py +205 -0
  43. api581/consequences.py +250 -0
  44. api581/contracts.py +1038 -0
  45. api581/cooling_selection.py +204 -0
  46. api581/cooling_water.py +305 -0
  47. api581/core.py +126 -0
  48. api581/corrosion_rate.py +38 -0
  49. api581/cost_tables.py +175 -0
  50. api581/cracking.py +74 -0
  51. api581/data/coverage.json +21361 -0
  52. api581/data/digital-twin.schema.json +538 -0
  53. api581/data/direct-test-queue.json +7 -0
  54. api581/data/native-engine-evidence.json +44 -0
  55. api581/data/python-api.schema.json +92706 -0
  56. api581/data/reference-fixture-evidence.json +2956 -0
  57. api581/data/scope-boundaries.schema.json +58 -0
  58. api581/data/software-test-evidence.json +4278 -0
  59. api581/data/standard-metadata.json +1328 -0
  60. api581/data/standard.schema.json +233 -0
  61. api581/data/supporting-text-review.json +1319 -0
  62. api581/digital_twin.py +491 -0
  63. api581/dispersion.py +45 -0
  64. api581/effects.py +232 -0
  65. api581/evaporation.py +181 -0
  66. api581/evidence.py +81 -0
  67. api581/external_corrosion.py +148 -0
  68. api581/external_cracking.py +63 -0
  69. api581/fatigue.py +56 -0
  70. api581/fireball.py +163 -0
  71. api581/flash_pool.py +111 -0
  72. api581/flash_solver.py +122 -0
  73. api581/groundwater.py +101 -0
  74. api581/h2sh2_corrosion.py +128 -0
  75. api581/hic_screened.py +89 -0
  76. api581/htha.py +45 -0
  77. api581/hydrochloric_acid.py +272 -0
  78. api581/hydrofluoric_acid.py +236 -0
  79. api581/ignition.py +204 -0
  80. api581/inspection_effectiveness.py +601 -0
  81. api581/jet_handoff.py +60 -0
  82. api581/level1.py +311 -0
  83. api581/level1_summary.py +35 -0
  84. api581/level1_toxic.py +313 -0
  85. api581/level1_type1.py +91 -0
  86. api581/level2_summary.py +237 -0
  87. api581/lining.py +173 -0
  88. api581/native_toxic.py +67 -0
  89. api581/outputs.py +5672 -0
  90. api581/oxidation.py +145 -0
  91. api581/pascc_screening.py +78 -0
  92. api581/physical_explosion.py +190 -0
  93. api581/pof.py +207 -0
  94. api581/pool_fire.py +273 -0
  95. api581/portfolio.py +68 -0
  96. api581/prd.py +204 -0
  97. api581/prd_consequences.py +245 -0
  98. api581/prd_history.py +44 -0
  99. api581/py.typed +0 -0
  100. api581/release.py +307 -0
  101. api581/representative_fluids.py +172 -0
  102. api581/result.py +50 -0
  103. api581/risk.py +191 -0
  104. api581/saturation.py +66 -0
  105. api581/scc_mechanisms.py +242 -0
  106. api581/schema.py +45 -0
  107. api581/slabx_adapter.py +261 -0
  108. api581/soil_corrosion.py +191 -0
  109. api581/source_partition.py +169 -0
  110. api581/spatial.py +87 -0
  111. api581/ssc_screening.py +77 -0
  112. api581/standard.py +281 -0
  113. api581/storage.py +42 -0
  114. api581/sulfidic_corrosion.py +197 -0
  115. api581/sulfuric_acid.py +209 -0
  116. api581/tank.py +260 -0
  117. api581/tank_corrosion.py +256 -0
  118. api581/tank_environment.py +186 -0
  119. api581/tank_financial.py +202 -0
  120. api581/tank_integrated.py +51 -0
  121. api581/tank_shell.py +141 -0
  122. api581/tank_source.py +152 -0
  123. api581/tank_thinning.py +204 -0
  124. api581/temper.py +105 -0
  125. api581/thinning.py +172 -0
  126. api581/thinning_conditions.py +212 -0
  127. api581/thinning_history.py +118 -0
  128. api581/thinning_selection.py +81 -0
  129. api581/thinning_type.py +34 -0
  130. api581/toxic_criteria.py +111 -0
  131. api581/toxic_level2.py +233 -0
  132. api581/two_phase_events.py +120 -0
  133. api581/workflow.py +42 -0
  134. api581_engine-0.1.4.dist-info/METADATA +419 -0
  135. api581_engine-0.1.4.dist-info/RECORD +139 -0
  136. api581_engine-0.1.4.dist-info/WHEEL +5 -0
  137. api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
  138. api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
  139. api581_engine-0.1.4.dist-info/top_level.txt +1 -0
api581/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}