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/pool_fire.py ADDED
@@ -0,0 +1,273 @@
1
+ """Pool fire radiation, with printed and independently integrated geometry bases."""
2
+ import math
3
+ from typing import Annotated, Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+ from .effects import PoolBurning, pool_burning, PoolArea, pool_area
7
+
8
+ Tilt = Annotated[float,Field(ge=0,lt=math.pi/2,strict=True,allow_inf_nan=False)]
9
+ EquationBasis = Literal["api581_printed", "geometry_checked"]
10
+ DistanceConvention = Literal["printed_surface_ratio", "surface_to_axis"]
11
+
12
+
13
+ class WaterPressure(Model):
14
+ ambient_temperature_k: Positive
15
+ relative_humidity_fraction: Fraction
16
+ ambient_pressure_pa_abs: Positive = 101325
17
+
18
+
19
+ @calculation("properties/water-partial-pressure", "Water partial pressure for radiation attenuation", WaterPressure,
20
+ ["Part3 eq(3.141), Annex3.B C20/C21; PDF pp552,648"],
21
+ limitations=["Uses printed1.013kPa,5328K and14.4114. RH in this equation is percent; public fraction is multiplied by100. Rejects pressure above declared ambient pressure. Empirical ambient correlation, not a general steam/ice or cryogenic property solver."])
22
+ def water_pressure(x):
23
+ pressure=1013*(100*x.relative_humidity_fraction)*math.exp(14.4114-5328/x.ambient_temperature_k)
24
+ ensure(pressure <= x.ambient_pressure_pa_abs,"Water partial pressure exceeds ambient pressure; correlation/conditions outside physical domain")
25
+ return {"water_partial_pressure_pa":pressure}
26
+
27
+
28
+ class Transmissivity(Model):
29
+ water_partial_pressure_pa: NonNegative
30
+ path_length_m: Positive
31
+
32
+
33
+ @calculation("effects/atmospheric-transmissivity", "Water-vapor thermal-radiation transmissivity", Transmissivity,
34
+ ["Part3 eq(3.140), Annex3.B C19; PDF pp552,648"],
35
+ limitations=["Equation exponent-.09 and C19=1.085 with pressure in kPa. Annex dimensional exponent.092 differs; equation exponent followed. Capped at physical maximum1. At zero water pressure uses limiting tau=1 and reports no finite uncapped value."])
36
+ def transmissivity(x):
37
+ if x.water_partial_pressure_pa == 0:
38
+ return {"transmissivity":1,"uncapped_transmissivity":None,"dry_air_limit":True}
39
+ # Log form avoids underflow in pressure*distance before negative exponent.
40
+ raw=1.085*math.exp(-.09*(math.log(x.water_partial_pressure_pa)-math.log(1000)+math.log(x.path_length_m)))
41
+ return {"transmissivity":min(1,raw),"uncapped_transmissivity":raw,"dry_air_limit":False}
42
+
43
+
44
+ class MixtureComponent(Model):
45
+ mole_fraction: Fraction
46
+ burning_rate_kg_m2_s: NonNegative
47
+
48
+
49
+ class MixtureBurning(Model):
50
+ components: list[MixtureComponent] = Field(min_length=1,max_length=100)
51
+
52
+
53
+ @calculation("effects/pool-mixture-burning-rate", "Mole-weighted liquid-mixture pool burning rate", MixtureBurning,
54
+ ["Part3 eq(3.130); PDF p550"],
55
+ limitations=["Source mole-fraction weighting of supplied component burning rates; not a mixture thermodynamic or unignited evaporation model. Fractions must sum to1 and are not silently normalized."])
56
+ def mixture_burning(x):
57
+ ensure(math.isclose(math.fsum(c.mole_fraction for c in x.components),1,rel_tol=0,abs_tol=1e-9),"Mole fractions must sum to1")
58
+ return {"burning_rate_kg_m2_s":math.fsum(c.mole_fraction*c.burning_rate_kg_m2_s for c in x.components)}
59
+
60
+
61
+ class FlameGeometry(Model):
62
+ pool_radius_m: Positive
63
+ burning_rate_kg_m2_s: Positive
64
+ air_density_kg_m3: Positive
65
+ vapor_density_kg_m3: Positive
66
+ wind_speed_m_s: NonNegative
67
+ gravity_m_s2: Positive = 9.81
68
+
69
+
70
+ @calculation("effects/pool-flame-geometry", "Thomas pool flame length and wind tilt", FlameGeometry,
71
+ ["Part3 eqs(3.135)-(3.137); PDF p551"],
72
+ limitations=["Air density enters Thomas length; vapor density enters nondimensional wind (not interchangeable). Wind must be qualified at source reference height6ft above grade. Printed powers.67,.333,-.21 followed. No automatic wind-height conversion or LH2 validation."])
73
+ def flame_geometry(x):
74
+ wind=max(1,x.wind_speed_m_s*(x.vapor_density_kg_m3/(2*x.gravity_m_s2*x.burning_rate_kg_m2_s*x.pool_radius_m))**.333)
75
+ length=110*x.pool_radius_m*(x.burning_rate_kg_m2_s/(x.air_density_kg_m3*math.sqrt(2*x.gravity_m_s2*x.pool_radius_m)))**.67*wind**(-.21)
76
+ tilt=math.acos(1/math.sqrt(wind))
77
+ ensure(math.isfinite(wind) and math.isfinite(length) and length > 0 and tilt < math.pi/2,
78
+ "Flame geometry exceeds finite numerical domain", "numerical_failure")
79
+ return {"nondimensional_wind_speed":wind,"flame_length_m":length,"flame_tilt_rad":tilt,
80
+ "vertical_flame_height_m":length*math.cos(tilt),"downwind_axis_extension_m":length*math.sin(tilt)}
81
+
82
+
83
+ class SurfaceEmissive(Model):
84
+ pool_radius_m: Positive
85
+ flame_length_m: Positive
86
+ burning_rate_kg_m2_s: Positive
87
+ heat_of_combustion_j_kg: Positive
88
+ radiative_fraction: Fraction = .35
89
+
90
+
91
+ @calculation("effects/pool-surface-emissive-power", "Pool flame surface emissive power", SurfaceEmissive,
92
+ ["Part3 eq(3.138); PDF p551"],
93
+ limitations=["Surface emissive power W/m2, not total radiated power W. Uses printed cylindrical side-plus-top area2*pi*R*L+pi*R^2. Uniform emissive surface; no smoke shielding or fuel-specific emissivity model."])
94
+ def surface_emissive(x):
95
+ burning_mass=x.burning_rate_kg_m2_s*math.pi*x.pool_radius_m**2
96
+ power=x.radiative_fraction*burning_mass*x.heat_of_combustion_j_kg
97
+ area=2*math.pi*x.pool_radius_m*x.flame_length_m+math.pi*x.pool_radius_m**2
98
+ ensure(math.isfinite(power) and math.isfinite(area) and area > 0,
99
+ "Pool radiated power/surface area exceeds numerical domain", "numerical_failure")
100
+ return {"burning_mass_rate_kg_s":burning_mass,"radiated_power_w":power,
101
+ "emitting_surface_area_m2":area,"surface_emissive_power_w_m2":power/area}
102
+
103
+
104
+ class CylinderView(Model):
105
+ length_radius_ratio: Positive
106
+ distance_radius_ratio: Positive
107
+ flame_tilt_rad: Tilt
108
+ equation_basis: EquationBasis
109
+
110
+
111
+ @calculation("effects/pool-cylinder-view-factor", "Pool cylinder vertical/horizontal/maximum view factors", CylinderView,
112
+ ["Part3 eqs(3.142)-(3.149); PDF pp552-553", "Independent oblique-cylinder surface integral: docs/POOL_RADIATION_VALIDATION.md"],
113
+ origin="explicit_printed_or_geometrically_checked_equations",
114
+ limitations=["Printed2016 Fv has atan(q), not atan(sqrt(q)), and numerator2Y(1+sin(theta)), not2Y(1+Xsin(theta)). api581_printed reproduces these; geometry_checked makes the two explicit changes verified against independent side-surface integration. Not an authorized standard erratum. Requires Y>1+Xsin(theta), outside downwind flame projection. Ground target in tilt plane; uniform side emission. Physical-range flag must be checked before flux use."])
115
+ def cylinder_view(x):
116
+ X,Y=x.length_radius_ratio,x.distance_radius_ratio
117
+ s,c=math.sin(x.flame_tilt_rad),math.cos(x.flame_tilt_rad)
118
+ ensure(Y > 1+X*s,"Target must be outside downwind flame projection: Y > 1+X*sin(tilt)")
119
+ A=(X*c)**2+(Y+1-X*s)**2
120
+ B=(X*c)**2+(Y-1-X*s)**2
121
+ D=Y*Y-1
122
+ C=1+D*c*c
123
+ q=A*(Y-1)/(B*(Y+1))
124
+ common=math.atan2(X*Y/(math.sqrt(D)*math.sqrt(C)),1-s*(X*Y-D*s)/C)
125
+ numerator=((X*c)**2+(Y-X*s)**2+1 if x.equation_basis == "geometry_checked"
126
+ else X*X+(Y+1)**2-2*Y*(1+s))
127
+ vertical_atan=math.atan(math.sqrt(q) if x.equation_basis == "geometry_checked" else q)
128
+ fv=(X*c/(Y-X*s)*numerator/(math.pi*math.sqrt(A*B))*vertical_atan
129
+ +c/(math.pi*math.sqrt(C))*common
130
+ -X*c/(math.pi*(Y-X*s))*math.atan(math.sqrt((Y-1)/(Y+1))))
131
+ fh=(math.atan(math.sqrt((Y+1)/(Y-1)))/math.pi
132
+ -(X*X+(Y+1)**2-2*(Y+1+X*Y*s))/(math.pi*math.sqrt(A*B))*math.atan(math.sqrt(q))
133
+ +s/(math.pi*math.sqrt(C))*common)
134
+ combined=math.hypot(fv,fh)
135
+ physical=min(fv,fh) >= -1e-12 and max(fv,fh,combined) <= 1+1e-12
136
+ return {"vertical_view_factor":fv,"horizontal_view_factor":fh,"maximum_view_factor":combined,
137
+ "physical_range":physical,"equation_basis":x.equation_basis}
138
+
139
+
140
+ class Atmosphere(Model):
141
+ transmissivity_model: Literal["constant","api_water_vapor"]
142
+ constant_transmissivity: Fraction | None = None
143
+ water_partial_pressure_pa: NonNegative | None = None
144
+
145
+
146
+ def _atmosphere(x,distance):
147
+ ensure((x.transmissivity_model == "constant" and x.constant_transmissivity is not None and x.water_partial_pressure_pa is None)
148
+ or (x.transmissivity_model == "api_water_vapor" and x.water_partial_pressure_pa is not None and x.constant_transmissivity is None),
149
+ "Provide exactly the parameter for selected transmissivity model")
150
+ return x.constant_transmissivity if x.transmissivity_model == "constant" else transmissivity(
151
+ Transmissivity(water_partial_pressure_pa=x.water_partial_pressure_pa,path_length_m=distance))["transmissivity"]
152
+
153
+
154
+ class RadiationSetup(Model):
155
+ pool_radius_m: Positive
156
+ flame_length_m: Positive
157
+ flame_tilt_rad: Tilt
158
+ surface_emissive_power_w_m2: NonNegative
159
+ atmosphere: Atmosphere
160
+ equation_basis: EquationBasis
161
+ distance_convention: DistanceConvention
162
+
163
+
164
+ class PoolFlux(RadiationSetup):
165
+ surface_distance_m: Positive
166
+
167
+
168
+ @calculation("effects/pool-radiation-flux", "Pool thermal radiation at a specified surface separation", PoolFlux,
169
+ ["Part3 eqs(3.139)-(3.149); PDF pp552-553"],
170
+ origin="api581_surface_flux_with_explicit_geometry_basis",
171
+ limitations=["Source nomenclature defines xs from flame surface, but printed Y=xs/R conflicts with axis-distance geometry. Caller selects printed_surface_ratio (Y=xs/R) or surface_to_axis (Y=(xs+R)/R). Atmospheric path always uses surface separation. Invalid physical view factors are rejected; no automatic formula fallback."])
172
+ def pool_flux(x):
173
+ distance=x.surface_distance_m+(x.pool_radius_m if x.distance_convention == "surface_to_axis" else 0)
174
+ view=cylinder_view(CylinderView(length_radius_ratio=x.flame_length_m/x.pool_radius_m,
175
+ distance_radius_ratio=distance/x.pool_radius_m,flame_tilt_rad=x.flame_tilt_rad,equation_basis=x.equation_basis))
176
+ ensure(view["physical_range"],"Selected printed/geometric view factor lies outside physical range", "invalid_view_factor")
177
+ tau=_atmosphere(x.atmosphere,x.surface_distance_m)
178
+ return {"received_flux_w_m2":tau*x.surface_emissive_power_w_m2*view["maximum_view_factor"],
179
+ "transmissivity":tau,"maximum_view_factor":view["maximum_view_factor"],"view_factor_distance_m":distance,
180
+ "equation_basis":x.equation_basis,"distance_convention":x.distance_convention}
181
+
182
+
183
+ class PoolDistance(RadiationSetup):
184
+ threshold_w_m2: Positive
185
+ maximum_surface_distance_m: Positive
186
+ distance_tolerance_m: Positive = 1e-5
187
+ maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
188
+
189
+
190
+ @calculation("effects/pool-fire-distance", "Pool radiation threshold separation and consequence area", PoolDistance,
191
+ ["Part3 §5.8.2.6; eqs(3.150)-(3.151); PDF p553"],
192
+ origin="api581_threshold_iteration_with_explicit_geometry_basis",
193
+ limitations=["Assumes decreasing flux outside downwind flame envelope. Solves only within supplied maximum distance; no implicit expansion or far-field substitution. If flux already below limit at domain boundary, returns conservative flame-envelope separation and boundary status, not a claimed threshold crossing. Area always pi*(surface_distance+pool_radius)^2. Printed formula/geometry and distance convention are explicit; no validated LH2 applicability."])
194
+ def pool_distance(x):
195
+ envelope=x.flame_length_m*math.sin(x.flame_tilt_rad)
196
+ minimum=envelope+(x.pool_radius_m if x.distance_convention == "printed_surface_ratio" else 0)
197
+ lo=minimum+max(1e-9,1e-8*x.pool_radius_m)
198
+ hi=x.maximum_surface_distance_m
199
+ ensure(hi > lo,"Maximum surface distance must lie outside flame/view-factor domain")
200
+ def flux(distance):
201
+ return pool_flux(PoolFlux(**{k:getattr(x,k) for k in RadiationSetup.model_fields},surface_distance_m=distance))["received_flux_w_m2"]
202
+ lower,upper=flux(lo),flux(hi)
203
+ ensure(upper <= x.threshold_w_m2,"Threshold not bracketed; increase maximum surface distance", "unbracketed_threshold")
204
+ if lower <= x.threshold_w_m2:
205
+ distance,status,iterations,residual=minimum,"below_limit_at_domain_boundary",0,None
206
+ else:
207
+ iterations=0
208
+ while hi-lo > x.distance_tolerance_m and iterations < x.maximum_iterations:
209
+ mid=lo+(hi-lo)/2
210
+ if flux(mid) > x.threshold_w_m2:lo=mid
211
+ else:hi=mid
212
+ iterations+=1
213
+ ensure(hi-lo <= x.distance_tolerance_m,"Threshold solver did not converge", "nonconvergent_threshold")
214
+ distance,status=hi,"threshold_solved"
215
+ residual=flux(distance)-x.threshold_w_m2
216
+ return {"safe_surface_distance_m":distance,"consequence_radius_m":distance+x.pool_radius_m,
217
+ "consequence_area_m2":math.pi*(distance+x.pool_radius_m)**2,"status":status,
218
+ "iterations":iterations,"flux_residual_w_m2":residual,"minimum_surface_domain_m":minimum,
219
+ "equation_basis":x.equation_basis,"distance_convention":x.distance_convention}
220
+
221
+
222
+ class PoolConsequence(Model):
223
+ burning: PoolBurning
224
+ liquid_rate_kg_s: NonNegative
225
+ available_liquid_mass_kg: NonNegative
226
+ liquid_density_kg_m3: Positive
227
+ pool_depth_m: Positive = .005
228
+ site_maximum_area_m2: Positive
229
+ air_density_kg_m3: Positive
230
+ vapor_density_kg_m3: Positive
231
+ wind_speed_m_s: NonNegative
232
+ radiative_fraction: Fraction = .35
233
+ atmosphere: Atmosphere
234
+ equation_basis: EquationBasis
235
+ distance_convention: DistanceConvention
236
+ maximum_surface_distance_m: Positive
237
+ personnel_threshold_w_m2: Positive = 12600
238
+ equipment_threshold_w_m2: Positive = 37800
239
+
240
+
241
+ @calculation("effects/pool-fire-consequence", "Compose burning, pool size, flame and radiation consequence areas", PoolConsequence,
242
+ ["Part3 §5.8.2; eqs(3.128)-(3.151); PDF pp550-553"],
243
+ origin="api581_pool_chain_with_explicit_geometry_basis",
244
+ limitations=["Single pool/event only; no ignition/event or hole weighting. Qualified liquid rainout rate/mass supplied; inventory input is liquid mass, not automatic substitution of ambiguous frac_ro in printed eq(3.132). Site limit mandatory. Explicit printed/geometry_checked and distance choices; source-view-factor discrepancies are not silently corrected. Uniform plume model, no smoke/terrain/flame-impingement or LH2-specific validation."])
245
+ def pool_consequence(x):
246
+ rate=pool_burning(x.burning)["burning_rate_kg_m2_s"]
247
+ ensure(math.isfinite(rate) and rate > 0,"Pool burning rate underflow/overflow", "numerical_failure")
248
+ area=pool_area(PoolArea(liquid_rate_kg_s=x.liquid_rate_kg_s,burning_rate_kg_m2_s=rate,
249
+ available_liquid_mass_kg=x.available_liquid_mass_kg,density_kg_m3=x.liquid_density_kg_m3,
250
+ pool_depth_m=x.pool_depth_m,site_maximum_area_m2=x.site_maximum_area_m2))
251
+ # Validate atmosphere contract even for an empty pool.
252
+ _atmosphere(x.atmosphere,1)
253
+ if area["pool_radius_m"] == 0:
254
+ return {"burning_rate_kg_m2_s":rate,**area,"flame_length_m":0,"flame_tilt_rad":0,
255
+ "surface_emissive_power_w_m2":0,"personnel_area_m2":0,"equipment_area_m2":0,
256
+ "personnel_safe_surface_distance_m":0,"equipment_safe_surface_distance_m":0,
257
+ "personnel_status":"no_pool","equipment_status":"no_pool",
258
+ "equation_basis":x.equation_basis,"distance_convention":x.distance_convention}
259
+ flame=flame_geometry(FlameGeometry(pool_radius_m=area["pool_radius_m"],burning_rate_kg_m2_s=rate,
260
+ air_density_kg_m3=x.air_density_kg_m3,vapor_density_kg_m3=x.vapor_density_kg_m3,wind_speed_m_s=x.wind_speed_m_s))
261
+ emitted=surface_emissive(SurfaceEmissive(pool_radius_m=area["pool_radius_m"],flame_length_m=flame["flame_length_m"],
262
+ burning_rate_kg_m2_s=rate,heat_of_combustion_j_kg=x.burning.heat_of_combustion_j_kg,radiative_fraction=x.radiative_fraction))
263
+ setup=dict(pool_radius_m=area["pool_radius_m"],flame_length_m=flame["flame_length_m"],flame_tilt_rad=flame["flame_tilt_rad"],
264
+ surface_emissive_power_w_m2=emitted["surface_emissive_power_w_m2"],atmosphere=x.atmosphere,
265
+ equation_basis=x.equation_basis,distance_convention=x.distance_convention,maximum_surface_distance_m=x.maximum_surface_distance_m)
266
+ personnel=pool_distance(PoolDistance(**setup,threshold_w_m2=x.personnel_threshold_w_m2))
267
+ equipment=pool_distance(PoolDistance(**setup,threshold_w_m2=x.equipment_threshold_w_m2))
268
+ return {"burning_rate_kg_m2_s":rate,**area,"flame_length_m":flame["flame_length_m"],"flame_tilt_rad":flame["flame_tilt_rad"],
269
+ "surface_emissive_power_w_m2":emitted["surface_emissive_power_w_m2"],
270
+ "personnel_area_m2":personnel["consequence_area_m2"],"equipment_area_m2":equipment["consequence_area_m2"],
271
+ "personnel_safe_surface_distance_m":personnel["safe_surface_distance_m"],"equipment_safe_surface_distance_m":equipment["safe_surface_distance_m"],
272
+ "personnel_status":personnel["status"],"equipment_status":equipment["status"],
273
+ "equation_basis":x.equation_basis,"distance_convention":x.distance_convention}
api581/portfolio.py ADDED
@@ -0,0 +1,68 @@
1
+ """Portfolio-level risk aggregation for digital-twin and site assessments.
2
+
3
+ This is an integration calculation: each component risk is supplied by a
4
+ qualified component assessment and is not recomputed here. Component
5
+ scenarios must be disjoint (or the caller must partition them before calling).
6
+ """
7
+ import math
8
+
9
+ from pydantic import Field
10
+
11
+ from .core import Model, NonNegative, calculation, ensure
12
+
13
+
14
+ class PortfolioComponent(Model):
15
+ component_id: str = Field(min_length=1, max_length=200)
16
+ annual_failure_frequency: NonNegative
17
+ equipment_area_risk_m2_per_year: NonNegative
18
+ personnel_area_risk_m2_per_year: NonNegative
19
+ financial_risk_per_year: NonNegative
20
+
21
+
22
+ class PortfolioRisk(Model):
23
+ components: list[PortfolioComponent] = Field(min_length=1, max_length=100000)
24
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
25
+ disjointness_basis: str = Field(min_length=1, max_length=1000)
26
+
27
+
28
+ @calculation(
29
+ "risk/portfolio",
30
+ "Aggregate qualified component risks for a site or digital-twin portfolio",
31
+ PortfolioRisk,
32
+ ["Part 1 §4.3.1; Part 1 §4.3.2"],
33
+ origin="engineering_utility",
34
+ limitations=[
35
+ "Each component row must already be a mutually exclusive annual risk contribution on the same currency and consequence basis.",
36
+ "This API does not deduplicate shared release scenarios, normalize currencies, or infer individual/societal risk.",
37
+ "The disjointness_basis is an auditable owner declaration, not an automatic proof of independence.",
38
+ ],
39
+ )
40
+ def portfolio(x: PortfolioRisk):
41
+ ensure(
42
+ len({row.component_id for row in x.components}) == len(x.components),
43
+ "Duplicate component_id",
44
+ )
45
+ frequency = math.fsum(row.annual_failure_frequency for row in x.components)
46
+ equipment = math.fsum(row.equipment_area_risk_m2_per_year for row in x.components)
47
+ personnel = math.fsum(row.personnel_area_risk_m2_per_year for row in x.components)
48
+ financial = math.fsum(row.financial_risk_per_year for row in x.components)
49
+ contributions = {
50
+ row.component_id: {
51
+ "annual_failure_frequency": row.annual_failure_frequency,
52
+ "equipment_area_risk_m2_per_year": row.equipment_area_risk_m2_per_year,
53
+ "personnel_area_risk_m2_per_year": row.personnel_area_risk_m2_per_year,
54
+ "financial_risk_per_year": row.financial_risk_per_year,
55
+ }
56
+ for row in x.components
57
+ }
58
+ return {
59
+ "component_count": len(x.components),
60
+ "annual_failure_frequency": frequency,
61
+ "equipment_area_risk_m2_per_year": equipment,
62
+ "personnel_area_risk_m2_per_year": personnel,
63
+ "area_risk_m2_per_year": max(equipment, personnel),
64
+ "financial_risk_per_year": financial,
65
+ "currency": x.currency,
66
+ "disjointness_basis": x.disjointness_basis,
67
+ "component_contributions": contributions,
68
+ }
api581/prd.py ADDED
@@ -0,0 +1,204 @@
1
+ """Part 1 §7 pressure-relief-device reliability primitives."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field, StrictBool
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+ from .pof import Weibull, weibull, HOLES
7
+
8
+ Device = Literal["conventional", "balanced_bellows", "pilot_operated", "rupture_disk"]
9
+ Mode = Literal["fail_to_open", "leak"]
10
+ Severity = Literal["mild", "moderate", "severe"]
11
+
12
+
13
+ class PrdDefaults(Model):
14
+ device_type: Device
15
+ failure_mode: Mode
16
+ severity: Severity
17
+ closed_system_or_flare: StrictBool = False
18
+ soft_seated: StrictBool = False
19
+
20
+
21
+ @calculation("prd/default-weibull", "PRD default Weibull parameters and design modifiers", PrdDefaults,
22
+ ["Part 1 Tables 7.5,7.12; §7.2.4(g),§7.3.2(g); PDF pp53,62,83,87"],
23
+ limitations=["Owner selects severity independently for FAIL and LEAK. Uses printed rounded pilot-operated eta (33.7,8.0,3.5), not another division of these entries. Closed-system factor .75 applies only to conventional FAIL; soft-seat factor 1.25 only to conventional/balanced LEAK. Environmental and inspection adjustments are separate."])
24
+ def prd_defaults(x):
25
+ i = ("mild","moderate","severe").index(x.severity)
26
+ if x.failure_mode == "fail_to_open":
27
+ shape = 1.8
28
+ eta = 50.5 if x.device_type == "rupture_disk" else ((33.7,8,3.5) if x.device_type == "pilot_operated" else (50.5,23.9,17.6))[i]
29
+ factor = .75 if x.device_type == "conventional" and x.closed_system_or_flare else 1
30
+ else:
31
+ shape = 1.6
32
+ eta = 17.5 if x.device_type == "rupture_disk" else ((16,14,11.5) if x.device_type == "balanced_bellows" else (17.5,15.5,13.1))[i]
33
+ factor = 1.25 if x.soft_seated and x.device_type in ("conventional","balanced_bellows") else 1
34
+ return {"shape":shape,"default_characteristic_life_years":eta,"design_factor":factor,
35
+ "design_adjusted_characteristic_life_years":eta*factor}
36
+
37
+
38
+ class PrdEnvironment(Model):
39
+ device_type: Device
40
+ failure_mode: Mode
41
+ operating_temperature_k: Positive
42
+ operating_to_set_pressure_ratio: NonNegative
43
+ installed_piping_vibration: StrictBool
44
+ pulsating_or_cyclic_service: StrictBool
45
+ actuations_per_year: NonNegative
46
+ history_of_chatter: StrictBool
47
+ qualified_pilot_high_ratio_exception: StrictBool = False
48
+
49
+
50
+ @calculation("prd/environment-factor", "PRD environmental characteristic-life modifier", PrdEnvironment,
51
+ ["Part 1 Table 7.6; PDF p83"],
52
+ limitations=["Products of applicable modifiers. LEAK excessive-actuation modifier is not reapplied when operating-ratio modifier applies (Table7.6 note2). Pilot high-ratio exception must be explicitly qualified. Printed strict 200<T<500 F and T>500 F leave exactly 500 F unresolved for LEAK; it is rejected. FAIL does not use temperature/ratio/vibration/cyclic factors."])
53
+ def prd_environment(x):
54
+ ensure(not x.qualified_pilot_high_ratio_exception or x.device_type == "pilot_operated", "High operating-ratio exception applies only to qualified pilot-operated devices")
55
+ factors = {"chatter":.5 if x.history_of_chatter else 1,
56
+ "excessive_actuation":.5 if x.actuations_per_year > 5 else 1}
57
+ if x.failure_mode == "leak":
58
+ t200 = (200-32)*5/9+273.15
59
+ t500 = (500-32)*5/9+273.15
60
+ ensure(not math.isclose(x.operating_temperature_k,t500,rel_tol=0,abs_tol=1e-9),
61
+ "Exactly 500 F is not assigned by printed Table7.6 temperature bands", "unresolved_applicability")
62
+ threshold = .95 if x.device_type == "pilot_operated" else .9
63
+ ratio_applies = x.device_type != "rupture_disk" and x.operating_to_set_pressure_ratio > threshold and not x.qualified_pilot_high_ratio_exception
64
+ factors.update(temperature=.6 if x.operating_temperature_k > t500 else (.8 if x.operating_temperature_k > t200 else 1),
65
+ operating_ratio=.5 if ratio_applies else 1,
66
+ vibration=.8 if x.installed_piping_vibration else 1,
67
+ cyclic_service=.8 if x.pulsating_or_cyclic_service else 1)
68
+ if ratio_applies:
69
+ factors["excessive_actuation"] = 1
70
+ return {"environment_factor":math.prod(factors.values()),"individual_factors":factors}
71
+
72
+
73
+ class PrdSetPressure(Model):
74
+ device_type: Device
75
+ operating_to_set_pressure_ratio: NonNegative
76
+
77
+
78
+ @calculation("prd/set-pressure-factor", "PRD leakage set-pressure probability modifier", PrdSetPressure,
79
+ ["Part 1 Table 7.7; eq (1.36); PDF pp61,84"],
80
+ limitations=["Pressure ratio supplied on a consistent pressure basis. Applies to leakage probability after Weibull evaluation, not to eta. Rupture-disk factor=1."])
81
+ def prd_set_pressure(x):
82
+ limit = .95 if x.device_type == "pilot_operated" else .9
83
+ return {"set_pressure_factor":1 if x.device_type == "rupture_disk" else min(limit,x.operating_to_set_pressure_ratio)/limit}
84
+
85
+
86
+ class PrdOverpressure(Model):
87
+ overpressure_to_set_pressure_ratio: Positive
88
+
89
+
90
+ @calculation("prd/overpressure-factor", "PRD fail-to-open overpressure modifier", PrdOverpressure,
91
+ ["Part 1 §7.2.4(j); eq (1.31); PDF pp57-58"])
92
+ def prd_overpressure(x):
93
+ return {"overpressure_factor":max(.2,min(1,1-(x.overpressure_to_set_pressure_ratio-1.3)/3.375))}
94
+
95
+
96
+ class DemandCase(Model):
97
+ initiating_event_frequency_per_year: NonNegative
98
+ demand_rate_reduction_factor: Fraction
99
+
100
+
101
+ class PrdDemand(Model):
102
+ cases: list[DemandCase] = Field(min_length=1, max_length=100)
103
+
104
+
105
+ @calculation("prd/demand-rate", "PRD case and total demand rates", PrdDemand,
106
+ ["Part 1 §7.2.3; eqs (1.12)-(1.13); PDF p50"],
107
+ limitations=["Reduction factor is the residual fraction multiplying initiating frequency, not its reciprocal. Owner qualifies independent demand cases and LOPA credits; defaults/automatic relief-design basis are not inferred."])
108
+ def prd_demand(x):
109
+ values = [c.initiating_event_frequency_per_year*c.demand_rate_reduction_factor for c in x.cases]
110
+ return {"case_demand_rates_per_year":values,"total_demand_rate_per_year":math.fsum(values)}
111
+
112
+
113
+ class InspectionUpdate(Model):
114
+ shape: Positive
115
+ prior_characteristic_life_years: Positive
116
+ duration_years: NonNegative
117
+ effectiveness: Literal["ineffective", "fairly", "usually", "highly"]
118
+ result: Literal["pass", "fail", "no_leak", "leak"]
119
+ piping_plugged: StrictBool = False
120
+
121
+
122
+ @calculation("prd/inspection-update", "PRD single inspection characteristic-life update", InspectionUpdate,
123
+ ["Part 1 §7.2.4(i); eqs (1.17)-(1.21); Tables7.8,7.9; PDF pp54-57,84"],
124
+ limitations=["Implements printed eq (1.20), including CF_pass in its second term. Ineffective tests and durations<1 year get no update. Pass/no-leak cannot reduce eta, fail/leak cannot increase eta. Plugged piping overrides pass to fail. Caller sequences records and handles overhaul/as-new date and invalidation after design change. Invalid weighted probability is rejected, not clipped."])
125
+ def prd_inspection_update(x):
126
+ prior = weibull(Weibull(shape=x.shape,characteristic_life_years=x.prior_characteristic_life_years,
127
+ age_years=x.duration_years))["cumulative_failure_probability"]
128
+ failed = x.piping_plugged or x.result in ("fail","leak")
129
+ if x.effectiveness == "ineffective" or x.duration_years < 1:
130
+ return {"prior_failure_probability":prior,"conditional_failure_probability":None,
131
+ "weighted_failure_probability":prior,"updated_characteristic_life_years":x.prior_characteristic_life_years,
132
+ "update_applied":False,"effective_failed_result":failed}
133
+ pass_cf,fail_cf = {"fairly":(.5,.7),"usually":(.7,.95),"highly":(.9,.95)}[x.effectiveness]
134
+ conditional = fail_cf*prior+(1-pass_cf)*(1-prior) if failed else (1-pass_cf)*(1-prior)
135
+ if failed:
136
+ weighted = .5*(prior+conditional) if x.effectiveness == "fairly" else conditional
137
+ else:
138
+ weighted = prior+.2*(x.duration_years/x.prior_characteristic_life_years)*(conditional-prior)
139
+ ensure(0 < weighted < 1, "Inspection update gives probability outside open interval (0,1)", "outside_update_domain")
140
+ eta = x.duration_years/(-math.log1p(-weighted))**(1/x.shape)
141
+ eta = min(eta,x.prior_characteristic_life_years) if failed else max(eta,x.prior_characteristic_life_years)
142
+ return {"prior_failure_probability":prior,"conditional_failure_probability":conditional,
143
+ "weighted_failure_probability":weighted,"updated_characteristic_life_years":eta,
144
+ "update_applied":True,"effective_failed_result":failed}
145
+
146
+
147
+ class ProtectedEquipment(Model):
148
+ generic_frequencies_per_year: dict[Literal["small","medium","large","rupture"], NonNegative]
149
+ total_damage_factor: NonNegative
150
+ management_factor: Positive
151
+ overpressure_to_mawp_ratio: Positive
152
+
153
+
154
+ @calculation("prd/protected-equipment", "Protected equipment overpressure loss-of-containment model", ProtectedEquipment,
155
+ ["Part 1 §7.2.5; eqs (1.32)-(1.33); PDF p58"],
156
+ limitations=["Uses normal damage-adjusted frequency at pressure<=MAWP; above MAWP applies printed exponential and caps source-model probability at1. Source mixes annual-frequency/probability terms; returned source_model_loc_probability follows the published model, not a Poisson conversion. Caller supplies DF at evaluation time and all four GFF holes."])
157
+ def protected_equipment(x):
158
+ ensure(set(x.generic_frequencies_per_year)==set(HOLES), "All four generic-frequency hole rows are required")
159
+ total = math.fsum(x.generic_frequencies_per_year.values())
160
+ ensure(total > 0, "Generic frequency total must be positive")
161
+ baseline = total*x.total_damage_factor*x.management_factor
162
+ if x.overpressure_to_mawp_ratio <= 1 or baseline == 0:
163
+ probability = min(1,baseline)
164
+ else:
165
+ logp = math.log(baseline)+math.log(.0312881)+3.464837*x.overpressure_to_mawp_ratio
166
+ probability = 1 if logp >= 0 else math.exp(logp)
167
+ return {"normal_failure_frequency_per_year":baseline,"source_model_loc_probability":probability,
168
+ "by_hole_loc_probability":{k:probability*v/total for k,v in x.generic_frequencies_per_year.items()}}
169
+
170
+
171
+ class PrdFailFrequency(Model):
172
+ failure_on_demand_probability: Fraction
173
+ demand_rate_per_year: NonNegative
174
+ protected_equipment_loc_probability: Fraction
175
+
176
+
177
+ @calculation("prd/fail-frequency", "PRD fail-to-open loss-of-containment scenario frequency", PrdFailFrequency,
178
+ ["Part 1 §7.2.2; eq (1.11); PDF p49"],
179
+ limitations=["One demand case: multiplies scenario-adjusted POFOD, demand rate and protected-equipment LOC model probability. Sum only justified distinct scenarios; dependencies and common-cause effects require owner analysis."])
180
+ def prd_fail_frequency(x):
181
+ return {"annual_failure_frequency":x.failure_on_demand_probability*x.demand_rate_per_year*x.protected_equipment_loc_probability}
182
+
183
+
184
+ class BaseReliability(Model):
185
+ defaults: PrdDefaults
186
+ environment: PrdEnvironment
187
+ age_years: NonNegative
188
+ overpressure_to_set_pressure_ratio: Positive = 1.3
189
+
190
+
191
+ @calculation("prd/base-reliability", "PRD default/design/environment/scenario probability", BaseReliability,
192
+ ["Part 1 eqs (1.14)-(1.16),(1.34)-(1.36); Tables7.5-7.7,7.12"],
193
+ limitations=["Uninspected/default reliability path only; no implicit inspection history. Computes cumulative Weibull probability at age and applies Fop for FAIL or Fset for LEAK. LEAK result is not automatically reinterpreted as an instantaneous annual frequency. Caller supplies qualified severity, environmental and pressure inputs."])
194
+ def prd_base_reliability(x):
195
+ ensure(x.defaults.device_type == x.environment.device_type and x.defaults.failure_mode == x.environment.failure_mode,
196
+ "Default and environment device/failure mode must match")
197
+ base,env = prd_defaults(x.defaults),prd_environment(x.environment)
198
+ eta = base["design_adjusted_characteristic_life_years"]*env["environment_factor"]
199
+ probability = weibull(Weibull(shape=base["shape"],characteristic_life_years=eta,age_years=x.age_years))["cumulative_failure_probability"]
200
+ factor = (prd_overpressure(PrdOverpressure(overpressure_to_set_pressure_ratio=x.overpressure_to_set_pressure_ratio))["overpressure_factor"]
201
+ if x.defaults.failure_mode == "fail_to_open" else
202
+ prd_set_pressure(PrdSetPressure(device_type=x.defaults.device_type,operating_to_set_pressure_ratio=x.environment.operating_to_set_pressure_ratio))["set_pressure_factor"])
203
+ return {"shape":base["shape"],"modified_characteristic_life_years":eta,"environment_factor":env["environment_factor"],
204
+ "scenario_probability_factor":factor,"unadjusted_cumulative_probability":probability,"scenario_adjusted_probability":probability*factor}