api581-engine 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api581/__init__.py +80 -0
- api581/_amine_tables.py +105 -0
- api581/_h2sh2_tables.py +161 -0
- api581/_slabx_worker.py +174 -0
- api581/_sulfidic_tables.py +517 -0
- api581/_sulfuric_tables.py +328 -0
- api581/acid_sour_water.py +238 -0
- api581/acscc_screening.py +88 -0
- api581/alkaline_sour_water.py +267 -0
- api581/amine_corrosion.py +277 -0
- api581/amine_screening.py +103 -0
- api581/api/__init__.py +4 -0
- api581/api/api520.py +88 -0
- api581/api/bundle.py +121 -0
- api581/api/consequence.py +102 -0
- api581/api/damage.py +1831 -0
- api581/api/dispersion.py +121 -0
- api581/api/effects.py +318 -0
- api581/api/events.py +96 -0
- api581/api/financial.py +115 -0
- api581/api/frequency.py +40 -0
- api581/api/inspection.py +96 -0
- api581/api/level1.py +238 -0
- api581/api/level2.py +74 -0
- api581/api/planning.py +17 -0
- api581/api/prd.py +190 -0
- api581/api/properties.py +130 -0
- api581/api/release.py +252 -0
- api581/api/reliability.py +40 -0
- api581/api/risk.py +71 -0
- api581/api/tank.py +490 -0
- api581/api520.py +205 -0
- api581/area_contribution.py +72 -0
- api581/blast.py +232 -0
- api581/brittle.py +205 -0
- api581/bundle.py +300 -0
- api581/catalog.py +442 -0
- api581/caustic_screening.py +103 -0
- api581/clad_thinning.py +146 -0
- api581/clscc_screening.py +55 -0
- api581/co2_corrosion.py +313 -0
- api581/co2_screening.py +205 -0
- api581/consequences.py +250 -0
- api581/contracts.py +1038 -0
- api581/cooling_selection.py +204 -0
- api581/cooling_water.py +305 -0
- api581/core.py +126 -0
- api581/corrosion_rate.py +38 -0
- api581/cost_tables.py +175 -0
- api581/cracking.py +74 -0
- api581/data/coverage.json +21361 -0
- api581/data/digital-twin.schema.json +538 -0
- api581/data/direct-test-queue.json +7 -0
- api581/data/native-engine-evidence.json +44 -0
- api581/data/python-api.schema.json +92706 -0
- api581/data/reference-fixture-evidence.json +2956 -0
- api581/data/scope-boundaries.schema.json +58 -0
- api581/data/software-test-evidence.json +4278 -0
- api581/data/standard-metadata.json +1328 -0
- api581/data/standard.schema.json +233 -0
- api581/data/supporting-text-review.json +1319 -0
- api581/digital_twin.py +491 -0
- api581/dispersion.py +45 -0
- api581/effects.py +232 -0
- api581/evaporation.py +181 -0
- api581/evidence.py +81 -0
- api581/external_corrosion.py +148 -0
- api581/external_cracking.py +63 -0
- api581/fatigue.py +56 -0
- api581/fireball.py +163 -0
- api581/flash_pool.py +111 -0
- api581/flash_solver.py +122 -0
- api581/groundwater.py +101 -0
- api581/h2sh2_corrosion.py +128 -0
- api581/hic_screened.py +89 -0
- api581/htha.py +45 -0
- api581/hydrochloric_acid.py +272 -0
- api581/hydrofluoric_acid.py +236 -0
- api581/ignition.py +204 -0
- api581/inspection_effectiveness.py +601 -0
- api581/jet_handoff.py +60 -0
- api581/level1.py +311 -0
- api581/level1_summary.py +35 -0
- api581/level1_toxic.py +313 -0
- api581/level1_type1.py +91 -0
- api581/level2_summary.py +237 -0
- api581/lining.py +173 -0
- api581/native_toxic.py +67 -0
- api581/outputs.py +5672 -0
- api581/oxidation.py +145 -0
- api581/pascc_screening.py +78 -0
- api581/physical_explosion.py +190 -0
- api581/pof.py +207 -0
- api581/pool_fire.py +273 -0
- api581/portfolio.py +68 -0
- api581/prd.py +204 -0
- api581/prd_consequences.py +245 -0
- api581/prd_history.py +44 -0
- api581/py.typed +0 -0
- api581/release.py +307 -0
- api581/representative_fluids.py +172 -0
- api581/result.py +50 -0
- api581/risk.py +191 -0
- api581/saturation.py +66 -0
- api581/scc_mechanisms.py +242 -0
- api581/schema.py +45 -0
- api581/slabx_adapter.py +261 -0
- api581/soil_corrosion.py +191 -0
- api581/source_partition.py +169 -0
- api581/spatial.py +87 -0
- api581/ssc_screening.py +77 -0
- api581/standard.py +281 -0
- api581/storage.py +42 -0
- api581/sulfidic_corrosion.py +197 -0
- api581/sulfuric_acid.py +209 -0
- api581/tank.py +260 -0
- api581/tank_corrosion.py +256 -0
- api581/tank_environment.py +186 -0
- api581/tank_financial.py +202 -0
- api581/tank_integrated.py +51 -0
- api581/tank_shell.py +141 -0
- api581/tank_source.py +152 -0
- api581/tank_thinning.py +204 -0
- api581/temper.py +105 -0
- api581/thinning.py +172 -0
- api581/thinning_conditions.py +212 -0
- api581/thinning_history.py +118 -0
- api581/thinning_selection.py +81 -0
- api581/thinning_type.py +34 -0
- api581/toxic_criteria.py +111 -0
- api581/toxic_level2.py +233 -0
- api581/two_phase_events.py +120 -0
- api581/workflow.py +42 -0
- api581_engine-0.1.4.dist-info/METADATA +419 -0
- api581_engine-0.1.4.dist-info/RECORD +139 -0
- api581_engine-0.1.4.dist-info/WHEEL +5 -0
- api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
- api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
- api581_engine-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Austenitic external and CUI chloride SCC, Part 2 §§17-18."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from .core import Model, NonNegative, calculation, ensure
|
|
5
|
+
from .external_corrosion import RateQuery, DRIVERS, CoatingExposure, coating_exposure
|
|
6
|
+
from .scc_mechanisms import Assessment
|
|
7
|
+
from .pof import InspectionEquivalent, inspection_equivalent
|
|
8
|
+
from .cracking import SccBase, scc_base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SusceptibilityQuery(RateQuery):
|
|
12
|
+
mechanism: Literal["external_clscc","cui_clscc"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@calculation("damage/external-clscc-base-susceptibility", "External/CUI CLSCC climate-temperature base susceptibility", SusceptibilityQuery,
|
|
16
|
+
["Part 2 Tables 17.2,18.2; PDF pp249,255"],
|
|
17
|
+
limitations=["Eligible austenitic material/service required. CUI complexity/condition/chloride adjustments not included. Source rows overlap at 93 C, requiring manual classification."])
|
|
18
|
+
def base_susceptibility(x):
|
|
19
|
+
t=x.temperature_k-273.15
|
|
20
|
+
ensure(not math.isclose(t,93,abs_tol=1e-9,rel_tol=0),"Source temperature rows overlap at 93 C", "ambiguous_source")
|
|
21
|
+
if t<49 or t>149: level="none"
|
|
22
|
+
else:
|
|
23
|
+
column=DRIVERS.index(x.driver)
|
|
24
|
+
if x.mechanism=="external_clscc": rows=[("medium","low","none","high"),("low","low","none","medium")]
|
|
25
|
+
else: rows=[("high","medium","low","high"),("medium","low","none","high")]
|
|
26
|
+
level=rows[t>93][column]
|
|
27
|
+
return {"base_susceptibility":level}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CrackingAssessment(Assessment):
|
|
31
|
+
coating_age_years: NonNegative
|
|
32
|
+
coating_quality: Literal["none_or_poor","medium","high"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assess(x):
|
|
36
|
+
ensure(not x.unrepaired_cracks,"Unrepaired cracking requires FFS evaluation", "ffs_required")
|
|
37
|
+
level="high" if x.confirmed_cracking else x.susceptibility
|
|
38
|
+
svi={"none":0,"low":1,"medium":10,"high":50}[level]
|
|
39
|
+
counts=inspection_equivalent(InspectionEquivalent(mechanism="external_clscc",**x.inspections.model_dump()))
|
|
40
|
+
grade,count="E",0
|
|
41
|
+
for letter in "abcd":
|
|
42
|
+
if counts[letter]: grade,count=letter.upper(),counts[letter]; break
|
|
43
|
+
ensure(count<=6,"Equivalent inspection count exceeds source table support")
|
|
44
|
+
base=scc_base(SccBase(severity_index=svi,inspection_grade=grade,inspection_count=count))["base_damage_factor"]
|
|
45
|
+
exposure=coating_exposure(CoatingExposure(years_since_thickness_reading=x.years_since_qualifying_inspection,
|
|
46
|
+
coating_age_years=x.coating_age_years,coating_quality=x.coating_quality))
|
|
47
|
+
age_factor=max(exposure["corrosion_exposure_years"],1)**1.1
|
|
48
|
+
return {"effective_susceptibility":level,"severity_index":svi,"equivalent_inspection_grade":grade,
|
|
49
|
+
"equivalent_inspection_count":count,"base_damage_factor":base,"age_factor":age_factor,
|
|
50
|
+
"coating_credit_years":exposure["coating_credit_years"],"cracking_exposure_years":exposure["corrosion_exposure_years"],
|
|
51
|
+
"damage_factor":base*age_factor}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@calculation("damage/external-clscc", "External chloride SCC damage factor", CrackingAssessment,
|
|
55
|
+
["Part 2 §17.6.3 eqs (2.68)-(2.76), Table 17.3"], limitations=["Qualified susceptibility and inspection age supplied; eligible material/service screening is separate. External severity scale differs from internal CLSCC."])
|
|
56
|
+
def external_clscc(x):
|
|
57
|
+
return assess(x)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@calculation("damage/cui-clscc", "Chloride SCC under insulation damage factor", CrackingAssessment,
|
|
61
|
+
["Part 2 §18.6.3 eqs (2.77)-(2.85), Table 17.3"], limitations=["Caller supplies susceptibility AFTER complexity, insulation-condition and chloride adjustments. Eligible material/service screening remains separate."])
|
|
62
|
+
def cui_clscc(x):
|
|
63
|
+
return assess(x)
|
api581/fatigue.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Mechanical fatigue of piping, Part 2 §24; category choices are explicit."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, calculation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FatigueBase(Model):
|
|
8
|
+
previous_failure_count: int = Field(ge=0,strict=True)
|
|
9
|
+
shaking_severity: Literal["minor","moderate","severe"]
|
|
10
|
+
shaking_duration_band: Literal["less_than_2_weeks","2_to_13_weeks","13_to_52_weeks"]
|
|
11
|
+
cyclic_loading: Literal["reciprocating_machinery","prv_chatter","high_pressure_drop_valve","none"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@calculation("damage/fatigue-base", "Piping mechanical fatigue base factor", FatigueBase,
|
|
15
|
+
["Part 2 §24.6.3 STEP 1-5, eq (2.92); PDF p289"], limitations=["Piping only; caller selects vibration/duration band. Source gives no explicit duration factor above 52 weeks or unique numeric classification at 13 weeks."])
|
|
16
|
+
def fatigue_base(x):
|
|
17
|
+
previous=1 if x.previous_failure_count==0 else (50 if x.previous_failure_count==1 else 500)
|
|
18
|
+
shaking={"minor":1,"moderate":50,"severe":500}[x.shaking_severity]
|
|
19
|
+
duration={"less_than_2_weeks":1,"2_to_13_weeks":.2,"13_to_52_weeks":.02}[x.shaking_duration_band]
|
|
20
|
+
cyclic={"reciprocating_machinery":50,"prv_chatter":25,"high_pressure_drop_valve":10,"none":1}[x.cyclic_loading]
|
|
21
|
+
return {"previous_failure_factor":previous,"shaking_factor":shaking,"shaking_duration_factor":duration,
|
|
22
|
+
"cyclic_loading_factor":cyclic,"base_damage_factor":max(previous,shaking*duration,cyclic)}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FatigueAdjustments(Model):
|
|
26
|
+
corrective_action: Literal["complete_engineering_analysis","experience_based_modification","none"]
|
|
27
|
+
total_pipe_fittings: int = Field(ge=0,strict=True)
|
|
28
|
+
damaged_or_improper_supports: bool
|
|
29
|
+
broken_or_directly_welded_gussets: bool
|
|
30
|
+
branch_design: Literal["threaded_socketweld_saddle_on","saddle_in","tee_or_weldolet","sweepolet"]
|
|
31
|
+
any_branch_above_nps_2: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@calculation("damage/fatigue-adjustments", "Piping mechanical fatigue adjustment factors", FatigueAdjustments,
|
|
35
|
+
["Part 2 §24.6.3 STEP 6, eq (2.93); PDF p290"])
|
|
36
|
+
def fatigue_adjustments(x):
|
|
37
|
+
factors={"corrective_action_factor":{"complete_engineering_analysis":.002,"experience_based_modification":.2,"none":2}[x.corrective_action],
|
|
38
|
+
"complexity_factor":.5 if x.total_pipe_fittings<=5 else (1 if x.total_pipe_fittings<=10 else 2),
|
|
39
|
+
"pipe_condition_factor":2 if x.damaged_or_improper_supports or x.broken_or_directly_welded_gussets else 1,
|
|
40
|
+
"joint_branch_factor":{"threaded_socketweld_saddle_on":2,"saddle_in":1,"tee_or_weldolet":.2,"sweepolet":.02}[x.branch_design],
|
|
41
|
+
"branch_diameter_factor":.02 if x.any_branch_above_nps_2 else 1}
|
|
42
|
+
import math
|
|
43
|
+
return {**factors,"combined_adjustment_factor":math.prod(factors.values())}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FatigueAssessment(Model):
|
|
47
|
+
base: FatigueBase
|
|
48
|
+
adjustments: FatigueAdjustments
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@calculation("damage/mechanical-fatigue", "Piping mechanical fatigue damage factor", FatigueAssessment,
|
|
52
|
+
["Part 2 eqs (2.92)-(2.93)"], limitations=["Piping only; qualitative categories supplied by caller. Not a stress-range/cycle-count fatigue life or FFS calculation."])
|
|
53
|
+
def mechanical_fatigue(x):
|
|
54
|
+
base=fatigue_base(x.base)
|
|
55
|
+
factors=fatigue_adjustments(x.adjustments)
|
|
56
|
+
return {**base,**factors,"damage_factor":base["base_damage_factor"]*factors["combined_adjustment_factor"]}
|
api581/fireball.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Fireball radiation with explicit printed versus elevated-sphere geometry."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
|
|
6
|
+
from .effects import (FireballGeometry, fireball_geometry, FireballFraction,
|
|
7
|
+
fireball_fraction, FireballEmissive, fireball_emissive)
|
|
8
|
+
from .pool_fire import Atmosphere, _atmosphere
|
|
9
|
+
|
|
10
|
+
GeometryBasis = Literal["api581_printed", "elevated_sphere_maximum"]
|
|
11
|
+
_GEOMETRY_LIMIT = (
|
|
12
|
+
"Explicit basis required: api581_printed follows eqs(3.165)-(3.166), "
|
|
13
|
+
"C=.5*hypot(D,xs), F=D^2/(D^2+xs^2), without using H=.75D. "
|
|
14
|
+
"elevated_sphere_maximum is a separately identified geometric interpretation: "
|
|
15
|
+
"grade receiver at horizontal radius xs, sphere center H=.75D, receiver plane "
|
|
16
|
+
"normal facing the center, F=(D/(2*hypot(H,xs)))^2. It is not an authorized erratum."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FireballMass(Model):
|
|
21
|
+
available_mass_kg: NonNegative
|
|
22
|
+
flammable_mass_fraction: Fraction
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@calculation("effects/fireball-mass", "Flammable mass available to a rupture fireball", FireballMass,
|
|
26
|
+
["Part3 eq(3.157); PDF p555"],
|
|
27
|
+
limitations=["Mass fraction of available release inventory, not mole fraction or dispersion cloud mass. Instantaneous rupture applicability must be established by caller."])
|
|
28
|
+
def mass(x):
|
|
29
|
+
return {"flammable_mass_kg":x.available_mass_kg*x.flammable_mass_fraction}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SphereView(Model):
|
|
33
|
+
diameter_m: Positive
|
|
34
|
+
distance_m: NonNegative
|
|
35
|
+
geometry_basis: GeometryBasis
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@calculation("effects/fireball-view-factor", "Fireball spherical radiation view factor", SphereView,
|
|
39
|
+
["Part3 eqs(3.159),(3.165)-(3.166); PDF pp555-556"],
|
|
40
|
+
origin="api581_with_explicit_fireball_geometry_basis", limitations=[_GEOMETRY_LIMIT])
|
|
41
|
+
def view(x):
|
|
42
|
+
if x.geometry_basis == "api581_printed":
|
|
43
|
+
center=.5*math.hypot(x.diameter_m,x.distance_m)
|
|
44
|
+
# The printed xs is used as the attenuation path, as stated below eq(3.166).
|
|
45
|
+
path=x.distance_m
|
|
46
|
+
else:
|
|
47
|
+
center=math.hypot(.75*x.diameter_m,x.distance_m)
|
|
48
|
+
path=center-.5*x.diameter_m
|
|
49
|
+
ensure(math.isfinite(center) and center > 0,"Fireball center distance outside numerical domain","numerical_failure")
|
|
50
|
+
factor=(.5*x.diameter_m/center)**2
|
|
51
|
+
ensure(factor > 0,"Fireball view factor underflow","numerical_failure")
|
|
52
|
+
return {"view_factor":factor,"model_center_distance_m":center,
|
|
53
|
+
"attenuation_path_m":path,"geometry_basis":x.geometry_basis}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RadiationSetup(Model):
|
|
57
|
+
diameter_m: Positive
|
|
58
|
+
surface_emissive_power_w_m2: NonNegative
|
|
59
|
+
atmosphere: Atmosphere
|
|
60
|
+
geometry_basis: GeometryBasis
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FireballFlux(RadiationSetup):
|
|
64
|
+
distance_m: NonNegative
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tau(atmosphere,path):
|
|
68
|
+
# Validate the chosen atmospheric parameter even at zero distance/zero energy.
|
|
69
|
+
selected=_atmosphere(atmosphere,path if path > 0 else 1)
|
|
70
|
+
if path == 0 and atmosphere.transmissivity_model == "api_water_vapor":
|
|
71
|
+
return 1.0 # Physical cap of the zero-path limiting value.
|
|
72
|
+
return selected
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@calculation("effects/fireball-radiation-flux", "Fireball received thermal radiation", FireballFlux,
|
|
76
|
+
["Part3 eqs(3.140),(3.164)-(3.166); PDF pp552,555-556"],
|
|
77
|
+
origin="api581_with_explicit_fireball_geometry_basis",
|
|
78
|
+
limitations=[_GEOMETRY_LIMIT,
|
|
79
|
+
"Printed basis uses nominal xs for atmospheric attenuation. Elevated sphere uses center-to-receiver distance minus radius. Zero printed path uses capped limiting transmissivity1. No flame contact or LH2-specific validation."])
|
|
80
|
+
def flux(x):
|
|
81
|
+
geometry=view(SphereView(diameter_m=x.diameter_m,distance_m=x.distance_m,geometry_basis=x.geometry_basis))
|
|
82
|
+
tau=_tau(x.atmosphere,geometry["attenuation_path_m"])
|
|
83
|
+
return {**geometry,"transmissivity":tau,
|
|
84
|
+
"received_flux_w_m2":tau*x.surface_emissive_power_w_m2*geometry["view_factor"]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FireballDistance(RadiationSetup):
|
|
88
|
+
threshold_w_m2: Positive
|
|
89
|
+
maximum_distance_m: Positive
|
|
90
|
+
distance_tolerance_m: Positive = 1e-5
|
|
91
|
+
maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@calculation("effects/fireball-distance", "Fireball thermal threshold distance and area", FireballDistance,
|
|
95
|
+
["Part3 eqs(3.164)-(3.168); PDF pp555-556"],
|
|
96
|
+
origin="api581_threshold_iteration_with_explicit_fireball_geometry_basis",
|
|
97
|
+
limitations=[_GEOMETRY_LIMIT,
|
|
98
|
+
"Monotone spherical flux solution within caller maximum distance; no automatic bracket expansion. Returns safe upper bracket, with error bounded by distance_tolerance_m. Area=pi*distance^2 per printed equations; elevated-sphere distance is horizontal radius. If origin is below threshold returns zero area and no claimed threshold root."])
|
|
99
|
+
def distance(x):
|
|
100
|
+
setup=x.model_dump(include=set(RadiationSetup.model_fields))
|
|
101
|
+
def received(at):
|
|
102
|
+
return flux(FireballFlux(**setup,distance_m=at))["received_flux_w_m2"]
|
|
103
|
+
low,high=0.0,x.maximum_distance_m
|
|
104
|
+
at_origin=received(low)
|
|
105
|
+
if at_origin <= x.threshold_w_m2:
|
|
106
|
+
return {"safe_distance_m":0,"consequence_area_m2":0,"status":"below_limit_at_origin",
|
|
107
|
+
"iterations":0,"flux_residual_w_m2":None,"geometry_basis":x.geometry_basis}
|
|
108
|
+
ensure(received(high) <= x.threshold_w_m2,"Fireball threshold not bracketed by supplied maximum distance","unbracketed_threshold")
|
|
109
|
+
iterations=0
|
|
110
|
+
while high-low > x.distance_tolerance_m and iterations < x.maximum_iterations:
|
|
111
|
+
mid=low+(high-low)/2
|
|
112
|
+
ensure(low < mid < high,"Fireball distance iteration reached floating point resolution","numerical_failure")
|
|
113
|
+
if received(mid) > x.threshold_w_m2:
|
|
114
|
+
low=mid
|
|
115
|
+
else:
|
|
116
|
+
high=mid
|
|
117
|
+
iterations+=1
|
|
118
|
+
ensure(high-low <= x.distance_tolerance_m,"Fireball distance did not converge","nonconvergent_threshold")
|
|
119
|
+
return {"safe_distance_m":high,"consequence_area_m2":math.pi*high**2,"status":"threshold_solved",
|
|
120
|
+
"iterations":iterations,"flux_residual_w_m2":received(high)-x.threshold_w_m2,"geometry_basis":x.geometry_basis}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class FireballConsequence(FireballMass):
|
|
124
|
+
heat_of_combustion_j_kg: Positive
|
|
125
|
+
burst_pressure_pa_abs: Positive
|
|
126
|
+
atmosphere: Atmosphere
|
|
127
|
+
geometry_basis: GeometryBasis
|
|
128
|
+
maximum_distance_m: Positive
|
|
129
|
+
personnel_threshold_w_m2: Positive = 12600
|
|
130
|
+
equipment_threshold_w_m2: Positive = 37800
|
|
131
|
+
distance_tolerance_m: Positive = 1e-5
|
|
132
|
+
maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@calculation("effects/fireball-consequence", "Rupture fireball radiation consequence chain", FireballConsequence,
|
|
136
|
+
["Part3 eqs(3.157)-(3.168); PDF pp555-556"],
|
|
137
|
+
origin="api581_composition_with_explicit_fireball_geometry_basis",
|
|
138
|
+
limitations=[_GEOMETRY_LIMIT,
|
|
139
|
+
"Instantaneous rupture only. Caller qualifies inventory, combustion heat, and burst pressure: normal operating pressure for normal rupture or likely elevated pressure for PRD failure. Pressure correlation giving radiative fraction above1 is rejected. Constant or water-vapor atmospheric model is explicit. No ignition probability weighting, duration-dose injury model, or LH2 validation."])
|
|
140
|
+
def consequence(x):
|
|
141
|
+
_tau(x.atmosphere,1)
|
|
142
|
+
flammable=mass(FireballMass(available_mass_kg=x.available_mass_kg,flammable_mass_fraction=x.flammable_mass_fraction))["flammable_mass_kg"]
|
|
143
|
+
beta=fireball_fraction(FireballFraction(burst_pressure_pa_abs=x.burst_pressure_pa_abs))["radiative_fraction"]
|
|
144
|
+
geometry=fireball_geometry(FireballGeometry(flammable_mass_kg=flammable))
|
|
145
|
+
base={"flammable_mass_kg":flammable,**geometry,"radiative_fraction":beta,"geometry_basis":x.geometry_basis}
|
|
146
|
+
if flammable == 0:
|
|
147
|
+
return {**base,"surface_emissive_power_w_m2":0,"personnel_area_m2":0,"equipment_area_m2":0,
|
|
148
|
+
"personnel_safe_distance_m":0,"equipment_safe_distance_m":0,
|
|
149
|
+
"personnel_status":"no_flammable_mass","equipment_status":"no_flammable_mass"}
|
|
150
|
+
ensure(geometry["diameter_m"] > 0 and geometry["duration_s"] > 0,
|
|
151
|
+
"Fireball geometry underflow","numerical_failure")
|
|
152
|
+
emissive=fireball_emissive(FireballEmissive(flammable_mass_kg=flammable,heat_of_combustion_j_kg=x.heat_of_combustion_j_kg,
|
|
153
|
+
radiative_fraction=beta,diameter_m=geometry["diameter_m"],duration_s=geometry["duration_s"]))["surface_emissive_power_w_m2"]
|
|
154
|
+
ensure(math.isfinite(emissive),"Fireball emissive power exceeds numerical domain","numerical_failure")
|
|
155
|
+
setup={"diameter_m":geometry["diameter_m"],"surface_emissive_power_w_m2":emissive,"atmosphere":x.atmosphere,
|
|
156
|
+
"geometry_basis":x.geometry_basis,"maximum_distance_m":x.maximum_distance_m,
|
|
157
|
+
"distance_tolerance_m":x.distance_tolerance_m,"maximum_iterations":x.maximum_iterations}
|
|
158
|
+
personnel=distance(FireballDistance(**setup,threshold_w_m2=x.personnel_threshold_w_m2))
|
|
159
|
+
equipment=distance(FireballDistance(**setup,threshold_w_m2=x.equipment_threshold_w_m2))
|
|
160
|
+
return {**base,"surface_emissive_power_w_m2":emissive,
|
|
161
|
+
"personnel_area_m2":personnel["consequence_area_m2"],"equipment_area_m2":equipment["consequence_area_m2"],
|
|
162
|
+
"personnel_safe_distance_m":personnel["safe_distance_m"],"equipment_safe_distance_m":equipment["safe_distance_m"],
|
|
163
|
+
"personnel_status":personnel["status"],"equipment_status":equipment["status"]}
|
api581/flash_pool.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Qualified EOS -> rainout -> steady boiling pool -> isolated native dispersion."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Positive, NonNegative, calculation, ensure, REGISTRY
|
|
5
|
+
from .flash_solver import PureLiquidFlashPartition, pure_liquid_flash_partition
|
|
6
|
+
from .saturation import SaturationPressure, saturation_pressure
|
|
7
|
+
from .evaporation import SurfaceInteraction, BoilingSteadySource, boiling_steady_source
|
|
8
|
+
from .slabx_adapter import (NativeAtmosphere, NativeSubstance, NativePool, native_pool,
|
|
9
|
+
NativePoolFootprint, native_pool_footprint, NativePoolFlashFire, native_pool_flash_fire)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FlashedBoilingPool(PureLiquidFlashPartition):
|
|
13
|
+
ground_temperature_k: Positive
|
|
14
|
+
surface: SurfaceInteraction
|
|
15
|
+
maximum_pool_area_m2: Positive
|
|
16
|
+
engine: Literal['slabx','slabx-lh2']
|
|
17
|
+
atmosphere: NativeAtmosphere
|
|
18
|
+
receptor_height_m: NonNegative
|
|
19
|
+
averaging_time_s: Positive
|
|
20
|
+
plume_distance_limit_m: Positive = Field(le=1e6)
|
|
21
|
+
puff_time_limit_s: Positive | None = None
|
|
22
|
+
puff_steps: int = Field(default=40,ge=10,le=1000,strict=True)
|
|
23
|
+
threshold_volume_fraction: Positive = Field(le=1)
|
|
24
|
+
threshold_source: str = Field(min_length=1,max_length=1000)
|
|
25
|
+
meander_closure: Literal['ermak','sigma_theta']
|
|
26
|
+
lh2_plume_width_coupling: bool
|
|
27
|
+
native_property_basis: str = Field(min_length=1,max_length=1000)
|
|
28
|
+
timeout_s: Positive = Field(default=120,le=600)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_SOURCE_LIMITATIONS=[
|
|
32
|
+
'Initially liquid pure fluid only, atmospheric final pressure101325Pa, positive adjusted constant rate and released mass. Boiling pool requires Tb<Tground, area support and time-to-steady shorter than mass/rate supply duration. Unsupported/zero rainout source fails explicitly.',
|
|
33
|
+
'Native constant source covers ONLY the steady window after time-to-steady, not startup, growing pool, depletion, post-release evaporation or simultaneous jet. Startup pool-feed mass is explicitly unmodeled; jet masses remain separate. Native time origin is start of steady window; no combined jet/pool footprint or risk is inferred.',
|
|
34
|
+
'Pure-fluid normal-boiling saturated cp, densities and latent heat map to native constant properties only with an explicit qualification basis. No fluid-wide or LH2 EOS/native model validation. Native LH2 premise and censoring still gate usable spatial results. Threshold is caller supplied, not automatically LFL/toxic.']
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@calculation('dispersion/flashed-boiling-pool-centreline','Pure-liquid flash through steady boiling-pool native centreline',FlashedBoilingPool,
|
|
38
|
+
['Part3 §§5.1.3,5.7.2-5.7.4,5.8.5-5.8.6; PDF pp539-544; external SLABx dispersion'],
|
|
39
|
+
origin='qualified_eos_to_steady_pool_native_composition',limitations=[
|
|
40
|
+
*REGISTRY['dispersion/native-pool-centreline'].limitations,*_SOURCE_LIMITATIONS])
|
|
41
|
+
def flashed_boiling_pool(x):
|
|
42
|
+
source,native=_prepare_flashed_boiling_pool(x)
|
|
43
|
+
return {**source,**native_pool(native),'integration_scope':'flashed_pure_liquid_boiling_pool_steady_window_centreline'}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _prepare_flashed_boiling_pool(x):
|
|
47
|
+
ensure(bool(x.native_property_basis.strip()) and bool(x.threshold_source.strip()),'Native property and threshold qualification required')
|
|
48
|
+
ensure(x.final_pressure_pa_abs==101325.,'Native pool integration requires atmospheric final pressure101325Pa')
|
|
49
|
+
ensure(x.adjusted_mass_rate_kg_s>0 and x.released_mass_kg>0,'Positive adjusted rate and released mass required','no_source_window')
|
|
50
|
+
partition=pure_liquid_flash_partition(PureLiquidFlashPartition(**x.model_dump(include=set(PureLiquidFlashPartition.model_fields))))
|
|
51
|
+
ensure(partition['pool_liquid_rate_kg_s']>0,'No liquid rainout source for boiling pool','no_pool_feed')
|
|
52
|
+
saturation=saturation_pressure(SaturationPressure(fluid=x.fluid,pressure_pa_abs=x.final_pressure_pa_abs,
|
|
53
|
+
qualification_basis=x.qualification_basis))
|
|
54
|
+
supply_duration=x.released_mass_kg/x.adjusted_mass_rate_kg_s
|
|
55
|
+
pool=boiling_steady_source(BoilingSteadySource(bubble_temperature_k=saturation['bubble_temperature_k'],
|
|
56
|
+
ground_temperature_k=x.ground_temperature_k,latent_heat_j_kg=saturation['latent_heat_j_kg'],surface=x.surface,
|
|
57
|
+
liquid_feed_rate_kg_s=partition['pool_liquid_rate_kg_s'],pool_vapor_density_kg_m3=saturation['vapor_density_kg_m3'],
|
|
58
|
+
supply_duration_s=supply_duration,maximum_pool_area_m2=x.maximum_pool_area_m2))
|
|
59
|
+
ensure(pool['support_status']=='supported','Steady boiling pool unsupported: '+pool['support_status'],'pool_source_unsupported')
|
|
60
|
+
start=pool['time_to_steady_s']
|
|
61
|
+
duration=supply_duration-start
|
|
62
|
+
ensure(x.averaging_time_s<=duration,'Averaging time exceeds supported steady-source window','unsupported_averaging_window')
|
|
63
|
+
substance=NativeSubstance(fluid_id=x.fluid.casefold(),molecular_weight_kg_kmol=saturation['molecular_weight_kg_kmol'],
|
|
64
|
+
vapor_heat_capacity_j_kg_k=saturation['vapor_specific_heat_j_kg_k'],liquid_heat_capacity_j_kg_k=saturation['liquid_specific_heat_j_kg_k'],
|
|
65
|
+
latent_heat_j_kg=saturation['latent_heat_j_kg'],normal_boiling_point_k=saturation['bubble_temperature_k'],
|
|
66
|
+
liquid_density_kg_m3=saturation['liquid_density_kg_m3'],property_source='CoolProp8.0.0 saturated normal-boiling properties; '+x.native_property_basis)
|
|
67
|
+
native_keys=('engine','atmosphere','receptor_height_m','averaging_time_s','plume_distance_limit_m','puff_time_limit_s',
|
|
68
|
+
'puff_steps','threshold_volume_fraction','threshold_source','meander_closure','lh2_plume_width_coupling','timeout_s')
|
|
69
|
+
native=NativePool(**x.model_dump(include=set(native_keys)),substance=substance,
|
|
70
|
+
vapor_source_rate_kg_s=pool['vapor_source_rate_kg_s'],source_area_m2=pool['pool_area_m2'],release_duration_s=duration,
|
|
71
|
+
source_qualification='Steady boiling pool only, excludes startup/jet; '+x.atmospheric_partition_basis)
|
|
72
|
+
source={**partition,**saturation,**pool,'steady_window_start_s':start,'steady_window_duration_s':duration,
|
|
73
|
+
'steady_evaporated_mass_kg':pool['vapor_source_rate_kg_s']*duration,
|
|
74
|
+
'startup_unmodeled_pool_mass_kg':partition['pool_liquid_rate_kg_s']*start}
|
|
75
|
+
return source,native
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FlashedBoilingPoolFootprint(FlashedBoilingPool):
|
|
79
|
+
crosswind_half_span_m: Positive
|
|
80
|
+
crosswind_points_per_side: int = Field(default=65,ge=3,le=501,strict=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@calculation('dispersion/flashed-boiling-pool-footprint','Pure-liquid flash through steady boiling-pool planar footprint',FlashedBoilingPoolFootprint,
|
|
84
|
+
['Part3 §§5.7.2-5.7.4,5.8.5-5.8.6; external native planar concentration field'],
|
|
85
|
+
origin='qualified_eos_to_steady_pool_native_footprint',limitations=[
|
|
86
|
+
*_SOURCE_LIMITATIONS,
|
|
87
|
+
*REGISTRY['dispersion/native-pool-footprint'].limitations])
|
|
88
|
+
def flashed_boiling_pool_footprint(x):
|
|
89
|
+
source,native=_prepare_flashed_boiling_pool(x)
|
|
90
|
+
footprint=native_pool_footprint(NativePoolFootprint(**native.model_dump(),
|
|
91
|
+
crosswind_half_span_m=x.crosswind_half_span_m,crosswind_points_per_side=x.crosswind_points_per_side))
|
|
92
|
+
return {**source,**footprint,'integration_scope':'flashed_pure_liquid_boiling_pool_steady_window_footprint'}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FlashedBoilingPoolFlashFire(FlashedBoilingPoolFootprint):
|
|
96
|
+
criterion_role: Literal['qualified_lfl']
|
|
97
|
+
receptor_height_m: Literal[0.0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@calculation('dispersion/flashed-boiling-pool-flash-fire','Pure-liquid flash through steady boiling-pool flash-fire areas',FlashedBoilingPoolFlashFire,
|
|
101
|
+
['Part3 §§5.7.2-5.7.4,5.8.6,4.8.2.3; native LFL planar field'],
|
|
102
|
+
origin='qualified_eos_to_steady_pool_native_flash_fire',limitations=[
|
|
103
|
+
*REGISTRY['dispersion/flashed-boiling-pool-footprint'].limitations,
|
|
104
|
+
*REGISTRY['dispersion/native-pool-flash-fire'].limitations,
|
|
105
|
+
'Only this selected steady pool vapor scenario. No startup/jet/post-release pool consequence or ignition/GFF weighting. LFL declaration and averaging require owner qualification; no automatic criterion lookup or final risk.'])
|
|
106
|
+
def flashed_boiling_pool_flash_fire(x):
|
|
107
|
+
source,native=_prepare_flashed_boiling_pool(x)
|
|
108
|
+
consequence=native_pool_flash_fire(NativePoolFlashFire(**native.model_dump(),
|
|
109
|
+
crosswind_half_span_m=x.crosswind_half_span_m,crosswind_points_per_side=x.crosswind_points_per_side,
|
|
110
|
+
criterion_role=x.criterion_role))
|
|
111
|
+
return {**source,**consequence,'integration_scope':'flashed_pure_liquid_boiling_pool_steady_window_flash_fire'}
|
api581/flash_solver.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Optional pure-fluid CoolProp flash backend with typed public registry integration."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field, model_validator
|
|
5
|
+
from .core import Model, Positive, NonNegative, Fraction, ensure, calculation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PureFluidFlash(Model):
|
|
9
|
+
fluid: str = Field(pattern=r'^[A-Za-z][A-Za-z0-9_-]{0,79}$')
|
|
10
|
+
storage_pressure_pa_abs: Positive
|
|
11
|
+
storage_temperature_k: Positive | None = None
|
|
12
|
+
storage_vapor_mass_fraction: Fraction | None = None
|
|
13
|
+
final_pressure_pa_abs: Positive
|
|
14
|
+
method: Literal['isentropic','isenthalpic']
|
|
15
|
+
qualification_basis: str = Field(min_length=1)
|
|
16
|
+
|
|
17
|
+
@model_validator(mode='after')
|
|
18
|
+
def storage_state_is_unambiguous(self):
|
|
19
|
+
if (self.storage_temperature_k is None)==(self.storage_vapor_mass_fraction is None):
|
|
20
|
+
raise ValueError('Provide exactly one storage temperature or equilibrium vapor mass fraction')
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@calculation('properties/pure-fluid-flash','Qualified pure-fluid isentropic or isenthalpic flash',PureFluidFlash,
|
|
25
|
+
['Part3 §5.1.3,STEP1.3; PDF pp539-541'],origin='optional_coolprop_8_0_0_pure_fluid_flash',limitations=[
|
|
26
|
+
'Optional CoolProp8.0.0 required. Pure-fluid HEOS expansion only; no mixtures,arbitrary backend strings,supercritical final pressure or equilibrium chemistry. Isentropic source preference and acceptable isenthalpic alternative explicitly selected. Returns final equilibrium vapor mass fraction,not incremental vaporized fraction or API581 rainout. Single-phase Q=-1 is not a physical quality: phase identifies0/1 vapor fraction. Storage uses exactly one of PT (unambiguous single phase) or pressure/equilibrium mass quality (triple-point to subcritical coexistence). Saturated PT ambiguities and invalid EOS states fail explicitly. Signed quality change includes condensation and is not rainout. Pure-propane reference verified; no independent fluid-wide/LH2 validation or hydraulic two-phase flow calculation. Final pressure supplied explicitly; atmospheric applicability owner-qualified.'])
|
|
27
|
+
def solve_pure_fluid_flash(inputs: PureFluidFlash) -> dict:
|
|
28
|
+
"""Internal backend for Part3 §5.1.3; requires optional CoolProp8.0.0."""
|
|
29
|
+
ensure(bool(inputs.qualification_basis.strip()),'Fluid identity and EOS applicability qualification required')
|
|
30
|
+
ensure(inputs.final_pressure_pa_abs < inputs.storage_pressure_pa_abs,'Flash expansion requires final pressure below storage')
|
|
31
|
+
try:
|
|
32
|
+
import CoolProp
|
|
33
|
+
from CoolProp.CoolProp import PropsSI, PhaseSI, get_fluid_param_string
|
|
34
|
+
except ImportError:
|
|
35
|
+
ensure(False,'Pure-fluid flash requires optional CoolProp8.0.0','optional_engine_missing')
|
|
36
|
+
ensure(CoolProp.__version__=='8.0.0','Flash backend requires tested CoolProp8.0.0','engine_version_mismatch')
|
|
37
|
+
fluid=inputs.fluid
|
|
38
|
+
try:
|
|
39
|
+
ensure(get_fluid_param_string(fluid,'pure')=='true',
|
|
40
|
+
'Pseudopure fluids and mixtures are outside pure-fluid flash scope','unsupported_flash_fluid')
|
|
41
|
+
# No arbitrary backend strings or mixtures; fixed pure-fluid HEOS backend.
|
|
42
|
+
critical=PropsSI('pcrit',fluid)
|
|
43
|
+
ensure(inputs.final_pressure_pa_abs < critical,'Final pressure must be below critical pressure')
|
|
44
|
+
key='Smass' if inputs.method=='isentropic' else 'Hmass'
|
|
45
|
+
p=inputs.storage_pressure_pa_abs
|
|
46
|
+
if inputs.storage_vapor_mass_fraction is not None:
|
|
47
|
+
ensure(PropsSI('ptriple',fluid)<=p<critical,
|
|
48
|
+
'Equilibrium storage quality requires triple-point to subcritical pressure','unsupported_storage_phase')
|
|
49
|
+
storage_vapor_fraction=inputs.storage_vapor_mass_fraction
|
|
50
|
+
coordinate,coordinate_value='Q',storage_vapor_fraction
|
|
51
|
+
storage_phase=PhaseSI('P',p,coordinate,coordinate_value,fluid)
|
|
52
|
+
ensure(storage_phase=='twophase','EOS did not return an equilibrium storage state','invalid_engine_result')
|
|
53
|
+
t=PropsSI('T','P',p,coordinate,coordinate_value,fluid)
|
|
54
|
+
state_basis='pressure_quality'
|
|
55
|
+
else:
|
|
56
|
+
t=inputs.storage_temperature_k
|
|
57
|
+
coordinate,coordinate_value='T',t
|
|
58
|
+
storage_phase=PhaseSI('P',p,coordinate,coordinate_value,fluid)
|
|
59
|
+
if storage_phase in ('liquid','supercritical_liquid'):
|
|
60
|
+
storage_vapor_fraction=0.
|
|
61
|
+
elif storage_phase in ('gas','supercritical_gas'):
|
|
62
|
+
storage_vapor_fraction=1.
|
|
63
|
+
else:
|
|
64
|
+
ensure(False,'Storage PT state must be unambiguous single-phase liquid or gas','unsupported_storage_phase')
|
|
65
|
+
state_basis='pressure_temperature'
|
|
66
|
+
value=PropsSI(key,'P',p,coordinate,coordinate_value,fluid)
|
|
67
|
+
final=inputs.final_pressure_pa_abs
|
|
68
|
+
temperature=PropsSI('T','P',final,key,value,fluid)
|
|
69
|
+
phase=PhaseSI('P',final,key,value,fluid)
|
|
70
|
+
quality=PropsSI('Q','P',final,key,value,fluid)
|
|
71
|
+
if phase=='twophase':
|
|
72
|
+
ensure(0<=quality<=1,'EOS returned invalid two-phase mass quality','invalid_engine_result')
|
|
73
|
+
vapor_fraction=quality
|
|
74
|
+
elif phase in ('liquid','supercritical_liquid'):
|
|
75
|
+
vapor_fraction=0.
|
|
76
|
+
elif phase in ('gas','supercritical_gas'):
|
|
77
|
+
vapor_fraction=1.
|
|
78
|
+
else:
|
|
79
|
+
ensure(False,'Unsupported flashed phase: '+phase,'unsupported_flash_phase')
|
|
80
|
+
density=PropsSI('Dmass','P',final,key,value,fluid)
|
|
81
|
+
result=dict(flashed_temperature_k=temperature,vapor_mass_fraction=vapor_fraction,flashed_phase=phase,
|
|
82
|
+
bulk_density_kg_m3=density,conserved_property=key,conserved_value_si=value,engine_version=CoolProp.__version__,
|
|
83
|
+
storage_phase=storage_phase,storage_vapor_mass_fraction=storage_vapor_fraction,
|
|
84
|
+
vapor_mass_fraction_change=vapor_fraction-storage_vapor_fraction,
|
|
85
|
+
storage_temperature_k=t,storage_state_basis=state_basis)
|
|
86
|
+
ensure(all(math.isfinite(result[k]) for k in ('flashed_temperature_k','vapor_mass_fraction','bulk_density_kg_m3','conserved_value_si','storage_temperature_k')),
|
|
87
|
+
'Nonfinite EOS flash result','invalid_engine_result')
|
|
88
|
+
ensure(temperature>0 and density>0 and t>0,'Nonpositive EOS temperature/density','invalid_engine_result')
|
|
89
|
+
return result
|
|
90
|
+
except ValueError as exc:
|
|
91
|
+
from .core import DomainError
|
|
92
|
+
if isinstance(exc,DomainError):
|
|
93
|
+
raise
|
|
94
|
+
ensure(False,'CoolProp could not solve supplied pure-fluid state','flash_solver_failure')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PureLiquidFlashPartition(PureFluidFlash):
|
|
98
|
+
adjusted_mass_rate_kg_s: NonNegative
|
|
99
|
+
released_mass_kg: NonNegative
|
|
100
|
+
equation_basis: Literal['api581_printed','post_flash_mass_balance']
|
|
101
|
+
atmospheric_partition_basis: str = Field(min_length=1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@calculation('release/pure-liquid-flash-partition','Pure-liquid flash with explicit pool/jet composition basis',PureLiquidFlashPartition,
|
|
105
|
+
['Part3 §§5.7.1-5.7.3 eqs(3.98)-(3.102); PDF p543'],
|
|
106
|
+
origin='qualified_pure_liquid_flash_source_partition_composition',limitations=[
|
|
107
|
+
'Optional CoolProp8.0.0; only unambiguous initially liquid pure fluid. Final pressure and atmospheric applicability must be qualified. Kletz rainout correlation is used, not experimentally validated for every fluid. Printed entrainment and conservation interpretation remain explicitly distinct; no silent erratum. Input mass/rate already detection/isolation/inventory adjusted. No hydraulic rate calculation, pool evaporation, mixtures or LH2 validation.'])
|
|
108
|
+
def pure_liquid_flash_partition(inputs: PureLiquidFlashPartition) -> dict:
|
|
109
|
+
from .source_partition import Rainout, rainout, SourcePartition, source_partition
|
|
110
|
+
ensure(bool(inputs.atmospheric_partition_basis.strip()),'Atmospheric partition applicability basis required')
|
|
111
|
+
state=solve_pure_fluid_flash(PureFluidFlash(**inputs.model_dump(include=set(PureFluidFlash.model_fields))))
|
|
112
|
+
ensure(state['storage_vapor_mass_fraction']==0.,
|
|
113
|
+
'Liquid-flash partition requires initially liquid storage; gas condensation is a separate path','unsupported_partition_storage_phase')
|
|
114
|
+
quality=state['vapor_mass_fraction']
|
|
115
|
+
fraction=rainout(Rainout(flash_mass_fraction=quality))['rainout_mass_fraction']
|
|
116
|
+
composition=(dict(stored_liquid_mass_fraction=1.,flash_mass_fraction=quality)
|
|
117
|
+
if inputs.equation_basis=='api581_printed' else dict(post_flash_vapor_mass_fraction=quality))
|
|
118
|
+
partition=source_partition(SourcePartition(adjusted_mass_rate_kg_s=inputs.adjusted_mass_rate_kg_s,
|
|
119
|
+
released_mass_kg=inputs.released_mass_kg,rainout_mass_fraction=fraction,equation_basis=inputs.equation_basis,
|
|
120
|
+
rainout_basis_source='API581 Kletz eqs(3.98)-(3.99); '+inputs.atmospheric_partition_basis,
|
|
121
|
+
flash_property_source='CoolProp8.0.0 '+inputs.method+'; '+inputs.qualification_basis,**composition))
|
|
122
|
+
return {**state,**partition,'rainout_mass_fraction':fraction}
|
api581/groundwater.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Annex2.B §11.3.3 groundwater guidance; no invented TKS/rate correlation."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, calculation, ensure
|
|
6
|
+
from .thinning import WallLoss, Posterior
|
|
7
|
+
from .clad_thinning import PressureStrength, CodeStrength
|
|
8
|
+
from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Units=Literal['printed_si','printed_us_converted']
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GroundwaterChemistry(Model):
|
|
15
|
+
chloride_mg_l: NonNegative
|
|
16
|
+
sulfate_mg_l: NonNegative
|
|
17
|
+
dissolved_co2_mg_l: NonNegative
|
|
18
|
+
bicarbonate_mg_l: NonNegative
|
|
19
|
+
carbonate_mg_l: NonNegative
|
|
20
|
+
sulfide_mg_l: NonNegative
|
|
21
|
+
ammonia_mg_l: NonNegative
|
|
22
|
+
concentration_basis: str = Field(min_length=1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@calculation('damage/groundwater-tks','Groundwater Total Key Species on a qualified common concentration basis',GroundwaterChemistry,
|
|
26
|
+
['Part2 Annex2.B §11.3.3; PDF p405'],limitations=[
|
|
27
|
+
'Sum of seven explicitly supplied species concentrations, public mg/L on a qualified common analytical basis. No missing species assumed zero, molar/mass conversion, speciation or charge balance. Inputs must not double-count total inorganic carbon or report nitrogen/sulfur equivalents without conversion qualification. Source supplies no TKS thresholds or TKS-to-corrosion-rate equation; no automatic corrosivity or corrosion rate inferred.'])
|
|
28
|
+
def groundwater_tks(x):
|
|
29
|
+
ensure(bool(x.concentration_basis.strip()),'Consistent analytical species concentration basis required')
|
|
30
|
+
values=[getattr(x,name) for name in GroundwaterChemistry.model_fields if name!='concentration_basis']
|
|
31
|
+
try:
|
|
32
|
+
total=math.fsum(values)
|
|
33
|
+
except OverflowError:
|
|
34
|
+
ensure(False,'TKS sum exceeds finite numerical domain','numerical_failure')
|
|
35
|
+
return {'total_key_species_mg_l':total}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GroundwaterRate(Model):
|
|
39
|
+
corrosion_rate_m_year: NonNegative
|
|
40
|
+
unit_basis: Units
|
|
41
|
+
shared_boundary_policy: Literal['lower_severity','higher_severity'] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@calculation('damage/groundwater-rate-classification','Groundwater steel corrosivity category from supplied corrosion rate',GroundwaterRate,
|
|
45
|
+
['Part2 Annex2.B §11.3.3(a)-(d); PDF p405'],limitations=[
|
|
46
|
+
'Classifies a supplied steel corrosion rate, not TKS. Printed SI limits.03/.25/1.27mm/year differ from1/10/50mpy converted exactly. Explicit unit basis required. The medium/high shared.25mm/year or10mpy endpoint needs explicit lower/higher severity policy. Low is strictly below first threshold; very high strictly above last. No material-specific rate prediction, treatment credit or degradation immunity.'])
|
|
47
|
+
def groundwater_rate_classification(x):
|
|
48
|
+
first,middle,last=(.00003,.00025,.00127) if x.unit_basis=='printed_si' else (.0000254,.000254,.00127)
|
|
49
|
+
rate=x.corrosion_rate_m_year
|
|
50
|
+
shared=rate==middle
|
|
51
|
+
ensure((x.shared_boundary_policy is not None)==shared,
|
|
52
|
+
'Medium/high shared endpoint requires a boundary policy, unused policy must be omitted','ambiguous_groundwater_boundary')
|
|
53
|
+
category=('low' if rate<first else 'medium' if rate<middle else
|
|
54
|
+
('medium' if x.shared_boundary_policy=='lower_severity' else 'high') if shared else
|
|
55
|
+
'high' if rate<=last else 'very_high')
|
|
56
|
+
bounds={'low':(0.,first,'inclusive','exclusive'),'medium':(first,middle,'inclusive','inclusive'),
|
|
57
|
+
'high':(middle,last,'inclusive','inclusive'),'very_high':(last,None,'exclusive','unbounded')}
|
|
58
|
+
lower,upper,lower_relation,upper_relation=bounds[category]
|
|
59
|
+
return dict(corrosion_rate_m_year=rate,corrosivity_category=category,selected_unit_basis=x.unit_basis,
|
|
60
|
+
category_lower_bound_m_year=lower,category_upper_bound_m_year=upper,
|
|
61
|
+
lower_bound_relation=lower_relation,upper_bound_relation=upper_relation,shared_boundary_encountered=shared)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GroundwaterAssessment(Model):
|
|
65
|
+
chemistry: GroundwaterChemistry
|
|
66
|
+
qualified_rate: GroundwaterRate
|
|
67
|
+
steel_applicability_basis: str = Field(min_length=1)
|
|
68
|
+
specialist_rate_basis: str = Field(min_length=1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@calculation('damage/groundwater-assessment','Qualified groundwater chemistry and externally established steel corrosion rate',GroundwaterAssessment,
|
|
72
|
+
['Part2 Annex2.B §11.3.3; PDF p405'],origin='source_guidance_with_qualified_external_rate',limitations=[
|
|
73
|
+
'Competent water-corrosivity specialist must establish the steel corrosion rate independently. TKS is reported alongside that rate, never used to invent source cutoffs or a rate regression. No groundwater-specific quantitative rate equation is provided by the standard. Chemistry, material and specialist qualifications are supplied, not independently verified.'])
|
|
74
|
+
def groundwater_assessment(x):
|
|
75
|
+
ensure(bool(x.steel_applicability_basis.strip()) and bool(x.specialist_rate_basis.strip()),
|
|
76
|
+
'Steel applicability and competent-specialist rate bases required')
|
|
77
|
+
return {**groundwater_tks(x.chemistry),**groundwater_rate_classification(x.qualified_rate),
|
|
78
|
+
'rate_source':'qualified_external_groundwater_rate','tks_to_rate_model_available':False}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class GroundwaterThinning(Model):
|
|
82
|
+
corrosion: GroundwaterAssessment
|
|
83
|
+
assessment_distribution: Literal['general','local']
|
|
84
|
+
starting_thickness_m: Positive
|
|
85
|
+
elapsed_years: NonNegative
|
|
86
|
+
strength: PressureStrength | CodeStrength = Field(discriminator='method')
|
|
87
|
+
inspections: Posterior
|
|
88
|
+
conditions: ThinningConditions
|
|
89
|
+
qualification_basis: str = Field(min_length=1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@calculation('damage/groundwater-thinning-assessment','Qualified groundwater rate through non-AST unclad thinning',GroundwaterThinning,
|
|
93
|
+
['Part2 Annex2.B §11.3.3; Part2 §4.5.7,Table4.8'],origin='qualified_external_groundwater_rate_to_thinning',limitations=[
|
|
94
|
+
'Same externally qualified groundwater rate drives loss and non-AST unclad thinning; general/local inspection and steel applicability require explicit basis. No automatic pitting multiplier, TKS-derived rate, MIC/deposit exclusions or treatment credit. Monitoring row must be other; no borrowed acid/sour-water credits. Qualified experience replacement remains separately available under Table4.8. No cladding, AST, FFS or all-mechanism assessment.'])
|
|
95
|
+
def groundwater_thinning(x):
|
|
96
|
+
ensure(x.conditions.monitoring.row.mechanism=='other','Groundwater must use other-mechanism monitoring row')
|
|
97
|
+
corrosion=groundwater_assessment(x.corrosion)
|
|
98
|
+
result=qualified_unclad(QualifiedUnclad(assessment_scope='non_ast_unclad',qualification_basis=x.qualification_basis,
|
|
99
|
+
wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion['corrosion_rate_m_year'],elapsed_years=x.elapsed_years),
|
|
100
|
+
strength=x.strength,inspections=x.inspections,conditions=x.conditions))
|
|
101
|
+
return {**corrosion,**result,'assessment_distribution':x.assessment_distribution}
|