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/effects.py ADDED
@@ -0,0 +1,232 @@
1
+ """Independent consequence primitives; explicit applicability and provenance."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
6
+
7
+
8
+ class JetPower(Model):
9
+ mass_rate_kg_s: NonNegative
10
+ heat_of_combustion_j_kg: Positive
11
+ radiative_fraction: Fraction = .35
12
+
13
+
14
+ @calculation("effects/jet-fire-power", "Jet fire radiated power", JetPower,
15
+ ["Part 3 §5.8.3.2 eq (3.152); PDF p554"],
16
+ limitations=["SI radiated power; external applicability validation required for LH2."])
17
+ def jet_power(x):
18
+ return {"radiated_power_w": x.mass_rate_kg_s * x.heat_of_combustion_j_kg * x.radiative_fraction}
19
+
20
+
21
+ class JetFlux(Model):
22
+ radiated_power_w: NonNegative
23
+ distance_m: Positive
24
+ atmospheric_transmissivity: Fraction
25
+
26
+
27
+ @calculation("effects/jet-fire-flux", "Point-source jet fire received radiation", JetFlux,
28
+ ["Part 3 eqs (3.153)-(3.154); PDF p554"], limitations=["Far-field point source at grade; flame impingement and near field not represented."])
29
+ def jet_flux(x):
30
+ return {"received_flux_w_m2": x.atmospheric_transmissivity * x.radiated_power_w / (4 * math.pi * x.distance_m ** 2)}
31
+
32
+
33
+ class JetDistance(Model):
34
+ radiated_power_w: NonNegative
35
+ threshold_w_m2: Positive
36
+ transmissivity_model: Literal["constant", "api_water_vapor"]
37
+ atmospheric_transmissivity: Fraction | None = None
38
+ water_partial_pressure_pa: Positive | None = None
39
+
40
+
41
+ @calculation("effects/jet-fire-distance", "Jet fire threshold radius and consequence area", JetDistance,
42
+ ["Part 3 eqs (3.140), (3.152)-(3.156)"],
43
+ limitations=["Constant transmissivity is an explicitly supplied approximation; api_water_vapor uses distance-dependent attenuation capped at physical maximum 1.",
44
+ "Water-vapor mode uses eq (3.140) exponent -0.09 with C19=1.085 and pressure in kPa; the base-edition table labels C19 dimensions with exponent 0.092, recorded as a source discrepancy.",
45
+ "Point source at grade; LH2-specific validation outstanding."])
46
+ def jet_distance(x):
47
+ ensure((x.transmissivity_model == "constant" and x.atmospheric_transmissivity is not None and x.water_partial_pressure_pa is None)
48
+ or (x.transmissivity_model == "api_water_vapor" and x.water_partial_pressure_pa is not None and x.atmospheric_transmissivity is None),
49
+ "Provide exactly the parameter for the selected transmissivity model")
50
+ if x.radiated_power_w == 0 or (x.transmissivity_model == "constant" and x.atmospheric_transmissivity == 0):
51
+ return {"threshold_distance_m": 0, "consequence_area_m2": 0, "transmissivity_at_distance": None}
52
+ def tau(r):
53
+ return x.atmospheric_transmissivity if x.transmissivity_model == "constant" else min(1, 1.085 * (x.water_partial_pressure_pa / 1000 * r) ** (-.09))
54
+ high = math.sqrt(x.radiated_power_w / (4 * math.pi * x.threshold_w_m2))
55
+ low = 0.0
56
+ for _ in range(100):
57
+ mid = (low + high) / 2
58
+ if mid == 0:
59
+ break
60
+ flux = tau(mid) * x.radiated_power_w / (4 * math.pi * mid ** 2)
61
+ if flux > x.threshold_w_m2:
62
+ low = mid
63
+ else:
64
+ high = mid
65
+ radius = (low + high) / 2
66
+ return {"threshold_distance_m": radius, "consequence_area_m2": math.pi * radius ** 2,
67
+ "transmissivity_at_distance": tau(radius)}
68
+
69
+
70
+ class FlashFire(Model):
71
+ area_within_lfl_m2: NonNegative
72
+
73
+
74
+ @calculation("effects/flash-fire", "Flash fire consequence areas", FlashFire,
75
+ ["Part 3 §4.8.2.3, §5.8.6"], limitations=["LFL footprint must come from a validated dispersion model."])
76
+ def flash_fire(x):
77
+ return {"personnel_area_m2": x.area_within_lfl_m2, "equipment_area_m2": .25 * x.area_within_lfl_m2}
78
+
79
+
80
+ class PoolArea(Model):
81
+ liquid_rate_kg_s: NonNegative
82
+ burning_rate_kg_m2_s: Positive
83
+ available_liquid_mass_kg: NonNegative
84
+ density_kg_m3: Positive
85
+ pool_depth_m: Positive = .005
86
+ site_maximum_area_m2: Positive
87
+
88
+
89
+ @calculation("effects/pool-fire-area", "Pool burning area and radius", PoolArea,
90
+ ["Part 3 eqs (3.131)-(3.134); PDF pp550-551"],
91
+ limitations=["Pool geometry only; burning rate supplied by caller; surface radiation/view factors not included. Site limit is mandatory."])
92
+ def pool_area(x):
93
+ burning_area = x.liquid_rate_kg_s / x.burning_rate_kg_m2_s
94
+ mass_area = x.available_liquid_mass_kg / (x.density_kg_m3 * x.pool_depth_m)
95
+ area = min(burning_area, mass_area, x.site_maximum_area_m2)
96
+ return {"burning_area_m2": burning_area, "inventory_limited_area_m2": mass_area,
97
+ "pool_area_m2": area, "pool_radius_m": math.sqrt(area / math.pi)}
98
+
99
+
100
+ class EmpiricalArea(Model):
101
+ coefficient_a: NonNegative
102
+ exponent_b: Positive
103
+ release_magnitude: NonNegative
104
+ magnitude_unit: Literal["kg/s", "kg"]
105
+ coefficient_source: str = Field(min_length=1, max_length=500)
106
+
107
+
108
+ @calculation("consequence/power-law-area", "Level 1 consequence power-law primitive", EmpiricalArea,
109
+ ["Part 3 eqs (3.16)-(3.17)"],
110
+ limitations=["Caller-supplied SI coefficients only; does not select reference fluid or perform Type 0 blending. Not a complete Level 1 COF."])
111
+ def power_area(x):
112
+ return {"area_m2": x.coefficient_a * x.release_magnitude ** x.exponent_b,
113
+ "coefficient_source": x.coefficient_source}
114
+
115
+
116
+ class EventTree(Model):
117
+ phase: Literal["vapor", "liquid"]
118
+ release_type: Literal["continuous", "instantaneous"]
119
+ ignition_probability: Fraction
120
+ immediate_given_ignition: Fraction
121
+ vce_given_delayed: Fraction
122
+
123
+
124
+ @calculation("events/leakage-tree", "Single-phase leakage event tree", EventTree,
125
+ ["Part 3 §5.8.1.6, Figure 5.3"],
126
+ limitations=["Leakage only, not rupture Figure 5.4; branch probabilities supplied by caller. Two-phase tree deferred."])
127
+ def leakage_tree(x):
128
+ pi, immediate, vce = x.ignition_probability, x.immediate_given_ignition, x.vce_given_delayed
129
+ fire = "pool_fire" if x.phase == "liquid" else ("jet_fire" if x.release_type == "continuous" else "fireball")
130
+ probabilities = {"safe_dispersion": 1 - pi, fire: pi * immediate,
131
+ "vce": pi * (1 - immediate) * vce, "flash_fire": pi * (1 - immediate) * (1 - vce)}
132
+ return {"terminal_probabilities": probabilities, "probability_sum": math.fsum(probabilities.values())}
133
+
134
+
135
+ class Probit(Model):
136
+ intercept: float = Field(allow_inf_nan=False)
137
+ slope: Positive
138
+ concentration_exponent: Positive
139
+ concentration: NonNegative
140
+ exposure_duration: NonNegative
141
+ concentration_unit: str = Field(min_length=1, max_length=50)
142
+ duration_unit: str = Field(min_length=1, max_length=50)
143
+ coefficient_source: str = Field(min_length=1, max_length=500)
144
+
145
+
146
+ @calculation("effects/toxic-probit", "User-parameterized toxic probit", Probit,
147
+ ["Part 3 §5.9.2"], origin="engineering_utility",
148
+ limitations=["Coefficient units must match supplied concentration/time. This generic utility uses caller-qualified coefficients; Table4.14 species calculations are separately available through toxic-species-probit."])
149
+ def toxic_probit(x):
150
+ if x.concentration == 0 or x.exposure_duration == 0:
151
+ return {"probit": None, "effect_probability": 0}
152
+ y = x.intercept + x.slope * (x.concentration_exponent * math.log(x.concentration) + math.log(x.exposure_duration))
153
+ return {"probit": y, "effect_probability": .5 * math.erfc(-(y - 5) / math.sqrt(2))}
154
+
155
+
156
+ class PoolBurning(Model):
157
+ heat_of_combustion_j_kg: Positive
158
+ latent_heat_j_kg: Positive
159
+ boiling: bool
160
+ specific_heat_j_kg_k: Positive | None = None
161
+ boiling_temperature_k: Positive | None = None
162
+ ambient_temperature_k: Positive | None = None
163
+
164
+
165
+ @calculation("effects/pool-burning-rate", "Pool fire surface mass burning rate", PoolBurning,
166
+ ["Part 3 eqs (3.128)-(3.129), Annex 3.B C17; PDF pp550,648"],
167
+ limitations=["Empirical pool burning correlation; LH2 applicability not validated. Does not model unignited pool evaporation."])
168
+ def pool_burning(x):
169
+ denominator = x.latent_heat_j_kg
170
+ if not x.boiling:
171
+ ensure(all(v is not None for v in (x.specific_heat_j_kg_k, x.boiling_temperature_k, x.ambient_temperature_k)),
172
+ "Non-boiling pool requires Cp, boiling and ambient temperatures")
173
+ ensure(x.boiling_temperature_k >= x.ambient_temperature_k, "Non-boiling pool requires boiling temperature >= ambient")
174
+ denominator += x.specific_heat_j_kg_k * (x.boiling_temperature_k - x.ambient_temperature_k)
175
+ return {"burning_rate_kg_m2_s": .001481 * x.heat_of_combustion_j_kg / denominator}
176
+
177
+
178
+ class FireballGeometry(Model):
179
+ flammable_mass_kg: NonNegative
180
+
181
+
182
+ @calculation("effects/fireball-geometry", "Fireball diameter, height and duration", FireballGeometry,
183
+ ["Part 3 eqs (3.158)-(3.161), Annex 3.B C22-C24; PDF pp555,648"],
184
+ limitations=["Instantaneous rupture fireball model; no continuous-release fireball. Empirical exponents follow printed 0.333/0.167."])
185
+ def fireball_geometry(x):
186
+ mass = x.flammable_mass_kg
187
+ diameter = 5.8 * mass ** .333
188
+ duration = .45 * mass ** .333 if mass <= 29937 else 2.6 * mass ** .167
189
+ return {"diameter_m": diameter, "center_height_m": .75 * diameter, "duration_s": duration}
190
+
191
+
192
+ class FireballFraction(Model):
193
+ burst_pressure_pa_abs: Positive
194
+
195
+
196
+ @calculation("effects/fireball-radiative-fraction", "Fireball pressure-based radiative fraction", FireballFraction,
197
+ ["Part 3 eq (3.163), Annex 3.B C25; PDF pp555,648"],
198
+ limitations=["Raw correlation; results above 1 are rejected rather than silently capped. Applicability must be independently established."])
199
+ def fireball_fraction(x):
200
+ fraction = .0296 * (x.burst_pressure_pa_abs / 1000) ** .32
201
+ ensure(fraction <= 1, "Pressure correlation exceeds a physical radiative fraction of 1")
202
+ return {"radiative_fraction": fraction}
203
+
204
+
205
+ class FireballEmissive(Model):
206
+ flammable_mass_kg: NonNegative
207
+ heat_of_combustion_j_kg: Positive
208
+ radiative_fraction: Fraction
209
+ diameter_m: Positive
210
+ duration_s: Positive
211
+
212
+
213
+ @calculation("effects/fireball-emissive-power", "Fireball surface emissive power", FireballEmissive,
214
+ ["Part 3 eq (3.162); PDF p555"])
215
+ def fireball_emissive(x):
216
+ return {"surface_emissive_power_w_m2": x.radiative_fraction * x.flammable_mass_kg * x.heat_of_combustion_j_kg /
217
+ (math.pi * x.diameter_m ** 2 * x.duration_s)}
218
+
219
+
220
+ class TntEquivalence(Model):
221
+ flammable_cloud_mass_kg: NonNegative
222
+ combustion_heat_j_kg: Positive
223
+ explosion_yield: Fraction
224
+ tnt_heat_j_kg: Positive
225
+ tnt_heat_source: str = Field(min_length=1, max_length=500)
226
+
227
+
228
+ @calculation("effects/vce-tnt-equivalence", "VCE TNT equivalent mass", TntEquivalence,
229
+ ["Part 3 eq (3.169); PDF pp557-558"],
230
+ limitations=["TNT heat is mandatory: original text J/kg and Btu/lb values are inconsistent. Cloud mass definition and yield must be consistent. Blast pressure and distances require separate caller-qualified fit-domain inputs; equivalence alone does not establish blast-curve applicability."])
231
+ def tnt_equivalence(x):
232
+ return {"tnt_equivalent_mass_kg": x.explosion_yield * x.flammable_cloud_mass_kg * x.combustion_heat_j_kg / x.tnt_heat_j_kg}
api581/evaporation.py ADDED
@@ -0,0 +1,181 @@
1
+ """Unignited boiling/non-boiling pool vapor sources, with steady-state gates."""
2
+ import math
3
+ from typing import Literal
4
+ from .core import Model, NonNegative, Positive, calculation, ensure
5
+
6
+ _SURFACES={"concrete":(.92,4.16e-7,1),"soil_average":(.96,4.59e-7,3),
7
+ "soil_sandy_dry":(.26,1.98e-7,3),"soil_sandy_moist_8pct":(.59,3.36e-7,3)}
8
+
9
+
10
+ class SurfaceLookup(Model):
11
+ surface: Literal["concrete","soil_average","soil_sandy_dry","soil_sandy_moist_8pct"]
12
+
13
+
14
+ class SurfaceInteraction(Model):
15
+ thermal_conductivity_w_m_k: Positive
16
+ thermal_diffusivity_m2_s: Positive
17
+ roughness_factor: Positive
18
+
19
+
20
+ @calculation("properties/pool-surface-interaction", "SI liquid-pool ground interaction properties", SurfaceLookup,
21
+ ["Part3 Table5.2M; PDF p582"],
22
+ limitations=["Printed SI table values directly; no exact back-conversion of rounded US table. Historical representative ground properties, not measured site or cryogenic validation. Concrete is source default but public selection is explicit."])
23
+ def surface_lookup(x):
24
+ k,a,rough=_SURFACES[x.surface]
25
+ return {"thermal_conductivity_w_m_k":k,"thermal_diffusivity_m2_s":a,"roughness_factor":rough}
26
+
27
+
28
+ class PoolTemperatures(Model):
29
+ bubble_temperature_k: Positive
30
+ ground_temperature_k: Positive
31
+
32
+
33
+ @calculation("release/pool-evaporation-regime", "Pool boiling/non-boiling temperature classification", PoolTemperatures,
34
+ ["Part3 §5.7.4.2-5.7.4.3; PDF p544"],
35
+ limitations=["Tb<Tg boiling; Tb>Tg non-boiling. Source gives no equality rule: equal temperatures return boundary_requires_qualification. Bubble temperature and ground temperature are supplied, not calculated with an EOS."])
36
+ def regime(x):
37
+ label="boiling" if x.bubble_temperature_k < x.ground_temperature_k else (
38
+ "non_boiling" if x.bubble_temperature_k > x.ground_temperature_k else "boundary_requires_qualification")
39
+ return {"regime":label}
40
+
41
+
42
+ class BoilingSetup(PoolTemperatures):
43
+ latent_heat_j_kg: Positive
44
+ surface: SurfaceInteraction
45
+ gravity_m_s2: Positive = 9.81
46
+
47
+
48
+ def _boiling_coefficient(x):
49
+ ensure(x.bubble_temperature_k < x.ground_temperature_k,"Boiling model requires Tb<Tg")
50
+ k=x.surface.thermal_conductivity_w_m_k
51
+ a=x.surface.thermal_diffusivity_m2_s
52
+ coefficient=math.pi**1.5*x.surface.roughness_factor*k*(x.ground_temperature_k-x.bubble_temperature_k)/(
53
+ x.latent_heat_j_kg*math.sqrt(math.pi*a))
54
+ ensure(math.isfinite(coefficient) and coefficient > 0,"Boiling coefficient outside finite positive numerical domain","numerical_failure")
55
+ return coefficient
56
+
57
+
58
+ class BoilingEvaporation(BoilingSetup):
59
+ vapor_volumetric_rate_m3_s: NonNegative
60
+ time_to_steady_s: NonNegative
61
+
62
+
63
+ @calculation("release/boiling-pool-evaporation", "Printed boiling-pool evaporation correlation", BoilingEvaporation,
64
+ ["Part3 eq(3.103), Annex3.B C14=1; PDF pp544,647; Vdot/tp nomenclature PDF579"],
65
+ limitations=["Uses pi^1.5, not1.5. Vdot is vapor volume leaving pool surface, not liquid feed volume. Time is time to steady state per source nomenclature; this primitive does not assert an arbitrary transient evolution. No rainout, flash/EOS, inventory, site limit or LH2 validation."])
66
+ def boiling_evaporation(x):
67
+ coefficient=_boiling_coefficient(x)
68
+ rate=coefficient*math.sqrt(2*x.gravity_m_s2*x.vapor_volumetric_rate_m3_s)*x.time_to_steady_s
69
+ ensure(rate > 0 or x.vapor_volumetric_rate_m3_s == 0 or x.time_to_steady_s == 0,
70
+ "Boiling evaporation rate underflow","numerical_failure")
71
+ return {"evaporation_rate_kg_s":rate,"latent_heat_rate_w":rate*x.latent_heat_j_kg}
72
+
73
+
74
+ class BoilingRadius(Model):
75
+ vapor_volumetric_rate_m3_s: NonNegative
76
+ time_to_steady_s: NonNegative
77
+ gravity_m_s2: Positive = 9.81
78
+
79
+
80
+ @calculation("release/boiling-pool-radius", "Boiling-pool spreading radius at steady-state time", BoilingRadius,
81
+ ["Part3 eq(3.104); PDF p544; Vdot/tp nomenclature PDF579"],
82
+ limitations=["r=sqrt(2/3)*(8*g*Vdot/pi)^.25*tp^.75. Vdot is vapor leaving the surface. Unconstrained correlation: no bund/terrain/site-area clipping or inventory mass balance."])
83
+ def boiling_radius(x):
84
+ radius=math.sqrt(2/3)*(8*x.gravity_m_s2*x.vapor_volumetric_rate_m3_s/math.pi)**.25*x.time_to_steady_s**.75
85
+ ensure(radius > 0 or x.vapor_volumetric_rate_m3_s == 0 or x.time_to_steady_s == 0,
86
+ "Boiling pool radius underflow","numerical_failure")
87
+ area=math.pi*radius**2
88
+ ensure(area > 0 or radius == 0,"Boiling pool area underflow","numerical_failure")
89
+ return {"pool_radius_m":radius,"pool_area_m2":area}
90
+
91
+
92
+ class BoilingSteadySource(BoilingSetup):
93
+ liquid_feed_rate_kg_s: NonNegative
94
+ pool_vapor_density_kg_m3: Positive
95
+ supply_duration_s: Positive
96
+ maximum_pool_area_m2: Positive
97
+
98
+
99
+ @calculation("release/boiling-pool-steady-source", "Boiling equilibrium vapor source with duration/site support checks", BoilingSteadySource,
100
+ ["Part3 eqs(3.103)-(3.104), §5.7.4.2; PDF p544; Vdot definition PDF579"],
101
+ origin="api581_steady_pool_with_explicit_support_checks",
102
+ limitations=["At equilibrium evaporation=liquid feed; Vdot=feed/pool-vapor-density, not feed/liquid-density. Solves tp from eq3.103 then radius from3.104. Usable source returned only if feed persists beyond tp and equilibrium area fits supplied site limit. Failed support is not silently clipped or replaced by feed. No growing-pool transient, finite-inventory depletion, drainage or post-release evaporation solver."])
103
+ def boiling_steady_source(x):
104
+ coefficient=_boiling_coefficient(x)
105
+ feed=x.liquid_feed_rate_kg_s
106
+ if feed == 0:
107
+ return {"equilibrium_evaporation_rate_kg_s":0,"vapor_volumetric_rate_m3_s":0,"time_to_steady_s":None,
108
+ "pool_radius_m":0,"pool_area_m2":0,"vapor_source_rate_kg_s":0,"support_status":"no_feed"}
109
+ volume=feed/x.pool_vapor_density_kg_m3
110
+ ensure(math.isfinite(volume) and volume > 0,"Pool vapor volumetric rate outside numerical domain","numerical_failure")
111
+ time=feed/(coefficient*math.sqrt(2*x.gravity_m_s2*volume))
112
+ ensure(math.isfinite(time) and time > 0,"Pool steady-state time outside numerical domain","numerical_failure")
113
+ geometry=boiling_radius(BoilingRadius(vapor_volumetric_rate_m3_s=volume,time_to_steady_s=time,gravity_m_s2=x.gravity_m_s2))
114
+ ensure(math.isfinite(geometry["pool_area_m2"]),"Pool equilibrium area outside numerical domain","numerical_failure")
115
+ duration_limited=time >= x.supply_duration_s
116
+ area_limited=geometry["pool_area_m2"] > x.maximum_pool_area_m2
117
+ status=("duration_and_area_limited" if duration_limited and area_limited else "duration_limited" if duration_limited else (
118
+ "area_limited" if area_limited else "supported"))
119
+ return {"equilibrium_evaporation_rate_kg_s":feed,"vapor_volumetric_rate_m3_s":volume,"time_to_steady_s":time,**geometry,
120
+ "vapor_source_rate_kg_s":feed if status == "supported" else None,"support_status":status}
121
+
122
+
123
+ class NonBoilingSetup(PoolTemperatures):
124
+ bubble_pressure_at_ground_pa_abs: NonNegative
125
+ ambient_pressure_pa_abs: Positive = 101325
126
+ molecular_weight_kg_kmol: Positive
127
+ storage_temperature_k: Positive
128
+ wind_speed_m_s: NonNegative
129
+
130
+
131
+ def _nonboiling_coefficient(x):
132
+ ensure(x.bubble_temperature_k > x.ground_temperature_k,"Non-boiling model requires Tb>Tg")
133
+ ensure(x.bubble_pressure_at_ground_pa_abs < x.ambient_pressure_pa_abs,
134
+ "Non-boiling bubble pressure at ground temperature must be below atmospheric pressure")
135
+ # Printed Pb,g is kPa; R=8314 J/(kmol K), C15=4.685.
136
+ coefficient=4.685*(x.bubble_pressure_at_ground_pa_abs/1000*x.molecular_weight_kg_kmol/(
137
+ 8314*x.storage_temperature_k))*x.wind_speed_m_s**.78
138
+ ensure(math.isfinite(coefficient),"Non-boiling coefficient outside numerical domain","numerical_failure")
139
+ ensure(coefficient > 0 or x.bubble_pressure_at_ground_pa_abs == 0 or x.wind_speed_m_s == 0,
140
+ "Non-boiling coefficient underflow","numerical_failure")
141
+ return coefficient
142
+
143
+
144
+ class NonBoilingEvaporation(NonBoilingSetup):
145
+ pool_radius_m: NonNegative
146
+
147
+
148
+ @calculation("release/nonboiling-pool-evaporation", "Non-boiling pool wind-driven vapor generation", NonBoilingEvaporation,
149
+ ["Part3 eq(3.105), Annex3.B C15; PDF pp544,647; P/R/Ts units PDF578-579"],
150
+ limitations=["Follows printed C15=4.685 with Pb,g in kPa and R=8314; Pa public input divided by1000. Denominator uses printed storage Ts, not automatic substitution of ground temperature. Wind exponent.78 and radius exponent1.89. Wind qualified at6ft above grade. Zero wind gives zero correlation rate, not a model of molecular diffusion/quiescent evaporation. No site/inventory clipping or LH2 validation."])
151
+ def nonboiling_evaporation(x):
152
+ coefficient=_nonboiling_coefficient(x)
153
+ rate=coefficient*x.pool_radius_m**1.89
154
+ ensure(rate > 0 or coefficient == 0 or x.pool_radius_m == 0,"Non-boiling evaporation underflow","numerical_failure")
155
+ return {"evaporation_rate_kg_s":rate}
156
+
157
+
158
+ class NonBoilingSteadySource(NonBoilingSetup):
159
+ liquid_feed_rate_kg_s: NonNegative
160
+ maximum_pool_area_m2: Positive
161
+
162
+
163
+ @calculation("release/nonboiling-pool-steady-source", "Non-boiling equilibrium source radius and site support check", NonBoilingSteadySource,
164
+ ["Part3 eq(3.105), §5.7.4.3; PDF p544"],
165
+ origin="api581_steady_pool_inverse_with_explicit_site_check",
166
+ limitations=["Solves evaporation=feed for radius. Time to equilibrium is not supplied by this equation and is not invented. Positive feed with zero wind/vapor pressure has no finite equilibrium in this correlation. Site limit failures return no usable source; no arbitrary radius clipping. Requires a separately qualified sustained feed/equilibrium assumption, not a transient pool or mass-balance simulation."])
167
+ def nonboiling_steady_source(x):
168
+ coefficient=_nonboiling_coefficient(x)
169
+ feed=x.liquid_feed_rate_kg_s
170
+ if feed == 0:
171
+ return {"equilibrium_evaporation_rate_kg_s":0,"pool_radius_m":0,"pool_area_m2":0,
172
+ "vapor_source_rate_kg_s":0,"support_status":"no_feed"}
173
+ if coefficient == 0:
174
+ return {"equilibrium_evaporation_rate_kg_s":None,"pool_radius_m":None,"pool_area_m2":None,
175
+ "vapor_source_rate_kg_s":None,"support_status":"no_finite_steady_state"}
176
+ radius=math.exp((math.log(feed)-math.log(coefficient))/1.89)
177
+ area=math.pi*radius**2
178
+ ensure(math.isfinite(area) and area > 0 and radius > 0,"Non-boiling equilibrium geometry outside numerical domain","numerical_failure")
179
+ supported=area <= x.maximum_pool_area_m2
180
+ return {"equilibrium_evaporation_rate_kg_s":feed,"pool_radius_m":radius,"pool_area_m2":area,
181
+ "vapor_source_rate_kg_s":feed if supported else None,"support_status":"supported" if supported else "area_limited"}
api581/evidence.py ADDED
@@ -0,0 +1,81 @@
1
+ """Packaged verification evidence accessors for installed consumers."""
2
+ from __future__ import annotations
3
+
4
+ from hashlib import sha256
5
+ from importlib.resources import files
6
+ from typing import Any, TypedDict
7
+ import json
8
+
9
+
10
+ _RESOURCE = "native-engine-evidence.json"
11
+ _SOFTWARE_TEST_RESOURCE = "software-test-evidence.json"
12
+ _REFERENCE_FIXTURE_RESOURCE = "reference-fixture-evidence.json"
13
+
14
+
15
+ class ValidationEvidence(TypedDict):
16
+ """Stable JSON shape linking a calculation to its verification record."""
17
+ manifest: str
18
+ packaged_manifest: str
19
+ verification_scripts: list[str]
20
+
21
+
22
+ def native_engine_evidence() -> dict[str, Any]:
23
+ """Return a fresh copy of the packaged native-engine evidence manifest."""
24
+ resource = files("api581").joinpath("data", _RESOURCE)
25
+ return json.loads(resource.read_text(encoding="utf-8"))
26
+
27
+
28
+ def native_engine_evidence_hash() -> str:
29
+ """Return the SHA-256 of the packaged native-engine evidence manifest."""
30
+ resource = files("api581").joinpath("data", _RESOURCE)
31
+ return sha256(resource.read_bytes()).hexdigest()
32
+
33
+
34
+ def software_test_evidence() -> dict[str, Any]:
35
+ """Return the packaged calculation-to-test-module evidence manifest."""
36
+ resource = files("api581").joinpath("data", _SOFTWARE_TEST_RESOURCE)
37
+ return json.loads(resource.read_text(encoding="utf-8"))
38
+
39
+
40
+ def software_test_evidence_hash() -> str:
41
+ """Return the SHA-256 of the packaged software-test evidence manifest."""
42
+ resource = files("api581").joinpath("data", _SOFTWARE_TEST_RESOURCE)
43
+ return sha256(resource.read_bytes()).hexdigest()
44
+
45
+
46
+ def reference_fixture_evidence() -> dict[str, Any]:
47
+ """Return deterministic hand-calculated reference fixtures."""
48
+ resource = files("api581").joinpath("data", _REFERENCE_FIXTURE_RESOURCE)
49
+ return json.loads(resource.read_text(encoding="utf-8"))
50
+
51
+
52
+ def reference_fixture_evidence_hash() -> str:
53
+ resource = files("api581").joinpath("data", _REFERENCE_FIXTURE_RESOURCE)
54
+ return sha256(resource.read_bytes()).hexdigest()
55
+
56
+
57
+ def calculation_test_modules(identifier: str) -> list[str]:
58
+ """Return direct test-module references for one registered calculation."""
59
+ from .catalog import calculation_evidence
60
+ return list(calculation_evidence(identifier)["software_test_modules"])
61
+
62
+
63
+ def calculation_public_test_modules(identifier: str) -> list[str]:
64
+ """Return test modules that invoke the public ``evaluate`` boundary."""
65
+ from .catalog import calculation_evidence
66
+ return list(calculation_evidence(identifier)["direct_public_test_modules"])
67
+
68
+
69
+ def calculation_validation_evidence(identifier: str) -> list[ValidationEvidence]:
70
+ """Return validation-manifest links for one registered calculation ID."""
71
+ from .catalog import calculation_evidence
72
+ return calculation_evidence(identifier)["validation_evidence"]
73
+
74
+
75
+ __all__ = [
76
+ "ValidationEvidence", "native_engine_evidence", "native_engine_evidence_hash",
77
+ "software_test_evidence", "software_test_evidence_hash", "calculation_test_modules",
78
+ "calculation_public_test_modules",
79
+ "reference_fixture_evidence", "reference_fixture_evidence_hash",
80
+ "calculation_validation_evidence",
81
+ ]
@@ -0,0 +1,148 @@
1
+ """External corrosion/CUI rate, coating and Bayesian damage calculations."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
6
+ from .thinning import StrengthRatio, strength_ratio, Posterior, posterior, ThinningBase, thinning_base
7
+
8
+ Driver=Literal["marine_or_cooling_tower","temperate","arid_dry","severe"]
9
+ DRIVERS=("marine_or_cooling_tower","temperate","arid_dry","severe")
10
+ # SI mm/year. External -8 C severe cell contradicts US table; do not silently repair it.
11
+ EXTERNAL=[(-12,(0,0,0,0)),(-8,(.025,0,0,None)),(6,(.127,.076,.025,.254)),(32,(.127,.076,.025,.254)),
12
+ (71,(.127,.051,.025,.254)),(107,(.025,0,0,.051)),(121,(0,0,0,0))]
13
+ CUI=[(-12,(0,0,0,0)),(-8,(.025,0,0,.076)),(6,(.127,.076,.025,.254)),(32,(.127,.076,.025,.254)),
14
+ (71,(.254,.127,.051,.508)),(107,(.127,.025,.025,.254)),(135,(.051,.025,0,.254)),(162,(.025,0,0,.127)),(176,(0,0,0,0))]
15
+
16
+
17
+ class RateQuery(Model):
18
+ temperature_k: Positive
19
+ driver: Driver
20
+
21
+
22
+ def interpolate_rate(table,x):
23
+ t=x.temperature_k-273.15
24
+ column=DRIVERS.index(x.driver)
25
+ ensure(table[0][0]-1e-9<=t<=table[-1][0]+1e-9,"Temperature outside source table range; no extrapolation")
26
+ for temp,values in table:
27
+ if math.isclose(t,temp,abs_tol=1e-9,rel_tol=0):
28
+ ensure(values[column] is not None,"Conflicting SI/US source cell requires reconciliation", "ambiguous_source")
29
+ return values[column]/1000
30
+ for (a,va),(b,vb) in zip(table,table[1:]):
31
+ if a<t<b:
32
+ ensure(va[column] is not None and vb[column] is not None,"Interpolation touches a conflicting source cell", "ambiguous_source")
33
+ return (va[column]+(vb[column]-va[column])*(t-a)/(b-a))/1000
34
+ ensure(False,"Unresolved table interpolation")
35
+
36
+
37
+ @calculation("damage/external-base-rate", "External corrosion SI base rate", RateQuery,
38
+ ["Part 2 Table 15.2M; PDF p231"], limitations=["Linear interpolation only within -12..121 C. Severe -8 C cell and adjacent interpolation are disabled due to SI/US mismatch."])
39
+ def external_base_rate(x):
40
+ return {"base_corrosion_rate_m_year":interpolate_rate(EXTERNAL,x)}
41
+
42
+
43
+ @calculation("damage/cui-base-rate", "CUI SI base corrosion rate", RateQuery,
44
+ ["Part 2 Table 16.2M; PDF p243"], limitations=["Linear interpolation only within -12..176 C; material/service screening separate."])
45
+ def cui_base_rate(x):
46
+ return {"base_corrosion_rate_m_year":interpolate_rate(CUI,x)}
47
+
48
+
49
+ class ExposurePeriod(Model):
50
+ temperature_k: Positive
51
+ duration_fraction: Fraction
52
+
53
+
54
+ class WeightedRate(Model):
55
+ mechanism: Literal["external","cui"]
56
+ driver: Driver
57
+ periods: list[ExposurePeriod]=Field(min_length=1,max_length=1000)
58
+
59
+
60
+ @calculation("damage/external-time-weighted-rate", "Time-weighted external/CUI base rate", WeightedRate,
61
+ ["Part 2 Tables 15.2M/16.2M note 3"], limitations=["Weights describe operating-time fractions and must sum to one; all period temperatures must lie within the selected source table."])
62
+ def time_weighted_rate(x):
63
+ ensure(math.isclose(math.fsum(p.duration_fraction for p in x.periods),1,abs_tol=1e-9),"Duration fractions must sum to 1")
64
+ table=EXTERNAL if x.mechanism=="external" else CUI
65
+ rates=[interpolate_rate(table,RateQuery(temperature_k=p.temperature_k,driver=x.driver)) for p in x.periods]
66
+ return {"base_corrosion_rate_m_year":math.fsum(p.duration_fraction*r for p,r in zip(x.periods,rates)),"period_rates_m_year":rates}
67
+
68
+
69
+ class RateAdjustment(Model):
70
+ base_corrosion_rate_m_year: NonNegative
71
+ water_retaining_design: bool
72
+ soil_or_water_interface: bool
73
+
74
+
75
+ @calculation("damage/external-rate-adjust", "External corrosion design/interface adjustment", RateAdjustment,
76
+ ["Part 2 eq (2.34); PDF p224"])
77
+ def external_rate_adjust(x):
78
+ factor=2 if x.water_retaining_design or x.soil_or_water_interface else 1
79
+ return {"design_interface_factor":factor,"corrosion_rate_m_year":x.base_corrosion_rate_m_year*factor}
80
+
81
+
82
+ class CuiAdjustment(RateAdjustment):
83
+ insulation: Literal["unknown","foamglass","pearlite","fiberglass","mineral_wool","calcium_silicate","asbestos"]
84
+ complexity: Literal["below_average","average","above_average"]
85
+ insulation_condition: Literal["below_average","average","above_average"]
86
+
87
+
88
+ @calculation("damage/cui-rate-adjust", "CUI insulation/condition/complexity adjustment", CuiAdjustment,
89
+ ["Part 2 eq (2.51), §16.6.3, Table 16.3; PDF pp234-235,244"])
90
+ def cui_rate_adjust(x):
91
+ insulation={"foamglass":.75,"pearlite":1}.get(x.insulation,1.25)
92
+ complexity={"below_average":.75,"average":1,"above_average":1.25}[x.complexity]
93
+ condition={"below_average":1.25,"average":1,"above_average":.75}[x.insulation_condition]
94
+ design=2 if x.water_retaining_design or x.soil_or_water_interface else 1
95
+ return {"insulation_factor":insulation,"complexity_factor":complexity,"condition_factor":condition,"design_interface_factor":design,
96
+ "corrosion_rate_m_year":x.base_corrosion_rate_m_year*insulation*complexity*condition*design}
97
+
98
+
99
+ class CoatingExposure(Model):
100
+ years_since_thickness_reading: NonNegative
101
+ coating_age_years: NonNegative
102
+ coating_quality: Literal["none_or_poor","medium","high"]
103
+
104
+
105
+ @calculation("damage/coating-exposure", "Coating-adjusted corrosion exposure time", CoatingExposure,
106
+ ["Part 2 eqs (2.36)-(2.42),(2.53)-(2.59); PDF pp224,235"])
107
+ def coating_exposure(x):
108
+ window={"none_or_poor":0,"medium":5,"high":15}[x.coating_quality]
109
+ credit=min(window,x.coating_age_years)
110
+ if x.years_since_thickness_reading<x.coating_age_years:
111
+ credit-=min(window,x.coating_age_years-x.years_since_thickness_reading)
112
+ credit=min(x.years_since_thickness_reading,max(0,credit))
113
+ return {"coating_credit_years":credit,"corrosion_exposure_years":x.years_since_thickness_reading-credit}
114
+
115
+
116
+ class ExternalAssessment(Model):
117
+ rate: RateAdjustment
118
+ coating: CoatingExposure
119
+ strength: StrengthRatio
120
+ inspections: Posterior
121
+
122
+
123
+ class CuiAssessment(ExternalAssessment):
124
+ rate: CuiAdjustment
125
+
126
+
127
+ def assess(x):
128
+ rate=cui_rate_adjust(x.rate) if isinstance(x.rate,CuiAdjustment) else external_rate_adjust(x.rate)
129
+ exposure=coating_exposure(x.coating)
130
+ wall_loss=rate["corrosion_rate_m_year"]*exposure["corrosion_exposure_years"]/x.strength.starting_thickness_m
131
+ strength=strength_ratio(x.strength)
132
+ post=posterior(x.inspections)
133
+ base=thinning_base(ThinningBase(wall_loss_fraction=wall_loss,strength_ratio=strength["strength_ratio"],posterior=post["posterior_probabilities"]))
134
+ return {"corrosion_rate_m_year":rate["corrosion_rate_m_year"],**exposure,"wall_loss_fraction":wall_loss,**strength,**post,
135
+ "reliability_indices":base["reliability_indices"],"weighted_failure_probability":base["weighted_failure_probability"],"damage_factor":base["base_damage_factor"]}
136
+
137
+
138
+ @calculation("damage/external-corrosion", "External corrosion Bayesian damage factor", ExternalAssessment,
139
+ ["Part 2 §15.6.3 eqs (2.34)-(2.50)"], limitations=["Internal-pressure-governed strength path only; eligible material/service, base rate, flow stress and inspection validity supplied. No internal-thinning floor/adjustments applied."])
140
+ def external_assessment(x):
141
+ return assess(x)
142
+
143
+
144
+ @calculation("damage/cui", "Corrosion-under-insulation Bayesian damage factor", CuiAssessment,
145
+ ["Part 2 §16.6.3 eqs (2.51)-(2.67)"], limitations=["Internal-pressure-governed strength path (2.63) only; the Min/Max discrepancy in source (2.62) is not inferred. Base rate and inspection validity supplied. No internal-thinning floor/adjustments applied.",
146
+ "Source STEP 13 references Tables 5.6/5.7, which do not identify prior/conditional probability tables. This implementation explicitly adopts general-thinning Tables 4.5/4.6, consistent with external-corrosion methodology; this interpretation requires independent engineering verification."])
147
+ def cui_assessment(x):
148
+ return assess(x)