api581-engine 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api581/__init__.py +80 -0
- api581/_amine_tables.py +105 -0
- api581/_h2sh2_tables.py +161 -0
- api581/_slabx_worker.py +174 -0
- api581/_sulfidic_tables.py +517 -0
- api581/_sulfuric_tables.py +328 -0
- api581/acid_sour_water.py +238 -0
- api581/acscc_screening.py +88 -0
- api581/alkaline_sour_water.py +267 -0
- api581/amine_corrosion.py +277 -0
- api581/amine_screening.py +103 -0
- api581/api/__init__.py +4 -0
- api581/api/api520.py +88 -0
- api581/api/bundle.py +121 -0
- api581/api/consequence.py +102 -0
- api581/api/damage.py +1831 -0
- api581/api/dispersion.py +121 -0
- api581/api/effects.py +318 -0
- api581/api/events.py +96 -0
- api581/api/financial.py +115 -0
- api581/api/frequency.py +40 -0
- api581/api/inspection.py +96 -0
- api581/api/level1.py +238 -0
- api581/api/level2.py +74 -0
- api581/api/planning.py +17 -0
- api581/api/prd.py +190 -0
- api581/api/properties.py +130 -0
- api581/api/release.py +252 -0
- api581/api/reliability.py +40 -0
- api581/api/risk.py +71 -0
- api581/api/tank.py +490 -0
- api581/api520.py +205 -0
- api581/area_contribution.py +72 -0
- api581/blast.py +232 -0
- api581/brittle.py +205 -0
- api581/bundle.py +300 -0
- api581/catalog.py +442 -0
- api581/caustic_screening.py +103 -0
- api581/clad_thinning.py +146 -0
- api581/clscc_screening.py +55 -0
- api581/co2_corrosion.py +313 -0
- api581/co2_screening.py +205 -0
- api581/consequences.py +250 -0
- api581/contracts.py +1038 -0
- api581/cooling_selection.py +204 -0
- api581/cooling_water.py +305 -0
- api581/core.py +126 -0
- api581/corrosion_rate.py +38 -0
- api581/cost_tables.py +175 -0
- api581/cracking.py +74 -0
- api581/data/coverage.json +21361 -0
- api581/data/digital-twin.schema.json +538 -0
- api581/data/direct-test-queue.json +7 -0
- api581/data/native-engine-evidence.json +44 -0
- api581/data/python-api.schema.json +92706 -0
- api581/data/reference-fixture-evidence.json +2956 -0
- api581/data/scope-boundaries.schema.json +58 -0
- api581/data/software-test-evidence.json +4278 -0
- api581/data/standard-metadata.json +1328 -0
- api581/data/standard.schema.json +233 -0
- api581/data/supporting-text-review.json +1319 -0
- api581/digital_twin.py +491 -0
- api581/dispersion.py +45 -0
- api581/effects.py +232 -0
- api581/evaporation.py +181 -0
- api581/evidence.py +81 -0
- api581/external_corrosion.py +148 -0
- api581/external_cracking.py +63 -0
- api581/fatigue.py +56 -0
- api581/fireball.py +163 -0
- api581/flash_pool.py +111 -0
- api581/flash_solver.py +122 -0
- api581/groundwater.py +101 -0
- api581/h2sh2_corrosion.py +128 -0
- api581/hic_screened.py +89 -0
- api581/htha.py +45 -0
- api581/hydrochloric_acid.py +272 -0
- api581/hydrofluoric_acid.py +236 -0
- api581/ignition.py +204 -0
- api581/inspection_effectiveness.py +601 -0
- api581/jet_handoff.py +60 -0
- api581/level1.py +311 -0
- api581/level1_summary.py +35 -0
- api581/level1_toxic.py +313 -0
- api581/level1_type1.py +91 -0
- api581/level2_summary.py +237 -0
- api581/lining.py +173 -0
- api581/native_toxic.py +67 -0
- api581/outputs.py +5672 -0
- api581/oxidation.py +145 -0
- api581/pascc_screening.py +78 -0
- api581/physical_explosion.py +190 -0
- api581/pof.py +207 -0
- api581/pool_fire.py +273 -0
- api581/portfolio.py +68 -0
- api581/prd.py +204 -0
- api581/prd_consequences.py +245 -0
- api581/prd_history.py +44 -0
- api581/py.typed +0 -0
- api581/release.py +307 -0
- api581/representative_fluids.py +172 -0
- api581/result.py +50 -0
- api581/risk.py +191 -0
- api581/saturation.py +66 -0
- api581/scc_mechanisms.py +242 -0
- api581/schema.py +45 -0
- api581/slabx_adapter.py +261 -0
- api581/soil_corrosion.py +191 -0
- api581/source_partition.py +169 -0
- api581/spatial.py +87 -0
- api581/ssc_screening.py +77 -0
- api581/standard.py +281 -0
- api581/storage.py +42 -0
- api581/sulfidic_corrosion.py +197 -0
- api581/sulfuric_acid.py +209 -0
- api581/tank.py +260 -0
- api581/tank_corrosion.py +256 -0
- api581/tank_environment.py +186 -0
- api581/tank_financial.py +202 -0
- api581/tank_integrated.py +51 -0
- api581/tank_shell.py +141 -0
- api581/tank_source.py +152 -0
- api581/tank_thinning.py +204 -0
- api581/temper.py +105 -0
- api581/thinning.py +172 -0
- api581/thinning_conditions.py +212 -0
- api581/thinning_history.py +118 -0
- api581/thinning_selection.py +81 -0
- api581/thinning_type.py +34 -0
- api581/toxic_criteria.py +111 -0
- api581/toxic_level2.py +233 -0
- api581/two_phase_events.py +120 -0
- api581/workflow.py +42 -0
- api581_engine-0.1.4.dist-info/METADATA +419 -0
- api581_engine-0.1.4.dist-info/RECORD +139 -0
- api581_engine-0.1.4.dist-info/WHEEL +5 -0
- api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
- api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
- api581_engine-0.1.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Geometry/soil-to-financial-risk composition for a single AST bottom."""
|
|
2
|
+
from pydantic import Field
|
|
3
|
+
from .core import Model, NonNegative, Positive, Fraction, REGISTRY, calculation, finite_tree
|
|
4
|
+
from .tank_source import BottomSource, bottom_source
|
|
5
|
+
from .tank_environment import Location, GroundwaterArrival, groundwater_arrival
|
|
6
|
+
from .tank_financial import TankRepairBasis, DamageBasis, BottomFinancial, BottomAssessment, bottom_assessment
|
|
7
|
+
from .tank_environment import Hole
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BottomEconomics(Model):
|
|
11
|
+
repair: TankRepairBasis
|
|
12
|
+
outage_multiplier: Positive
|
|
13
|
+
override_outage_days: dict[Hole, NonNegative] = Field(default_factory=dict)
|
|
14
|
+
production_cost_per_day: NonNegative
|
|
15
|
+
cost_by_location_per_bbl: dict[Location, NonNegative]
|
|
16
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
17
|
+
cost_basis: str = Field(min_length=1, max_length=500)
|
|
18
|
+
fraction_leaving_dike: Fraction
|
|
19
|
+
fraction_on_site: Fraction
|
|
20
|
+
fraction_off_site: Fraction
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BottomIntegrated(DamageBasis):
|
|
24
|
+
source: BottomSource
|
|
25
|
+
distance_to_groundwater_m: NonNegative
|
|
26
|
+
economics: BottomEconomics
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@calculation("tank/bottom-integrated-assessment", "AST bottom geometry and soil through source, financial consequence and annual risk", BottomIntegrated,
|
|
30
|
+
[*REGISTRY["tank/bottom-source-assessment"].references,"Part3 eq(3.244); PDF p601",
|
|
31
|
+
*REGISTRY["tank/bottom-risk-assessment"].references],
|
|
32
|
+
origin="api581_composition_with_supplied_damage_and_valuation",
|
|
33
|
+
limitations=[*REGISTRY["tank/bottom-source-assessment"].limitations,
|
|
34
|
+
*REGISTRY["tank/bottom-risk-assessment"].limitations,
|
|
35
|
+
"Computes source inventory/rates/volumes, summed table GFF and seepage arrival from the same geometry/soil request. DF applicability and monetary qualification are external supplied assumptions. Single bottom only; no shell, whole-tank common-cause or digital-twin sensor assimilation inferred."])
|
|
36
|
+
def integrated(x):
|
|
37
|
+
source = bottom_source(x.source)
|
|
38
|
+
finite_tree(source)
|
|
39
|
+
arrival = groundwater_arrival(GroundwaterArrival(distance_to_groundwater_m=x.distance_to_groundwater_m,
|
|
40
|
+
seepage_velocity_m_day=source["seepage_velocity_m_day"]))
|
|
41
|
+
financial = BottomFinancial(**x.economics.model_dump(),
|
|
42
|
+
generic_frequency_by_hole_per_year=source["generic_frequency_by_hole_per_year"],
|
|
43
|
+
generic_frequency_source="Part2 Table3.1; total eq3.208; computed within same bottom source",
|
|
44
|
+
tank_diameter_m=x.source.tank_diameter_m,
|
|
45
|
+
leaked_volume_by_hole_bbl={h:source["released_volume_by_hole_bbl"][h] for h in ("small","medium","large")},
|
|
46
|
+
total_tank_volume_bbl=source["available_volume_bbl"],detection_time_days=source["detection_time_days"],
|
|
47
|
+
groundwater_arrival_time_days=arrival["arrival_time_days"])
|
|
48
|
+
result = bottom_assessment(BottomAssessment(financial=financial,management_score=x.management_score,
|
|
49
|
+
total_damage_factor=x.total_damage_factor,damage_factor_source=x.damage_factor_source))
|
|
50
|
+
return {**source,**result,"groundwater_arrival_time_days":arrival["arrival_time_days"],
|
|
51
|
+
"integration_scope":"bottom_geometry_soil_source_financial_risk_with_supplied_df"}
|
api581/tank_shell.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""AST diked pool effects and continuous-shell Level2 area/risk composition."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, REGISTRY, calculation, ensure, finite_tree
|
|
6
|
+
from .pool_fire import PoolConsequence, pool_consequence
|
|
7
|
+
from .level2_summary import FlammableCase, flammable_case
|
|
8
|
+
from .toxic_level2 import ToxicCloudArea, ToxicCase, toxic_case
|
|
9
|
+
from .tank_environment import HOLES, FrequencyWeights, _weights
|
|
10
|
+
from .tank_financial import TankComponent, generic_frequency, DamageBasis, ShellFinancial, ShellAssessment, shell_assessment
|
|
11
|
+
from .tank_source import ShellSource, shell_source
|
|
12
|
+
from .tank_integrated import BottomEconomics
|
|
13
|
+
|
|
14
|
+
DikeBasis = Literal["burning_pool_area", "burning_and_consequence_area"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DikedPool(PoolConsequence):
|
|
18
|
+
site_maximum_area_m2: Positive | None = None
|
|
19
|
+
dike_area_m2: Positive
|
|
20
|
+
dike_limit_basis: DikeBasis
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@calculation("tank/diked-pool-consequence", "AST pool radiation with explicit dike-area interpretation", DikedPool,
|
|
24
|
+
["Part3 §6.7.2; PDF p596", *REGISTRY["effects/pool-fire-consequence"].references],
|
|
25
|
+
origin="api581_pool_chain_with_explicit_ast_dike_interpretation",
|
|
26
|
+
limitations=[*REGISTRY["effects/pool-fire-consequence"].limitations,
|
|
27
|
+
"Source says pool fire area must not exceed dike area without distinguishing burning pool from radiation consequence. Required burning_pool_area caps physical pool before radiation; burning_and_consequence_area additionally clips reported consequence areas numerically. Neither is an authorized erratum. Raw radiation areas and threshold separations remain separately labeled; numerical clipping is not a spatial dike intersection. No ignition/hole weighting."])
|
|
28
|
+
def diked_pool(x):
|
|
29
|
+
cap = min(x.dike_area_m2, x.site_maximum_area_m2) if x.site_maximum_area_m2 is not None else x.dike_area_m2
|
|
30
|
+
fields = {k:getattr(x,k) for k in PoolConsequence.model_fields}
|
|
31
|
+
fields["site_maximum_area_m2"] = cap
|
|
32
|
+
result = pool_consequence(PoolConsequence(**fields))
|
|
33
|
+
raw_equipment, raw_personnel = result["equipment_area_m2"], result["personnel_area_m2"]
|
|
34
|
+
equipment = min(raw_equipment,x.dike_area_m2) if x.dike_limit_basis == "burning_and_consequence_area" else raw_equipment
|
|
35
|
+
personnel = min(raw_personnel,x.dike_area_m2) if x.dike_limit_basis == "burning_and_consequence_area" else raw_personnel
|
|
36
|
+
distances = {"unclipped_"+k:result.pop(k) for k in ("personnel_safe_surface_distance_m","equipment_safe_surface_distance_m")}
|
|
37
|
+
return {**result,**distances,"equipment_area_m2":equipment,"personnel_area_m2":personnel,
|
|
38
|
+
"unclipped_equipment_area_m2":raw_equipment,"unclipped_personnel_area_m2":raw_personnel,
|
|
39
|
+
"effective_pool_area_limit_m2":cap,"dike_area_m2":x.dike_area_m2,"dike_limit_basis":x.dike_limit_basis,
|
|
40
|
+
"equipment_area_clipped":equipment < raw_equipment,"personnel_area_clipped":personnel < raw_personnel}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ShellCase(FlammableCase):
|
|
44
|
+
release_type: Literal["continuous"] = "continuous"
|
|
45
|
+
toxic_components: list[ToxicCloudArea] = Field(max_length=100)
|
|
46
|
+
toxic_applicability: Literal["evaluated","not_applicable"]
|
|
47
|
+
toxic_applicability_source: str = Field(min_length=1,max_length=1000)
|
|
48
|
+
dike_area_m2: Positive
|
|
49
|
+
burning_pool_area_m2: NonNegative
|
|
50
|
+
pool_dike_limit_basis: DikeBasis
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_AREA_LIMITS = [
|
|
54
|
+
"AST shell release type is continuous, including rupture. Raw event probabilities and flammable/toxic footprints are externally qualified. All five canonical flammable events required; inapplicable fireball branch zero. Shared non-ignition probability weights toxic once; empty toxic components require explicit not-applicable basis.",
|
|
55
|
+
"NFNT is not determined for AST per §6.10 and is absent from the request. Pool burning area must fit dike; explicit dike interpretation and raw footprint provenance are required. Burning area0 requires zero pool radiation footprint; no circular radiation area is silently clipped after weighting.",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@calculation("tank/shell-level2-case", "AST continuous shell event-weighted flammable and toxic areas", ShellCase,
|
|
60
|
+
["Part3 §§6.5,6.7.2,6.8-6.11; PDF pp596,598", "Part3 eqs(3.176)-(3.177),(3.188)"],
|
|
61
|
+
origin="api581_ast_conditional_event_composition",limitations=_AREA_LIMITS)
|
|
62
|
+
def shell_case(x):
|
|
63
|
+
ensure(x.toxic_applicability_source.strip(), "Toxic applicability source must be nonblank")
|
|
64
|
+
ensure(bool(x.toxic_components) == (x.toxic_applicability == "evaluated"), "Toxic components must match applicability")
|
|
65
|
+
ensure(x.burning_pool_area_m2 <= x.dike_area_m2, "Burning pool exceeds dike area", "dike_area_exceeded")
|
|
66
|
+
pool = next((o for o in x.outcomes if o.event == "pool_fire"),None)
|
|
67
|
+
pool_areas = [] if pool is None else [pool.equipment_area_m2,pool.personnel_area_m2]
|
|
68
|
+
if x.correlated_jet_pool is not None:
|
|
69
|
+
pool_areas += [x.correlated_jet_pool.pool_equipment_area_m2,x.correlated_jet_pool.pool_personnel_area_m2]
|
|
70
|
+
ensure(x.burning_pool_area_m2 > 0 or all(a == 0 for a in pool_areas), "No burning pool requires zero pool footprints")
|
|
71
|
+
if x.pool_dike_limit_basis == "burning_and_consequence_area":
|
|
72
|
+
ensure(all(a <= x.dike_area_m2 for a in pool_areas), "Pool consequence area exceeds selected literal dike cap", "dike_area_exceeded")
|
|
73
|
+
flame = flammable_case(FlammableCase(**x.model_dump(include=set(FlammableCase.model_fields))))
|
|
74
|
+
toxic = toxic_case(ToxicCase(components=x.toxic_components,non_ignition_probability=x.non_ignition_probability))["personnel_area_m2"] if x.toxic_components else 0.
|
|
75
|
+
return {**flame,"toxic_personnel_area_m2":toxic,"nfnt_status":"not_determined_for_ast"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ShellSummary(Model):
|
|
79
|
+
cases: list[ShellCase] = Field(min_length=4,max_length=4)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@calculation("tank/shell-level2-summary", "AST four-hole GFF-weighted flammable plus toxic final areas", ShellSummary,
|
|
83
|
+
["Part3 §§6.10-6.11,eqs(3.178)-(3.179),(3.191),(3.202)-(3.204); PDF pp560,567,572-573,598"],
|
|
84
|
+
origin="api581_ast_level2_mechanism_composition",limitations=_AREA_LIMITS+[
|
|
85
|
+
"Exactly four canonical holes on one common dike basis. Uses COURSE-1-10 Table3.1 frequencies with eq3.208 total. Mechanism GFF averages precede final equipment=flammable and personnel=flammable+toxic; this is Level2 additive composition with NFNT absent, not Level1 maximum selection. No DF in GFF averaging."])
|
|
86
|
+
def shell_summary(x):
|
|
87
|
+
ensure({c.hole for c in x.cases} == set(HOLES), "Exactly four distinct canonical holes required")
|
|
88
|
+
ensure(len({(c.dike_area_m2,c.pool_dike_limit_basis) for c in x.cases}) == 1, "Four cases must share dike area and interpretation")
|
|
89
|
+
gff = generic_frequency(TankComponent(component_type="COURSE-1-10"))
|
|
90
|
+
weights = _weights(FrequencyWeights(generic_frequency_by_hole_per_year=gff["by_hole_per_year"]))
|
|
91
|
+
cases = {c.hole:shell_case(c) for c in x.cases}
|
|
92
|
+
names = ("flammable_equipment_area_m2","flammable_personnel_area_m2","toxic_personnel_area_m2")
|
|
93
|
+
values = {name:math.fsum(weights[h]*cases[h][name] for h in HOLES) for name in names}
|
|
94
|
+
personnel = values["flammable_personnel_area_m2"]+values["toxic_personnel_area_m2"]
|
|
95
|
+
return {**values,"equipment_area_m2":values["flammable_equipment_area_m2"],"personnel_area_m2":personnel,
|
|
96
|
+
"consequence_area_m2":max(values["flammable_equipment_area_m2"],personnel),
|
|
97
|
+
"generic_frequency_per_year":gff["total_per_year"],"nfnt_status":"not_determined_for_ast",
|
|
98
|
+
**{"by_hole_"+name:{h:cases[h][name] for h in HOLES} for name in names}}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ShellEconomics(BottomEconomics):
|
|
102
|
+
equipment_cost_per_m2: NonNegative
|
|
103
|
+
population_density_per_m2: NonNegative
|
|
104
|
+
cost_per_injury: NonNegative
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ShellIntegrated(DamageBasis):
|
|
108
|
+
source: ShellSource
|
|
109
|
+
cases: list[ShellCase] = Field(min_length=4,max_length=4)
|
|
110
|
+
economics: ShellEconomics
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@calculation("tank/shell-integrated-assessment", "AST shell source, supplied event footprints, financial consequence and annual risk", ShellIntegrated,
|
|
114
|
+
[*REGISTRY["tank/shell-source-assessment"].references,*REGISTRY["tank/shell-level2-summary"].references,
|
|
115
|
+
*REGISTRY["tank/shell-risk-assessment"].references],
|
|
116
|
+
origin="api581_ast_source_with_qualified_event_footprints_and_supplied_damage",
|
|
117
|
+
limitations=[*REGISTRY["tank/shell-source-assessment"].limitations,*REGISTRY["tank/shell-level2-summary"].limitations,
|
|
118
|
+
*REGISTRY["tank/shell-risk-assessment"].limitations,
|
|
119
|
+
"Computes source/GFF/environment from one geometry request and inserts event/GFF-weighted AST areas into financial risk. Footprints and event probabilities remain external and must be qualified for this computed source. Does not infer dispersion, EOS, source-to-footprint consistency or whole-tank spatial overlap. Dry source requires zero flammable/toxic footprints for every hole."])
|
|
120
|
+
def shell_integrated(x):
|
|
121
|
+
source = shell_source(x.source)
|
|
122
|
+
finite_tree(source)
|
|
123
|
+
areas = shell_summary(ShellSummary(cases=x.cases))
|
|
124
|
+
finite_tree(areas)
|
|
125
|
+
if not source["course_wet"]:
|
|
126
|
+
for case in x.cases:
|
|
127
|
+
ensure(case.burning_pool_area_m2 == 0, "Dry source cannot have a burning pool")
|
|
128
|
+
ensure(all(o.equipment_area_m2 == o.personnel_area_m2 == 0 for o in case.outcomes), "Dry source cannot have flammable footprints")
|
|
129
|
+
ensure(not case.toxic_components or all(c.cloud_area_m2 == 0 for c in case.toxic_components), "Dry source cannot have toxic footprints")
|
|
130
|
+
if case.correlated_jet_pool is not None:
|
|
131
|
+
ensure(all(getattr(case.correlated_jet_pool,k) == 0 for k in (
|
|
132
|
+
"jet_equipment_area_m2","jet_personnel_area_m2","pool_equipment_area_m2","pool_personnel_area_m2")),
|
|
133
|
+
"Dry source cannot have correlated footprints")
|
|
134
|
+
financial = ShellFinancial(**x.economics.model_dump(),
|
|
135
|
+
generic_frequency_by_hole_per_year=source["generic_frequency_by_hole_per_year"],
|
|
136
|
+
generic_frequency_source="Part2 Table3.1; sum eq3.208; computed within same shell source",
|
|
137
|
+
released_volume_by_hole_bbl=source["released_volume_by_hole_bbl"],
|
|
138
|
+
equipment_area_m2=areas["equipment_area_m2"],personnel_area_m2=areas["personnel_area_m2"])
|
|
139
|
+
risk = shell_assessment(ShellAssessment(financial=financial,management_score=x.management_score,
|
|
140
|
+
total_damage_factor=x.total_damage_factor,damage_factor_source=x.damage_factor_source))
|
|
141
|
+
return {**source,**areas,**risk,"integration_scope":"shell_geometry_source_supplied_event_footprints_financial_risk"}
|
api581/tank_source.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""AST tabulated hole selection and geometry-to-release source composition."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
|
|
6
|
+
from . import tank as t
|
|
7
|
+
from .tank_financial import TankComponent, generic_frequency
|
|
8
|
+
|
|
9
|
+
HOLES = ("small", "medium", "large", "rupture")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ShellHoles(Model):
|
|
13
|
+
tank_diameter_m: Positive
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BottomHoles(ShellHoles):
|
|
17
|
+
has_release_prevention_barrier: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _holes(diameters):
|
|
21
|
+
areas = {h: math.pi/4*d*d for h,d in zip(HOLES,diameters)}
|
|
22
|
+
ensure(all(a > 0 or d == 0 for a,d in zip(areas.values(),diameters)), "Hole area underflow", "numerical_failure")
|
|
23
|
+
return {"diameter_by_hole_m": dict(zip(HOLES,diameters)), "area_by_hole_m2": areas}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@calculation("tank/shell-hole-sizes", "AST shell-course representative hole diameters and areas", ShellHoles,
|
|
27
|
+
["Part3 Tables6.3/6.3M; PDF p606"], limitations=[
|
|
28
|
+
"Small3.175mm, medium6.35mm, large50.8mm, rupture=tank diameter/4. This selects representative holes, not measured-hole classification. Rupture diameter is a source convention, not validation of tank-diameter/4 orifice hydraulics."])
|
|
29
|
+
def shell_holes(x):
|
|
30
|
+
ensure(x.tank_diameter_m/4 > 0, "Rupture diameter underflow", "numerical_failure")
|
|
31
|
+
return _holes((.003175,.00635,.0508,x.tank_diameter_m/4))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@calculation("tank/bottom-hole-sizes", "AST bottom representative hole diameters and areas", BottomHoles,
|
|
35
|
+
["Part3 Tables6.4/6.4M; PDF pp606-607"], limitations=[
|
|
36
|
+
"Small3.175mm with RPB or12.7mm without, medium/large zero, rupture=tank diameter/4. Printed SI Table6.4M rupture row shows only Yes; US Table6.4 explicitly covers Yes/No under shared tank-diameter/4 formula. No-RPB rupture follows that US row converted to SI, not an authorized SI erratum. No rupture flow/hole count inferred from Table6.5."])
|
|
37
|
+
def bottom_holes(x):
|
|
38
|
+
ensure(x.tank_diameter_m/4 > 0, "Rupture diameter underflow", "numerical_failure")
|
|
39
|
+
return _holes((.003175 if x.has_release_prevention_barrier else .0127,0.,0.,x.tank_diameter_m/4))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ShellSource(Model):
|
|
43
|
+
tank_diameter_m: Positive
|
|
44
|
+
fill_height_m: NonNegative
|
|
45
|
+
course_index: int = Field(ge=1,strict=True)
|
|
46
|
+
course_height_m: Positive
|
|
47
|
+
fluid: t.Fluid
|
|
48
|
+
diameter_boundary_basis: Literal["printed_3_17_mm","inch_0_125"]
|
|
49
|
+
discharge_coefficient: Fraction = .61
|
|
50
|
+
gravity_m_s2: Positive = 9.81
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class BottomSource(Model):
|
|
54
|
+
tank_diameter_m: Positive
|
|
55
|
+
fill_height_m: NonNegative
|
|
56
|
+
fluid: t.Fluid
|
|
57
|
+
soil: t.Soil
|
|
58
|
+
water_density_kg_m3: Positive
|
|
59
|
+
water_dynamic_viscosity_pa_s: Positive
|
|
60
|
+
foundation: Literal["concrete_asphalt","other"]
|
|
61
|
+
has_release_prevention_barrier: bool
|
|
62
|
+
detection_overlap_basis: Literal["foundation_priority","barrier_priority"] | None = None
|
|
63
|
+
transition_hole_basis: Literal["api581_printed","per_hole_scaled"]
|
|
64
|
+
soil_contact_factor: Positive = .21
|
|
65
|
+
gravity_m_s2: Positive = 9.81
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _release_outputs(holes,inventory,rates,detection,volumes,component_type):
|
|
69
|
+
gff = generic_frequency(TankComponent(component_type=component_type))
|
|
70
|
+
return {**holes,**inventory,"generic_frequency_by_hole_per_year":gff["by_hole_per_year"],
|
|
71
|
+
"generic_total_per_year":gff["total_per_year"],"printed_generic_total_per_year":gff["printed_total_per_year"],
|
|
72
|
+
"generic_total_basis":gff["total_basis"],
|
|
73
|
+
"native_rate_by_hole_bbl_day":{h:rates[h]["native_rate_bbl_day"] if rates[h] is not None else None for h in HOLES},
|
|
74
|
+
"mass_rate_by_hole_kg_s":{h:rates[h]["mass_rate_kg_s"] if rates[h] is not None else None for h in HOLES},
|
|
75
|
+
"detection_time_by_hole_days":detection,
|
|
76
|
+
"release_duration_by_hole_days":{h:volumes[h]["release_duration_days"] for h in HOLES},
|
|
77
|
+
"released_volume_by_hole_bbl":{h:volumes[h]["released_volume_bbl"] for h in HOLES},
|
|
78
|
+
"released_volume_by_hole_m3":{h:volumes[h]["released_volume_m3"] for h in HOLES},
|
|
79
|
+
"released_mass_by_hole_kg":{h:volumes[h]["released_mass_kg"] for h in HOLES},
|
|
80
|
+
"release_type":"continuous"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_SOURCE_LIMITS = [
|
|
84
|
+
"Uses representative Table6.1M liquid properties, constant head and C13=6.29bbl/m3. No operating-temperature EOS, connected inventory or transient drainage. Source mass=Bbl printing is not used as kg: mass is volume*density, the explicitly qualified dimensional interpretation.",
|
|
85
|
+
"Table3.1 hole frequencies use eq3.208 summed total, with printed total returned separately. One component only; no whole-tank course summation, area consequences or damage applicability inferred. AST release type remains continuous. No generic detection/isolation reduction factors.",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@calculation("tank/shell-source-assessment", "Compose AST shell holes, head, inventory, rates and released quantities", ShellSource,
|
|
90
|
+
["Part3 §§6.1-6.7,Tables6.1M/6.3M,eqs(3.208)-(3.209),(3.214)-(3.227); PDF pp592-597,604,606"], limitations=_SOURCE_LIMITS+[
|
|
91
|
+
"Equal-height courses; source at course lower edge. Above-fill courses use physical dry inventory/head0. Shell3.17mm vs3.175mm detection ambiguity requires explicit boundary basis. Eq3.209 evaluated for all four representative holes as STEP3.3 specifies; nominal rupture rate is a printed constant-head convention, not validated catastrophic-rupture hydraulics. Rupture uses full above-course inventory with null duration/detection; no invented rupture duration."])
|
|
92
|
+
def shell_source(x):
|
|
93
|
+
properties = t.fluid_table(t.FluidLookup(fluid=x.fluid))
|
|
94
|
+
rho = properties["liquid_density_kg_m3"]
|
|
95
|
+
holes = shell_holes(ShellHoles(tank_diameter_m=x.tank_diameter_m))
|
|
96
|
+
inventory = t.shell_inventory(t.ShellInventory(tank_diameter_m=x.tank_diameter_m,
|
|
97
|
+
fill_height_m=x.fill_height_m,liquid_density_kg_m3=rho,course_index=x.course_index,course_height_m=x.course_height_m))
|
|
98
|
+
ensure(all(math.isfinite(v) for v in inventory.values()), "Shell inventory overflow", "numerical_failure")
|
|
99
|
+
rates, detection, volumes = {}, {}, {}
|
|
100
|
+
for h in HOLES:
|
|
101
|
+
diameter = holes["diameter_by_hole_m"][h]
|
|
102
|
+
rates[h] = t.shell_flow(t.ShellFlow(hole_diameter_m=diameter,liquid_head_m=inventory["available_liquid_head_m"],
|
|
103
|
+
liquid_density_kg_m3=rho,discharge_coefficient=x.discharge_coefficient,gravity_m_s2=x.gravity_m_s2))
|
|
104
|
+
ensure(all(math.isfinite(v) for v in rates[h].values()), "Shell flow overflow", "numerical_failure")
|
|
105
|
+
detection[h] = None if h == "rupture" else t.shell_detection(t.ShellDetection(
|
|
106
|
+
hole_diameter_m=diameter,diameter_boundary_basis=x.diameter_boundary_basis))["detection_time_days"]
|
|
107
|
+
volumes[h] = t.release_volume(t.ReleaseVolume(release_case="rupture" if h == "rupture" else "leak",
|
|
108
|
+
native_rate_bbl_day=rates[h]["native_rate_bbl_day"],available_volume_m3=inventory["available_volume_m3"],
|
|
109
|
+
liquid_density_kg_m3=rho,detection_time_days=detection[h] if detection[h] is not None else 0))
|
|
110
|
+
return {**_release_outputs(holes,inventory,rates,detection,volumes,"COURSE-1-10"),
|
|
111
|
+
"liquid_density_kg_m3":rho,"source_scope":"single_shell_course"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@calculation("tank/bottom-source-assessment", "Compose AST soil, holes, inventory, seepage and released quantities", BottomSource,
|
|
115
|
+
["Part3 §§6.1-6.7,Tables6.1M/6.2M/6.4/6.4M/6.5,eqs(3.205)-(3.212),(3.219)-(3.230); PDF pp592-598,604-607"], limitations=_SOURCE_LIMITS+[
|
|
116
|
+
"Soil arithmetic mean preserves reversed printed bounds; explicit water reference properties required. RPB small-hole head.0762m when filled; empty tank zero. SI C37 threshold retained despite SI/US mismatch. Transition count omission requires explicit printed vs per-hole-scaled basis. No-RPB rupture diameter follows complete US Table6.4 row; SI rupture row is incomplete.",
|
|
117
|
+
"Medium/large holes have zero count/flow. Table6.5 gives no rupture count, so rupture flow and duration are null; rupture volume is whole inventory per eq3.230. Bottom detection with concrete/asphalt AND RPB requires explicit priority. Count outside30.5-91.4m is flagged, not qualified by this composition."])
|
|
118
|
+
def bottom_source(x):
|
|
119
|
+
properties = t.fluid_table(t.FluidLookup(fluid=x.fluid))
|
|
120
|
+
rho = properties["liquid_density_kg_m3"]
|
|
121
|
+
soil = t.soil_table(t.SoilLookup(soil=x.soil))
|
|
122
|
+
water = t.water_conductivity(t.WaterConductivity(**{k:soil[k] for k in (
|
|
123
|
+
"printed_lower_bound_cm_s","printed_upper_bound_cm_s")}))["water_hydraulic_conductivity_m_day"]
|
|
124
|
+
conductivity = t.fluid_conductivity(t.FluidConductivity(water_hydraulic_conductivity_m_day=water,
|
|
125
|
+
liquid_density_kg_m3=rho,dynamic_viscosity_pa_s=properties["dynamic_viscosity_pa_s"],
|
|
126
|
+
water_density_kg_m3=x.water_density_kg_m3,water_dynamic_viscosity_pa_s=x.water_dynamic_viscosity_pa_s))["product_hydraulic_conductivity_m_day"]
|
|
127
|
+
ensure(math.isfinite(conductivity), "Product conductivity overflow", "numerical_failure")
|
|
128
|
+
velocity = t.seepage(t.Seepage(product_hydraulic_conductivity_m_day=conductivity,porosity=soil["porosity"]))["seepage_velocity_m_day"]
|
|
129
|
+
holes = bottom_holes(BottomHoles(tank_diameter_m=x.tank_diameter_m,
|
|
130
|
+
has_release_prevention_barrier=x.has_release_prevention_barrier))
|
|
131
|
+
counts = t.bottom_hole_count(t.BottomHoleCount(tank_diameter_m=x.tank_diameter_m))
|
|
132
|
+
inventory = t.bottom_inventory(t.BottomInventory(tank_diameter_m=x.tank_diameter_m,fill_height_m=x.fill_height_m,liquid_density_kg_m3=rho))
|
|
133
|
+
ensure(all(math.isfinite(v) for v in inventory.values()), "Bottom inventory overflow", "numerical_failure")
|
|
134
|
+
detect = t.bottom_detection(t.BottomDetection(foundation=x.foundation,
|
|
135
|
+
has_release_prevention_barrier=x.has_release_prevention_barrier,overlap_basis=x.detection_overlap_basis))
|
|
136
|
+
rates, detection, volumes = {}, {}, {}
|
|
137
|
+
for h in HOLES:
|
|
138
|
+
rates[h] = None if h == "rupture" else t.bottom_flow(t.BottomFlow(hole_diameter_m=holes["diameter_by_hole_m"][h],
|
|
139
|
+
liquid_height_m=x.fill_height_m,product_hydraulic_conductivity_m_day=conductivity,liquid_density_kg_m3=rho,
|
|
140
|
+
number_of_holes=counts[h+"_hole_count"],has_release_prevention_barrier=x.has_release_prevention_barrier,
|
|
141
|
+
transition_hole_basis=x.transition_hole_basis,soil_contact_factor=x.soil_contact_factor,gravity_m_s2=x.gravity_m_s2))
|
|
142
|
+
rate = 0 if rates[h] is None else rates[h]["native_rate_bbl_day"]
|
|
143
|
+
ensure(math.isfinite(rate), "Bottom flow overflow", "numerical_failure")
|
|
144
|
+
detection[h] = None if h == "rupture" else detect["detection_time_days"]
|
|
145
|
+
volumes[h] = t.release_volume(t.ReleaseVolume(release_case="rupture" if h == "rupture" else "leak",
|
|
146
|
+
native_rate_bbl_day=rate,available_volume_m3=inventory["available_volume_m3"],liquid_density_kg_m3=rho,
|
|
147
|
+
detection_time_days=detect["detection_time_days"]))
|
|
148
|
+
return {**_release_outputs(holes,inventory,rates,detection,volumes,"TANKBOTTOM"),**counts,
|
|
149
|
+
"liquid_density_kg_m3":rho,"product_hydraulic_conductivity_m_day":conductivity,"seepage_velocity_m_day":velocity,
|
|
150
|
+
"bottom_flow_equation":rates["small"]["equation"],"applied_small_hole_multiplier":rates["small"]["applied_hole_multiplier"],
|
|
151
|
+
"detection_time_days":detect["detection_time_days"],"detection_selected_basis":detect["selected_basis"],
|
|
152
|
+
"source_scope":"single_tank_bottom"}
|
api581/tank_thinning.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""2016 AST-bottom thinning: dedicated Art equation, table and adjustments."""
|
|
2
|
+
from bisect import bisect_left
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, REGISTRY, calculation, ensure, finite_tree
|
|
6
|
+
from .tank_corrosion import BottomCorrosion, assessment as corrosion_assessment
|
|
7
|
+
from .tank_source import BottomSource
|
|
8
|
+
from .tank_integrated import BottomEconomics, BottomIntegrated, integrated
|
|
9
|
+
|
|
10
|
+
Grade = Literal["a","b","c","d","e"]
|
|
11
|
+
Lookup = Literal["exact_rows_only","linear_between_rows","conservative_upper_row"]
|
|
12
|
+
_ART = tuple(i/20 for i in range(21))
|
|
13
|
+
# Column order is E,D,C,B,A, as printed in Table4.7 (not the text's4.8).
|
|
14
|
+
_ROWS = (
|
|
15
|
+
(.1,.1,.1,.1,.1),(4,1,.5,.4,.4),(14,3,1,.7,.7),(32,8,2,1,1),
|
|
16
|
+
(56,18,6,2,1),(87,32,11,4,3),(125,53,21,9,6),(170,80,36,16,12),
|
|
17
|
+
(222,115,57,29,21),(281,158,86,47,36),(347,211,124,73,58),
|
|
18
|
+
(420,273,173,109,89),(500,346,234,158,133),(587,430,309,222,192),
|
|
19
|
+
(681,527,401,305,270),(782,635,510,409,370),(890,757,638,538,498),
|
|
20
|
+
(1005,893,789,696,658),(1126,1044,963,888,856),(1255,1209,1163,1118,1098),
|
|
21
|
+
(1390,1390,1390,1390,1390))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BottomMinimumThickness(Model):
|
|
25
|
+
release_prevention_barrier: bool
|
|
26
|
+
thickness_basis: Literal["api581_2016_default","documented_owner_method"]
|
|
27
|
+
owner_minimum_thickness_m: Positive | None = None
|
|
28
|
+
owner_method_basis: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@calculation("tank/bottom-minimum-thickness", "AST floor minimum thickness for thinning calculation", BottomMinimumThickness,
|
|
32
|
+
["Part2 §4.5.7 STEP5(3)-(4); PDF p153"],limitations=[
|
|
33
|
+
"Historical 2016 source defaults0.1in without RPB and0.05in with RPB converted exactly to SI. Owner documented alternate method permitted. Not current API653 acceptance, design-code compliance or FFS verification."])
|
|
34
|
+
def minimum_thickness(x):
|
|
35
|
+
if x.thickness_basis == "api581_2016_default":
|
|
36
|
+
ensure(x.owner_minimum_thickness_m is None and x.owner_method_basis is None,"Default thickness cannot have owner override")
|
|
37
|
+
value = .00127 if x.release_prevention_barrier else .00254
|
|
38
|
+
else:
|
|
39
|
+
ensure(x.owner_minimum_thickness_m is not None and x.owner_method_basis is not None and bool(x.owner_method_basis.strip()),"Owner thickness requires value and documented method")
|
|
40
|
+
value = x.owner_minimum_thickness_m
|
|
41
|
+
return {"minimum_thickness_m":value,"thickness_basis":x.thickness_basis}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BottomWallParameter(Model):
|
|
45
|
+
starting_thickness_m: Positive
|
|
46
|
+
corrosion_rate_m_year: NonNegative
|
|
47
|
+
elapsed_years: NonNegative
|
|
48
|
+
minimum_thickness_m: Positive
|
|
49
|
+
corrosion_allowance_m: NonNegative
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@calculation("tank/bottom-wall-parameter", "AST floor remaining thickness and Art parameter", BottomWallParameter,
|
|
53
|
+
["Part2 §4.5.7 STEP6(1),eq(2.12); PDF p153"],limitations=[
|
|
54
|
+
"Art=max(1-(starting_thickness-rate*elapsed)/(minimum_thickness+corrosion_allowance),0). This is not non-AST fractional wall loss. Starting thickness and elapsed time must refer to same qualified baseline. Negative predicted remaining thickness is returned diagnostically; no clamp to hide penetration. Art>1 is outside the damage table and rejected by subsequent lookup."])
|
|
55
|
+
def wall_parameter(x):
|
|
56
|
+
loss = x.corrosion_rate_m_year*x.elapsed_years
|
|
57
|
+
remaining = x.starting_thickness_m-loss
|
|
58
|
+
denominator = x.minimum_thickness_m+x.corrosion_allowance_m
|
|
59
|
+
finite_tree([loss,remaining,denominator])
|
|
60
|
+
art = max(1-remaining/denominator,0.)
|
|
61
|
+
finite_tree(art)
|
|
62
|
+
return {"art_parameter":art,"predicted_remaining_thickness_m":remaining,"predicted_wall_loss_m":loss,
|
|
63
|
+
"reference_thickness_m":denominator,"at_or_below_minimum_thickness":remaining <= x.minimum_thickness_m,
|
|
64
|
+
"predicted_penetration":remaining <= 0}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BottomInspectionSelection(Model):
|
|
68
|
+
eligible_inspection_grades: list[Grade] = Field(max_length=10000)
|
|
69
|
+
eligibility_basis: str = Field(min_length=1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@calculation("tank/bottom-inspection-selection", "Select one AST floor inspection credit", BottomInspectionSelection,
|
|
73
|
+
["Part2 §§4.5.5-4.5.6,Table4.7; PDF pp151-152,163"],limitations=[
|
|
74
|
+
"Only best effectiveness of eligible inspections is credited, never stacked or Bayesian-upgraded. Empty list/E gives no credit. Caller qualifies actual inspection effectiveness under Annex2.C.5 and inclusion in baseline-to-assessment period; furnished-to-measured baseline reset does not preserve earlier inspection credit automatically."])
|
|
75
|
+
def inspection_selection(x):
|
|
76
|
+
ensure(bool(x.eligibility_basis.strip()),"Inspection eligibility basis must not be blank")
|
|
77
|
+
grade = min(x.eligible_inspection_grades,default="e")
|
|
78
|
+
return {"selected_inspection_grade":grade,"credited_inspection_count":0 if grade == "e" else 1,
|
|
79
|
+
"eligible_inspection_count":len(x.eligible_inspection_grades)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class BottomBaseDamage(Model):
|
|
83
|
+
art_parameter: NonNegative
|
|
84
|
+
inspection_grade: Grade
|
|
85
|
+
lookup_policy: Lookup
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@calculation("tank/bottom-thinning-base", "AST floor tabulated base thinning damage factor", BottomBaseDamage,
|
|
89
|
+
["Part2 §4.5.7 STEP13,Table4.7; PDF pp155,163"],limitations=[
|
|
90
|
+
"Uses all21x5 printed cells. STEP13 calls it Table4.8, but actual AST table is4.7; actual4.8 is online monitoring. Table covers0<=Art<=1. No extrapolation or terminal-row saturation for Art>1. Between-row handling is explicit owner numerical policy: linear, upper-row conservative, or exact-only; source gives no explicit interpolation instruction. Inspection columns assume at most one credited inspection."])
|
|
91
|
+
def base_damage(x):
|
|
92
|
+
ensure(x.art_parameter <= 1,"AST Art exceeds1; predicted penetration is outside tabulated damage domain", "ast_damage_table_exceeded")
|
|
93
|
+
i = bisect_left(_ART,x.art_parameter)
|
|
94
|
+
column = "edcba".index(x.inspection_grade)
|
|
95
|
+
if _ART[i] == x.art_parameter:
|
|
96
|
+
lo = hi = i
|
|
97
|
+
weight = 0.
|
|
98
|
+
value = float(_ROWS[i][column])
|
|
99
|
+
else:
|
|
100
|
+
ensure(x.lookup_policy != "exact_rows_only","Art lies between table rows; choose qualified interpolation policy", "ast_damage_between_rows")
|
|
101
|
+
lo, hi = i-1, i
|
|
102
|
+
weight = (x.art_parameter-_ART[lo])/(_ART[hi]-_ART[lo])
|
|
103
|
+
value = float(_ROWS[hi][column]) if x.lookup_policy == "conservative_upper_row" else _ROWS[lo][column]+weight*(_ROWS[hi][column]-_ROWS[lo][column])
|
|
104
|
+
return {"base_damage_factor":value,"lower_art_row":_ART[lo],"upper_art_row":_ART[hi],
|
|
105
|
+
"interpolation_weight":weight,"lookup_policy":x.lookup_policy}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class BottomAdjustments(Model):
|
|
109
|
+
construction: Literal["welded","riveted"]
|
|
110
|
+
maintained_per_api653: bool
|
|
111
|
+
settlement_category: Literal["exceeds_api653","meets_api653","never_evaluated","concrete_no_settlement"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@calculation("tank/bottom-thinning-adjustment-factors", "AST floor construction maintenance and settlement factors", BottomAdjustments,
|
|
115
|
+
["Part2 §4.5.7 STEP15(4)-(6); PDF p156"],limitations=[
|
|
116
|
+
"Owner qualifies actual construction, historical API653 maintenance and settlement category. Factors are not a structural settlement assessment. Online monitoring for AST-bottom corrosion uses other-mechanism table value1 in integrated APIs; no invented monitoring credit."])
|
|
117
|
+
def adjustment_factors(x):
|
|
118
|
+
return {"welded_construction_factor":1. if x.construction == "welded" else 10.,
|
|
119
|
+
"maintenance_factor":1. if x.maintained_per_api653 else 5.,
|
|
120
|
+
"settlement_factor":{"exceeds_api653":2.,"meets_api653":1.,"never_evaluated":1.5,"concrete_no_settlement":1.}[x.settlement_category]}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BottomThinningBasis(Model):
|
|
124
|
+
starting_thickness_m: Positive
|
|
125
|
+
elapsed_years: NonNegative
|
|
126
|
+
baseline_basis: str = Field(min_length=1)
|
|
127
|
+
corrosion_allowance_m: NonNegative
|
|
128
|
+
minimum_thickness: BottomMinimumThickness
|
|
129
|
+
inspections: BottomInspectionSelection
|
|
130
|
+
lookup_policy: Lookup
|
|
131
|
+
adjustments: BottomAdjustments
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class BottomThinning(BottomThinningBasis):
|
|
135
|
+
corrosion_rate_m_year: NonNegative
|
|
136
|
+
corrosion_rate_basis: str = Field(min_length=1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _thinning(basis, rate):
|
|
140
|
+
ensure(bool(basis.baseline_basis.strip()),"Thickness/time baseline basis must not be blank")
|
|
141
|
+
minimum = minimum_thickness(basis.minimum_thickness)
|
|
142
|
+
wall = wall_parameter(BottomWallParameter(starting_thickness_m=basis.starting_thickness_m,corrosion_rate_m_year=rate,
|
|
143
|
+
elapsed_years=basis.elapsed_years,minimum_thickness_m=minimum["minimum_thickness_m"],corrosion_allowance_m=basis.corrosion_allowance_m))
|
|
144
|
+
selected = inspection_selection(basis.inspections)
|
|
145
|
+
base = base_damage(BottomBaseDamage(art_parameter=wall["art_parameter"],inspection_grade=selected["selected_inspection_grade"],lookup_policy=basis.lookup_policy))
|
|
146
|
+
factors = adjustment_factors(basis.adjustments)
|
|
147
|
+
value = base["base_damage_factor"]*factors["welded_construction_factor"]*factors["maintenance_factor"]*factors["settlement_factor"]
|
|
148
|
+
finite_tree(value)
|
|
149
|
+
return {**minimum,**wall,**selected,**base,**factors,"thinning_damage_factor":max(value,.1),"online_monitoring_factor":1.}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@calculation("tank/bottom-thinning-assessment", "AST floor dedicated thinning damage assessment", BottomThinning,
|
|
153
|
+
["Part2 §4.5.7,eqs(2.12),(2.23),Tables4.7-4.8; PDF pp152-156,163-164"],limitations=[
|
|
154
|
+
"Carbon-steel unlined AST bottom with qualified supplied net corrosion rate. Uses dedicated table, not pressure strength-ratio/Bayesian non-AST equations. One inspection credit. Injection/deadleg factors1 as non-piping; monitoring1 for other-corrosion-mechanism row. No automatic baseline reset, coating/lining credit, variable corrosion history or all-mechanism DF. Minimum-thickness flag is diagnostic, not acceptance/FFS."])
|
|
155
|
+
def thinning_assessment(x):
|
|
156
|
+
ensure(bool(x.corrosion_rate_basis.strip()),"Corrosion-rate basis must not be blank")
|
|
157
|
+
return _thinning(x,x.corrosion_rate_m_year)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class BottomCorrosionThinning(Model):
|
|
161
|
+
corrosion: BottomCorrosion
|
|
162
|
+
thinning: BottomThinningBasis
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@calculation("tank/bottom-corrosion-thinning-assessment", "AST floor two-sided corrosion through thinning damage", BottomCorrosionThinning,
|
|
166
|
+
[*REGISTRY["tank/bottom-corrosion-assessment"].references,*REGISTRY["tank/bottom-thinning-assessment"].references],
|
|
167
|
+
origin="api581_composition",limitations=[
|
|
168
|
+
"Carbon-steel unlined AST floor only. Comparable baseline corrosion rates and provenance required; do not multiply already-corrected measured net rates again. RPB resistivity override and CP inspection gate enforced. Temperature and minimum-rate policies remain explicit.",
|
|
169
|
+
*REGISTRY["tank/bottom-thinning-assessment"].limitations,
|
|
170
|
+
"Same combined product/soil rate drives Art; same RPB status must drive corrosion and default minimum thickness. No second external-corrosion DF added for the same soil-side loss."])
|
|
171
|
+
def corrosion_thinning(x):
|
|
172
|
+
ensure((x.corrosion.bottom_type != "single_bottom") == x.thinning.minimum_thickness.release_prevention_barrier,"RPB status must agree between corrosion and thinning")
|
|
173
|
+
rate = corrosion_assessment(x.corrosion)
|
|
174
|
+
finite_tree(rate)
|
|
175
|
+
return {**rate,**_thinning(x.thinning,rate["combined_corrosion_rate_m_year"])}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class BottomCorrosionRisk(Model):
|
|
179
|
+
damage: BottomCorrosionThinning
|
|
180
|
+
source: BottomSource
|
|
181
|
+
distance_to_groundwater_m: NonNegative
|
|
182
|
+
economics: BottomEconomics
|
|
183
|
+
management_score: NonNegative = Field(le=1000)
|
|
184
|
+
damage_scope: Literal["qualified_thinning_only"]
|
|
185
|
+
other_mechanisms_exclusion_basis: str = Field(min_length=1)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@calculation("tank/bottom-corrosion-risk-assessment", "AST floor corrosion through source consequence and annual financial risk", BottomCorrosionRisk,
|
|
189
|
+
[*REGISTRY["tank/bottom-corrosion-thinning-assessment"].references,*REGISTRY["tank/bottom-integrated-assessment"].references],
|
|
190
|
+
origin="api581_composition_with_qualified_thinning_only_scope",limitations=[
|
|
191
|
+
*REGISTRY["tank/bottom-corrosion-thinning-assessment"].limitations,
|
|
192
|
+
*REGISTRY["tank/bottom-source-assessment"].limitations,
|
|
193
|
+
*REGISTRY["tank/bottom-financial-consequence"].limitations,
|
|
194
|
+
"This composition derives DF internally for explicit qualified thinning-only scope; other applicable mechanisms must be excluded with owner basis. Uniform computed DF and Fms apply to all four holes. Annual financial risk=sum(GFF)*DF*Fms*already-GFF-weighted consequence. Single bottom, not all-mechanism or whole-tank risk. Corrosion, thickness and source RPB status must agree."])
|
|
195
|
+
def corrosion_risk(x):
|
|
196
|
+
ensure(bool(x.other_mechanisms_exclusion_basis.strip()),"Thinning-only risk requires exclusion basis for other mechanisms")
|
|
197
|
+
ensure(x.source.has_release_prevention_barrier == (x.damage.corrosion.bottom_type != "single_bottom"),"RPB status must agree between damage and release source")
|
|
198
|
+
damage = corrosion_thinning(x.damage)
|
|
199
|
+
finite_tree(damage)
|
|
200
|
+
risk = integrated(BottomIntegrated(source=x.source,distance_to_groundwater_m=x.distance_to_groundwater_m,economics=x.economics,
|
|
201
|
+
management_score=x.management_score,total_damage_factor=damage["thinning_damage_factor"],
|
|
202
|
+
damage_factor_source="Same-request AST-bottom eq2.12,Table4.7,eq2.23; owner-qualified thinning-only scope"))
|
|
203
|
+
return {**damage,**risk,"total_damage_factor":damage["thinning_damage_factor"],
|
|
204
|
+
"integration_scope":"bottom_corrosion_thinning_source_financial_risk_qualified_thinning_only"}
|