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,209 @@
1
+ """2016 sulfuric source tables, explicit header/cell conflicts and thinning."""
2
+ from bisect import bisect_left
3
+ from itertools import product
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
7
+ from ._sulfuric_tables import TABLES
8
+ from .thinning import Posterior, WallLoss
9
+ from .clad_thinning import PressureStrength, CodeStrength
10
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
11
+
12
+ Material=Literal["carbon_steel","ss_304","ss_316","alloy_20","alloy_c276","alloy_b2"]
13
+ Group=Literal["carbon_steel","stainless_steel","higher_alloy"]
14
+ Units=Literal["printed_si","printed_us_converted"]
15
+
16
+
17
+ def _qualified(value,message):
18
+ ensure(bool(value.strip()),message)
19
+
20
+
21
+ def _group(material):
22
+ return "carbon_steel" if material=="carbon_steel" else "stainless_steel" if material.startswith("ss_") else "higher_alloy"
23
+
24
+
25
+ class SulfuricAxes(Model):
26
+ material: Material
27
+ unit_basis: Units
28
+ carbon_si_velocity_policy: Literal["reject_ambiguous_header","qualified_printed_m_s","qualified_us_ft_s_header"]="reject_ambiguous_header"
29
+ carbon_si_velocity_qualification_basis: str | None=None
30
+
31
+
32
+ @calculation("damage/sulfuric-table-axes", "Sulfuric material table concentration temperature and velocity axes", SulfuricAxes,
33
+ ["Part2 Tables2.B.5.2-5.7M; PDF pp365-372"],limitations=[
34
+ "Six named materials only. CS SI header saysm/s but repeats US numericft/s headings0,1,2,3,4.5,6.5,8.5,11.5,12: default reject, qualified literalSI or US-header interpretation explicit. SI/US temperature axes retained literally, includingAlloy20 SI70C versus US163.5F. No automatic temperature/unit repair, extrapolation or cross-material substitution. Axes alone do not establish table applicability."])
35
+ def table_axes(x):
36
+ ambiguous=x.material=="carbon_steel" and x.unit_basis=="printed_si"
37
+ if ambiguous:
38
+ ensure(x.carbon_si_velocity_policy!="reject_ambiguous_header" and x.carbon_si_velocity_qualification_basis is not None and bool(x.carbon_si_velocity_qualification_basis.strip()),
39
+ "Carbon-steel SI velocity header requires explicit qualified interpretation","sulfuric_si_velocity_header")
40
+ else:
41
+ ensure(x.carbon_si_velocity_policy=="reject_ambiguous_header" and x.carbon_si_velocity_qualification_basis is None,"Carbon SI header policy unused for this table")
42
+ data=TABLES[x.material+"_"+x.unit_basis]
43
+ velocity=list(data["velocities_m_s"])
44
+ if ambiguous and x.carbon_si_velocity_policy=="qualified_us_ft_s_header":
45
+ velocity=[v*.3048 for v in velocity]
46
+ return {"acid_mass_fraction_nodes":sorted(data["concentrations"]),"temperature_nodes_k":list(data["temperatures_k"]),
47
+ "velocity_nodes_m_s":velocity,"source_table_id":"2.B.5."+str(("carbon_steel","ss_304","ss_316","alloy_20","alloy_c276","alloy_b2").index(x.material)+2)+("M" if x.unit_basis=="printed_si" else ""),
48
+ "carbon_si_velocity_interpretation":x.carbon_si_velocity_policy if ambiguous else "not_applicable"}
49
+
50
+
51
+ def _bracket(nodes,value):
52
+ value=next((n for n in nodes if abs(n-value)<=1e-10),value)
53
+ ensure(nodes[0]<=value<=nodes[-1],"Outside sulfuric table; extrapolation not permitted")
54
+ i=bisect_left(nodes,value)
55
+ return (i,i,0.) if value==nodes[i] else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
56
+
57
+
58
+ class SulfuricRate(SulfuricAxes):
59
+ acid_mass_fraction: Fraction
60
+ maximum_temperature_k: Positive
61
+ velocity_m_s: NonNegative
62
+ lookup_policy: Literal["exact_nodes_only","trilinear","maximum_bracketing_value"]
63
+ oxidants_present: bool | None=None
64
+ disputed_304_cell_policy: Literal["reject_disputed_cell","qualified_printed_literal"]="reject_disputed_cell"
65
+ disputed_304_qualification_basis: str | None=None
66
+
67
+
68
+ @calculation("damage/sulfuric-table-rate", "Sulfuric concentration temperature velocity material corrosion table", SulfuricRate,
69
+ ["Part2 Annex2.B §5,Tables5.2-5.7M,Figure5.1; PDF pp363-373"],limitations=[
70
+ "1776physical SI/US cells. Rates are source maximum estimates, not certified universal bounds. CS turbulent localized enhancement already included, no added3x multiplier or automatic20..50% actual-rate discount. Literal999mpy/25.37mm/y flagged, not infinity/missing.30475wt%,middleT,highestv prints99mpy/2.51mm/y amid999: default reject exact cell/any bracketing corner; qualified printed literal requires basis and flag, no invented999 correction. B2 requires explicit no-oxidant state; oxidizingB2 rejected to source specialist route. Other tables reject unused oxidant input, no oxidant improvement credit. Trilinear/max-corner are explicit numerical policies, not original interpolation instruction; nonmonotonic concentration cannot be replaced by global monotonic rule."])
71
+ def table_rate(x):
72
+ axes=table_axes(x)
73
+ if x.material=="alloy_b2":
74
+ ensure(x.oxidants_present is not None and not x.oxidants_present,"Oxidizing/unknown B2 sulfuric service requires specialist estimate","sulfuric_b2_oxidizing")
75
+ else:
76
+ ensure(x.oxidants_present is None,"Oxidant input only used for B2 table")
77
+ q=x.disputed_304_cell_policy=="qualified_printed_literal"
78
+ ensure((x.disputed_304_qualification_basis is not None and bool(x.disputed_304_qualification_basis.strip()) and x.material=="ss_304") if q else x.disputed_304_qualification_basis is None,
79
+ "304 disputed cell policy and material/qualification basis must agree")
80
+ data=TABLES[x.material+"_"+x.unit_basis]
81
+ a,b,w=_bracket(axes["acid_mass_fraction_nodes"],x.acid_mass_fraction)
82
+ i,j,v=_bracket(axes["temperature_nodes_k"],x.maximum_temperature_k)
83
+ l,m,u=_bracket(axes["velocity_nodes_m_s"],x.velocity_m_s)
84
+ ensure(x.lookup_policy!="exact_nodes_only" or (a==b and i==j and l==m),"Exact sulfuric policy does not interpolate")
85
+ corners=list(product((a,b),(i,j),(l,m)))
86
+ def rate(c,t,s):
87
+ return data["rates"][len(data["concentrations"])-1-c][t][s]
88
+ disputed=x.material=="ss_304" and any(axes["acid_mass_fraction_nodes"][c]==.75 and t==1 and s==2 for c,t,s in corners)
89
+ ensure(not disputed or q,"Sulfuric304 lookup touches disputed99mpy/2.51mm/y cell","sulfuric_disputed_304_cell")
90
+ if x.lookup_policy=="maximum_bracketing_value":
91
+ native=max(rate(*p) for p in corners)
92
+ else:
93
+ native=sum(wc*wt*wv*rate(c,t,s) for (c,wc),(t,wt),(s,wv) in product(((a,1-w),(b,w)),((i,1-v),(j,v)),((l,1-u),(m,u))))
94
+ return {"corrosion_rate_m_year":native*(.001 if x.unit_basis=="printed_si" else .0000254),"source_table_id":axes["source_table_id"],
95
+ "carbon_si_velocity_interpretation":axes["carbon_si_velocity_interpretation"],"source_999_corner_used":any(rate(*p)==(25.37 if x.unit_basis=="printed_si" else 999) for p in corners),
96
+ "disputed_304_cell_used":disputed,"source_localized_enhancement_already_included":x.material=="carbon_steel" and m>3}
97
+
98
+
99
+ class SulfuricThinningClassification(Model):
100
+ material_group: Group
101
+ velocity_m_s: NonNegative
102
+ threshold_basis: Literal["overview_printed_si","overview_us_converted","detailed_carbon_body_si"]
103
+ equality_policy: Literal["reject_ambiguous_boundary","qualified_general","qualified_local"]
104
+ equality_qualification_basis: str | None=None
105
+
106
+
107
+ @calculation("damage/sulfuric-thinning-classification", "Sulfuric source general/local thinning velocity classification", SulfuricThinningClassification,
108
+ ["Part2 Table2.B.1.2,Annex2.B §5.1; PDF pp323,363"],limitations=[
109
+ "Overview SI thresholdsCS.61/SS1.22/higher1.83m/s versus2/4/6ft/s; detailed CS bodyapprox.91m/s. Explicit basis preserves differences. Overview CS/SS equality overlaps; general equality resolution requires qualified policy, never guessed. Distinct from online-monitoring Table4.8 thresholds3/5/7ft/s and table velocity nodes; do not force all classifications equal. Actual distribution and inspection coverage external."])
110
+ def thinning_classification(x):
111
+ q=x.equality_policy!="reject_ambiguous_boundary"
112
+ ensure((x.equality_qualification_basis is not None and bool(x.equality_qualification_basis.strip())) if q else x.equality_qualification_basis is None,"Equality policy and basis must agree")
113
+ if x.threshold_basis=="detailed_carbon_body_si":
114
+ ensure(x.material_group=="carbon_steel","Detailed carbon-body threshold only applies to carbon steel")
115
+ threshold=.91
116
+ else:
117
+ index=("carbon_steel","stainless_steel","higher_alloy").index(x.material_group)
118
+ threshold=(.61,1.22,1.83)[index] if x.threshold_basis=="overview_printed_si" else (2,4,6)[index]*.3048
119
+ equal=x.velocity_m_s==threshold
120
+ if equal:
121
+ ensure(q,"Sulfuric exact velocity boundary needs qualified general/local resolution","sulfuric_thinning_boundary")
122
+ kind="general" if x.equality_policy=="qualified_general" else "local"
123
+ else:
124
+ kind="general" if x.velocity_m_s<threshold else "local"
125
+ return {"source_thinning_type":kind,"thinning_velocity_threshold_m_s":threshold,"thinning_velocity_boundary_resolved":equal}
126
+
127
+
128
+ class SulfuricTableService(Model):
129
+ method: Literal["source_table"]
130
+ table: SulfuricRate
131
+ table_conditions_and_passivity_qualified: bool
132
+ qualification_basis: str = Field(min_length=1)
133
+
134
+
135
+ class SulfuricSpecialistRate(Model):
136
+ method: Literal["qualified_specialist_rate"]
137
+ material_description: str = Field(min_length=1)
138
+ material_group: Group
139
+ acid_mass_fraction: Fraction
140
+ maximum_temperature_k: Positive
141
+ velocity_m_s: NonNegative
142
+ oxidants_present: bool
143
+ corrosion_rate_m_year: NonNegative
144
+ source_reference: str = Field(min_length=1)
145
+ qualification_basis: str = Field(min_length=1)
146
+
147
+
148
+ class SulfuricAssessment(Model):
149
+ sulfuric_service_present: bool
150
+ applicability_basis: str = Field(min_length=1)
151
+ rate_model: SulfuricTableService | SulfuricSpecialistRate | None=Field(default=None,discriminator="method")
152
+ classification: SulfuricThinningClassification | None=None
153
+
154
+
155
+ @calculation("damage/sulfuric-corrosion-assessment", "Qualified sulfuric material rate and thinning classification assessment", SulfuricAssessment,
156
+ ["Part2 Annex2.B §5,Tables5.1-5.7M,Figure5.1,Table1.2; PDF pp323,363-373"],origin="api581_composition_with_explicit_source_policies",limitations=[
157
+ "Same velocity/material group must drive supplied classification and selected rate. Table conditions including concentration,maximum local/nozzle conditions,passivity and impurity applicability owner-qualified. No-service0 is sulfuric-specific, not based on free-water absence;100wt% sulfuric table remains valid. OxidizingB2 uses explicit qualified specialist rate/provenance, not fictitious oxidant multiplier or source table prediction. Specialist route may cover other qualified material/conditions. Source maximum estimates retained without extra3x or automatic actual-rate discount. General/local thresholds and online-monitoring thresholds intentionally distinct; no all-mechanismDF/FFS or corrosion equilibrium inferred."])
158
+ def assessment(x):
159
+ _qualified(x.applicability_basis,"Sulfuric service and concentration applicability qualification required")
160
+ if not x.sulfuric_service_present:
161
+ ensure(x.rate_model is None and x.classification is None,"No-sulfuric branch cannot carry unused rate/classification")
162
+ return {"corrosion_rate_m_year":0.,"source_table_id":None,"carbon_si_velocity_interpretation":"not_applicable","source_999_corner_used":False,
163
+ "disputed_304_cell_used":False,"source_localized_enhancement_already_included":False,"source_thinning_type":None,"thinning_velocity_threshold_m_s":None,
164
+ "thinning_velocity_boundary_resolved":False,"rate_source":None,"calculation_branch":"no_sulfuric_service"}
165
+ ensure(x.rate_model is not None and x.classification is not None,"Sulfuric service requires rate model and classification")
166
+ m=x.rate_model
167
+ _qualified(m.qualification_basis,"Actual material/conditions and selected rate qualification required")
168
+ if m.method=="source_table":
169
+ ensure(m.table_conditions_and_passivity_qualified,"Source table conditions/passivity must be qualified")
170
+ velocity=m.table.velocity_m_s; group=_group(m.table.material)
171
+ result=table_rate(m.table)
172
+ else:
173
+ _qualified(m.material_description,"Specialist material identification required")
174
+ _qualified(m.source_reference,"Specialist rate source reference required")
175
+ ensure(m.acid_mass_fraction>0,"Exposed sulfuric service requires positive acid concentration")
176
+ velocity=m.velocity_m_s; group=m.material_group
177
+ result={"corrosion_rate_m_year":m.corrosion_rate_m_year,"source_table_id":None,"carbon_si_velocity_interpretation":"not_applicable",
178
+ "source_999_corner_used":False,"disputed_304_cell_used":False,"source_localized_enhancement_already_included":False}
179
+ ensure(x.classification.velocity_m_s==velocity and x.classification.material_group==group,"Rate and thinning classification must use same velocity/material group")
180
+ return {**result,**thinning_classification(x.classification),"rate_source":"source_maximum_estimate" if m.method=="source_table" else "qualified_specialist_estimate","calculation_branch":"qualified_sulfuric_service"}
181
+
182
+
183
+ class SulfuricThinning(Model):
184
+ corrosion: SulfuricAssessment
185
+ assessment_distribution: Literal["general","local"]
186
+ distribution_qualification_basis: str = Field(min_length=1)
187
+ starting_thickness_m: Positive
188
+ elapsed_years: NonNegative
189
+ strength: PressureStrength | CodeStrength=Field(discriminator="method")
190
+ inspections: Posterior
191
+ conditions: ThinningConditions
192
+ qualification_basis: str = Field(min_length=1)
193
+
194
+
195
+ @calculation("damage/sulfuric-thinning-assessment", "Qualified sulfuric corrosion through non-AST unclad thinning DF", SulfuricThinning,
196
+ ["Part2 Annex2.B §5,Table1.2; §4.5.7,Table4.8"],origin="api581_composition_with_explicit_source_policies",limitations=[
197
+ "Same final rate drives wall loss; same physical velocity/material group must be used for sulfuric monitoring and source classification, but their threshold values differ. Source general/local and actual owner-qualified inspection distribution retained separately. No extra localized3x multiplication or rate discount. No-service0 not DF0; uncertainty/floor retained. Non-AST unclad only, no source condition certification,all-mechanismDF or code/FFS acceptance."])
198
+ def thinning_assessment(x):
199
+ _qualified(x.distribution_qualification_basis,"Actual inspection coverage and general/local distribution qualification required")
200
+ corrosion=assessment(x.corrosion)
201
+ row=x.conditions.monitoring.row
202
+ ensure(row.mechanism=="sulfuric_acid","Sulfuric corrosion requires sulfuric monitoring row")
203
+ if x.corrosion.classification is not None:
204
+ ensure(row.velocity_m_s==x.corrosion.classification.velocity_m_s and row.sulfuric_material_group==x.corrosion.classification.material_group,
205
+ "Monitoring and corrosion must use same physical velocity/material group")
206
+ result=qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
207
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
208
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
209
+ return {**corrosion,**result,"assessment_distribution":x.assessment_distribution}
api581/tank.py ADDED
@@ -0,0 +1,260 @@
1
+ """AST source, inventory and seepage calculations with printed native units."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+
7
+ Fluid=Literal["gasoline","light_diesel","heavy_diesel","fuel_oil","crude_oil","heavy_fuel_oil","heavy_crude_oil"]
8
+ Soil=Literal["coarse_sand","fine_sand","very_fine_sand","silt","sandy_clay","clay","concrete_asphalt","gravel"]
9
+ _FLUIDS={"gasoline":(100,684.018,.00401),"light_diesel":(149,734.011,.00104),"heavy_diesel":(205,764.527,.00246),
10
+ "fuel_oil":(280,775.019,.0369),"crude_oil":(280,775.019,.0369),"heavy_fuel_oil":(422,900.026,.046),"heavy_crude_oil":(422,900.026,.046)}
11
+ _SOILS={"coarse_sand":(.1,.01,.33),"fine_sand":(.01,.001,.33),"very_fine_sand":(.001,.00001,.33),
12
+ "silt":(1e-5,1e-6,.41),"sandy_clay":(1e-6,1e-7,.45),"clay":(1e-7,1e-8,.50),
13
+ "concrete_asphalt":(1e-10,1e-11,.3),"gravel":(1,10,.4)}
14
+ BBL_PER_M3=6.29 # Printed C13, not a silently substituted exact oil-barrel conversion.
15
+
16
+
17
+ class FluidLookup(Model):
18
+ fluid: Fluid
19
+
20
+
21
+ @calculation("tank/fluid-table", "AST SI representative fluid properties", FluidLookup,
22
+ ["Part3 Table6.1M; PDF p604"],limitations=["Historical representative liquid properties, not an operating-temperature EOS. Printed SI density/viscosity retained. No automatic alias to a Level1 representative fluid or LH2 properties."])
23
+ def fluid_table(x):
24
+ mw,rho,mu=_FLUIDS[x.fluid]
25
+ return {"molecular_weight_kg_kmol":mw,"liquid_density_kg_m3":rho,"dynamic_viscosity_pa_s":mu}
26
+
27
+
28
+ class SoilLookup(Model):
29
+ soil: Soil
30
+
31
+
32
+ @calculation("tank/soil-table", "AST SI soil conductivity bounds and porosity", SoilLookup,
33
+ ["Part3 Table6.2M; PDF p605"],limitations=["Printed lower/upper labels are reversed for most rows; fields preserve printed labels and values without sorting. Eq3.207 arithmetic mean is symmetric. Raw conductivity units cm/s, not m/day. Representative soil values need site qualification."])
34
+ def soil_table(x):
35
+ lower,upper,porosity=_SOILS[x.soil]
36
+ return {"printed_lower_bound_cm_s":lower,"printed_upper_bound_cm_s":upper,"porosity":porosity}
37
+
38
+
39
+ class WaterConductivity(Model):
40
+ printed_lower_bound_cm_s: NonNegative
41
+ printed_upper_bound_cm_s: NonNegative
42
+
43
+
44
+ @calculation("tank/soil-water-conductivity", "Mean water hydraulic conductivity in m/day", WaterConductivity,
45
+ ["Part3 eq(3.207),Annex3.B C31; PDF pp593,648"],limitations=["Uses arithmetic mean, not geometric mean: C31=864 converts cm/s to m/day. Accepts reversed printed bound ordering without relabeling it."])
46
+ def water_conductivity(x):
47
+ return {"water_hydraulic_conductivity_m_day":432*x.printed_lower_bound_cm_s+432*x.printed_upper_bound_cm_s}
48
+
49
+
50
+ class FluidConductivity(Model):
51
+ water_hydraulic_conductivity_m_day: NonNegative
52
+ liquid_density_kg_m3: Positive
53
+ dynamic_viscosity_pa_s: Positive
54
+ water_density_kg_m3: Positive
55
+ water_dynamic_viscosity_pa_s: Positive
56
+
57
+
58
+ @calculation("tank/fluid-conductivity", "Product hydraulic conductivity from water reference", FluidConductivity,
59
+ ["Part3 eq(3.205); PDF p592"],limitations=["Water density/viscosity must be supplied at qualified reference conditions. Product/water density ratio and inverse viscosity ratio applied; no hidden standard-water defaults or temperature correction."])
60
+ def fluid_conductivity(x):
61
+ if x.water_hydraulic_conductivity_m_day == 0:
62
+ return {"product_hydraulic_conductivity_m_day":0}
63
+ value=math.exp(math.log(x.water_hydraulic_conductivity_m_day)+math.log(x.liquid_density_kg_m3)-math.log(x.water_density_kg_m3)
64
+ +math.log(x.water_dynamic_viscosity_pa_s)-math.log(x.dynamic_viscosity_pa_s))
65
+ ensure(value > 0,"Product conductivity underflow","numerical_failure")
66
+ return {"product_hydraulic_conductivity_m_day":value}
67
+
68
+
69
+ class Seepage(Model):
70
+ product_hydraulic_conductivity_m_day: NonNegative
71
+ porosity: Fraction
72
+
73
+
74
+ @calculation("tank/seepage-velocity", "AST product seepage velocity through soil", Seepage,
75
+ ["Part3 eq(3.206); PDF p592"],limitations=["Conductivity divided by strictly positive porosity. Representative porous-medium approximation; no groundwater plume or capillary/unsaturated transport solver."])
76
+ def seepage(x):
77
+ ensure(x.porosity > 0,"Porosity must be strictly positive")
78
+ return {"seepage_velocity_m_day":x.product_hydraulic_conductivity_m_day/x.porosity}
79
+
80
+
81
+ class BottomHoleCount(Model):
82
+ tank_diameter_m: Positive
83
+
84
+
85
+ @calculation("tank/bottom-hole-count", "AST bottom small-hole count from diameter", BottomHoleCount,
86
+ ["Part3 Table6.5,Annex3.B C36; PDF pp607,649"],limitations=["max(1,nint((D/30.5)^2)) with positive half-up nearest-integer rounding, not Python bankers rounding. Medium/large counts0. Formula evaluated outside tabulated30.5-91.4m range is flagged; no extrapolation validity claimed."])
87
+ def bottom_hole_count(x):
88
+ value=(x.tank_diameter_m/30.5)**2
89
+ ensure(math.isfinite(value),"Hole count outside numerical domain","numerical_failure")
90
+ return {"small_hole_count":max(1,math.floor(value+.5)),"medium_hole_count":0,"large_hole_count":0,
91
+ "outside_tabulated_diameter_range":not 30.5 <= x.tank_diameter_m <= 91.4}
92
+
93
+
94
+ class BottomInventory(Model):
95
+ tank_diameter_m: Positive
96
+ fill_height_m: NonNegative
97
+ liquid_density_kg_m3: Positive
98
+
99
+
100
+ def _inventory(diameter,head,density):
101
+ volume=math.pi/4*diameter**2*head if head > 0 else 0
102
+ ensure(volume > 0 or head == 0,"Positive tank inventory underflow","numerical_failure")
103
+ mass=volume*density
104
+ ensure(mass > 0 or volume == 0,"Positive inventory mass underflow","numerical_failure")
105
+ return {"available_volume_m3":volume,"available_volume_bbl":volume*BBL_PER_M3,"available_mass_kg":mass}
106
+
107
+
108
+ @calculation("tank/bottom-inventory", "Full cylindrical AST bottom inventory", BottomInventory,
109
+ ["Part3 eqs(3.219)-(3.221); PDF p596"],limitations=["Vertical cylindrical tank, full contents above bottom. Printed C13=6.29bbl/m3 retained. No roof volume, dished geometry or connected inventory."])
110
+ def bottom_inventory(x):
111
+ return _inventory(x.tank_diameter_m,x.fill_height_m,x.liquid_density_kg_m3)
112
+
113
+
114
+ class ShellInventory(BottomInventory):
115
+ course_index: int = Field(ge=1,strict=True)
116
+ course_height_m: Positive
117
+
118
+
119
+ @calculation("tank/shell-course-inventory", "Inventory and head above AST shell-course lower edge", ShellInventory,
120
+ ["Part3 eqs(3.214)-(3.218); PDF pp595-596"],limitations=["Leak located at course lower edge, equal course heights, index starts1. Above-fill courses return physical dry head/inventory0 instead of negative fluid. This dry-course handling is explicit geometry interpretation. Printed C13=6.29 retained."])
121
+ def shell_inventory(x):
122
+ elevation=(x.course_index-1)*x.course_height_m
123
+ head=max(0,x.fill_height_m-elevation)
124
+ return {**_inventory(x.tank_diameter_m,head,x.liquid_density_kg_m3),"course_bottom_elevation_m":elevation,
125
+ "available_liquid_head_m":head,"course_wet":head > 0}
126
+
127
+
128
+ def _flow(native,density):
129
+ volume=native/(BBL_PER_M3*86400)
130
+ mass=volume*density
131
+ ensure(volume > 0 and mass > 0 or native == 0,"Positive tank flow conversion underflow","numerical_failure")
132
+ return {"native_rate_bbl_day":native,"volume_rate_m3_s":volume,"mass_rate_kg_s":mass}
133
+
134
+
135
+ class ShellFlow(Model):
136
+ hole_diameter_m: NonNegative
137
+ liquid_head_m: NonNegative
138
+ liquid_density_kg_m3: Positive
139
+ discharge_coefficient: Fraction = .61
140
+ gravity_m_s2: Positive = 9.81
141
+
142
+
143
+ @calculation("tank/shell-flow", "Constant-head AST shell-course liquid discharge", ShellFlow,
144
+ ["Part3 eq(3.209),Annex3.B C32; PDF pp593-594,648"],limitations=["Printed C32=.543 with orifice area mm2, g/head in SI, native result bbl/day. Converts result to m3/s with C13=6.29; rounded constants differ slightly from exact Torricelli units. Cd constrained to source fully-turbulent.60-.65 range, default.61. Constant head, not tank drainage transient or detection/isolation reduction."])
145
+ def shell_flow(x):
146
+ ensure(.60 <= x.discharge_coefficient <= .65,"Shell discharge coefficient outside source.60-.65 range")
147
+ area=math.pi*x.hole_diameter_m**2/4
148
+ native=.543*x.discharge_coefficient*(area*1e6)*math.sqrt(2*x.gravity_m_s2*x.liquid_head_m)
149
+ ensure(native > 0 or x.hole_diameter_m == 0 or x.liquid_head_m == 0,"Shell source rate underflow","numerical_failure")
150
+ return {**_flow(native,x.liquid_density_kg_m3),"hole_area_m2":area}
151
+
152
+
153
+ class BottomFlow(Model):
154
+ hole_diameter_m: NonNegative
155
+ liquid_height_m: NonNegative
156
+ product_hydraulic_conductivity_m_day: NonNegative
157
+ liquid_density_kg_m3: Positive
158
+ number_of_holes: int = Field(ge=0,strict=True)
159
+ has_release_prevention_barrier: bool
160
+ transition_hole_basis: Literal["api581_printed","per_hole_scaled"]
161
+ soil_contact_factor: Positive = .21
162
+ gravity_m_s2: Positive = 9.81
163
+
164
+
165
+ @calculation("tank/bottom-flow", "AST bottom Bernoulli/Giraud/transition discharge", BottomFlow,
166
+ ["Part3 eqs(3.210)-(3.212),Annex3.B C33-C40; PDF pp594,649"],
167
+ origin="api581_bottom_flow_with_explicit_transition_count_basis",
168
+ limitations=["Native units d mm, head m, Kh m/day, result bbl/day. Printed C37 SI/US regime thresholds disagree under conversion; SI threshold used literally, no cross-unit regime equivalence claimed. Eq3.210/211 multiply number of holes; printed3.212 omits it. Required api581_printed preserves omission; per_hole_scaled is explicit alternative, not authorized erratum. RPB substitutes head.0762m when liquid exists; empty tank remains zero. Soil-contact factor.21-1.15. Small-hole constant-head soil leak model, not rupture inventory or groundwater transport."])
169
+ def bottom_flow(x):
170
+ ensure(.21 <= x.soil_contact_factor <= 1.15,"Soil contact factor outside source.21-1.15 range")
171
+ d=x.hole_diameter_m*1000
172
+ h=0 if x.liquid_height_m == 0 else .0762 if x.has_release_prevention_barrier else x.liquid_height_m
173
+ kh=x.product_hydraulic_conductivity_m_day
174
+ if d == 0 or h == 0 or kh == 0 or x.number_of_holes == 0:
175
+ return {**_flow(0,x.liquid_density_kg_m3),"per_hole_native_rate_bbl_day":0,"equation":"zero_flow","liquid_head_used_m":h,
176
+ "lower_conductivity_threshold_m_day":None,"upper_conductivity_threshold_m_day":None,
177
+ "applied_hole_multiplier":0,"transition_hole_basis":x.transition_hole_basis}
178
+ upper=86.4*d**2
179
+ lower=1.408e-8*math.exp((1.8*math.log(d)-math.log(x.soil_contact_factor)-.4*math.log(h))/.74)
180
+ ensure(math.isfinite(upper) and math.isfinite(lower) and 0 < lower <= upper,"Bottom-flow threshold ordering outside model domain")
181
+ if kh > upper:
182
+ single=.0815*math.pi*d**2*math.sqrt(2*x.gravity_m_s2*h)
183
+ equation="3.210"
184
+ elif kh <= lower:
185
+ single=29.6195*x.soil_contact_factor*d**.2*h**.9*kh**.74
186
+ equation="3.211"
187
+ else:
188
+ ld,lh,lk=math.log10(d),math.log10(h),math.log10(kh)
189
+ m=5.9352-.4324*ld+.5405*lh
190
+ ensure(m > 0,"Transition exponent m must be positive")
191
+ base=(3.9365+2*ld-lk)/m
192
+ ensure(base > 0,"Transition fractional-power base must be positive")
193
+ single=1.1341*10**(2*ld+.5*lh-.74*base**m)
194
+ equation="3.212"
195
+ multiplier=1 if equation == "3.212" and x.transition_hole_basis == "api581_printed" else x.number_of_holes
196
+ ensure(single > 0,"Positive bottom discharge underflow","numerical_failure")
197
+ return {**_flow(single*multiplier,x.liquid_density_kg_m3),"per_hole_native_rate_bbl_day":single,"equation":equation,
198
+ "liquid_head_used_m":h,"lower_conductivity_threshold_m_day":lower,"upper_conductivity_threshold_m_day":upper,
199
+ "applied_hole_multiplier":multiplier,"transition_hole_basis":x.transition_hole_basis}
200
+
201
+
202
+ class ShellDetection(Model):
203
+ hole_diameter_m: NonNegative
204
+ diameter_boundary_basis: Literal["printed_3_17_mm","inch_0_125"]
205
+
206
+
207
+ @calculation("tank/shell-detection-time", "AST shell leak detection-time criterion", ShellDetection,
208
+ ["Part3 §6.7.2 STEP7.2,eq(3.223); PDF pp596-597"],limitations=["Source prints3.17mm paired with.125inch=3.175mm; required basis preserves selection. Diameter at/below selected boundary7days, above1day. AST does not apply generic detection/isolation reduction factors."])
209
+ def shell_detection(x):
210
+ boundary=.00317 if x.diameter_boundary_basis == "printed_3_17_mm" else .003175
211
+ return {"detection_time_days":7 if x.hole_diameter_m <= boundary else 1,"diameter_boundary_m":boundary}
212
+
213
+
214
+ class BottomDetection(Model):
215
+ foundation: Literal["concrete_asphalt","other"]
216
+ has_release_prevention_barrier: bool
217
+ overlap_basis: Literal["foundation_priority","barrier_priority"] | None = None
218
+
219
+
220
+ @calculation("tank/bottom-detection-time", "AST bottom leak detection-time criterion", BottomDetection,
221
+ ["Part3 §6.7.3 STEP7.2; PDF p597"],limitations=["Concrete/asphalt7days, RPB30days, otherwise360days. Conditions overlap if concrete/asphalt AND RPB: explicit priority required instead of inventing precedence. This is source detection time, not a generic isolation-system factor."])
222
+ def bottom_detection(x):
223
+ concrete=x.foundation == "concrete_asphalt"
224
+ if concrete and x.has_release_prevention_barrier:
225
+ ensure(x.overlap_basis is not None,"Concrete/asphalt with RPB requires explicit detection priority")
226
+ return {"detection_time_days":7 if x.overlap_basis == "foundation_priority" else 30,
227
+ "selected_basis":"concrete_asphalt" if x.overlap_basis == "foundation_priority" else "barrier"}
228
+ return {"detection_time_days":7 if concrete else 30 if x.has_release_prevention_barrier else 360,
229
+ "selected_basis":"concrete_asphalt" if concrete else "barrier" if x.has_release_prevention_barrier else "unprotected"}
230
+
231
+
232
+ class ReleaseVolume(Model):
233
+ release_case: Literal["leak","rupture"]
234
+ native_rate_bbl_day: NonNegative
235
+ available_volume_m3: NonNegative
236
+ liquid_density_kg_m3: Positive
237
+ detection_time_days: NonNegative
238
+
239
+
240
+ @calculation("tank/release-volume", "Inventory-limited AST leakage or full-volume rupture", ReleaseVolume,
241
+ ["Part3 eqs(3.223)-(3.230),§6.5; PDF pp596-598"],
242
+ origin="api581_release_volume_with_dimensional_mass_conversion",
243
+ limitations=["Leak volume is min(rate*min(inventory/rate,detection time),inventory); rupture uses all available volume without invented duration. Source AST release type remains continuous. Eq3.225/227 print mass=Bbl without density/conversion; printed_mass_index preserves that number separately. released_mass_kg uses(volume m3*density) from3.218/221, an explicit dimensional interpretation, not an authorized erratum. C13=6.29 retained."])
244
+ def release_volume(x):
245
+ available=x.available_volume_m3*BBL_PER_M3
246
+ if x.release_case == "rupture":
247
+ native=available
248
+ duration=None
249
+ elif x.native_rate_bbl_day == 0 or available == 0:
250
+ native=duration=0
251
+ else:
252
+ duration=min(available/x.native_rate_bbl_day,x.detection_time_days)
253
+ ensure(duration > 0 or x.detection_time_days == 0,"Positive AST leak duration underflow","numerical_failure")
254
+ native=min(available,x.native_rate_bbl_day*duration)
255
+ ensure(native > 0 or x.detection_time_days == 0,"Positive AST release volume underflow","numerical_failure")
256
+ volume=x.available_volume_m3 if native == available else native/BBL_PER_M3
257
+ mass=volume*x.liquid_density_kg_m3
258
+ ensure(volume > 0 and mass > 0 or native == 0,"AST volume/mass conversion underflow","numerical_failure")
259
+ return {"release_duration_days":duration,"released_volume_bbl":native,"released_volume_m3":volume,
260
+ "released_mass_kg":mass,"printed_mass_index":native,"release_type":"continuous"}