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
api581/oxidation.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""2016 high-temperature oxidation tables, screen and non-AST thinning."""
|
|
2
|
+
from bisect import bisect_left
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, Positive, NonNegative, calculation, ensure
|
|
6
|
+
from .thinning import Posterior, WallLoss
|
|
7
|
+
from .clad_thinning import PressureStrength, CodeStrength
|
|
8
|
+
from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
|
|
9
|
+
|
|
10
|
+
Material = Literal["carbon_steel","cr_1_25","cr_2_25","cr_5","cr_7","cr_9","cr_12","ss_304","ss_309","ss_310_hk","alloy_800h_hp"]
|
|
11
|
+
Units = Literal["printed_si","printed_us_converted"]
|
|
12
|
+
_C = (496,524,552,579,607,635,663,691,718,746,774,802,829,857,885,913,941,968,996,1024,1052,1079,1107,1135)
|
|
13
|
+
_F = tuple(range(925,2076,50))
|
|
14
|
+
_US = (
|
|
15
|
+
(2,4,6,9,14,22,33,48),
|
|
16
|
+
(2,3,4,7,12,18,30,46),
|
|
17
|
+
(1,1,2,4,9,14,24,41),
|
|
18
|
+
(1,1,1,2,4,6,15,35,65),
|
|
19
|
+
(1,1,1,1,1,2,3,6,17,37,60),
|
|
20
|
+
(1,1,1,1,1,1,1,2,5,11,23,40,60),
|
|
21
|
+
(1,1,1,1,1,1,1,1,3,8,15,30,50),
|
|
22
|
+
(1,1,1,1,1,1,1,1,1,2,3,4,6,9,13,18,25,35,48),
|
|
23
|
+
(1,1,1,1,1,1,1,1,1,1,2,3,4,6,8,10,13,16,20,30,40,50),
|
|
24
|
+
(1,1,1,1,1,1,1,1,1,1,1,2,3,4,5,7,8,10,13,15,19,23,27,31),
|
|
25
|
+
(1,1,1,1,1,1,1,1,1,1,1,2,3,4,6,8,10,13,17,21,27,33,41,50))
|
|
26
|
+
_SI = (
|
|
27
|
+
(.05,.1,.15,.23,.36,.56,.84,1.22),
|
|
28
|
+
(.05,.08,.1,.18,.3,.46,.76,1.17),
|
|
29
|
+
(.03,.03,.05,.1,.23,.36,.61,1.04),
|
|
30
|
+
(.03,.03,.03,.05,.1,.15,.38,.89,1.65),
|
|
31
|
+
(.03,.03,.03,.03,.03,.05,.08,.15,.43,.94,1.52),
|
|
32
|
+
(.03,.03,.03,.03,.03,.03,.03,.05,.13,.28,.58,1.02,1.52),
|
|
33
|
+
(.03,.03,.03,.03,.03,.03,.03,.03,.08,.2,.38,.76,1.27),
|
|
34
|
+
(.03,.03,.03,.03,.03,.03,.03,.03,.03,.05,.08,.1,.15,.23,.33,.46,.64,.89,1.22),
|
|
35
|
+
(.03,.03,.03,.03,.03,.03,.03,.03,.03,.03,.05,.08,.1,.15,.2,.25,.33,.41,.51,.76,1.02,1.27),
|
|
36
|
+
(.03,.03,.03,.03,.03,.03,.03,.03,.03,.03,.03,.05,.08,.1,.13,.18,.2,.25,.33,.38,.48,.58,.69,.79),
|
|
37
|
+
(.03,.03,.03,.03,.03,.03,.03,.03,.03,.03,.03,.05,.08,.1,.15,.2,.25,.33,.43,.53,.69,.84,1.04,1.27))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OxidationScreen(Model):
|
|
41
|
+
maximum_metal_temperature_k: Positive
|
|
42
|
+
oxygen_present: bool
|
|
43
|
+
threshold_basis: Units
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@calculation("damage/oxidation-screen", "High-temperature oxidation temperature and oxygen screening", OxidationScreen,
|
|
47
|
+
["Part2 Annex2.B Table1.1,§9.1; PDF pp322,389"],limitations=[
|
|
48
|
+
"Annex screen requires metal temperature>=482C(900F) AND oxygen present. Printed SI482C differs from exact900F conversion: basis explicit. Screening out does not assert rate0 or no other damage; description says onset approximately482C forCS and higher for alloys. Maximum METAL temperature controls, including furnace tube metal, not bulk gas/fluid temperature."])
|
|
49
|
+
def screen(x):
|
|
50
|
+
threshold = 482+273.15 if x.threshold_basis == "printed_si" else (900-32)*5/9+273.15
|
|
51
|
+
return {"screen_temperature_threshold_k":threshold,"temperature_screen_met":x.maximum_metal_temperature_k >= threshold,
|
|
52
|
+
"evaluate_high_temperature_oxidation":x.maximum_metal_temperature_k >= threshold and x.oxygen_present}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OxidationTableRow(Model):
|
|
56
|
+
material: Material
|
|
57
|
+
unit_basis: Units
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@calculation("damage/oxidation-table-row", "Full oxidation table row with explicit unavailable cells", OxidationTableRow,
|
|
61
|
+
["Part2 Tables2.B.9.2/9.2M/9.3/9.3M; PDF pp390-391"],limitations=[
|
|
62
|
+
"11 named material rows,24 temperature columns,SI andUS. Combined source material labels310SS/HK and800H/HP preserved as grouped categories, not arbitrary alloy equivalence. Rounded SI temperatures/rates differ from exact US conversions. Printed dashes returnedNone, never0 or material failure-temperature certification. No continuous interpolation in chromium composition."])
|
|
63
|
+
def table_row(x):
|
|
64
|
+
i = Material.__args__.index(x.material)
|
|
65
|
+
native = (_SI if x.unit_basis == "printed_si" else _US)[i]
|
|
66
|
+
rates = list(native)+[None]*(24-len(native))
|
|
67
|
+
nodes = [c+273.15 for c in _C] if x.unit_basis == "printed_si" else [(f-32)*5/9+273.15 for f in _F]
|
|
68
|
+
conversion = .001 if x.unit_basis == "printed_si" else .0000254
|
|
69
|
+
return {"temperature_nodes_k":nodes,"corrosion_rates_m_year":[v*conversion if v is not None else None for v in rates],
|
|
70
|
+
"native_rates":rates,"native_rate_unit":"mm/year" if x.unit_basis == "printed_si" else "mpy",
|
|
71
|
+
"source_table_ids":["2.B.9.2M","2.B.9.3M"] if x.unit_basis == "printed_si" else ["2.B.9.2","2.B.9.3"]}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OxidationRate(OxidationTableRow):
|
|
75
|
+
maximum_metal_temperature_k: Positive
|
|
76
|
+
lookup_policy: Literal["exact_nodes_only","linear_between_nodes","upper_temperature_node"]
|
|
77
|
+
below_table_policy: Literal["reject_below_table","qualified_first_node_value"]
|
|
78
|
+
first_node_qualification_basis: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@calculation("damage/oxidation-rate", "Material and maximum-metal-temperature oxidation rate", OxidationRate,
|
|
82
|
+
["Part2 §9.3,Tables2.B.9.2-9.3M; PDF pp389-391"],limitations=[
|
|
83
|
+
"No source interpolation rule: exact,linear or upper-temperature-node numerical policy must be selected. No high-temperature extrapolation or interpolation touching unavailable dash. Below first printed node496C/925F is rejected or explicitly qualified first-node estimate; source screening begins482C/900F, does not supply this gap's rate. First-node policy not a zero/ambient-corrosion assumption. Adjacent tables are joined only where both bordering cells exist. Primitive lookup does not itself qualify oxygen or actual metal-temperature identification."])
|
|
84
|
+
def rate(x):
|
|
85
|
+
qualified = x.below_table_policy == "qualified_first_node_value"
|
|
86
|
+
ensure((x.first_node_qualification_basis is not None and bool(x.first_node_qualification_basis.strip())) if qualified else x.first_node_qualification_basis is None,
|
|
87
|
+
"First-node policy and qualification basis must agree")
|
|
88
|
+
row = table_row(x)
|
|
89
|
+
nodes,values = row["temperature_nodes_k"],row["corrosion_rates_m_year"]
|
|
90
|
+
t = next((n for n in nodes if abs(n-x.maximum_metal_temperature_k) <= 1e-9),x.maximum_metal_temperature_k)
|
|
91
|
+
ensure(t <= nodes[-1],"Temperature exceeds oxidation table; no extrapolation","oxidation_table_range")
|
|
92
|
+
first = t < nodes[0]
|
|
93
|
+
ensure(not first or qualified,"Temperature below first oxidation row; explicit qualified estimate required","oxidation_table_range")
|
|
94
|
+
if first:
|
|
95
|
+
lo = hi = 0
|
|
96
|
+
weight,basis = 0.,"qualified_first_node"
|
|
97
|
+
else:
|
|
98
|
+
hi = bisect_left(nodes,t)
|
|
99
|
+
lo = hi if t == nodes[hi] else hi-1
|
|
100
|
+
weight = 0. if lo == hi else (t-nodes[lo])/(nodes[hi]-nodes[lo])
|
|
101
|
+
ensure(lo == hi or x.lookup_policy != "exact_nodes_only","Exact oxidation-node policy does not interpolate")
|
|
102
|
+
basis = "exact_cell" if lo == hi else "upper_temperature_node" if x.lookup_policy == "upper_temperature_node" else "linear_between_nodes"
|
|
103
|
+
ensure(values[lo] is not None and values[hi] is not None,"Oxidation table cell unavailable; no zero or dash interpolation","oxidation_rate_unavailable")
|
|
104
|
+
value = values[hi] if basis == "upper_temperature_node" else values[lo]+weight*(values[hi]-values[lo])
|
|
105
|
+
return {"corrosion_rate_m_year":value,"lower_temperature_node_k":nodes[lo],"upper_temperature_node_k":nodes[hi],
|
|
106
|
+
"interpolation_weight":weight,"rate_calculation_basis":basis,"first_node_value_used":first}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OxidationAssessment(Model):
|
|
110
|
+
oxidation: OxidationRate
|
|
111
|
+
oxygen_present: bool
|
|
112
|
+
screening_threshold_basis: Units
|
|
113
|
+
applicability_basis: str = Field(min_length=1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@calculation("damage/oxidation-assessment", "Screened qualified maximum-metal-temperature oxidation estimate", OxidationAssessment,
|
|
117
|
+
["Part2 Annex2.B Table1.1,§9,Tables2.B.9.2-9.3M; PDF pp322,389-391"],origin="api581_composition_with_explicit_table_policy",limitations=[
|
|
118
|
+
"Owner qualifies named material, oxygen and actual maximum exposed metal temperature. Screen-out rejects quantitative estimate instead of reporting invented rate0. One assessed surface/exposure; no implicit second-side loss or duplicate atmospheric/CUI corrosion DF. Source calls oxidation general thinning. Does not evaluate creep, thermal fatigue, oxide spallation cycling, carburization/sulfidation, metallurgy, service suitability or total damage."])
|
|
119
|
+
def oxidation_assessment(x):
|
|
120
|
+
ensure(bool(x.applicability_basis.strip()),"Oxygen/material/maximum-metal-temperature qualification basis required")
|
|
121
|
+
screened = screen(OxidationScreen(maximum_metal_temperature_k=x.oxidation.maximum_metal_temperature_k,oxygen_present=x.oxygen_present,threshold_basis=x.screening_threshold_basis))
|
|
122
|
+
ensure(screened["evaluate_high_temperature_oxidation"],"Oxidation screening criteria not met; no quantitative zero rate inferred")
|
|
123
|
+
return {**screened,**rate(x.oxidation),"thinning_type":"general"}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class OxidationThinning(Model):
|
|
127
|
+
corrosion: OxidationAssessment
|
|
128
|
+
starting_thickness_m: Positive
|
|
129
|
+
elapsed_years: NonNegative
|
|
130
|
+
strength: PressureStrength | CodeStrength = Field(discriminator="method")
|
|
131
|
+
inspections: Posterior
|
|
132
|
+
conditions: ThinningConditions
|
|
133
|
+
qualification_basis: str = Field(min_length=1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@calculation("damage/oxidation-thinning-assessment", "Oxidation rate through qualified non-AST unclad thinning DF", OxidationThinning,
|
|
137
|
+
["Part2 Annex2.B §9; Part2 §4.5.7"],origin="api581_composition_with_explicit_table_policy",limitations=[
|
|
138
|
+
"Same qualified oxidation rate drives single constant-baseline unclad non-AST thinning. Strength/allowable stress/flow stress/minimum thickness must be qualified at governing design temperature; no room-temperature strength assumption. Monitoring row must be other, table factor1; qualified experience replacement remains available underTable4.8 note1. Liquid-corrosion rows cannot supply borrowed credits. Inspection eligibility needs owner qualification for oxidation. No AST/cladding, automatic creep/high-temperature code evaluation, FFS or all-mechanism DF."])
|
|
139
|
+
def oxidation_thinning(x):
|
|
140
|
+
ensure(x.conditions.monitoring.row.mechanism == "other","Oxidation uses other-mechanism monitoring row; liquid-corrosion credits cannot be borrowed")
|
|
141
|
+
corrosion = oxidation_assessment(x.corrosion)
|
|
142
|
+
result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
|
|
143
|
+
wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
|
|
144
|
+
strength=x.strength,inspections=x.inspections,conditions=x.conditions))
|
|
145
|
+
return {**corrosion,**result}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""PASCC sulfur-exposure/material screen; shutdown mitigation is not screening."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, NonNegative, calculation, ensure
|
|
5
|
+
from .scc_mechanisms import PasccSusceptibility, pascc_susceptibility, Assessment, InspectionCounts, assess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PasccScreen(Model):
|
|
9
|
+
material: Literal['austenitic_stainless_steel','nickel_based_alloy','other_material']
|
|
10
|
+
sulfur_bearing_compounds_exposure: bool
|
|
11
|
+
exposure_basis: str = Field(min_length=1)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@calculation('damage/pascc-screening','PASCC austenitic stainless/nickel alloy sulfur exposure screen',PasccScreen,
|
|
15
|
+
['Part2 §11.3; PDF p201'],limitations=[
|
|
16
|
+
'Austenitic stainless steel or nickel-based alloy exposed to sulfur-bearing compounds requires PASCC susceptibility evaluation. No automatic exclusion for normal-operation dryness, low-carbon/stabilized grade, low operating temperature, alkaline wash or nitrogen purge; shutdown air/moisture and mitigation remain separate evaluation. Material category does not authorize every Table11.2 cell. Screen-out is outside this procedure, not global immunity or zero DF.'])
|
|
17
|
+
def pascc_screening(x):
|
|
18
|
+
ensure(bool(x.exposure_basis.strip()),'Qualified alloy identity and sulfur exposure history basis required')
|
|
19
|
+
material=x.material!='other_material'
|
|
20
|
+
return {'evaluate_pascc':material and x.sulfur_bearing_compounds_exposure,
|
|
21
|
+
'screening_reason':'outside_material_scope' if not material else 'sulfur_exposure_absent' if not x.sulfur_bearing_compounds_exposure else 'evaluate'}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PasccFigure(Model):
|
|
25
|
+
alloy: PasccSusceptibility
|
|
26
|
+
sulfur_oxygen_water_during_operation: bool
|
|
27
|
+
sulfur_oxygen_water_during_shutdown: bool
|
|
28
|
+
qualified_rp0170_downtime_protection: bool
|
|
29
|
+
protection_basis: str | None = None
|
|
30
|
+
confirmed_cracking: bool
|
|
31
|
+
unrepaired_cracks: bool
|
|
32
|
+
service_alloy_basis: str = Field(min_length=1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@calculation('damage/pascc-figure-susceptibility','PASCC Figure11.1 exposure and qualified downtime protection',PasccFigure,
|
|
36
|
+
['Part2 Figure11.1,Table11.2; PDF pp205-206'],limitations=[
|
|
37
|
+
'Caller qualifies simultaneous sulfur/oxygen/water exposure in operation/shutdown and Table11.2 alloy/thermal history. No exposure in either phase returns none without table lookup. RP0170 downtime protection, when qualified, reduces table susceptibility one level high->medium->low->none exactly as Figure11.1. Confirmed cracks forcehigh before exposure/protection; unrepaired cracks requireFFS. No RP0170 compliance verification or automatic shutdown exposure inference. Existing SI427C table threshold/unsupported cells retained.'])
|
|
38
|
+
def pascc_figure_susceptibility(x):
|
|
39
|
+
ensure(not x.unrepaired_cracks,'Unrepaired PASCC requires separate FFS evaluation','ffs_required')
|
|
40
|
+
ensure(bool(x.service_alloy_basis.strip()),'Qualified alloy/thermal history and operation/shutdown exposure basis required')
|
|
41
|
+
ensure((x.protection_basis is not None)==x.qualified_rp0170_downtime_protection,'Downtime protection and basis must agree')
|
|
42
|
+
if x.protection_basis is not None:
|
|
43
|
+
ensure(bool(x.protection_basis.strip()),'Downtime protection basis must be nonblank')
|
|
44
|
+
if x.confirmed_cracking:
|
|
45
|
+
base,selected,used='high','high',False
|
|
46
|
+
elif not (x.sulfur_oxygen_water_during_operation or x.sulfur_oxygen_water_during_shutdown):
|
|
47
|
+
base,selected,used='none','none',False
|
|
48
|
+
else:
|
|
49
|
+
base=pascc_susceptibility(x.alloy)['susceptibility']
|
|
50
|
+
levels=('none','low','medium','high')
|
|
51
|
+
used=x.qualified_rp0170_downtime_protection
|
|
52
|
+
selected=levels[max(0,levels.index(base)-int(used))]
|
|
53
|
+
return {'table_or_override_susceptibility':base,'susceptibility':selected,'downtime_reduction_applied':used}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PasccQualifiedAssessment(Model):
|
|
57
|
+
screening: PasccScreen
|
|
58
|
+
service: PasccFigure
|
|
59
|
+
years_since_qualifying_inspection: NonNegative
|
|
60
|
+
inspections: InspectionCounts
|
|
61
|
+
inspection_basis: str = Field(min_length=1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@calculation('damage/pascc-qualified-assessment','PASCC screen/Figure11.1 through inspection and age DF',PasccQualifiedAssessment,
|
|
65
|
+
['Part2 §§11.3,11.6.3,Figure11.1,Tables11.2-11.3; eq(2.30); PDF pp201-206'],
|
|
66
|
+
origin='qualified_pascc_exposure_protection_to_df',limitations=[
|
|
67
|
+
'Requires positive material/sulfur screen and independent alloy-group identity/thermal-history qualification. Nickel-family screening cannot use H/L-grade300SS or321SS table groups. Mixed-named table groups require owner matching, not automatic grade inference. Figure11.1 exposures/protection and mechanism-specific inspection period remain explicit. Confirmed cracks override mitigation; unrepairedFFS. Existing equivalent-count/age rules retained. No whole-asset risk or FFS.'])
|
|
68
|
+
def pascc_qualified_assessment(x):
|
|
69
|
+
selected=pascc_figure_susceptibility(x.service)
|
|
70
|
+
ensure(bool(x.inspection_basis.strip()),'PASCC-specific inspection period and coverage basis required')
|
|
71
|
+
screen=pascc_screening(x.screening)
|
|
72
|
+
ensure(screen['evaluate_pascc'],'PASCC material/sulfur screen not met','pascc_outside_screen')
|
|
73
|
+
if x.screening.material=='nickel_based_alloy':
|
|
74
|
+
ensure(x.service.alloy.material_group not in ('h_grade_300ss','l_grade_300ss','321ss'),
|
|
75
|
+
'Nickel family contradicts stainless-only table group','pascc_material_mismatch')
|
|
76
|
+
result=assess('pascc',Assessment(susceptibility=selected['susceptibility'],confirmed_cracking=x.service.confirmed_cracking,
|
|
77
|
+
unrepaired_cracks=False,years_since_qualifying_inspection=x.years_since_qualifying_inspection,inspections=x.inspections))
|
|
78
|
+
return {**screen,**selected,**result}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Physical explosion/BLEVE and Level2 NFNT aggregation in explicit SI units."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
|
|
6
|
+
from .blast import (BlastFitDomain, BlastDistance, distance,
|
|
7
|
+
BuildingPressureThreshold, building_threshold)
|
|
8
|
+
|
|
9
|
+
Hole = Literal["small", "medium", "large", "rupture"]
|
|
10
|
+
Gamma = Annotated[float,Field(gt=1,strict=True,allow_inf_nan=False)]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RuptureConditions(Model):
|
|
14
|
+
burst_pressure_pa_abs: Positive
|
|
15
|
+
ambient_pressure_pa_abs: Positive = 101325
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _pressure_difference(x):
|
|
19
|
+
ensure(x.burst_pressure_pa_abs >= x.ambient_pressure_pa_abs,
|
|
20
|
+
"Burst pressure must be at least ambient absolute pressure")
|
|
21
|
+
return x.burst_pressure_pa_abs-x.ambient_pressure_pa_abs
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PhysicalTnt(RuptureConditions):
|
|
25
|
+
vapor_volume_m3: NonNegative
|
|
26
|
+
heat_capacity_ratio: Gamma
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@calculation("effects/physical-explosion-tnt", "Gas-vessel Brode energy and TNT equivalent", PhysicalTnt,
|
|
30
|
+
["Part3 eq(3.192), Annex3.B C29; PDF pp569,648; pressure units PDF578"],
|
|
31
|
+
limitations=["C29=4.303e-4 with source pressures in kPa; Pa inputs therefore use4.303e-7 kg/J. Uses vapor space volume, not total two-phase vessel volume. k>1, absolute burst pressure>=ambient. Returns potential rupture energy, not ignition-dependent combustion energy; no real-gas or vessel fragmentation calculation."])
|
|
32
|
+
def physical_tnt(x):
|
|
33
|
+
delta=_pressure_difference(x)
|
|
34
|
+
energy=x.vapor_volume_m3*delta/(x.heat_capacity_ratio-1)
|
|
35
|
+
equivalent=4.303e-7*energy
|
|
36
|
+
ensure(math.isfinite(energy) and math.isfinite(equivalent),"Physical rupture energy exceeds numerical domain","numerical_failure")
|
|
37
|
+
ensure(equivalent > 0 or delta == 0 or x.vapor_volume_m3 == 0,
|
|
38
|
+
"Physical rupture energy underflow","numerical_failure")
|
|
39
|
+
return {"vapor_stored_energy_j":energy,"tnt_equivalent_mass_kg":equivalent}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FlashedAmount(Model):
|
|
43
|
+
stored_liquid_mass_kg: NonNegative
|
|
44
|
+
liquid_flash_mass_fraction: Fraction
|
|
45
|
+
molecular_weight_kg_kmol: Positive
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@calculation("release/bleve-flashed-amount", "Flashed liquid mass and amount for BLEVE", FlashedAmount,
|
|
49
|
+
["Part3 STEP10.3(1), nomenclature nv/MW; PDF pp571,576"],
|
|
50
|
+
origin="api581_input_preparation",
|
|
51
|
+
limitations=["Caller supplies liquid flash mass fraction qualified at atmospheric release; this is not an EOS/isenthalpic flash solver. Amount is kmol (source kg-mol), not mol. Only stored liquid is counted; pre-existing vapor is handled separately."])
|
|
52
|
+
def flashed_amount(x):
|
|
53
|
+
mass=x.stored_liquid_mass_kg*x.liquid_flash_mass_fraction
|
|
54
|
+
amount=mass/x.molecular_weight_kg_kmol
|
|
55
|
+
ensure(math.isfinite(amount),"Flashed amount exceeds numerical domain","numerical_failure")
|
|
56
|
+
ensure(amount > 0 or x.stored_liquid_mass_kg == 0 or x.liquid_flash_mass_fraction == 0,
|
|
57
|
+
"Flashed amount underflow","numerical_failure")
|
|
58
|
+
return {"flashed_liquid_mass_kg":mass,"flashed_amount_kmol":amount}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BleveLiquidTnt(RuptureConditions):
|
|
62
|
+
flashed_amount_kmol: NonNegative
|
|
63
|
+
storage_temperature_k: Positive
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@calculation("effects/bleve-liquid-tnt", "Flashed-liquid BLEVE expansion energy and TNT equivalent", BleveLiquidTnt,
|
|
67
|
+
["Part3 eq(3.195), Annex3.B C30; PDF pp569,648; nv/R units PDF576,578"],
|
|
68
|
+
limitations=["Uses printed C30=9.76e-8 kg/J and R=8314 J/(kmol*K), nv in kmol, storage temperature K, natural log of absolute burst/ambient pressure. Does not infer flash amount from fluid properties. Vapor-space contribution is excluded and must be added separately for two-phase contents. No combustion/fireball energy included."])
|
|
69
|
+
def bleve_liquid_tnt(x):
|
|
70
|
+
delta=_pressure_difference(x)
|
|
71
|
+
# log1p preserves tiny positive overpressure; logarithm difference avoids
|
|
72
|
+
# overflow of the pressure ratio for widely separated positive pressures.
|
|
73
|
+
log_ratio=(math.log1p(delta/x.ambient_pressure_pa_abs) if delta <= x.ambient_pressure_pa_abs
|
|
74
|
+
else math.log(x.burst_pressure_pa_abs)-math.log(x.ambient_pressure_pa_abs))
|
|
75
|
+
energy=x.flashed_amount_kmol*8314*x.storage_temperature_k*log_ratio
|
|
76
|
+
equivalent=9.76e-8*energy
|
|
77
|
+
ensure(math.isfinite(energy) and math.isfinite(equivalent),"BLEVE expansion energy exceeds numerical domain","numerical_failure")
|
|
78
|
+
ensure(equivalent > 0 or delta == 0 or x.flashed_amount_kmol == 0,
|
|
79
|
+
"BLEVE expansion energy underflow","numerical_failure")
|
|
80
|
+
return {"liquid_expansion_energy_j":energy,"tnt_equivalent_mass_kg":equivalent}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class BlastTargets(Model):
|
|
84
|
+
hole: Hole
|
|
85
|
+
fit_domain: BlastFitDomain
|
|
86
|
+
personnel_building_collapse_probability: Fraction = .5
|
|
87
|
+
equipment_threshold_overpressure_pa: Positive = 34500
|
|
88
|
+
distance_tolerance_m: Positive = 1e-5
|
|
89
|
+
maximum_iterations: int = Field(default=100,ge=10,le=300,strict=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _targets(x,potential):
|
|
93
|
+
event=potential if x.hole == "rupture" else 0
|
|
94
|
+
pressure=building_threshold(BuildingPressureThreshold(
|
|
95
|
+
building_collapse_probability=x.personnel_building_collapse_probability))["side_on_overpressure_pa"]
|
|
96
|
+
setup=dict(tnt_equivalent_mass_kg=event,fit_domain=x.fit_domain,
|
|
97
|
+
distance_tolerance_m=x.distance_tolerance_m,maximum_iterations=x.maximum_iterations)
|
|
98
|
+
personnel=distance(BlastDistance(**setup,threshold_overpressure_pa=pressure))
|
|
99
|
+
equipment=distance(BlastDistance(**setup,threshold_overpressure_pa=x.equipment_threshold_overpressure_pa))
|
|
100
|
+
return {"potential_tnt_equivalent_mass_kg":potential,"event_tnt_equivalent_mass_kg":event,
|
|
101
|
+
"personnel_threshold_overpressure_pa":pressure,"equipment_threshold_overpressure_pa":x.equipment_threshold_overpressure_pa,
|
|
102
|
+
"personnel_building_collapse_probability":x.personnel_building_collapse_probability,
|
|
103
|
+
"personnel_safe_distance_m":personnel["safe_distance_m"],"equipment_safe_distance_m":equipment["safe_distance_m"],
|
|
104
|
+
"personnel_area_m2":personnel["consequence_area_m2"],"equipment_area_m2":equipment["consequence_area_m2"],
|
|
105
|
+
"personnel_status":personnel["status"] if x.hole == "rupture" else "not_rupture",
|
|
106
|
+
"equipment_status":equipment["status"] if x.hole == "rupture" else "not_rupture","hole":x.hole}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PhysicalConsequence(PhysicalTnt,BlastTargets):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@calculation("effects/physical-explosion-consequence", "Gas-vessel rupture blast consequence", PhysicalConsequence,
|
|
114
|
+
["Part3 eqs(3.192)-(3.194), STEP10.2; PDF pp569-571"],
|
|
115
|
+
origin="api581_physical_explosion_composition",
|
|
116
|
+
limitations=["Only rupture hole produces blast area; other holes return not_rupture with zero event equivalent and area while retaining potential inventory rupture energy. Personnel default is probit5 (50% building collapse), equipment default34500Pa. Reuses caller-qualified TNT fit domain; no extrapolation, direct lethality, fragmentation or LH2 validation."])
|
|
117
|
+
def physical_consequence(x):
|
|
118
|
+
energy=physical_tnt(PhysicalTnt(**x.model_dump(include=set(PhysicalTnt.model_fields))))
|
|
119
|
+
return {"vapor_stored_energy_j":energy["vapor_stored_energy_j"],**_targets(x,energy["tnt_equivalent_mass_kg"])}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class VaporPortion(Model):
|
|
123
|
+
vapor_volume_m3: NonNegative
|
|
124
|
+
heat_capacity_ratio: Gamma
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class BleveConsequence(BleveLiquidTnt,BlastTargets):
|
|
128
|
+
stored_vapor: VaporPortion | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@calculation("effects/bleve-consequence", "Liquid/two-phase BLEVE blast consequence", BleveConsequence,
|
|
132
|
+
["Part3 eqs(3.192),(3.195)-(3.197), STEP10.3; PDF pp569-572"],
|
|
133
|
+
origin="api581_bleve_composition",
|
|
134
|
+
limitations=["Sums liquid and pre-existing vapor TNT equivalents using their distinct printed C30/C29 coefficients. Shared absolute burst/ambient pressure; vapor volume must exclude liquid, flashed amount must exclude pre-existing vapor. No automatic EOS, superheat applicability, fireball or combustion calculation. Only rupture produces blast area. Caller-qualified fit domain and building-collapse proxy limits apply; LH2 validation outstanding."])
|
|
135
|
+
def bleve_consequence(x):
|
|
136
|
+
liquid=bleve_liquid_tnt(BleveLiquidTnt(**x.model_dump(include=set(BleveLiquidTnt.model_fields))))
|
|
137
|
+
vapor={"vapor_stored_energy_j":0,"tnt_equivalent_mass_kg":0}
|
|
138
|
+
if x.stored_vapor is not None:
|
|
139
|
+
vapor=physical_tnt(PhysicalTnt(**x.stored_vapor.model_dump(),burst_pressure_pa_abs=x.burst_pressure_pa_abs,
|
|
140
|
+
ambient_pressure_pa_abs=x.ambient_pressure_pa_abs))
|
|
141
|
+
total=liquid["tnt_equivalent_mass_kg"]+vapor["tnt_equivalent_mass_kg"]
|
|
142
|
+
ensure(math.isfinite(total),"Combined BLEVE TNT equivalent exceeds numerical domain","numerical_failure")
|
|
143
|
+
return {"liquid_expansion_energy_j":liquid["liquid_expansion_energy_j"],"vapor_stored_energy_j":vapor["vapor_stored_energy_j"],
|
|
144
|
+
"liquid_tnt_equivalent_mass_kg":liquid["tnt_equivalent_mass_kg"],"vapor_tnt_equivalent_mass_kg":vapor["tnt_equivalent_mass_kg"],
|
|
145
|
+
**_targets(x,total)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class NfntCase(Model):
|
|
149
|
+
hole: Hole
|
|
150
|
+
non_ignition_probability: Fraction
|
|
151
|
+
physical_equipment_area_m2: NonNegative = 0
|
|
152
|
+
physical_personnel_area_m2: NonNegative = 0
|
|
153
|
+
bleve_equipment_area_m2: NonNegative = 0
|
|
154
|
+
bleve_personnel_area_m2: NonNegative = 0
|
|
155
|
+
steam_chemical_personnel_area_m2: NonNegative = 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@calculation("consequence/level2-nfnt-case", "Level2 NFNT maximum areas and non-ignition weighting", NfntCase,
|
|
159
|
+
["Part3 eqs(3.198)-(3.199), §5.10.5; PDF p570"],
|
|
160
|
+
limitations=["Inputs are event-conditional unweighted areas. Uses maximum, not sum, then applies non-ignition probability once. Physical/BLEVE areas must be zero for non-rupture holes. Steam/chemical area can be supplied by Level1 NFNT calculation. This does not infer event-tree probabilities."])
|
|
161
|
+
def nfnt_case(x):
|
|
162
|
+
ensure(x.hole == "rupture" or not any((x.physical_equipment_area_m2,x.physical_personnel_area_m2,
|
|
163
|
+
x.bleve_equipment_area_m2,x.bleve_personnel_area_m2)),"Physical/BLEVE areas must be zero outside rupture case")
|
|
164
|
+
equipment=max(x.physical_equipment_area_m2,x.bleve_equipment_area_m2)
|
|
165
|
+
personnel=max(x.physical_personnel_area_m2,x.bleve_personnel_area_m2,x.steam_chemical_personnel_area_m2)
|
|
166
|
+
return {"equipment_area_m2":x.non_ignition_probability*equipment,"personnel_area_m2":x.non_ignition_probability*personnel}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class NfntFrequencyCase(NfntCase):
|
|
170
|
+
generic_frequency_per_year: NonNegative
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class NfntSummary(Model):
|
|
174
|
+
cases: list[NfntFrequencyCase] = Field(min_length=4,max_length=4)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@calculation("consequence/level2-nfnt-summary", "Four-hole Level2 NFNT GFF-weighted final areas", NfntSummary,
|
|
178
|
+
["Part3 eqs(3.198)-(3.201); PDF p570"],
|
|
179
|
+
origin="api581_nfnt_four_hole_composition",
|
|
180
|
+
limitations=["Requires small,medium,large,rupture once each, including zero-frequency holes. Weights per-case non-ignition-adjusted maximum areas by supplied generic frequencies, not DF-adjusted failure frequencies. No annual risk or financial consequence is computed by this area average."])
|
|
181
|
+
def nfnt_summary(x):
|
|
182
|
+
ensure({c.hole for c in x.cases} == {"small","medium","large","rupture"},"Require four distinct canonical hole sizes")
|
|
183
|
+
total=math.fsum(c.generic_frequency_per_year for c in x.cases)
|
|
184
|
+
ensure(math.isfinite(total) and total > 0,"Total generic frequency must be finite and positive")
|
|
185
|
+
areas={c.hole:nfnt_case(NfntCase(**c.model_dump(exclude={"generic_frequency_per_year"}))) for c in x.cases}
|
|
186
|
+
return {"total_generic_frequency_per_year":total,
|
|
187
|
+
"equipment_area_m2":math.fsum(c.generic_frequency_per_year/total*areas[c.hole]["equipment_area_m2"] for c in x.cases),
|
|
188
|
+
"personnel_area_m2":math.fsum(c.generic_frequency_per_year/total*areas[c.hole]["personnel_area_m2"] for c in x.cases),
|
|
189
|
+
"by_hole_equipment_area_m2":{h:v["equipment_area_m2"] for h,v in areas.items()},
|
|
190
|
+
"by_hole_personnel_area_m2":{h:v["personnel_area_m2"] for h,v in areas.items()}}
|
api581/pof.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Part 1 frequency/reliability and Part 2 governing factor calculations."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
|
|
6
|
+
|
|
7
|
+
HOLES = ("small", "medium", "large", "rupture")
|
|
8
|
+
STANDARD = (8e-6, 2e-5, 2e-6, 6e-7)
|
|
9
|
+
GFF = {k: STANDARD for k in ["COMPR", "HEXSS", "HEXTS", "PIPE-8", "PIPE-10", "PIPE-12",
|
|
10
|
+
"PIPE-16", "PIPEGT16", "PUMP2S", "PUMPR", "PUMP1S", "KODRUM", "COLBTM", "FINFAN",
|
|
11
|
+
"FILTER", "DRUM", "REACTOR", "COLTOP", "COLMID"]}
|
|
12
|
+
GFF.update({"COMPC": (8e-6, 2e-5, 2e-6, 0), "PIPE-1": (2.8e-5, 0, 0, 2.6e-6),
|
|
13
|
+
"PIPE-2": (2.8e-5, 0, 0, 2.6e-6), "PIPE-4": (8e-6, 2e-5, 0, 2.6e-6),
|
|
14
|
+
"PIPE-6": (8e-6, 2e-5, 0, 2.6e-6)})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Component(Model):
|
|
18
|
+
component_type: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@calculation("frequency/generic", "Component generic failure frequencies", Component,
|
|
22
|
+
["Part 2 Table 3.1; PDF p147"], limitations=["Non-tank lookup. Tank rows with differing printed/summed totals are exposed separately by tank/generic-frequency using eq3.208. HEXTUBE is not a 2016 table entry."])
|
|
23
|
+
def generic(x):
|
|
24
|
+
ensure(x.component_type in GFF, f"Unsupported component_type; supported: {', '.join(GFF)}")
|
|
25
|
+
values = GFF[x.component_type]
|
|
26
|
+
return {"by_hole_per_year": dict(zip(HOLES, values)), "total_per_year": math.fsum(values)}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Management(Model):
|
|
30
|
+
score: NonNegative = Field(le=1000)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@calculation("frequency/management-factor", "Management systems factor", Management,
|
|
34
|
+
["Part 2 §3.5.4 eqs (2.9)-(2.10); PDF p145"])
|
|
35
|
+
def management(x):
|
|
36
|
+
percent = x.score / 10
|
|
37
|
+
return {"score_percent": percent, "management_factor": 10 ** (1 - .02 * percent)}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Frequency(Model):
|
|
41
|
+
generic_frequency_per_year: NonNegative
|
|
42
|
+
total_damage_factor: NonNegative
|
|
43
|
+
management_factor: Positive
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@calculation("frequency/adjusted", "Damage-adjusted annual failure frequency", Frequency,
|
|
47
|
+
["Part 1 eq (1.1); Part 2 eq (2.1)"], limitations=["DF is supplied by caller; this endpoint does not assess damage applicability."])
|
|
48
|
+
def adjusted(x):
|
|
49
|
+
return {"annual_failure_frequency": x.generic_frequency_per_year * x.total_damage_factor * x.management_factor,
|
|
50
|
+
"unit": "1/year"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PofMethodSelection(Model):
|
|
54
|
+
method: Literal["structural_reliability", "statistical_generic_data", "expert_judgment", "combined"]
|
|
55
|
+
qualification_basis: str = Field(min_length=1, max_length=1000)
|
|
56
|
+
uncertainty_quantified: bool
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@calculation(
|
|
60
|
+
"frequency/pof-method-selection",
|
|
61
|
+
"Probability-of-failure methodology selection",
|
|
62
|
+
PofMethodSelection,
|
|
63
|
+
["Part 2 §3.2; PDF p142"],
|
|
64
|
+
limitations=[
|
|
65
|
+
"Records the API 581 method route and qualification evidence; it does not calculate a structural reliability probability, fit a statistical distribution, or convert expert judgment into a numeric POF. Numeric Level 1 POF remains frequency/adjusted using GFF×DF×FMS."
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
def pof_method_selection(x):
|
|
69
|
+
ensure(bool(x.qualification_basis.strip()), "POF method qualification_basis must be nonblank")
|
|
70
|
+
return {
|
|
71
|
+
"method": x.method,
|
|
72
|
+
"qualification_basis": x.qualification_basis,
|
|
73
|
+
"uncertainty_quantified": x.uncertainty_quantified,
|
|
74
|
+
"numeric_pof_route": "gff_df_fms" if x.method in ("statistical_generic_data", "combined") else "external_method_result",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class GoverningDamage(Model):
|
|
79
|
+
thinning: NonNegative
|
|
80
|
+
lining: NonNegative | None = None
|
|
81
|
+
external: list[NonNegative] = Field(max_length=4)
|
|
82
|
+
scc: list[NonNegative] = Field(max_length=9)
|
|
83
|
+
htha: NonNegative
|
|
84
|
+
fatigue: NonNegative
|
|
85
|
+
brittle_fracture: NonNegative
|
|
86
|
+
temper_embrittlement: NonNegative
|
|
87
|
+
embrittlement_885f: NonNegative
|
|
88
|
+
sigma_embrittlement: NonNegative
|
|
89
|
+
thinning_external_distribution: Literal["local", "general"]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@calculation("damage/combine", "Governing and total damage factors", GoverningDamage,
|
|
93
|
+
["Part 2 §3.4.2 eqs (2.2)-(2.8); PDF pp142-143"],
|
|
94
|
+
limitations=["Caller must identify applicable mechanisms; empty lists mean explicitly no applicable mechanism."])
|
|
95
|
+
def combine(x):
|
|
96
|
+
thin = min(x.thinning, x.lining) if x.lining is not None else x.thinning
|
|
97
|
+
ext, scc = max(x.external, default=0), max(x.scc, default=0)
|
|
98
|
+
active = lambda v: v if v > 1 else 0
|
|
99
|
+
brit = max(active(x.brittle_fracture) + active(x.temper_embrittlement),
|
|
100
|
+
active(x.embrittlement_885f), active(x.sigma_embrittlement))
|
|
101
|
+
wall = max(thin, ext) if x.thinning_external_distribution == "local" else thin + ext
|
|
102
|
+
return {"governing_thinning": thin, "governing_external": ext, "governing_scc": scc,
|
|
103
|
+
"governing_brittle": brit, "total_damage_factor": wall + scc + x.htha + brit + x.fatigue}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InspectionEquivalent(Model):
|
|
107
|
+
mechanism: Literal["scc", "external_clscc", "cui_clscc", "htha"]
|
|
108
|
+
a: int = Field(ge=0)
|
|
109
|
+
b: int = Field(ge=0)
|
|
110
|
+
c: int = Field(ge=0)
|
|
111
|
+
d: int = Field(ge=0)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@calculation("inspection/equivalent", "Equivalent inspection counts", InspectionEquivalent,
|
|
115
|
+
["Part 2 §3.4.3; PDF p143"], limitations=["Not applicable to thinning or external corrosion; E inspections cannot be upgraded."])
|
|
116
|
+
def inspection_equivalent(x):
|
|
117
|
+
d, rd = divmod(x.d, 2)
|
|
118
|
+
c, rc = divmod(x.c + d, 2)
|
|
119
|
+
b, rb = divmod(x.b + c, 2)
|
|
120
|
+
return {"a": x.a + b, "b": rb, "c": rc, "d": rd}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class InspectionEffectivenessSummary(Model):
|
|
124
|
+
mechanism: Literal["scc", "external_clscc", "cui_clscc", "htha"]
|
|
125
|
+
categories: list[Literal["A", "B", "C", "D", "E"]] = Field(min_length=1, max_length=100)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@calculation(
|
|
129
|
+
"inspection/effectiveness-summary",
|
|
130
|
+
"Combine inspection effectiveness history into highest category and equivalent counts",
|
|
131
|
+
InspectionEffectivenessSummary,
|
|
132
|
+
["Part 2 §3.4.3; Part 2 Annex C §2.C.2; PDF pp143,443-445"],
|
|
133
|
+
origin="api581_inspection_effectiveness_history_composition",
|
|
134
|
+
limitations=[
|
|
135
|
+
"Applies the printed A-D equivalence rule used for SCC-family mechanisms: two lower-category inspections promote one step; E inspections never receive credit. It does not assess whether the individual inspection method satisfies an Annex C table.",
|
|
136
|
+
"Categories must already be qualified by a mechanism-specific inspection API or owner-user review. No date, coverage, age escalation or inspection effectiveness inference is performed here."
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
def effectiveness_summary(x):
|
|
140
|
+
counts = {category.lower(): x.categories.count(category) for category in "ABCDE"}
|
|
141
|
+
equivalent = inspection_equivalent(InspectionEquivalent(
|
|
142
|
+
mechanism=x.mechanism, a=counts["a"], b=counts["b"], c=counts["c"], d=counts["d"]
|
|
143
|
+
))
|
|
144
|
+
highest = next((category for category in "ABCD" if counts[category.lower()]), "E")
|
|
145
|
+
return {
|
|
146
|
+
"mechanism": x.mechanism,
|
|
147
|
+
"highest_effectiveness_category": highest,
|
|
148
|
+
"inspection_count": len(x.categories),
|
|
149
|
+
"creditable_inspection_count": sum(counts[c.lower()] for c in "ABCD"),
|
|
150
|
+
"counts": counts,
|
|
151
|
+
"equivalent_counts": equivalent,
|
|
152
|
+
"e_inspection_count": counts["e"],
|
|
153
|
+
"qualification_status": "caller_qualified_categories",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Weibull(Model):
|
|
158
|
+
shape: Positive
|
|
159
|
+
characteristic_life_years: Positive
|
|
160
|
+
age_years: NonNegative
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@calculation("reliability/weibull", "Two-parameter Weibull reliability", Weibull,
|
|
164
|
+
["Part 1 eq (1.2), §8.3.2 eq (1.56)"],
|
|
165
|
+
limitations=["User-supplied parameters only; PRD environmental and inspection adjustments not included."])
|
|
166
|
+
def weibull(x):
|
|
167
|
+
z = (x.age_years / x.characteristic_life_years) ** x.shape
|
|
168
|
+
hazard = None if x.age_years == 0 and x.shape < 1 else (
|
|
169
|
+
x.shape / x.characteristic_life_years * (x.age_years / x.characteristic_life_years) ** (x.shape - 1)
|
|
170
|
+
if x.age_years > 0 else (1 / x.characteristic_life_years if x.shape == 1 else 0))
|
|
171
|
+
return {"cumulative_failure_probability": -math.expm1(-z), "survival_probability": math.exp(-z),
|
|
172
|
+
"hazard_per_year": hazard, "hazard_singular_at_origin": hazard is None}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class WeibullQuantile(Model):
|
|
176
|
+
shape: Positive
|
|
177
|
+
characteristic_life_years: Positive
|
|
178
|
+
probability: Fraction = Field(lt=1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@calculation("reliability/weibull-quantile", "Time to a Weibull failure probability", WeibullQuantile,
|
|
182
|
+
["Part 1 §8.3.2 eq (1.57)"])
|
|
183
|
+
def weibull_quantile(x):
|
|
184
|
+
return {"age_years": x.characteristic_life_years * (-math.log1p(-x.probability)) ** (1 / x.shape)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Mttf(Model):
|
|
188
|
+
shape: Positive
|
|
189
|
+
mean_life_years: Positive
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@calculation("reliability/weibull-from-mttf", "Convert mean life to Weibull characteristic life", Mttf,
|
|
193
|
+
["Part 1 §8.3.4.2"], origin="engineering_utility")
|
|
194
|
+
def from_mttf(x):
|
|
195
|
+
return {"characteristic_life_years": x.mean_life_years / math.gamma(1 + 1 / x.shape)}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class Poisson(Model):
|
|
199
|
+
annual_failure_frequency: NonNegative
|
|
200
|
+
duration_years: NonNegative
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@calculation("reliability/poisson-probability", "Constant-frequency period probability", Poisson,
|
|
204
|
+
["Part 1 §4.1 Probability of Failure", "Part 1 §4.1.2 Generic Failure Frequency Method"],
|
|
205
|
+
origin="engineering_utility", limitations=["Assumes a constant-rate Poisson process; not the GFF frequency itself."])
|
|
206
|
+
def poisson(x):
|
|
207
|
+
return {"period_failure_probability": -math.expm1(-x.annual_failure_frequency * x.duration_years)}
|