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,256 @@
1
+ """Carbon-steel AST floor corrosion, 2016 Annex 2.B section 14."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, calculation, ensure
6
+
7
+ Pad = Literal["soil_high_salt", "crushed_limestone", "native_soil", "construction_grade_sand",
8
+ "continuous_asphalt", "continuous_concrete", "oil_sand", "high_resistivity_low_chloride_sand"]
9
+ Bottom = Literal["rpb_not_per_api650", "rpb_per_api650", "single_bottom"]
10
+ Boundary = Literal["lower_resistivity_interval", "higher_resistivity_interval"]
11
+ Policy = Literal["apply_2_mpy_recommendation", "qualified_no_floor"]
12
+ _PADS = dict(zip(Pad.__args__, (1.5, 1.4, 1.3, 1.15, 1., 1., .7, .7)))
13
+
14
+
15
+ class BottomBase(Model):
16
+ side: Literal["soil", "product"]
17
+ unit_basis: Literal["printed_si", "printed_us_mpy"]
18
+
19
+
20
+ @calculation("tank/bottom-base-corrosion-rate", "AST floor reference base corrosion rate", BottomBase,
21
+ ["Part2 Annex2.B §14.3.1-14.3.2,Tables2.B.14.3,14.10; PDF pp430-435"], limitations=[
22
+ "Fallback rates only; comparable inspection-derived base rates take precedence. Rounded SI and US pairs differ. Soil reference: moderate resistivity, continuous asphalt/concrete, good drainage, no functioning CP, single bottom, below24C. Product reference: no required coating, below24C, no coil, no regular water draws. Not a measured net rate."])
23
+ def base(x):
24
+ si, us = (.13, 5.) if x.side == "soil" else (.05, 2.)
25
+ return dict(base_corrosion_rate_m_year=si/1000 if x.unit_basis == "printed_si" else us*.0000254,
26
+ printed_si_rate_mm_year=si, printed_us_rate_mpy=us, side=x.side, unit_basis=x.unit_basis)
27
+
28
+
29
+ class BottomSoilRateEquation(Model):
30
+ base_corrosion_rate_m_year: NonNegative
31
+ resistivity_factor: NonNegative
32
+ pad_factor: NonNegative
33
+ drainage_factor: NonNegative
34
+ cathodic_protection_factor: NonNegative
35
+ bottom_type_factor: NonNegative
36
+ temperature_factor: NonNegative
37
+
38
+
39
+ @calculation("tank/bottom-soil-rate-equation", "AST floor soil-side corrosion rate equation", BottomSoilRateEquation,
40
+ ["Part2 Annex2.B §14.2.1,eq(2.B.37); PDF p430"], limitations=[
41
+ "Independent equation kernel only. Factors and reference base rate are supplied and must be qualified from §14.3 tables or comparable inspection data. No soil classification, factor selection, AST applicability or measured-rate correction is inferred."])
42
+ def soil_rate_equation(x):
43
+ rate = _rate(x.base_corrosion_rate_m_year, [x.resistivity_factor, x.pad_factor,
44
+ x.drainage_factor, x.cathodic_protection_factor, x.bottom_type_factor,
45
+ x.temperature_factor])
46
+ return dict(soil_corrosion_rate_m_year=rate)
47
+
48
+
49
+ class BottomProductRateEquation(Model):
50
+ base_corrosion_rate_m_year: NonNegative
51
+ product_condition_factor: NonNegative
52
+ temperature_factor: NonNegative
53
+ steam_coil_factor: NonNegative
54
+ water_draw_factor: NonNegative
55
+
56
+
57
+ @calculation("tank/bottom-product-rate-equation", "AST floor product-side corrosion rate equation", BottomProductRateEquation,
58
+ ["Part2 Annex2.B §14.2.2,eq(2.B.38); PDF p430"], limitations=[
59
+ "Independent equation kernel only. Factors and reference base rate are supplied and must be qualified from §14.3 tables or comparable inspection data. No product wetness, temperature, coil or water-draw classification is inferred."])
60
+ def product_rate_equation(x):
61
+ rate = _rate(x.base_corrosion_rate_m_year, [x.product_condition_factor,
62
+ x.temperature_factor, x.steam_coil_factor, x.water_draw_factor])
63
+ return dict(product_corrosion_rate_m_year=rate)
64
+
65
+
66
+ class BottomResistivity(Model):
67
+ release_prevention_barrier: bool
68
+ soil_resistivity_ohm_m: Positive | None = None
69
+ shared_boundary_basis: Boundary | None = None
70
+
71
+
72
+ @calculation("tank/bottom-soil-resistivity-factor", "AST floor native-soil resistivity factor", BottomResistivity,
73
+ ["Part2 Annex2.B §14.3.1(a),Table2.B.14.4; PDF pp430,433"], limitations=[
74
+ "RPB or unknown resistivity uses1 by source instruction. Public ohm m; source ohm cm=100*ohm m. Shared1000/2000ohm cm endpoints require explicit interval basis. High-resistivity factor is.66 for AST, not the buried-pipe.60 factor."])
75
+ def resistivity(x):
76
+ v = x.soil_resistivity_ohm_m
77
+ if x.release_prevention_barrier or v is None:
78
+ ensure(x.shared_boundary_basis is None, "Interval basis is inapplicable when resistivity is not used")
79
+ return dict(resistivity_factor=1., selection_reason="rpb" if x.release_prevention_barrier else "unknown_assume_moderate")
80
+ if v in (10., 20.):
81
+ ensure(x.shared_boundary_basis is not None, "Shared resistivity endpoint requires interval basis", "ambiguous_soil_resistivity_boundary")
82
+ i = (1 if v == 10 else 2)+(x.shared_boundary_basis == "higher_resistivity_interval")
83
+ else:
84
+ ensure(x.shared_boundary_basis is None, "Interval basis applies only at shared endpoints")
85
+ i = 0 if v < 5 else 1 if v < 10 else 2 if v < 20 else 3 if v <= 100 else 4
86
+ return dict(resistivity_factor=(1.5, 1.25, 1., .83, .66)[i], selection_reason="resistivity_table")
87
+
88
+
89
+ class BottomPad(Model):
90
+ pad_type: Pad
91
+
92
+
93
+ @calculation("tank/bottom-pad-factor", "AST floor foundation pad factor", BottomPad,
94
+ ["Part2 Annex2.B Table2.B.14.5; PDF p434"], limitations=["Owner qualifies actual pad category; no interpolation or automatic soil classification."])
95
+ def pad(x):
96
+ return dict(pad_factor=_PADS[x.pad_type])
97
+
98
+
99
+ class BottomDrainage(Model):
100
+ drainage_category: Literal["one_third_frequently_underwater", "storm_water_collects", "no_storm_water_collects"]
101
+
102
+
103
+ @calculation("tank/bottom-drainage-factor", "AST floor drainage factor", BottomDrainage,
104
+ ["Part2 Annex2.B §14.3.1(c),Table2.B.14.6; PDF pp430,434"], limitations=[
105
+ "Owner selects category: table says one-third frequently underwater; prose describes more than one-third circumference underwater for extended periods. No invented numeric exposure classifier."])
106
+ def drainage(x):
107
+ return dict(drainage_factor={"one_third_frequently_underwater":3., "storm_water_collects":2., "no_storm_water_collects":1.}[x.drainage_category])
108
+
109
+
110
+ class BottomCP(Model):
111
+ protection_category: Literal["none", "present_not_per_api651", "present_per_api651"]
112
+ effectiveness_verified_by_high_level_inspection: bool
113
+ verification_basis: str | None = None
114
+
115
+
116
+ @calculation("tank/bottom-cp-factor", "AST floor cathodic protection factor with inspection credit gate", BottomCP,
117
+ ["Part2 Annex2.B §14.3.1(d),Table2.B.14.7; PDF pp431,434"], limitations=[
118
+ "Table nominal factors1/.66/.33; text allows no credit without High Level inspection verifying effective CP under referenced NACE RP0169 or equivalent. Owner supplies verification basis. Historical references do not establish current compliance."])
119
+ def cp(x):
120
+ verified = x.effectiveness_verified_by_high_level_inspection
121
+ if verified:
122
+ ensure(x.protection_category != "none", "Absent CP cannot receive verified credit")
123
+ ensure(x.verification_basis is not None and bool(x.verification_basis.strip()), "Verified CP requires inspection basis")
124
+ else:
125
+ ensure(x.verification_basis is None, "Unverified CP cannot supply verification basis")
126
+ nominal = {"none":1., "present_not_per_api651":.66, "present_per_api651":.33}[x.protection_category]
127
+ return dict(nominal_table_factor=nominal, cathodic_protection_factor=nominal if verified else 1., credit_applied=verified)
128
+
129
+
130
+ class BottomType(Model):
131
+ bottom_type: Bottom
132
+
133
+
134
+ @calculation("tank/bottom-type-factor", "AST floor construction factor", BottomType,
135
+ ["Part2 Annex2.B Table2.B.14.8; PDF p434"], limitations=["RPB classification is supplied by the owner; qualified RPB and single bottom both have factor1."])
136
+ def bottom_type(x):
137
+ return dict(bottom_type_factor=1.4 if x.bottom_type == "rpb_not_per_api650" else 1.)
138
+
139
+
140
+ class BottomTemperature(Model):
141
+ highest_operating_temperature_k: Positive
142
+ threshold_basis: Literal["printed_si", "printed_us_converted"]
143
+
144
+
145
+ @calculation("tank/bottom-temperature-factor", "AST floor soil and product temperature factor", BottomTemperature,
146
+ ["Part2 Annex2.B Tables2.B.14.9,14.12; PDF p435"], limitations=[
147
+ "Both tables have identical five bands. Printed SI24/66/93/121C differ from US75/150/200/250F converted to K; basis required. Above highest threshold source factor returns1; not a qualification of high-temperature or cryogenic service."])
148
+ def temperature(x):
149
+ bounds = [c+273.15 for c in (24.,66.,93.,121.)] if x.threshold_basis == "printed_si" else [(f-32)*5/9+273.15 for f in (75.,150.,200.,250.)]
150
+ i = next((i for i, b in enumerate(bounds) if x.highest_operating_temperature_k <= b), 4)
151
+ return dict(temperature_factor=(1.,1.1,1.3,1.4,1.)[i], source_row=i+1, threshold_basis=x.threshold_basis)
152
+
153
+
154
+ class BottomProductCondition(Model):
155
+ product_condition: Literal["wet", "dry"]
156
+
157
+
158
+ @calculation("tank/bottom-product-condition-factor", "AST floor product wetness factor", BottomProductCondition,
159
+ ["Part2 Annex2.B §14.3.2(a),Table2.B.14.11; PDF pp431,435"], limitations=["Wet means significant bottom sediment and water; owner qualifies actual condition."])
160
+ def product_condition(x):
161
+ return dict(product_condition_factor=2.5 if x.product_condition == "wet" else 1.)
162
+
163
+
164
+ class BottomSteamCoil(Model):
165
+ steam_coil_present: bool
166
+
167
+
168
+ @calculation("tank/bottom-steam-coil-factor", "AST floor steam coil factor", BottomSteamCoil,
169
+ ["Part2 Annex2.B Table2.B.14.13; PDF p436"], limitations=["Presence multiplier only; no detailed steam-leak thermal model."])
170
+ def steam_coil(x):
171
+ return dict(steam_coil_factor=1.15 if x.steam_coil_present else 1.)
172
+
173
+
174
+ class BottomWaterDraw(Model):
175
+ water_draw_practice: Literal["none_or_not_regular", "weekly", "after_every_receipt"]
176
+
177
+
178
+ @calculation("tank/bottom-water-draw-factor", "AST floor qualified regular water draw factor", BottomWaterDraw,
179
+ ["Part2 Annex2.B §14.3.2(d),Table2.B.14.14; PDF pp431,436"], limitations=["Credit.7 requires weekly draws or draws after every receipt; merely having a drain earns no credit."])
180
+ def water_draw(x):
181
+ return dict(water_draw_factor=1. if x.water_draw_practice == "none_or_not_regular" else .7)
182
+
183
+
184
+ class BottomCombine(Model):
185
+ soil_corrosion_rate_m_year: NonNegative
186
+ product_corrosion_rate_m_year: NonNegative
187
+ product_distribution: Literal["general", "local"]
188
+ minimum_rate_policy: Policy
189
+ no_floor_qualification_basis: str | None = None
190
+
191
+
192
+ @calculation("tank/bottom-corrosion-rate-combine", "AST floor two-sided corrosion-rate combination", BottomCombine,
193
+ ["Part2 Annex2.B §14.3.3; PDF p431"], limitations=[
194
+ "Soil corrosion is local. General product corrosion adds both rates; local product corrosion uses maximum. Source recommends minimum2mpy=.0000508m/year. Explicit qualified bypass retains raw value. General/local selection follows section14.3.3; summary Table2.B.1.2 lists AST product as local. No AST damage factor computed here."])
195
+ def combine(x):
196
+ apply = x.minimum_rate_policy == "apply_2_mpy_recommendation"
197
+ ensure((x.no_floor_qualification_basis is None) if apply else (x.no_floor_qualification_basis is not None and bool(x.no_floor_qualification_basis.strip())), "Floor policy and qualification basis must agree")
198
+ values = x.soil_corrosion_rate_m_year, x.product_corrosion_rate_m_year
199
+ raw = sum(values) if x.product_distribution == "general" else max(values)
200
+ return dict(raw_combined_rate_m_year=raw, combined_corrosion_rate_m_year=max(raw,.0000508) if apply else raw,
201
+ recommended_minimum_rate_m_year=.0000508, minimum_applied=apply and raw < .0000508,
202
+ combination_rule="sum" if x.product_distribution == "general" else "maximum")
203
+
204
+
205
+ class BottomCorrosion(Model):
206
+ material: Literal["carbon_steel"]
207
+ applicability_basis: str = Field(min_length=1)
208
+ soil_base_corrosion_rate_m_year: NonNegative
209
+ product_base_corrosion_rate_m_year: NonNegative
210
+ soil_base_rate_source: str = Field(min_length=1)
211
+ product_base_rate_source: str = Field(min_length=1)
212
+ bottom_type: Bottom
213
+ soil_resistivity_ohm_m: Positive | None = None
214
+ shared_boundary_basis: Boundary | None = None
215
+ pad: BottomPad
216
+ drainage: BottomDrainage
217
+ cathodic_protection: BottomCP
218
+ temperature: BottomTemperature
219
+ product_condition: BottomProductCondition
220
+ steam_coil: BottomSteamCoil
221
+ water_draw: BottomWaterDraw
222
+ product_distribution: Literal["general", "local"]
223
+ minimum_rate_policy: Policy
224
+ no_floor_qualification_basis: str | None = None
225
+
226
+
227
+ def _rate(base_rate, factors):
228
+ if base_rate == 0:
229
+ return 0.
230
+ rate = base_rate*math.prod(factors)
231
+ ensure(rate > 0 and math.isfinite(rate), "Corrosion-rate product outside finite positive range", "numerical_failure")
232
+ return rate
233
+
234
+
235
+ @calculation("tank/bottom-corrosion-assessment", "AST floor soil and product corrosion assessment", BottomCorrosion,
236
+ ["Part2 Annex2.B §14.3,eqs(2.B.37-38),Tables2.B.14.4-14; PDF pp430-436"], limitations=[
237
+ "Carbon-steel AST floor only. Explicit comparable base rates and provenance required; measured net rates must not be multiplied again without qualification. RPB soil-resistivity override derives from bottom type. Single highest operating temperature drives both source temperature factors. CP inspection gate enforced. No coating credit, remaining life, AST thinning damage factor or complete asset risk calculation."])
238
+ def assessment(x):
239
+ ensure(all(s.strip() for s in (x.applicability_basis,x.soil_base_rate_source,x.product_base_rate_source)), "Qualification and provenance must not be blank")
240
+ fsr = resistivity(BottomResistivity(release_prevention_barrier=x.bottom_type != "single_bottom",
241
+ soil_resistivity_ohm_m=x.soil_resistivity_ohm_m, shared_boundary_basis=x.shared_boundary_basis))["resistivity_factor"]
242
+ fpa, fdr = pad(x.pad)["pad_factor"], drainage(x.drainage)["drainage_factor"]
243
+ cp_result = cp(x.cathodic_protection)
244
+ fcp = cp_result["cathodic_protection_factor"]
245
+ ftb = bottom_type(BottomType(bottom_type=x.bottom_type))["bottom_type_factor"]
246
+ ft = temperature(x.temperature)["temperature_factor"]
247
+ fpc = product_condition(x.product_condition)["product_condition_factor"]
248
+ fsc, fwd = steam_coil(x.steam_coil)["steam_coil_factor"], water_draw(x.water_draw)["water_draw_factor"]
249
+ soil_rate = _rate(x.soil_base_corrosion_rate_m_year,[fsr,fpa,fdr,fcp,ftb,ft])
250
+ product_rate = _rate(x.product_base_corrosion_rate_m_year,[fpc,ft,fsc,fwd])
251
+ result = combine(BottomCombine(soil_corrosion_rate_m_year=soil_rate,product_corrosion_rate_m_year=product_rate,
252
+ product_distribution=x.product_distribution,minimum_rate_policy=x.minimum_rate_policy,no_floor_qualification_basis=x.no_floor_qualification_basis))
253
+ return dict(**result,soil_corrosion_rate_m_year=soil_rate,product_corrosion_rate_m_year=product_rate,
254
+ resistivity_factor=fsr,pad_factor=fpa,drainage_factor=fdr,cathodic_protection_factor=fcp,bottom_type_factor=ftb,
255
+ temperature_factor=ft,product_condition_factor=fpc,steam_coil_factor=fsc,water_draw_factor=fwd,
256
+ cp_credit_applied=cp_result["credit_applied"])
@@ -0,0 +1,186 @@
1
+ """AST environmental allocation and bottom repair; native volume unit is bbl."""
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
+ HOLES = ("small", "medium", "large", "rupture")
8
+ Hole = Literal["small", "medium", "large", "rupture"]
9
+ LOCATIONS = ("in_dike", "surface_on_site", "surface_off_site", "subsoil", "groundwater", "surface_water")
10
+ Location = Literal["in_dike", "surface_on_site", "surface_off_site", "subsoil", "groundwater", "surface_water"]
11
+ _COSTS = {"low": (10, 50, 100, 500, 1000, 500),
12
+ "medium": (10, 50, 250, 1500, 5000, 1500),
13
+ "high": (10, 50, 500, 3000, 10000, 5000)}
14
+
15
+
16
+ class EnvironmentalCostTable(Model):
17
+ sensitivity: Literal["low", "medium", "high"]
18
+
19
+
20
+ @calculation("tank/environment-cost-table", "AST estimated environmental costs in source USD/bbl", EnvironmentalCostTable,
21
+ ["Part3 Table6.6; PDF p608"], limitations=["Historical source estimates, not current prices. Source supplies no explicit price year for Table6.6; no 2001 assumption is inferred from repair Table4.15. End user must qualify applicability and escalation."])
22
+ def environment_cost_table(x):
23
+ return {"cost_by_location_usd_per_bbl": dict(zip(LOCATIONS, _COSTS[x.sensitivity]))}
24
+
25
+
26
+ class SurfaceSpill(Model):
27
+ released_volume_bbl: NonNegative
28
+ fraction_leaving_dike: Fraction
29
+ fraction_on_site: Fraction
30
+ fraction_off_site: Fraction
31
+
32
+
33
+ @calculation("tank/surface-spill-partition", "Sequential AST surface spill allocation", SurfaceSpill,
34
+ ["Part3 eqs(3.232)-(3.235),(3.238)-(3.241); PDF pp599-600"],
35
+ limitations=["Fractions are conditional: on-site applies to volume leaving dike; off-site applies to what remains after on-site. Use fractions0..1, not percentage numbers0..100. No dike capacity or surface transport simulation."])
36
+ def surface_spill(x):
37
+ return _surface(x, x.released_volume_bbl)
38
+
39
+
40
+ class GroundwaterArrival(Model):
41
+ distance_to_groundwater_m: NonNegative
42
+ seepage_velocity_m_day: NonNegative
43
+
44
+
45
+ @calculation("tank/groundwater-arrival", "AST seepage travel time to groundwater", GroundwaterArrival,
46
+ ["Part3 eq(3.244); PDF p601"], limitations=["Zero distance means already at groundwater; positive distance with zero velocity returns null arrival time, not infinity. No plume, retardation or groundwater migration model."])
47
+ def groundwater_arrival(x):
48
+ if x.distance_to_groundwater_m == 0:
49
+ return {"arrival_time_days": 0, "arrival_status": "already_at_groundwater"}
50
+ if x.seepage_velocity_m_day == 0:
51
+ return {"arrival_time_days": None, "arrival_status": "no_finite_arrival"}
52
+ arrival = x.distance_to_groundwater_m/x.seepage_velocity_m_day
53
+ ensure(math.isfinite(arrival) and arrival > 0, "Groundwater travel time outside numerical range", "numerical_failure")
54
+ return {"arrival_time_days": arrival, "arrival_status": "finite_travel"}
55
+
56
+
57
+ class SubsurfaceSpill(Model):
58
+ leaked_volume_bbl: NonNegative
59
+ detection_time_days: Positive
60
+ groundwater_arrival_time_days: NonNegative | None
61
+
62
+
63
+ @calculation("tank/subsurface-spill-partition", "AST bottom leakage allocation to groundwater and subsoil", SubsurfaceSpill,
64
+ ["Part3 eqs(3.245)-(3.247); PDF p601"], limitations=["Printed detection time is the denominator, even if inventory depletion limits actual leak duration. This is not a transient transport solution. Null arrival means no finite arrival. Positive detection time required; zero-volume leakage returns zero in both compartments."])
65
+ def subsurface_spill(x):
66
+ arrival = x.groundwater_arrival_time_days
67
+ fraction = 0 if arrival is None or arrival >= x.detection_time_days else 1-arrival/x.detection_time_days
68
+ groundwater = x.leaked_volume_bbl*fraction
69
+ return {"groundwater_bbl": groundwater, "subsoil_bbl": x.leaked_volume_bbl-groundwater,
70
+ "groundwater_fraction": fraction}
71
+
72
+
73
+ class FrequencyWeights(Model):
74
+ generic_frequency_by_hole_per_year: dict[Hole, NonNegative]
75
+
76
+
77
+ def _weights(x):
78
+ rates = x.generic_frequency_by_hole_per_year
79
+ ensure(set(rates) == set(HOLES), "Exactly small, medium, large and rupture frequencies required", "incomplete_hole_set")
80
+ scale = max(rates.values())
81
+ ensure(scale > 0, "Total generic frequency must be positive")
82
+ scaled = {h: rates[h]/scale for h in HOLES}
83
+ total = math.fsum(scaled.values())
84
+ return {h: scaled[h]/total for h in HOLES}
85
+
86
+
87
+ class EnvironmentalBasis(FrequencyWeights):
88
+ cost_by_location_per_bbl: dict[Location, NonNegative]
89
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
90
+ cost_basis: str = Field(min_length=1, max_length=500)
91
+ fraction_leaving_dike: Fraction
92
+ fraction_on_site: Fraction
93
+ fraction_off_site: Fraction
94
+
95
+
96
+ def _check_costs(x):
97
+ ensure(set(x.cost_by_location_per_bbl) == set(LOCATIONS), "All six environmental cost locations required", "incomplete_cost_set")
98
+ ensure(bool(x.cost_basis.strip()), "Cost basis must identify source or qualified valuation")
99
+
100
+
101
+ def _surface(x, volume):
102
+ outside = volume*x.fraction_leaving_dike
103
+ after_on_site = outside*(1-x.fraction_on_site)
104
+ return {"in_dike_bbl": volume*(1-x.fraction_leaving_dike),
105
+ "surface_on_site_bbl": outside*x.fraction_on_site,
106
+ "surface_off_site_bbl": after_on_site*x.fraction_off_site,
107
+ "surface_water_bbl": after_on_site*(1-x.fraction_off_site)}
108
+
109
+
110
+ def _surface_cost(x, volumes):
111
+ return math.fsum(volumes[k+"_bbl"]*x.cost_by_location_per_bbl[k]
112
+ for k in ("in_dike", "surface_on_site", "surface_off_site", "surface_water"))
113
+
114
+
115
+ class ShellEnvironment(EnvironmentalBasis):
116
+ released_volume_by_hole_bbl: dict[Hole, NonNegative]
117
+
118
+
119
+ _ENV_LIMITS = ["Caller supplies qualified per-hole GFF; total is their sum (eq3.208), including rupture in leak denominator. No ambiguous printed tank-total lookup. Frequencies are normalized once; no ignition probability applied.",
120
+ "Costs are per bbl in one declared currency and caller-declared cost basis. No implicit escalation, FX conversion or current-price claim. Table6.6 values require owner qualification."]
121
+
122
+
123
+ @calculation("tank/shell-environment-cost", "GFF-weighted AST shell environmental consequence", ShellEnvironment,
124
+ ["Part3 eqs(3.208),(3.231)-(3.243); PDF pp593,599-600"], limitations=_ENV_LIMITS)
125
+ def shell_environment(x):
126
+ _check_costs(x)
127
+ weights = _weights(x)
128
+ ensure(set(x.released_volume_by_hole_bbl) == set(HOLES), "Exactly four released-volume holes required", "incomplete_hole_set")
129
+ leak = math.fsum(weights[h]*x.released_volume_by_hole_bbl[h] for h in HOLES[:3])
130
+ rupture = weights["rupture"]*x.released_volume_by_hole_bbl["rupture"]
131
+ leak_volumes, rupture_volumes = _surface(x, leak), _surface(x, rupture)
132
+ leak_cost, rupture_cost = _surface_cost(x, leak_volumes), _surface_cost(x, rupture_volumes)
133
+ return {"weighted_leak_volume_bbl": leak, "weighted_rupture_volume_bbl": rupture,
134
+ "weighted_leak_surface_volumes_bbl": leak_volumes, "weighted_rupture_surface_volumes_bbl": rupture_volumes,
135
+ "leak_environment_cost": leak_cost, "rupture_environment_cost": rupture_cost,
136
+ "environment_cost": leak_cost+rupture_cost, "currency": x.currency, "cost_basis": x.cost_basis}
137
+
138
+
139
+ class BottomEnvironment(EnvironmentalBasis):
140
+ leaked_volume_by_hole_bbl: dict[Literal["small", "medium", "large"], NonNegative]
141
+ total_tank_volume_bbl: NonNegative
142
+ detection_time_days: Positive
143
+ groundwater_arrival_time_days: NonNegative | None
144
+
145
+
146
+ @calculation("tank/bottom-environment-cost", "GFF-weighted AST bottom environmental consequence", BottomEnvironment,
147
+ ["Part3 eqs(3.238)-(3.249); PDF pp600-601"], limitations=_ENV_LIMITS+[
148
+ "Bottom leaks enter subsoil/groundwater; rupture releases total tank inventory to four surface locations. Printed detection-time partition retained despite inventory-limited release duration. Surrounding-equipment and injury costs are not applicable to bottom assessment; this API returns environmental cost only."])
149
+ def bottom_environment(x):
150
+ _check_costs(x)
151
+ weights = _weights(x)
152
+ ensure(set(x.leaked_volume_by_hole_bbl) == set(HOLES[:3]), "Exactly three leakage holes required", "incomplete_hole_set")
153
+ parts = {h: subsurface_spill(SubsurfaceSpill(leaked_volume_bbl=x.leaked_volume_by_hole_bbl[h],
154
+ detection_time_days=x.detection_time_days, groundwater_arrival_time_days=x.groundwater_arrival_time_days)) for h in HOLES[:3]}
155
+ groundwater = math.fsum(weights[h]*parts[h]["groundwater_bbl"] for h in HOLES[:3])
156
+ subsoil = math.fsum(weights[h]*parts[h]["subsoil_bbl"] for h in HOLES[:3])
157
+ leak_cost = math.fsum((groundwater*x.cost_by_location_per_bbl["groundwater"], subsoil*x.cost_by_location_per_bbl["subsoil"]))
158
+ rupture = weights["rupture"]*x.total_tank_volume_bbl
159
+ rupture_volumes = _surface(x, rupture)
160
+ rupture_cost = _surface_cost(x, rupture_volumes)
161
+ return {"weighted_groundwater_volume_bbl": groundwater, "weighted_subsoil_volume_bbl": subsoil,
162
+ "weighted_rupture_volume_bbl": rupture, "weighted_rupture_surface_volumes_bbl": rupture_volumes,
163
+ "leak_environment_cost": leak_cost, "rupture_environment_cost": rupture_cost,
164
+ "environment_cost": leak_cost+rupture_cost, "currency": x.currency, "cost_basis": x.cost_basis}
165
+
166
+
167
+ class BottomRepair(FrequencyWeights):
168
+ tank_diameter_m: Positive
169
+ repair_cost_by_hole: dict[Hole, NonNegative]
170
+ material_factor: Positive
171
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
172
+ cost_basis: str = Field(min_length=1, max_length=500)
173
+
174
+
175
+ @calculation("tank/bottom-repair-cost", "AST bottom repair with rupture-only diameter scaling", BottomRepair,
176
+ ["Part3 eq(3.250),Tables4.15/4.16,Annex3.B C36; PDF pp532-533,601,649"], limitations=[
177
+ "Caller supplies all four repair costs and qualified GFF. Only rupture cost scales by (D/30.5m)^2; small/medium/large repair costs do not. Historical financial/repair-table returns USD2001; caller must explicitly qualify escalation/currency and retain consistent cost basis."])
178
+ def bottom_repair(x):
179
+ weights = _weights(x)
180
+ ensure(set(x.repair_cost_by_hole) == set(HOLES), "Exactly four repair cost holes required", "incomplete_hole_set")
181
+ ensure(bool(x.cost_basis.strip()), "Cost basis must identify source or qualified valuation")
182
+ # Avoid squaring diameter when rupture contributes nothing.
183
+ rupture = weights["rupture"]*x.repair_cost_by_hole["rupture"]
184
+ rupture = 0 if rupture == 0 else rupture*(x.tank_diameter_m/30.5)**2
185
+ cost = math.fsum([weights[h]*x.repair_cost_by_hole[h] for h in HOLES[:3]]+[rupture])*x.material_factor
186
+ return {"component_repair_cost": cost, "currency": x.currency, "cost_basis": x.cost_basis}
@@ -0,0 +1,202 @@
1
+ """AST component financial consequence and uniform-DF financial risk composition."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, calculation, ensure
6
+ from .pof import Management, management
7
+ from .cost_tables import REPAIR, MATERIAL, OUTAGE, SurroundingOutage, surrounding_outage
8
+ from .tank_environment import (
9
+ HOLES, Hole, FrequencyWeights, _weights, BottomEnvironment, ShellEnvironment,
10
+ bottom_environment, shell_environment, BottomRepair, bottom_repair,
11
+ )
12
+
13
+ TankType = Literal["TANKBOTTOM", "COURSE-1-10"]
14
+ _GFF = {"TANKBOTTOM": (7.2e-4, 0., 0., 2e-6), "COURSE-1-10": (7e-5, 2.5e-5, 5e-6, 1e-7)}
15
+ _PRINTED_TOTAL = {"TANKBOTTOM": 7.2e-4, "COURSE-1-10": 1e-4}
16
+
17
+
18
+ def _product(*values):
19
+ if any(v == 0 for v in values):
20
+ return 0.
21
+ value = math.prod(values)
22
+ ensure(math.isfinite(value) and value > 0, "Positive product outside numerical range", "numerical_failure")
23
+ return value
24
+
25
+
26
+ class TankComponent(Model):
27
+ component_type: TankType
28
+
29
+
30
+ @calculation("tank/generic-frequency", "AST generic frequencies with explicit equation-summed total", TankComponent,
31
+ ["Part2 Table3.1; Part3 eq(3.208); PDF pp147,593"], limitations=[
32
+ "Uses the sum of four hole frequencies authorized by eq3.208. Printed tank totals differ and are returned separately; this is not an authorized correction of Table3.1. COURSE-1-10 is one shell-course component, not the whole tank or ten multiplied courses."])
33
+ def generic_frequency(x):
34
+ values = _GFF[x.component_type]
35
+ total = math.fsum(values)
36
+ printed = _PRINTED_TOTAL[x.component_type]
37
+ return {"by_hole_per_year": dict(zip(HOLES, values)), "total_per_year": total,
38
+ "printed_total_per_year": printed, "sum_minus_printed_per_year": total-printed,
39
+ "total_basis": "hole_sum_eq3_208"}
40
+
41
+
42
+ class TankOutage(FrequencyWeights):
43
+ component_type: TankType
44
+ outage_multiplier: Positive
45
+ override_days: dict[Hole, NonNegative] = Field(default_factory=dict)
46
+
47
+
48
+ @calculation("tank/component-outage", "AST GFF-weighted component outage duration", TankOutage,
49
+ ["Part3 eq(3.88),Table4.17,§6.12; PDF pp506,534,599-601"], limitations=[
50
+ "All four qualified per-hole GFF are supplied by caller and normalized by their sum. Source NA is not zero: nonzero-frequency NA requires owner override. Zero-frequency NA contributes zero. No automatic diameter correction of outage duration."])
51
+ def component_outage(x):
52
+ weights = _weights(x)
53
+ days = dict(zip(HOLES, OUTAGE[x.component_type]))
54
+ days.update(x.override_days)
55
+ for h in HOLES:
56
+ ensure(days[h] is not None or x.generic_frequency_by_hole_per_year[h] == 0,
57
+ f"Outage NA for nonzero-frequency hole {h}; supply override_days", "missing_outage_override")
58
+ value = math.fsum(weights[h]*(days[h] if days[h] is not None else 0) for h in HOLES)*x.outage_multiplier
59
+ return {"component_outage_days": value, "effective_days_by_hole": days}
60
+
61
+
62
+ class TankRepairBasis(Model):
63
+ material_id: Literal[tuple(MATERIAL)]
64
+ price_factor_since_2001: Positive
65
+ usd_to_output_currency: Positive
66
+ cost_basis_year: int = Field(ge=2001, le=2200)
67
+
68
+
69
+ class OperatingCosts(Model):
70
+ repair: TankRepairBasis
71
+ outage_multiplier: Positive
72
+ override_outage_days: dict[Hole, NonNegative] = Field(default_factory=dict)
73
+ production_cost_per_day: NonNegative
74
+ generic_frequency_source: str = Field(min_length=1, max_length=500)
75
+
76
+
77
+ class BottomFinancial(BottomEnvironment, OperatingCosts):
78
+ tank_diameter_m: Positive
79
+
80
+
81
+ class ShellFinancial(ShellEnvironment, OperatingCosts):
82
+ equipment_area_m2: NonNegative
83
+ personnel_area_m2: NonNegative
84
+ equipment_cost_per_m2: NonNegative
85
+ population_density_per_m2: NonNegative
86
+ cost_per_injury: NonNegative
87
+
88
+
89
+ _FIN_LIMITS = [
90
+ "Per-hole GFF, release volumes and qualified environmental costs are supplied inputs. Environment, production, surrounding-equipment and injury unit costs must use the declared currency and repair.cost_basis_year; cost_basis records the common valuation. Table6.6 has no stated price year and must be qualified explicitly before combining with escalated USD2001 repair costs.",
91
+ "Component-only consequence. Damage applicability, release hydraulics, consequence areas and monetary qualification are not inferred. Per-hole frequencies normalize once including rupture. No additional ignition weighting or automatic whole-tank course summation.",
92
+ ]
93
+
94
+
95
+ def _environment(x, cls, fn):
96
+ # Only original validated fields enter a submodel; no derived values are coerced.
97
+ return fn(cls(**{k: getattr(x, k) for k in cls.model_fields}))
98
+
99
+
100
+ def _outage(x, component_type):
101
+ ensure(bool(x.generic_frequency_source.strip()), "Generic frequency source must be nonblank")
102
+ return component_outage(TankOutage(component_type=component_type,
103
+ generic_frequency_by_hole_per_year=x.generic_frequency_by_hole_per_year,
104
+ outage_multiplier=x.outage_multiplier, override_days=x.override_outage_days))["component_outage_days"]
105
+
106
+
107
+ def _repair(x, component_type):
108
+ weights = _weights(x)
109
+ if component_type == "TANKBOTTOM":
110
+ reference = bottom_repair(BottomRepair(
111
+ generic_frequency_by_hole_per_year=x.generic_frequency_by_hole_per_year,
112
+ tank_diameter_m=x.tank_diameter_m, repair_cost_by_hole=dict(zip(HOLES, REPAIR[component_type])),
113
+ material_factor=MATERIAL[x.repair.material_id], currency="USD", cost_basis="Table4.15 USD2001"))["component_repair_cost"]
114
+ else:
115
+ reference = math.fsum(weights[h]*v for h,v in zip(HOLES, REPAIR[component_type]))*MATERIAL[x.repair.material_id]
116
+ return _product(reference, x.repair.price_factor_since_2001, x.repair.usd_to_output_currency)
117
+
118
+
119
+ def _result(x, repair, outage, environment, surrounding=0., surrounding_days=0., injury=0., reference_surrounding=0.):
120
+ production = _product(outage+surrounding_days, x.production_cost_per_day)
121
+ return {"component_repair_cost": repair, "surrounding_equipment_cost": surrounding,
122
+ "business_interruption_cost": production, "injury_cost": injury, "environmental_cost": environment["environment_cost"],
123
+ "component_outage_days": outage, "surrounding_outage_days": surrounding_days,
124
+ "surrounding_cost_reference_usd_2001": reference_surrounding,
125
+ "leak_environment_cost": environment["leak_environment_cost"], "rupture_environment_cost": environment["rupture_environment_cost"],
126
+ "financial_consequence": math.fsum([repair, surrounding, production, injury, environment["environment_cost"]]),
127
+ "currency": x.currency, "cost_basis_year": x.repair.cost_basis_year, "cost_basis": x.cost_basis}
128
+
129
+
130
+ @calculation("tank/bottom-financial-consequence", "Compose AST bottom repair, production and environmental consequence", BottomFinancial,
131
+ ["Part3 §6.12.3,eqs(3.88),(3.90),(3.243)-(3.250); PDF pp506,600-601"], limitations=_FIN_LIMITS+[
132
+ "Bottom surrounding-equipment and injury costs are not applicable per §6.12.3 and returned zero. Rupture repair alone scales by diameter squared. Groundwater uses printed detection time, not inventory depletion time."])
133
+ def bottom_financial(x):
134
+ environment = _environment(x, BottomEnvironment, bottom_environment)
135
+ outage = _outage(x, "TANKBOTTOM")
136
+ return _result(x, _repair(x, "TANKBOTTOM"), outage, environment)
137
+
138
+
139
+ @calculation("tank/shell-financial-consequence", "Compose five AST shell-course financial consequence categories", ShellFinancial,
140
+ ["Part3 §6.12.2,eqs(3.85)-(3.91),(3.231)-(3.243); PDF pp505-506,599-600"], limitations=_FIN_LIMITS+[
141
+ "Areas must already be GFF-weighted from a qualified AST flammable/toxic assessment. No automatic AST NFNT area is inferred. Surrounding-outage correlation uses equivalent USD2001 via explicit escalation/FX factors; damage area is not converted again."])
142
+ def shell_financial(x):
143
+ environment = _environment(x, ShellEnvironment, shell_environment)
144
+ outage = _outage(x, "COURSE-1-10")
145
+ surrounding = _product(x.equipment_area_m2, x.equipment_cost_per_m2)
146
+ reference = surrounding/x.repair.usd_to_output_currency/x.repair.price_factor_since_2001
147
+ ensure(math.isfinite(reference), "Reference surrounding cost overflow", "numerical_failure")
148
+ ensure(reference > 0 or surrounding == 0, "Reference surrounding cost underflow", "numerical_failure")
149
+ days = surrounding_outage(SurroundingOutage(surrounding_damage_cost_usd=reference))["surrounding_outage_days"]
150
+ injury = _product(x.personnel_area_m2, x.population_density_per_m2, x.cost_per_injury)
151
+ return _result(x, _repair(x, "COURSE-1-10"), outage, environment, surrounding, days, injury, reference)
152
+
153
+
154
+ class DamageBasis(Model):
155
+ management_score: NonNegative = Field(le=1000)
156
+ total_damage_factor: NonNegative
157
+ damage_factor_source: str = Field(min_length=1, max_length=500)
158
+
159
+
160
+ class BottomAssessment(DamageBasis):
161
+ financial: BottomFinancial
162
+
163
+
164
+ class ShellAssessment(DamageBasis):
165
+ financial: ShellFinancial
166
+
167
+
168
+ _RISK_LIMITS = _FIN_LIMITS+[
169
+ "Uniform supplied DF and Fms apply to all four holes of one component. Annual financial risk is sum(GFF)*DF*Fms*already-GFF-weighted financial consequence. This composition does not calculate damage applicability, dynamic release, whole-tank common-cause risk or individual/societal risk.",
170
+ ]
171
+
172
+
173
+ def _assessment(x, fn):
174
+ ensure(bool(x.damage_factor_source.strip()), "Damage factor source must be nonblank")
175
+ financial = fn(x.financial)
176
+ fms = management(Management(score=x.management_score))["management_factor"]
177
+ rates = x.financial.generic_frequency_by_hole_per_year
178
+ total = math.fsum(rates.values())
179
+ adjusted = {h: _product(rates[h], x.total_damage_factor, fms) for h in HOLES}
180
+ annual = math.fsum(adjusted.values())
181
+ ensure(math.isfinite(annual), "Annual frequency overflow", "numerical_failure")
182
+ ensure(annual > 0 or x.total_damage_factor == 0, "Annual frequency underflow", "numerical_failure")
183
+ return {**financial, "generic_frequency_per_year": total, "management_factor": fms,
184
+ "annual_failure_frequency": annual, "annual_frequency_by_hole": adjusted,
185
+ "financial_risk_per_year": _product(annual, financial["financial_consequence"]),
186
+ "integration_scope": "supplied_uniform_df_and_tank_release_consequences"}
187
+
188
+
189
+ @calculation("tank/bottom-risk-assessment", "AST bottom financial consequence and annual risk", BottomAssessment,
190
+ ["Part1 eqs(1.1),(1.8); Part2 eqs(2.9)-(2.10); Part3 §6.12.3"], limitations=_RISK_LIMITS+[
191
+ "Bottom assessment returns financial risk only; no unsupported zero-valued physical consequence area is implied. Surrounding-equipment and injury costs are not applicable; rupture-only repair diameter scaling and printed detection-time groundwater partition retained."])
192
+ def bottom_assessment(x):
193
+ return _assessment(x, bottom_financial)
194
+
195
+
196
+ @calculation("tank/shell-risk-assessment", "AST shell-course financial and area risks", ShellAssessment,
197
+ ["Part1 eqs(1.1),(1.6)-(1.8); Part2 eqs(2.9)-(2.10); Part3 §6.12.2"], limitations=_RISK_LIMITS+[
198
+ "Equipment/personnel area risks use externally qualified GFF-weighted AST areas. No automatic AST NFNT, course summation or whole-tank spatial overlap resolution."])
199
+ def shell_assessment(x):
200
+ result = _assessment(x, shell_financial)
201
+ return {**result, "equipment_area_risk_m2_per_year": _product(result["annual_failure_frequency"], x.financial.equipment_area_m2),
202
+ "personnel_area_risk_m2_per_year": _product(result["annual_failure_frequency"], x.financial.personnel_area_m2)}