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/soil_corrosion.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""2016 Annex2.B §12 buried-carbon-steel soil-side corrosion rates."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, calculation, ensure, finite_tree
|
|
6
|
+
from .thinning import WallLoss, StrengthRatio, Posterior, UncladThinning, unclad
|
|
7
|
+
|
|
8
|
+
Profile = Literal["dry_inland_sand","variable_industrial_silt","saturated_coastal_clay"]
|
|
9
|
+
Coating = Literal["bare","fusion_bonded_epoxy","liquid_epoxy","asphalt_enamel","asphalt_mastic",
|
|
10
|
+
"coal_tar_enamel","extruded_pe_mastic_or_rubber","mill_pe_tape_mastic","field_pe_tape_mastic","three_layer_pe_pp"]
|
|
11
|
+
CP = Literal["unprotected_adjacent_cp_stray_current","no_cp","not_annual_or_partly_noncompliant",
|
|
12
|
+
"annual_on_potential_entire_structure","annual_polarized_or_instant_off_entire_structure"]
|
|
13
|
+
_BASE = {"dry_inland_sand":(.03,1.,"Sand; low chlorides inland/non-industrial; homogeneous fine silt or sand; dry desert-like"),
|
|
14
|
+
"variable_industrial_silt":(.13,5.,"Silt; moderate industrial contaminants; mixed particles; variable moisture"),
|
|
15
|
+
"saturated_coastal_clay":(.25,10.,"Clay; high chlorides coastal; >50% gravel; normally saturated")}
|
|
16
|
+
_COAT = {k:(1.,1.1,1.5,1.1) for k in ("fusion_bonded_epoxy","liquid_epoxy","asphalt_enamel","asphalt_mastic")}
|
|
17
|
+
_COAT.update(coal_tar_enamel=(1.,1.2,2.,1.5),extruded_pe_mastic_or_rubber=(1.,1.2,3.,1.5),
|
|
18
|
+
mill_pe_tape_mastic=(1.5,1.2,3.,1.5),field_pe_tape_mastic=(2.,2.,3.,1.5),three_layer_pe_pp=(1.,1.2,2.,1.2))
|
|
19
|
+
_CP = dict(zip(("unprotected_adjacent_cp_stray_current","no_cp","not_annual_or_partly_noncompliant",
|
|
20
|
+
"annual_on_potential_entire_structure","annual_polarized_or_instant_off_entire_structure"),(10.,1.,.8,.4,.05)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SoilBaseRate(Model):
|
|
24
|
+
condition_profile: Profile
|
|
25
|
+
unit_basis: Literal["printed_si","printed_us_mpy"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@calculation("damage/soil-base-rate-table", "Soil base corrosion-rate reference profiles", SoilBaseRate,
|
|
29
|
+
["Part2 Annex2.B §12.4.2,Table2.B.12.2; PDF pp417,420"],limitations=[
|
|
30
|
+
"Three full soil/contaminant/particle/moisture profiles, not automatic classification by soil name alone. Measured comparable rates take precedence under source guidance. Mixed conditions require qualified owner base rate, no invented interpolation. Printed SI.03/.13/.25mm/y differ from exact1/5/10mpy conversion; selected basis explicit."])
|
|
31
|
+
def base_rate(x):
|
|
32
|
+
si,mpy,description = _BASE[x.condition_profile]
|
|
33
|
+
rate = si/1000 if x.unit_basis == "printed_si" else mpy*.0000254
|
|
34
|
+
return {"corrosion_rate_m_year":rate,"printed_si_rate_mm_year":si,"printed_us_rate_mpy":mpy,
|
|
35
|
+
"selected_unit_basis":x.unit_basis,"condition_description":description}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SoilResistivity(Model):
|
|
39
|
+
soil_resistivity_ohm_m: Positive
|
|
40
|
+
shared_boundary_basis: Literal["lower_resistivity_interval","higher_resistivity_interval"] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@calculation("damage/soil-resistivity-factor", "Optional soil resistivity adjustment factor", SoilResistivity,
|
|
44
|
+
["Part2 Annex2.B §12.4.3,Table2.B.12.3; PDF pp417-418,420"],limitations=[
|
|
45
|
+
"Public resistivity ohm m; source ohm cm=100*ohm m. Shared1000/2000ohm cm endpoints require explicit lower/higher interval selection.500 belongs500-1000,10000 belongs2000-10000. No inferred continuous resistivity interpolation. Omit adjustment when already incorporated in base rate to avoid double counting."])
|
|
46
|
+
def resistivity(x):
|
|
47
|
+
value = x.soil_resistivity_ohm_m
|
|
48
|
+
if value in (10.,20.):
|
|
49
|
+
ensure(x.shared_boundary_basis is not None,"Shared resistivity endpoint requires interval basis","ambiguous_soil_resistivity_boundary")
|
|
50
|
+
index = (1 if value == 10. else 2)+(x.shared_boundary_basis == "higher_resistivity_interval")
|
|
51
|
+
else:
|
|
52
|
+
index = 0 if value < 5 else 1 if value < 10 else 2 if value < 20 else 3 if value <= 100 else 4
|
|
53
|
+
return {"resistivity_factor":(1.5,1.25,1.,.83,.6)[index],"source_row":index+1}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SoilTemperature(Model):
|
|
57
|
+
equipment_temperature_k: Positive
|
|
58
|
+
threshold_basis: Literal["printed_si","printed_us_converted"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@calculation("damage/soil-temperature-factor", "Buried equipment soil-corrosion temperature factor", SoilTemperature,
|
|
62
|
+
["Part2 Annex2.B §12.4.4,Table2.B.12.4; PDF pp418,420"],limitations=[
|
|
63
|
+
"SI uses49/104C; US uses120/220F converted exactly to K. Paired boundaries differ, so basis required. Middle interval includes both endpoints; outer factors1, middle2. This table lookup does not qualify cryogenic/high-temperature buried service or frozen-soil chemistry."])
|
|
64
|
+
def temperature(x):
|
|
65
|
+
low,high = (322.15,377.15) if x.threshold_basis == "printed_si" else ((120-32)*5/9+273.15,(220-32)*5/9+273.15)
|
|
66
|
+
return {"temperature_factor":2. if low <= x.equipment_temperature_k <= high else 1.,
|
|
67
|
+
"lower_boundary_k":low,"upper_boundary_k":high,"threshold_basis":x.threshold_basis}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SoilCathodicProtection(Model):
|
|
71
|
+
protection_category: CP
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@calculation("damage/soil-cp-factor", "Soil corrosion cathodic-protection effectiveness factor", SoilCathodicProtection,
|
|
75
|
+
["Part2 Annex2.B §12.4.5,Table2.B.12.5; PDF pp418,421"],limitations=[
|
|
76
|
+
"Owner classifies actual coverage, annual testing,2016 NACE-reference compliance and adjacent-CP stray-current risk. No-CP1 differs from unprotected/untested with adjacent-CP stray current10. Factor.05 requires full-structure annual polarized/instant-off criteria; not automatic interpretation of one potential reading. Large/dynamic stray currents require specialist analysis under source scope."])
|
|
77
|
+
def cathodic_protection(x):
|
|
78
|
+
return {"cathodic_protection_factor":_CP[x.protection_category]}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SoilCoating(Model):
|
|
82
|
+
coating_type: Coating
|
|
83
|
+
coating_age_years: NonNegative
|
|
84
|
+
rated_temperature_occasionally_exceeded: bool
|
|
85
|
+
maintenance_rare_or_none: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@calculation("damage/soil-coating-factor", "Product of independent soil-coating effectiveness factors", SoilCoating,
|
|
89
|
+
["Part2 Annex2.B §12.4.6,eq(2.B.22),Table2.B.12.6; PDF pp419,421"],limitations=[
|
|
90
|
+
"Factors apply independently; age multiplier only for age>20years, nonapplicable multipliers1. Bare factor1. Applies even without CP. Source worked mill-PE example prints1.5*1.2*3*1.5=13.5, but table-factor product is8.1; calculation follows factor multiplication, reports printed example separately and does not assert an authorized erratum."])
|
|
91
|
+
def coating(x):
|
|
92
|
+
if x.coating_type == "bare":
|
|
93
|
+
ensure(not x.rated_temperature_occasionally_exceeded and not x.maintenance_rare_or_none,
|
|
94
|
+
"Bare structure cannot have coating-temperature/maintenance conditions")
|
|
95
|
+
factors = [1.,1.,1.,1.]
|
|
96
|
+
else:
|
|
97
|
+
base,age,abuse,maintenance = _COAT[x.coating_type]
|
|
98
|
+
factors = [base,age if x.coating_age_years > 20 else 1.,abuse if x.rated_temperature_occasionally_exceeded else 1.,
|
|
99
|
+
maintenance if x.maintenance_rare_or_none else 1.]
|
|
100
|
+
example = x.coating_type == "mill_pe_tape_mastic" and x.coating_age_years > 20 and x.rated_temperature_occasionally_exceeded and x.maintenance_rare_or_none
|
|
101
|
+
return {"coating_effectiveness_factor":math.prod(factors),"base_factor":factors[0],"age_factor":factors[1],
|
|
102
|
+
"temperature_abuse_factor":factors[2],"maintenance_factor":factors[3],
|
|
103
|
+
"calculation_basis":"table_factor_product","printed_example_discrepancy_applies":example,
|
|
104
|
+
"printed_example_reported_factor":13.5 if example else None}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SoilRateEquation(Model):
|
|
108
|
+
base_corrosion_rate_m_year: NonNegative
|
|
109
|
+
resistivity_factor: Positive
|
|
110
|
+
temperature_factor: Positive
|
|
111
|
+
cathodic_protection_factor: Positive
|
|
112
|
+
coating_effectiveness_factor: Positive
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@calculation("damage/soil-rate-equation", "Soil corrosion multiplicative rate equation", SoilRateEquation,
|
|
116
|
+
["Part2 Annex2.B eq(2.B.21); PDF p417"],limitations=[
|
|
117
|
+
"CR=CRB*FSR*FT*FCP*FCE. Qualified factors supplied; no automatic material/service selection. Does not clamp to minimum corrosion rate or infer coating as zero-corrosion protection."])
|
|
118
|
+
def rate_equation(x):
|
|
119
|
+
if x.base_corrosion_rate_m_year == 0:
|
|
120
|
+
value = 0.
|
|
121
|
+
else:
|
|
122
|
+
value = math.exp(math.fsum(math.log(v) for v in (x.base_corrosion_rate_m_year,x.resistivity_factor,
|
|
123
|
+
x.temperature_factor,x.cathodic_protection_factor,x.coating_effectiveness_factor)))
|
|
124
|
+
ensure(value > 0,"Positive soil corrosion rate underflow","numerical_failure")
|
|
125
|
+
return {"corrosion_rate_m_year":value}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class SoilRate(Model):
|
|
129
|
+
material: Literal["carbon_steel"]
|
|
130
|
+
in_contact_with_soil: bool
|
|
131
|
+
applicability_basis: str = Field(min_length=1,max_length=1000)
|
|
132
|
+
base_corrosion_rate_m_year: NonNegative
|
|
133
|
+
base_rate_source: str = Field(min_length=1,max_length=1000)
|
|
134
|
+
resistivity_application: Literal["apply","already_in_base","omitted_by_qualification"]
|
|
135
|
+
resistivity: SoilResistivity | None = None
|
|
136
|
+
resistivity_omission_basis: str | None = Field(default=None,min_length=1,max_length=1000)
|
|
137
|
+
temperature: SoilTemperature
|
|
138
|
+
cathodic_protection: SoilCathodicProtection
|
|
139
|
+
coating: SoilCoating
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_SOIL_LIMITS = [
|
|
143
|
+
"Annex2.B screening requires carbon steel in soil contact. Owner qualifies buried-service applicability, actual soil/base rate and current/coating condition. Uses Table2.B.1.2 local thinning classification; not atmospheric/CUI corrosion, AST floor automatic model, MIC/stray-current detailed analysis or arbitrary cryogenic service.",
|
|
144
|
+
"Optional resistivity factor must be explicitly applied or omitted with basis, including when already incorporated in base rate. No duplicate soil-resistivity weighting. Source SI/US rate/temperature rounding, resistivity shared endpoints and coating example8.1vs13.5 remain explicit. Coating uses table product, not erroneous worked arithmetic.",
|
|
145
|
+
"Base-rate reference conditions must be qualified before reapplying CP,coating or temperature effects. Already-adjusted measured net rates can feed the existing thinning kernel directly; this composition does not infer which effects a measured rate already contains.",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@calculation("damage/soil-corrosion-rate", "Compose qualified soil-side corrosion base rate and table factors", SoilRate,
|
|
150
|
+
["Part2 Annex2.B Tables2.B.1.1/1.2,§12,eq(2.B.21); PDF pp322-323,417-421"],limitations=_SOIL_LIMITS)
|
|
151
|
+
def soil_rate(x):
|
|
152
|
+
ensure(x.in_contact_with_soil,"Soil contact screening not met","soil_screen_not_met")
|
|
153
|
+
ensure(x.applicability_basis.strip() and x.base_rate_source.strip(),"Applicability and base-rate sources must be nonblank")
|
|
154
|
+
if x.resistivity_application == "apply":
|
|
155
|
+
ensure(x.resistivity is not None and x.resistivity_omission_basis is None,"Applied resistivity needs input and no omission basis")
|
|
156
|
+
fsr = resistivity(x.resistivity)["resistivity_factor"]
|
|
157
|
+
else:
|
|
158
|
+
ensure(x.resistivity is None and x.resistivity_omission_basis is not None and x.resistivity_omission_basis.strip(),
|
|
159
|
+
"Omitted/already-in-base resistivity needs explicit basis and no duplicate input")
|
|
160
|
+
fsr = 1.
|
|
161
|
+
ft = temperature(x.temperature)["temperature_factor"]
|
|
162
|
+
fcp = cathodic_protection(x.cathodic_protection)["cathodic_protection_factor"]
|
|
163
|
+
fce = coating(x.coating)
|
|
164
|
+
factors = dict(resistivity_factor=fsr,temperature_factor=ft,cathodic_protection_factor=fcp,
|
|
165
|
+
coating_effectiveness_factor=fce["coating_effectiveness_factor"])
|
|
166
|
+
rate = rate_equation(SoilRateEquation(base_corrosion_rate_m_year=x.base_corrosion_rate_m_year,**factors))
|
|
167
|
+
return {**rate,**factors,"base_corrosion_rate_m_year":x.base_corrosion_rate_m_year,
|
|
168
|
+
"thinning_type":"local","resistivity_application":x.resistivity_application,
|
|
169
|
+
"coating_calculation_basis":fce["calculation_basis"],"coating_example_discrepancy_applies":fce["printed_example_discrepancy_applies"]}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class SoilThinning(Model):
|
|
173
|
+
soil: SoilRate
|
|
174
|
+
elapsed_years: NonNegative
|
|
175
|
+
strength: StrengthRatio
|
|
176
|
+
inspections: Posterior
|
|
177
|
+
online_monitoring_factor: Positive
|
|
178
|
+
assessment_scope: Literal["unclad_internal_pressure_governed_non_ast"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@calculation("damage/soil-thinning-assessment", "Soil corrosion rate through unclad non-AST thinning DF", SoilThinning,
|
|
182
|
+
["Part2 Annex2.B §12; Part2 §4.5.7 eqs(2.13),(2.18)-(2.23)"],
|
|
183
|
+
origin="api581_soil_rate_to_unclad_thinning_composition",limitations=_SOIL_LIMITS+[
|
|
184
|
+
"Single soil-side mechanism for qualified unclad non-AST internal-pressure-governed component and one constant-rate interval. No automatic history integration across coating-age20,CP or temperature changes. Same starting thickness from strength input feeds wall loss. Uses existing Bayesian thinning kernel and supplied monitoring factor. Injection/deadleg factors1 for this soil-only path. No AST floor, structural/external-pressure, cladding or automatic inspection-effectiveness determination. Result is not total all-mechanism DF or inspection acceptance."])
|
|
185
|
+
def soil_thinning(x):
|
|
186
|
+
rate = soil_rate(x.soil)
|
|
187
|
+
finite_tree(rate)
|
|
188
|
+
result = unclad(UncladThinning(wall=WallLoss(starting_thickness_m=x.strength.starting_thickness_m,
|
|
189
|
+
corrosion_rate_m_year=rate["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),strength=x.strength,
|
|
190
|
+
inspections=x.inspections,injection_point_factor=1.,deadleg_factor=1.,online_monitoring_factor=x.online_monitoring_factor))
|
|
191
|
+
return {**rate,**result,"assessment_scope":x.assessment_scope}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Level2 pressure-phase screening and explicit rainout/aerosol source bases."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
|
|
6
|
+
from .release import Orifice, orifice, Gas, gas, Liquid, liquid
|
|
7
|
+
|
|
8
|
+
Phase = Literal["vapor","liquid","two_phase"]
|
|
9
|
+
Basis = Literal["api581_printed","post_flash_mass_balance"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PhaseScreen(Model):
|
|
13
|
+
storage_pressure_pa_abs: Positive
|
|
14
|
+
saturation_pressure_at_storage_pa_abs: Positive
|
|
15
|
+
ambient_pressure_pa_abs: Positive = 101325
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@calculation("release/level2-release-phase", "Pressure-based Level2 release-phase screening", PhaseScreen,
|
|
19
|
+
["Part3 eqs(3.95)-(3.97); PDF p541"],
|
|
20
|
+
limitations=["Release phase at opening differs from storage phase and final atmospheric flashed phase. Source inequalities overlap at Psat=Ps and omit equality at Psat=Patm; these boundaries return no selected phase. Ps=Patm returns no_driving_pressure. Saturation pressure is caller-qualified; no supercritical EOS inference."])
|
|
21
|
+
def phase_screen(x):
|
|
22
|
+
ps,pv,pa=x.storage_pressure_pa_abs,x.saturation_pressure_at_storage_pa_abs,x.ambient_pressure_pa_abs
|
|
23
|
+
ensure(ps >= pa,"Storage pressure must be at least ambient pressure")
|
|
24
|
+
if ps == pa:
|
|
25
|
+
return {"release_phase":None,"status":"no_driving_pressure"}
|
|
26
|
+
if pv == ps:
|
|
27
|
+
return {"release_phase":None,"status":"saturation_storage_boundary"}
|
|
28
|
+
if pv == pa:
|
|
29
|
+
return {"release_phase":None,"status":"saturation_ambient_boundary"}
|
|
30
|
+
return {"release_phase":"vapor" if pv > ps else "two_phase" if pv > pa else "liquid","status":"classified"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Level2Orifice(PhaseScreen):
|
|
34
|
+
diameter_m: NonNegative
|
|
35
|
+
gas_temperature_k: Positive | None = None
|
|
36
|
+
gas_molecular_weight_kg_kmol: Positive | None = None
|
|
37
|
+
gas_heat_capacity_ratio: Positive | None = None
|
|
38
|
+
liquid_density_kg_m3: Positive | None = None
|
|
39
|
+
gas_discharge_coefficient: Fraction = .9
|
|
40
|
+
liquid_discharge_coefficient: Fraction = .61
|
|
41
|
+
liquid_viscosity_factor: Positive = 1
|
|
42
|
+
qualified_boundary_phase: Phase | None = None
|
|
43
|
+
boundary_phase_source: str | None = Field(default=None,min_length=1,max_length=1000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@calculation("release/level2-orifice-rate", "Level2 phase-selected gas/liquid/conservative two-phase source rate", Level2Orifice,
|
|
47
|
+
["Part3 §§5.3.3-5.3.6 eqs(3.95)-(3.97); PDF pp541-542; §4.3 eqs(3.3),(3.6)-(3.7)"],
|
|
48
|
+
origin="api581_phase_selected_orifice_composition",
|
|
49
|
+
limitations=["Two-phase branch uses source-permitted conservative LIQUID approximation, not HEM/Omega or a flashing two-phase flow solver. Vapor uses ideal gas constant upstream model. Boundary phase override requires a nonblank owner basis and cannot contradict a classified phase. No-driving-pressure returns zero without inventing phase. Caller qualifies saturation pressure and properties; no thermodynamic or LH2 validation."])
|
|
50
|
+
def level2_orifice(x):
|
|
51
|
+
selected=phase_screen(PhaseScreen(**x.model_dump(include=set(PhaseScreen.model_fields))))
|
|
52
|
+
phase=selected["release_phase"]
|
|
53
|
+
ensure((x.qualified_boundary_phase is None) == (x.boundary_phase_source is None),"Boundary phase and source must be supplied together")
|
|
54
|
+
if x.boundary_phase_source is not None:
|
|
55
|
+
ensure(bool(x.boundary_phase_source.strip()),"Boundary phase source must be nonblank")
|
|
56
|
+
if selected["status"] == "no_driving_pressure":
|
|
57
|
+
ensure(x.qualified_boundary_phase is None,"No phase override for zero driving pressure")
|
|
58
|
+
return {"mass_rate_kg_s":0,"area_m2":orifice(Orifice(diameter_m=x.diameter_m))["area_m2"],
|
|
59
|
+
"release_phase":None,"rate_method":"no_driving_pressure","phase_basis":"pressure_screen"}
|
|
60
|
+
if phase is None:
|
|
61
|
+
ensure(x.qualified_boundary_phase is not None,"Source pressure boundary requires qualified phase selection","phase_boundary_requires_qualification")
|
|
62
|
+
phase=x.qualified_boundary_phase
|
|
63
|
+
else:
|
|
64
|
+
ensure(x.qualified_boundary_phase is None or x.qualified_boundary_phase == phase,"Boundary override contradicts classified phase")
|
|
65
|
+
if phase == "vapor":
|
|
66
|
+
ensure(all(v is not None for v in (x.gas_temperature_k,x.gas_molecular_weight_kg_kmol,x.gas_heat_capacity_ratio)),
|
|
67
|
+
"Vapor phase requires gas temperature, molecular weight and heat capacity ratio")
|
|
68
|
+
ensure(x.gas_heat_capacity_ratio > 1,"Gas heat capacity ratio must exceed1")
|
|
69
|
+
result=gas(Gas(diameter_m=x.diameter_m,upstream_pressure_pa_abs=x.storage_pressure_pa_abs,
|
|
70
|
+
downstream_pressure_pa_abs=x.ambient_pressure_pa_abs,temperature_k=x.gas_temperature_k,
|
|
71
|
+
molecular_weight_kg_kmol=x.gas_molecular_weight_kg_kmol,heat_capacity_ratio=x.gas_heat_capacity_ratio,
|
|
72
|
+
discharge_coefficient=x.gas_discharge_coefficient))
|
|
73
|
+
method="ideal_gas"
|
|
74
|
+
else:
|
|
75
|
+
ensure(x.liquid_density_kg_m3 is not None,"Liquid or conservative two-phase source requires liquid density")
|
|
76
|
+
result=liquid(Liquid(diameter_m=x.diameter_m,upstream_pressure_pa_abs=x.storage_pressure_pa_abs,
|
|
77
|
+
downstream_pressure_pa_abs=x.ambient_pressure_pa_abs,density_kg_m3=x.liquid_density_kg_m3,
|
|
78
|
+
discharge_coefficient=x.liquid_discharge_coefficient,viscosity_factor=x.liquid_viscosity_factor))
|
|
79
|
+
method="liquid" if phase == "liquid" else "liquid_conservative_two_phase"
|
|
80
|
+
return {"mass_rate_kg_s":result["mass_rate_kg_s"],"area_m2":result["area_m2"],"release_phase":phase,"rate_method":method,
|
|
81
|
+
"phase_basis":"qualified_boundary" if selected["release_phase"] is None else "pressure_screen"}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Rainout(Model):
|
|
85
|
+
flash_mass_fraction: Fraction
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@calculation("release/kletz-rainout-fraction", "Kletz rainout mass-fraction correlation", Rainout,
|
|
89
|
+
["Part3 eqs(3.98)-(3.99); PDF p543"],
|
|
90
|
+
limitations=["Rainout=max(0,1-2*flash_fraction). Flash fraction must be supplied by qualified isentropic/isenthalpic atmospheric flash calculation. This correlation does not calculate flash state or verify entrainment composition; no LH2 validation."])
|
|
91
|
+
def rainout(x):
|
|
92
|
+
return {"rainout_mass_fraction":max(0,1-2*x.flash_mass_fraction)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateSplit(Model):
|
|
96
|
+
adjusted_mass_rate_kg_s: NonNegative
|
|
97
|
+
rainout_mass_fraction: Fraction
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@calculation("release/rainout-rate-split", "Rainout pool and aerosol-containing jet rates", RateSplit,
|
|
101
|
+
["Part3 eqs(3.100)-(3.101); PDF p543"],
|
|
102
|
+
limitations=["Pool=adjusted rate*rainout; jet=adjusted rate*(1-rainout). Jet may contain liquid aerosol and must not be labeled pure vapor. Rainout correlation/flash composition supplied separately; no pool evaporation in this split."])
|
|
103
|
+
def rate_split(x):
|
|
104
|
+
return {"pool_liquid_rate_kg_s":x.adjusted_mass_rate_kg_s*x.rainout_mass_fraction,
|
|
105
|
+
"jet_total_rate_kg_s":x.adjusted_mass_rate_kg_s*(1-x.rainout_mass_fraction)}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Entrainment(Model):
|
|
109
|
+
rainout_mass_fraction: Fraction
|
|
110
|
+
equation_basis: Basis
|
|
111
|
+
stored_liquid_mass_fraction: Fraction | None = None
|
|
112
|
+
flash_mass_fraction: Fraction | None = None
|
|
113
|
+
post_flash_vapor_mass_fraction: Fraction | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@calculation("release/jet-liquid-entrainment", "Printed or post-flash-mass-balance aerosol fraction", Entrainment,
|
|
117
|
+
["Part3 eq(3.102); PDF p543; phase-fraction nomenclature PDF575-576"],
|
|
118
|
+
origin="api581_with_explicit_entrainment_basis",
|
|
119
|
+
limitations=["api581_printed uses stored_liquid_fraction*flash_fraction/(1-rainout), exactly the printed product. Reports physical_range but does not prove EOS consistency. post_flash_mass_balance uses supplied total post-flash vapor fraction and remaining liquid after rainout, a separately labeled conservation interpretation, not an authorized erratum. Exactly the selected basis fields required. No jet at rainout1 has no defined composition; returns None, not division by zero."])
|
|
120
|
+
def entrainment(x):
|
|
121
|
+
printed=x.equation_basis == "api581_printed"
|
|
122
|
+
ensure((printed and x.stored_liquid_mass_fraction is not None and x.flash_mass_fraction is not None and x.post_flash_vapor_mass_fraction is None)
|
|
123
|
+
or (not printed and x.stored_liquid_mass_fraction is None and x.flash_mass_fraction is None and x.post_flash_vapor_mass_fraction is not None),
|
|
124
|
+
"Provide exactly the parameters for selected entrainment basis")
|
|
125
|
+
jet_fraction=1-x.rainout_mass_fraction
|
|
126
|
+
if not printed:
|
|
127
|
+
ensure(math.fsum([x.post_flash_vapor_mass_fraction,x.rainout_mass_fraction]) <= 1,
|
|
128
|
+
"Post-flash vapor plus rainout exceeds total mass")
|
|
129
|
+
if jet_fraction == 0:
|
|
130
|
+
ensure(printed or x.post_flash_vapor_mass_fraction == 0,"No-jet rainout cannot coexist with positive post-flash vapor")
|
|
131
|
+
return {"entrained_liquid_mass_fraction":None,"jet_vapor_mass_fraction":None,"physical_range":True,
|
|
132
|
+
"jet_fraction_nonzero":False,"equation_basis":x.equation_basis}
|
|
133
|
+
if printed:
|
|
134
|
+
fraction=x.stored_liquid_mass_fraction*x.flash_mass_fraction/jet_fraction
|
|
135
|
+
physical=0 <= fraction <= 1
|
|
136
|
+
vapor=1-fraction if physical else None
|
|
137
|
+
else:
|
|
138
|
+
# min removes only <=1ulp ratio overshoot at a rounded exact mass boundary.
|
|
139
|
+
vapor=min(1,x.post_flash_vapor_mass_fraction/jet_fraction)
|
|
140
|
+
fraction=1-vapor
|
|
141
|
+
physical=True
|
|
142
|
+
return {"entrained_liquid_mass_fraction":fraction,"jet_vapor_mass_fraction":vapor,"physical_range":physical,
|
|
143
|
+
"jet_fraction_nonzero":True,"equation_basis":x.equation_basis}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SourcePartition(Entrainment):
|
|
147
|
+
adjusted_mass_rate_kg_s: NonNegative
|
|
148
|
+
released_mass_kg: NonNegative
|
|
149
|
+
rainout_basis_source: str = Field(min_length=1,max_length=1000)
|
|
150
|
+
flash_property_source: str = Field(min_length=1,max_length=1000)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@calculation("release/level2-source-partition", "Pool/jet/vapor/aerosol mass and rate partition", SourcePartition,
|
|
154
|
+
["Part3 eqs(3.100)-(3.102), STEP7.3-7.6; PDF pp543,545"],
|
|
155
|
+
origin="api581_source_partition_with_explicit_entrainment_basis",
|
|
156
|
+
limitations=["Adjusted total rate and released mass must be qualified after detection/isolation and inventory limits. Pool+jet and jet-vapor+jet-liquid conserve mass. Printed composition is explicit and not asserted as EOS closure; nonphysical printed fractions rejected. Rainout and flash properties supplied with source; no pool evaporation, flash/EOS, two-phase hydraulic solver or actual dispersion run."])
|
|
157
|
+
def source_partition(x):
|
|
158
|
+
ensure(x.rainout_basis_source.strip() and x.flash_property_source.strip(),"Rainout and flash-property sources must be nonblank")
|
|
159
|
+
composition=entrainment(Entrainment(**x.model_dump(include=set(Entrainment.model_fields))))
|
|
160
|
+
ensure(composition["physical_range"],"Printed entrained-liquid fraction outside0..1 cannot define physical source","invalid_entrainment_fraction")
|
|
161
|
+
rates=rate_split(RateSplit(adjusted_mass_rate_kg_s=x.adjusted_mass_rate_kg_s,rainout_mass_fraction=x.rainout_mass_fraction))
|
|
162
|
+
pool_mass=x.released_mass_kg*x.rainout_mass_fraction
|
|
163
|
+
jet_mass=x.released_mass_kg*(1-x.rainout_mass_fraction)
|
|
164
|
+
liquid_fraction=composition["entrained_liquid_mass_fraction"] or 0
|
|
165
|
+
vapor_fraction=composition["jet_vapor_mass_fraction"] or 0
|
|
166
|
+
return {**composition,**rates,"pool_liquid_mass_kg":pool_mass,"jet_total_mass_kg":jet_mass,
|
|
167
|
+
"jet_liquid_rate_kg_s":rates["jet_total_rate_kg_s"]*liquid_fraction,
|
|
168
|
+
"jet_vapor_rate_kg_s":rates["jet_total_rate_kg_s"]*vapor_fraction,
|
|
169
|
+
"jet_liquid_mass_kg":jet_mass*liquid_fraction,"jet_vapor_mass_kg":jet_mass*vapor_fraction}
|
api581/spatial.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Bounded planar threshold area on an explicitly triangulated sampled grid."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from pydantic import Field, model_validator
|
|
5
|
+
from .core import Model, Positive, Fraction, calculation, ensure
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Coordinate = Annotated[float,Field(strict=True,allow_inf_nan=False)]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConcentrationGrid(Model):
|
|
12
|
+
downwind_coordinates_m: list[Coordinate] = Field(min_length=2,max_length=4000)
|
|
13
|
+
crosswind_coordinates_m: list[Coordinate] = Field(min_length=2,max_length=4000)
|
|
14
|
+
volume_fraction_by_crosswind_then_downwind: list[list[Fraction]]
|
|
15
|
+
threshold_volume_fraction: Positive = Field(le=1)
|
|
16
|
+
sampling_basis: str = Field(min_length=1,max_length=1000)
|
|
17
|
+
|
|
18
|
+
@model_validator(mode="after")
|
|
19
|
+
def grid(self):
|
|
20
|
+
x,y = self.downwind_coordinates_m,self.crosswind_coordinates_m
|
|
21
|
+
if len(x)*len(y) > 1000000:
|
|
22
|
+
raise ValueError("Spatial grid limited to1000000 nodes")
|
|
23
|
+
if not all(math.isfinite(v) for v in x+y) or any(b <= a for axis in (x,y) for a,b in zip(axis,axis[1:])):
|
|
24
|
+
raise ValueError("Coordinates must be finite and strictly increasing")
|
|
25
|
+
if len(self.volume_fraction_by_crosswind_then_downwind) != len(y) or any(
|
|
26
|
+
len(row) != len(x) for row in self.volume_fraction_by_crosswind_then_downwind):
|
|
27
|
+
raise ValueError("Concentrations must have shape[crosswind][downwind]")
|
|
28
|
+
if not self.sampling_basis.strip():
|
|
29
|
+
raise ValueError("Sampling basis must be nonblank")
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _triangle_area(vertices,threshold):
|
|
34
|
+
"""Clip a linear concentration triangle in a local unit-square frame."""
|
|
35
|
+
clipped = []
|
|
36
|
+
previous = vertices[-1]
|
|
37
|
+
for current in vertices:
|
|
38
|
+
a,b = previous[2] >= threshold,current[2] >= threshold
|
|
39
|
+
if a != b:
|
|
40
|
+
fraction = (threshold-previous[2])/(current[2]-previous[2])
|
|
41
|
+
clipped.append((previous[0]+fraction*(current[0]-previous[0]),
|
|
42
|
+
previous[1]+fraction*(current[1]-previous[1])))
|
|
43
|
+
if b:
|
|
44
|
+
clipped.append(current[:2])
|
|
45
|
+
previous = current
|
|
46
|
+
if len(clipped) < 3:
|
|
47
|
+
return 0.
|
|
48
|
+
return abs(math.fsum(a[0]*b[1]-b[0]*a[1] for a,b in zip(clipped,clipped[1:]+clipped[:1])))/2
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@calculation("dispersion/threshold-grid-area", "Planar concentration exceedance area on a sampled triangulated grid", ConcentrationGrid,
|
|
52
|
+
["Part3 §§5.8.5-5.8.6,5.9: external spatial concentration footprint boundary"],origin="engineering_spatial_postprocessing",
|
|
53
|
+
limitations=["Numerical postprocessing, not a printed API581 correlation. Each rectangular cell is split along lower-left to upper-right diagonal; concentration is linear on each triangle and the threshold polygon is clipped exactly on that interpolant. Results depend on supplied sampling, not an exact continuous-model contour.",
|
|
54
|
+
"Field is at one common receptor height/time-averaging/source basis. No circular area from centreline distance, vertical cloud mass or multi-weather/event union. Any edge reaching threshold censors the footprint: threshold_area_m2 is null; sampled_exceedance_area_m2 remains the within-grid diagnostic. Below-threshold nodes do not rule out unresolved subgrid peaks; caller must establish resolution/convergence."])
|
|
55
|
+
def threshold_grid_area(x):
|
|
56
|
+
xs,ys,c = x.downwind_coordinates_m,x.crosswind_coordinates_m,x.volume_fraction_by_crosswind_then_downwind
|
|
57
|
+
threshold = x.threshold_volume_fraction
|
|
58
|
+
boundaries = []
|
|
59
|
+
for side,values in (("upwind",[r[0] for r in c]),("downwind",[r[-1] for r in c]),
|
|
60
|
+
("negative_crosswind",c[0]),("positive_crosswind",c[-1])):
|
|
61
|
+
if any(v >= threshold for v in values):
|
|
62
|
+
boundaries.append(side)
|
|
63
|
+
terms,count = [],0
|
|
64
|
+
for j in range(len(ys)-1):
|
|
65
|
+
dy = ys[j+1]-ys[j]
|
|
66
|
+
for i in range(len(xs)-1):
|
|
67
|
+
dx = xs[i+1]-xs[i]
|
|
68
|
+
cell = dx*dy
|
|
69
|
+
ensure(math.isfinite(cell) and cell > 0,"Spatial cell area outside numerical range","numerical_failure")
|
|
70
|
+
v00,v10,v11,v01 = c[j][i],c[j][i+1],c[j+1][i+1],c[j+1][i]
|
|
71
|
+
if max(v00,v10,v11,v01) < threshold:
|
|
72
|
+
continue
|
|
73
|
+
fraction = 1. if min(v00,v10,v11,v01) >= threshold else (
|
|
74
|
+
_triangle_area([(0.,0.,v00),(1.,0.,v10),(1.,1.,v11)],threshold)
|
|
75
|
+
+_triangle_area([(0.,0.,v00),(1.,1.,v11),(0.,1.,v01)],threshold))
|
|
76
|
+
ensure(0 <= fraction <= 1+1e-12,"Invalid interpolated area fraction","numerical_failure")
|
|
77
|
+
value = min(fraction,1.)*cell
|
|
78
|
+
ensure(value > 0 or fraction == 0,"Positive spatial area underflow","numerical_failure")
|
|
79
|
+
terms.append(value)
|
|
80
|
+
count += value > 0
|
|
81
|
+
area = math.fsum(terms)
|
|
82
|
+
reached = any(v >= threshold for row in c for v in row)
|
|
83
|
+
return {"sampled_exceedance_area_m2":area,"threshold_area_m2":None if boundaries else area,
|
|
84
|
+
"footprint_status":"boundary_censored" if boundaries else "closed_on_supplied_grid" if reached else "not_reached",
|
|
85
|
+
"boundary_sides":boundaries,"positive_area_cell_count":count,
|
|
86
|
+
"downwind_node_count":len(xs),"crosswind_node_count":len(ys),
|
|
87
|
+
"interpolation_basis":"piecewise_linear_main_diagonal_triangles"}
|
api581/ssc_screening.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""SSC applicability screen kept separate from hydrogen severity and hardness."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, Positive, NonNegative, calculation, ensure
|
|
5
|
+
from .scc_mechanisms import WetH2sEnvironment, wet_h2s_environment, SscSusceptibility, ssc_susceptibility, Assessment, InspectionCounts, assess
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SscScreen(Model):
|
|
9
|
+
material: Literal['carbon_steel','low_alloy_steel','other_material']
|
|
10
|
+
water_present: bool
|
|
11
|
+
h2s_present: bool
|
|
12
|
+
exposure_basis: str = Field(min_length=1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@calculation('damage/ssc-screening','SSC carbon/low-alloy steel wet-H2S any-concentration screen',SscScreen,
|
|
16
|
+
['Part2 §8.3; PDF p184'],limitations=[
|
|
17
|
+
'Evaluate carbon/low-alloy steel whenever both water and H2S are present at any concentration. No1ppm, partial-pressure, pH, temperature, hardness or PWHT cutoff is used to exclude the mechanism at this screen. Presence flags require qualified local/process/upset exposure evidence. Environmental hydrogen severity, cyanide, weld/HAZ Brinnell hardness and heat treatment remain separate susceptibility inputs. Screen-out is outside this procedure, not DF0, FFS acceptance or immunity to other mechanisms.'])
|
|
18
|
+
def ssc_screening(x):
|
|
19
|
+
ensure(bool(x.exposure_basis.strip()),'Qualified steel, water and H2S exposure basis required')
|
|
20
|
+
material=x.material!='other_material'
|
|
21
|
+
return {'evaluate_ssc':material and x.water_present and x.h2s_present,
|
|
22
|
+
'screening_reason':'outside_material_scope' if not material else 'water_absent' if not x.water_present else
|
|
23
|
+
'h2s_absent' if not x.h2s_present else 'evaluate'}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@calculation('damage/hic-h2s-screening','HIC/SOHIC-H2S steel and wet-H2S any-concentration screen',SscScreen,
|
|
27
|
+
['Part2 §9.3; PDF p189'],limitations=[
|
|
28
|
+
'Source material/water/H2S screening criteria coincide with SSC, but this identifier explicitly evaluates HIC/SOHIC-H2S. Carbon and low-alloy steel both enter screening; downstream Table9.3 carbon-steel material/sulfur/product assumptions require independent qualification. PWHT, seamless pipe, low sulfur or low H2S concentration alone do not exclude screening. No automatic SSC/HIC equivalence, SOHIC immunity, blister/crack FFS acceptance or zero DF.'])
|
|
29
|
+
def hic_h2s_screening(x):
|
|
30
|
+
screen=ssc_screening(x)
|
|
31
|
+
return {'evaluate_hic_sohic_h2s':screen['evaluate_ssc'],'screening_reason':screen['screening_reason']}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SscQualifiedService(Model):
|
|
35
|
+
environment: WetH2sEnvironment
|
|
36
|
+
measured_brinnell_hardness: Positive
|
|
37
|
+
pwht: bool
|
|
38
|
+
service_basis: str = Field(min_length=1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@calculation('damage/ssc-qualified-susceptibility','SSC environment and measured hardness with cyanide susceptibility upgrade',SscQualifiedService,
|
|
42
|
+
['Part2 Tables8.2-8.3; PDF p187'],limitations=[
|
|
43
|
+
'Cyanide note upgrades susceptibility, not environmental severity, one category none->low->medium->high, capped at high, only when pH>8.3 and water H2S>1000ppm. Brinnell must be actually measured, not converted microhardness; PWHT and process chemistry require qualification. Existing source pH gaps and overlapping1000ppm columns fail explicitly. No automatic material applicability or chemical speciation.'])
|
|
44
|
+
def ssc_qualified_susceptibility(x):
|
|
45
|
+
ensure(bool(x.service_basis.strip()),'Qualified water chemistry, measured weldment Brinnell and PWHT basis required')
|
|
46
|
+
environment=wet_h2s_environment(x.environment)
|
|
47
|
+
base=ssc_susceptibility(SscSusceptibility(measured_brinnell_hardness=x.measured_brinnell_hardness,
|
|
48
|
+
pwht=x.pwht,environmental_severity=environment['environmental_severity']))['susceptibility']
|
|
49
|
+
levels=('none','low','medium','high')
|
|
50
|
+
selected=levels[min(3,levels.index(base)+int(environment['susceptibility_upgrade_required']))]
|
|
51
|
+
return {**environment,'unadjusted_susceptibility':base,'susceptibility':selected,
|
|
52
|
+
'cyanide_upgrade_applied':environment['susceptibility_upgrade_required']}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SscQualifiedAssessment(Model):
|
|
56
|
+
screening: SscScreen
|
|
57
|
+
service: SscQualifiedService
|
|
58
|
+
confirmed_cracking: bool
|
|
59
|
+
unrepaired_cracks: bool
|
|
60
|
+
years_since_qualifying_inspection: NonNegative
|
|
61
|
+
inspections: InspectionCounts
|
|
62
|
+
inspection_basis: str = Field(min_length=1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@calculation('damage/ssc-qualified-assessment','SSC material/exposure through environment/hardness/cyanide and DF',SscQualifiedAssessment,
|
|
66
|
+
['Part2 §§8.3,8.5-8.6,Tables8.2-8.4,Table6.3; eq(2.27); PDF pp184-188'],
|
|
67
|
+
origin='qualified_ssc_environment_hardness_to_df',limitations=[
|
|
68
|
+
'Selected material/wet-H2S screen must pass. Reuses existing Table8.2 gaps/overlap guards, Table8.3 actual Brinnell/PWHT susceptibility and one-category cyanide note before crack override. Confirmed cracking forces high; unrepaired cracks require FFS. Caller qualifies mechanism-specific inspection period and coverage. Existing equivalent-count support up to6, age escalation and table DF preserved. No generic thinning-history reuse, FFS or total asset risk.'])
|
|
69
|
+
def ssc_qualified_assessment(x):
|
|
70
|
+
ensure(not x.unrepaired_cracks,'Unrepaired SSC cracks require separate FFS evaluation','ffs_required')
|
|
71
|
+
ensure(bool(x.inspection_basis.strip()),'SSC-specific qualifying inspection age and coverage basis required')
|
|
72
|
+
screen=ssc_screening(x.screening)
|
|
73
|
+
ensure(screen['evaluate_ssc'],'SSC material/wet-H2S screen not met','ssc_outside_screen')
|
|
74
|
+
service=ssc_qualified_susceptibility(x.service)
|
|
75
|
+
result=assess('ssc',Assessment(susceptibility=service['susceptibility'],confirmed_cracking=x.confirmed_cracking,
|
|
76
|
+
unrepaired_cracks=False,years_since_qualifying_inspection=x.years_since_qualifying_inspection,inspections=x.inspections))
|
|
77
|
+
return {**screen,**service,**result}
|