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
@@ -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}