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/temper.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Part 2 §21 low-alloy temper embrittlement; unresolved correlations stay explicit."""
|
|
2
|
+
from typing import Annotated, Literal
|
|
3
|
+
from pydantic import Field, model_validator
|
|
4
|
+
from .core import Model, NonNegative, Positive, calculation, ensure
|
|
5
|
+
from .brittle import BrittleReference, BrittleBase, Interpolation, brittle_reference, brittle_base
|
|
6
|
+
|
|
7
|
+
WeightPercent = Annotated[float, Field(ge=0, le=100, strict=True, allow_inf_nan=False)]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JComposition(Model):
|
|
11
|
+
silicon_weight_percent: WeightPercent
|
|
12
|
+
manganese_weight_percent: WeightPercent
|
|
13
|
+
phosphorus_weight_percent: WeightPercent
|
|
14
|
+
tin_weight_percent: WeightPercent
|
|
15
|
+
|
|
16
|
+
@model_validator(mode="after")
|
|
17
|
+
def total(self):
|
|
18
|
+
if sum((self.silicon_weight_percent,self.manganese_weight_percent,
|
|
19
|
+
self.phosphorus_weight_percent,self.tin_weight_percent)) > 100:
|
|
20
|
+
raise ValueError("Composition weight percentages exceed 100")
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@calculation("damage/temper-j-factor", "Temper embrittlement composition J factor", JComposition,
|
|
25
|
+
["Part 2 §21.2; eq (2.87); PDF p273"],
|
|
26
|
+
limitations=["Inputs are numerical weight percentages, not 0-1 fractions. Correlation for 2.25Cr-1Mo/3Cr-1Mo susceptibility; factor alone does not determine DF. Printed eq (2.90) has an unresolved 10^4 exponent; automatic J-to-temperature-shift conversion is not enabled."])
|
|
27
|
+
def temper_j_factor(x):
|
|
28
|
+
return {"j_factor": (x.silicon_weight_percent+x.manganese_weight_percent)*
|
|
29
|
+
(x.phosphorus_weight_percent+x.tin_weight_percent)*1e4}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class XbarComposition(Model):
|
|
33
|
+
phosphorus_weight_percent: WeightPercent
|
|
34
|
+
antimony_weight_percent: WeightPercent
|
|
35
|
+
tin_weight_percent: WeightPercent
|
|
36
|
+
arsenic_weight_percent: WeightPercent
|
|
37
|
+
|
|
38
|
+
@model_validator(mode="after")
|
|
39
|
+
def total(self):
|
|
40
|
+
if sum((self.phosphorus_weight_percent,self.antimony_weight_percent,
|
|
41
|
+
self.tin_weight_percent,self.arsenic_weight_percent)) > 100:
|
|
42
|
+
raise ValueError("Composition weight percentages exceed 100")
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@calculation("damage/temper-xbar", "Temper embrittlement composition X-bar factor", XbarComposition,
|
|
47
|
+
["Part 2 §21.2; eq (2.88); PDF p273"],
|
|
48
|
+
limitations=["Inputs are numerical weight percentages, not fractions or ppm. Owner qualifies applicability to 1.25Cr-0.5Mo/1Cr-0.5Mo alloys."])
|
|
49
|
+
def temper_xbar(x):
|
|
50
|
+
return {"xbar_factor": (10*x.phosphorus_weight_percent+5*x.antimony_weight_percent+
|
|
51
|
+
4*x.tin_weight_percent+x.arsenic_weight_percent)*100}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class XbarShift(Model):
|
|
55
|
+
xbar_factor: NonNegative
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@calculation("damage/temper-xbar-shift", "X-bar long-term FATT temperature shift", XbarShift,
|
|
59
|
+
["Part 2 §21.6.3 STEP 4(3); eq (2.91); §21.7; PDF pp274-275"],
|
|
60
|
+
limitations=["Equation output is a Celsius temperature difference, hence numerically K, per §21.7. Negative predicted shifts are rejected; no implicit zero clipping. Long-term empirical relation; material and exposure applicability require owner qualification."])
|
|
61
|
+
def temper_xbar_shift(x):
|
|
62
|
+
shift = -87.335+11.437*x.xbar_factor-.1472*x.xbar_factor**2
|
|
63
|
+
ensure(shift >= 0, "X-bar correlation gives a negative embrittlement shift; supply qualified measured/engineering shift", "outside_correlation_domain")
|
|
64
|
+
return {"delta_fatt_k": shift}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GenerationShift(Model):
|
|
68
|
+
fabrication_year: int = Field(strict=True, ge=1965)
|
|
69
|
+
source_basis: Literal["printed_C", "printed_F_difference_converted"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@calculation("damage/temper-generation-shift", "Fabrication-era conservative FATT shift", GenerationShift,
|
|
73
|
+
["Part 2 §21.6.3 STEP 4(4); PDF p274"],
|
|
74
|
+
limitations=["Printed paired C/F shifts are inconsistent as temperature differences. Caller must choose printed_C or printed_F_difference_converted explicitly; this choice is preserved in output. The text says after 1988 and the preceding band ends 1987: year 1988 is rejected. No pre-1965 rule."])
|
|
75
|
+
def temper_generation_shift(x):
|
|
76
|
+
ensure(x.fabrication_year != 1988, "Year 1988 is not uniquely assigned by printed generation bands", "unresolved_applicability")
|
|
77
|
+
if x.fabrication_year >= 1989: generation, c, f = 4,66,150
|
|
78
|
+
elif x.fabrication_year >= 1981: generation, c, f = 3,121,250
|
|
79
|
+
elif x.fabrication_year >= 1973: generation, c, f = 2,149,300
|
|
80
|
+
else: generation, c, f = 1,177,350
|
|
81
|
+
return {"generation": generation, "delta_fatt_k": c if x.source_basis == "printed_C" else f*5/9,
|
|
82
|
+
"source_basis": x.source_basis}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TemperDamage(Model):
|
|
86
|
+
reference: BrittleReference
|
|
87
|
+
minimum_pressurization_temperature_k: Positive
|
|
88
|
+
delta_fatt_k: NonNegative
|
|
89
|
+
component_thickness_m: Positive
|
|
90
|
+
post_weld_heat_treated: bool = Field(strict=True)
|
|
91
|
+
interpolation: Interpolation
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@calculation("damage/temper-embrittlement", "Low-alloy temper embrittlement damage factor", TemperDamage,
|
|
95
|
+
["Part 2 §21.6.3 STEP 3-6; Tables 20.3,20.4M,20.5M; PDF pp274-275"],
|
|
96
|
+
limitations=["Owner qualifies susceptible alloy/exposure and minimum pressurization temperature. Uses supplied measured/qualified delta-FATT; no automatic ambiguous SCE or J-factor shift. No service-experience reduction or inspection credit is specified in §21 STEP 6, and neither is applied. US reference table converted to SI, printed SI base-DF grids; no extrapolation."])
|
|
97
|
+
def temper_damage(x):
|
|
98
|
+
ensure(x.reference.material_group == "low_alloy_steel", "Temper model requires low-alloy-steel reference table")
|
|
99
|
+
reference = brittle_reference(x.reference)
|
|
100
|
+
shifted = reference["reference_temperature_k"]+x.delta_fatt_k
|
|
101
|
+
delta = x.minimum_pressurization_temperature_k-shifted
|
|
102
|
+
base = brittle_base(BrittleBase(component_thickness_m=x.component_thickness_m,
|
|
103
|
+
cet_minus_reference_k=delta,post_weld_heat_treated=x.post_weld_heat_treated,interpolation=x.interpolation))
|
|
104
|
+
return {**reference,"shifted_reference_temperature_k":shifted,"minimum_minus_shifted_reference_k":delta,
|
|
105
|
+
"delta_fatt_k":x.delta_fatt_k,"damage_factor":base["base_damage_factor"],"table":base["table"]}
|
api581/thinning.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""2016 Part 2 §4.5.7 thinning calculations; no cladding/AST auto-selection."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field, model_validator
|
|
5
|
+
from .core import Model, Positive, NonNegative, Fraction, calculation
|
|
6
|
+
|
|
7
|
+
PRIORS = {"low": (.5, .3, .2), "medium": (.7, .2, .1), "high": (.8, .15, .05)}
|
|
8
|
+
LIKELIHOODS = {"a": (.9, .09, .01), "b": (.7, .2, .1), "c": (.5, .3, .2), "d": (.4, .33, .27)}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WallLoss(Model):
|
|
12
|
+
starting_thickness_m: Positive
|
|
13
|
+
corrosion_rate_m_year: NonNegative
|
|
14
|
+
elapsed_years: NonNegative
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@calculation("damage/thinning-wall-loss", "Unclad fractional wall loss", WallLoss,
|
|
18
|
+
["Part 2 eq (2.13)"], limitations=["No cladding/weld overlay; not AST-bottom equation (2.12)."])
|
|
19
|
+
def wall_loss(x):
|
|
20
|
+
return {"wall_loss_fraction": x.corrosion_rate_m_year * x.elapsed_years / x.starting_thickness_m}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FlowStress(Model):
|
|
24
|
+
yield_strength_pa: Positive
|
|
25
|
+
tensile_strength_pa: Positive
|
|
26
|
+
weld_efficiency: Fraction = Field(gt=0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@calculation("damage/flow-stress", "Thinning flow stress", FlowStress,
|
|
30
|
+
["Part 2 eq (2.16)"])
|
|
31
|
+
def flow_stress(x):
|
|
32
|
+
return {"flow_stress_pa": (x.yield_strength_pa + x.tensile_strength_pa) / 2 * x.weld_efficiency * 1.1}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StrengthRatio(Model):
|
|
36
|
+
differential_pressure_pa: NonNegative
|
|
37
|
+
diameter_m: Positive
|
|
38
|
+
flow_stress_pa: Positive
|
|
39
|
+
starting_thickness_m: Positive
|
|
40
|
+
shape: Literal["cylinder", "sphere", "head"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@calculation("damage/thinning-strength-ratio", "Internal-pressure thinning strength ratio", StrengthRatio,
|
|
44
|
+
["Part 2 eq (2.18); PDF p154"],
|
|
45
|
+
limitations=["Internal hoop pressure only; not suitable when external pressure or structural loads govern."])
|
|
46
|
+
def strength_ratio(x):
|
|
47
|
+
alpha = {"cylinder": 2, "sphere": 4, "head": 1.13}[x.shape]
|
|
48
|
+
return {"strength_ratio": x.differential_pressure_pa * x.diameter_m / (alpha * x.flow_stress_pa * x.starting_thickness_m)}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PriorSelection(Model):
|
|
52
|
+
confidence: Literal['low','medium','high'] | None = None
|
|
53
|
+
custom_prior_probabilities: list[Fraction] | None = Field(default=None,min_length=3,max_length=3)
|
|
54
|
+
custom_prior_basis: str | None = Field(default=None,min_length=1)
|
|
55
|
+
|
|
56
|
+
@model_validator(mode='after')
|
|
57
|
+
def qualified_prior_choice(self):
|
|
58
|
+
custom=self.custom_prior_probabilities is not None
|
|
59
|
+
if (self.confidence is not None)==custom:
|
|
60
|
+
raise ValueError('Select exactly one confidence table row or custom prior probabilities')
|
|
61
|
+
if custom:
|
|
62
|
+
if self.custom_prior_basis is None or not self.custom_prior_basis.strip():
|
|
63
|
+
raise ValueError('Custom prior probabilities require an experience/measurement qualification basis')
|
|
64
|
+
if not math.isclose(math.fsum(self.custom_prior_probabilities),1.,rel_tol=0.,abs_tol=1e-9):
|
|
65
|
+
raise ValueError('Custom prior probabilities must sum to1')
|
|
66
|
+
elif self.custom_prior_basis is not None:
|
|
67
|
+
raise ValueError('Custom prior basis is unused with a confidence table row')
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@calculation('damage/thinning-prior-selection','Table4.5 or qualified customized thinning damage-state prior',PriorSelection,
|
|
72
|
+
['Part2 §4.5.3; Table4.5; PDF pp150-151,163'],limitations=[
|
|
73
|
+
'Exactly one low/medium/high table row or three owner-qualified prior probabilities for damage states1/2/3 (rate factors1/2/4). Source permits customization from actual experience and confidence in thickness measurements; no automatic calibration or general-to-local applicability inference. Custom sum tolerance1e-9 removes only rounding by normalization. A zero prior cannot be revived by subsequent positive-likelihood inspections; no probability floor is invented.'])
|
|
74
|
+
def prior_selection(x):
|
|
75
|
+
values=list(PRIORS[x.confidence]) if x.custom_prior_probabilities is None else list(x.custom_prior_probabilities)
|
|
76
|
+
total=math.fsum(values)
|
|
77
|
+
return {'prior_probabilities':[p/total for p in values],
|
|
78
|
+
'prior_source':'table4_5' if x.custom_prior_probabilities is None else 'qualified_custom',
|
|
79
|
+
'confidence':x.confidence,'zero_prior_state_indices':[i+1 for i,p in enumerate(values) if p==0.]}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Posterior(PriorSelection):
|
|
83
|
+
inspections_a: int = Field(default=0, ge=0, le=100000)
|
|
84
|
+
inspections_b: int = Field(default=0, ge=0, le=100000)
|
|
85
|
+
inspections_c: int = Field(default=0, ge=0, le=100000)
|
|
86
|
+
inspections_d: int = Field(default=0, ge=0, le=100000)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@calculation("damage/thinning-posterior", "Bayesian thinning damage-state probabilities", Posterior,
|
|
90
|
+
["Part 2 eqs (2.19)-(2.20), Tables 4.5-4.6; PDF pp154,163"],
|
|
91
|
+
limitations=["Counts must include only inspections eligible in the assessed period; no equivalent-count conversion. Prior is exactly one confidence table row or qualified customized three-state probabilities per section4.5.3. Zero custom prior remains zero; no floor or automatic calibration."])
|
|
92
|
+
def posterior(x):
|
|
93
|
+
prior=prior_selection(PriorSelection(**x.model_dump(include=set(PriorSelection.model_fields))))['prior_probabilities']
|
|
94
|
+
logs = [math.log(p) if p>0 else -math.inf for p in prior]
|
|
95
|
+
for grade in "abcd":
|
|
96
|
+
for i in range(3):
|
|
97
|
+
logs[i] += getattr(x, "inspections_" + grade) * math.log(LIKELIHOODS[grade][i])
|
|
98
|
+
offset = max(logs)
|
|
99
|
+
weights = [math.exp(v - offset) for v in logs]
|
|
100
|
+
total = math.fsum(weights)
|
|
101
|
+
return {"posterior_probabilities": [v / total for v in weights]}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ThinningBase(Model):
|
|
105
|
+
wall_loss_fraction: NonNegative
|
|
106
|
+
strength_ratio: NonNegative
|
|
107
|
+
posterior: list[Fraction] = Field(min_length=3, max_length=3)
|
|
108
|
+
cov_wall_loss: Positive = .2
|
|
109
|
+
cov_strength: Positive = .2
|
|
110
|
+
cov_pressure: Positive = .05
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@calculation("damage/thinning-base", "Thinning reliability indices and base damage factor", ThinningBase,
|
|
114
|
+
["Part 2 eqs (2.21)-(2.22); PDF p155"], limitations=["Excludes AST bottoms; final adjustment factors applied separately."])
|
|
115
|
+
def thinning_base(x):
|
|
116
|
+
from .core import ensure
|
|
117
|
+
ensure(math.isclose(math.fsum(x.posterior), 1, abs_tol=1e-9), "Posterior probabilities must sum to 1")
|
|
118
|
+
betas = []
|
|
119
|
+
failures = []
|
|
120
|
+
for scale in (1, 2, 4):
|
|
121
|
+
loss = scale * x.wall_loss_fraction
|
|
122
|
+
variance = loss ** 2 * x.cov_wall_loss ** 2 + (1 - loss) ** 2 * x.cov_strength ** 2 + x.strength_ratio ** 2 * x.cov_pressure ** 2
|
|
123
|
+
beta = (1 - loss - x.strength_ratio) / math.sqrt(variance)
|
|
124
|
+
betas.append(beta)
|
|
125
|
+
failures.append(.5 * math.erfc(beta / math.sqrt(2)))
|
|
126
|
+
probability = math.fsum(p * f for p, f in zip(x.posterior, failures))
|
|
127
|
+
return {"reliability_indices": betas, "damage_state_failure_probabilities": failures,
|
|
128
|
+
"weighted_failure_probability": probability, "base_damage_factor": probability / 1.56e-4}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ThinningAdjustment(Model):
|
|
132
|
+
base_damage_factor: NonNegative
|
|
133
|
+
injection_point_factor: Positive
|
|
134
|
+
deadleg_factor: Positive
|
|
135
|
+
welded_construction_factor: Positive
|
|
136
|
+
maintenance_factor: Positive
|
|
137
|
+
settlement_factor: Positive
|
|
138
|
+
online_monitoring_factor: Positive
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@calculation("damage/thinning-adjust", "Apply thinning damage factor adjustments", ThinningAdjustment,
|
|
142
|
+
["Part 2 eq (2.23); PDF pp155-156"], limitations=["Caller supplies applicable factors; does not determine AST eligibility or monitoring category."])
|
|
143
|
+
def thinning_adjust(x):
|
|
144
|
+
value = x.base_damage_factor * x.injection_point_factor * x.deadleg_factor * x.welded_construction_factor * x.maintenance_factor * x.settlement_factor / x.online_monitoring_factor
|
|
145
|
+
return {"thinning_damage_factor": max(value, .1)}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class UncladThinning(Model):
|
|
149
|
+
wall: WallLoss
|
|
150
|
+
strength: StrengthRatio
|
|
151
|
+
inspections: Posterior
|
|
152
|
+
injection_point_factor: Positive
|
|
153
|
+
deadleg_factor: Positive
|
|
154
|
+
online_monitoring_factor: Positive
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@calculation("damage/thinning-unclad", "Compose unclad non-AST thinning damage calculation", UncladThinning,
|
|
158
|
+
["Part 2 §4.5.7 eqs (2.13), (2.18)-(2.23)"],
|
|
159
|
+
limitations=["Non-AST, unclad, internal-pressure-governed components only. Flow stress and corrosion rate are caller-supplied. Automatic corrosion mechanism/monitoring selection is not included."])
|
|
160
|
+
def unclad(x):
|
|
161
|
+
from .core import ensure
|
|
162
|
+
ensure(x.wall.starting_thickness_m == x.strength.starting_thickness_m, "Wall and strength thickness must match")
|
|
163
|
+
loss = wall_loss(x.wall)
|
|
164
|
+
sr = strength_ratio(x.strength)
|
|
165
|
+
post = posterior(x.inspections)
|
|
166
|
+
base = thinning_base(ThinningBase(wall_loss_fraction=loss["wall_loss_fraction"], strength_ratio=sr["strength_ratio"],
|
|
167
|
+
posterior=post["posterior_probabilities"]))
|
|
168
|
+
final = thinning_adjust(ThinningAdjustment(base_damage_factor=base["base_damage_factor"],
|
|
169
|
+
injection_point_factor=x.injection_point_factor, deadleg_factor=x.deadleg_factor,
|
|
170
|
+
online_monitoring_factor=x.online_monitoring_factor, welded_construction_factor=1,
|
|
171
|
+
maintenance_factor=1, settlement_factor=1))
|
|
172
|
+
return {**loss, **sr, **post, **base, **final}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""2016 thinning monitoring and localized-piping adjustment conditions."""
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, NonNegative, Positive, calculation, ensure, finite_tree
|
|
5
|
+
from .thinning import WallLoss, Posterior, UncladThinning, unclad
|
|
6
|
+
from .clad_thinning import CladWall, PressureStrength, CodeStrength, CladThinning, clad_thinning, UncladDesignThinning, unclad_design
|
|
7
|
+
|
|
8
|
+
Row = Literal["hydrochloric_acid","high_temperature_sulfidic_naphthenic","high_temperature_h2s_h2",
|
|
9
|
+
"sulfuric_low_velocity","sulfuric_high_velocity","hydrofluoric_acid","sour_water_low_velocity",
|
|
10
|
+
"sour_water_high_velocity","amine_low_velocity","amine_high_velocity","other"]
|
|
11
|
+
Method = Literal["key_process_variables","electrical_resistance_probes","corrosion_coupons"]
|
|
12
|
+
_METHODS = Method.__args__
|
|
13
|
+
_TABLE = dict(zip(Row.__args__,[(10.,10.,2.),(10.,10.,2.),(1.,10.,1.),(20.,10.,2.),(10.,10.,1.),
|
|
14
|
+
(10.,1.,1.),(20.,10.,2.),(10.,2.,2.),(20.,10.,2.),(10.,10.,1.),(1.,1.,1.)]))
|
|
15
|
+
_JOINT_ROWS = ("hydrochloric_acid","sulfuric_high_velocity")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MonitoringRow(Model):
|
|
19
|
+
mechanism: Literal["hydrochloric_acid","high_temperature_sulfidic_naphthenic","high_temperature_h2s_h2",
|
|
20
|
+
"sulfuric_acid","hydrofluoric_acid","sour_water","amine","other"]
|
|
21
|
+
velocity_m_s: NonNegative | None = None
|
|
22
|
+
sulfuric_material_group: Literal["carbon_steel","stainless_steel","higher_alloy"] | None = None
|
|
23
|
+
amine_velocity_regime: Literal["low","high"] | None = None
|
|
24
|
+
amine_regime_basis: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@calculation("damage/thinning-monitoring-row", "Select thinning online-monitoring table row", MonitoringRow,
|
|
28
|
+
["Part2 Table4.8; PDF p164"],limitations=[
|
|
29
|
+
"Sulfuric acid low/high thresholds3/5/7ft/s for CS/SS/higher alloys, sour water20ft/s; public m/s with exact.3048 conversion. Threshold equality is low. Amine low/high numeric threshold is not supplied by this table: owner regime and basis required. Mechanism applicability and actual material classification are external qualifications. Printed sulfuric-acid label appends H2S/H2; this API follows named sulfuric-acid row, preserves issue in documentation and does not assert official erratum."])
|
|
30
|
+
def monitoring_row(x):
|
|
31
|
+
velocity_case = x.mechanism in ("sulfuric_acid","sour_water")
|
|
32
|
+
ensure((x.velocity_m_s is not None) == velocity_case,"Velocity required only for sulfuric acid or sour water table classification")
|
|
33
|
+
ensure((x.sulfuric_material_group is not None) == (x.mechanism == "sulfuric_acid"),"Sulfuric material group applies only to sulfuric-acid row")
|
|
34
|
+
if x.mechanism == "amine":
|
|
35
|
+
ensure(x.amine_velocity_regime is not None and x.amine_regime_basis is not None and bool(x.amine_regime_basis.strip()),"Amine regime requires owner qualification; no numeric threshold inferred")
|
|
36
|
+
row, threshold = "amine_"+x.amine_velocity_regime+"_velocity", None
|
|
37
|
+
else:
|
|
38
|
+
ensure(x.amine_velocity_regime is None and x.amine_regime_basis is None,"Amine regime fields apply only to amine")
|
|
39
|
+
if velocity_case:
|
|
40
|
+
threshold = (dict(carbon_steel=3.,stainless_steel=5.,higher_alloy=7.)[x.sulfuric_material_group] if x.mechanism == "sulfuric_acid" else 20.)*.3048
|
|
41
|
+
row = ("sulfuric" if x.mechanism == "sulfuric_acid" else "sour_water")+("_low_velocity" if x.velocity_m_s <= threshold else "_high_velocity")
|
|
42
|
+
else:
|
|
43
|
+
row, threshold = x.mechanism, None
|
|
44
|
+
return {"monitoring_row":row,"velocity_threshold_m_s":threshold}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MonitoringCell(Model):
|
|
48
|
+
monitoring_row: Row
|
|
49
|
+
monitoring_method: Method
|
|
50
|
+
key_variables_with_probes: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@calculation("damage/thinning-monitoring-table-factor", "Thinning online-monitoring table cell and explicit joint exception", MonitoringCell,
|
|
54
|
+
["Part2 Table4.8; PDF p164"],limitations=[
|
|
55
|
+
"Table row factors in column order key variables/ER probes/coupons. HCl and high-velocity sulfuric key-variable factor rises10 to20 only in conjunction with probes. This independent cell lookup does not establish a qualified organized monitoring plan; use thinning-online-monitoring-factor for credit gate. Other methods require specialist judgment, not guessed table equivalence."])
|
|
56
|
+
def monitoring_cell(x):
|
|
57
|
+
if x.key_variables_with_probes:
|
|
58
|
+
ensure(x.monitoring_row in _JOINT_ROWS and x.monitoring_method == "key_process_variables","Joint20 exception applies only to named key-variable cells")
|
|
59
|
+
value = 20.
|
|
60
|
+
else:
|
|
61
|
+
value = _TABLE[x.monitoring_row][_METHODS.index(x.monitoring_method)]
|
|
62
|
+
return {"table_monitoring_factor":value}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OnlineMonitoring(Model):
|
|
66
|
+
row: MonitoringRow
|
|
67
|
+
methods: list[Method] = Field(max_length=3)
|
|
68
|
+
organized_monitoring_plan: bool
|
|
69
|
+
qualification_basis: str | None = None
|
|
70
|
+
key_variables_probe_conjunction_confirmed: bool = False
|
|
71
|
+
experience_factor: Positive | None = None
|
|
72
|
+
experience_factor_basis: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@calculation("damage/thinning-online-monitoring-factor", "Qualified thinning monitoring credit without stacking", OnlineMonitoring,
|
|
76
|
+
["Part2 §4.5.7 STEP15(1),Table4.8 notes1-3; PDF pp155,164"],limitations=[
|
|
77
|
+
"No organized plan means applied factor1 regardless of installed methods. Qualified plan must recognize mechanism, appropriate variables and specialist intervals. Choose maximum applicable method factor, never sum/product. Joint20 needs both key variables and ER probes plus confirmed conjunction. Table estimates may be replaced by owner-experience positive factor with basis under note1; replacement is entire applied factor, not a stacked bonus. No methods and no override gives1. Other monitoring methods require qualified experience basis; no automatic sensor-data efficacy classification."])
|
|
78
|
+
def online_monitoring(x):
|
|
79
|
+
selected = monitoring_row(x.row)
|
|
80
|
+
row = selected["monitoring_row"]
|
|
81
|
+
ensure(len(set(x.methods)) == len(x.methods),"Monitoring methods must be unique")
|
|
82
|
+
override = x.experience_factor is not None
|
|
83
|
+
ensure(override == (x.experience_factor_basis is not None),"Experience factor and basis must be provided together")
|
|
84
|
+
if override:
|
|
85
|
+
ensure(bool(x.experience_factor_basis.strip()),"Experience factor basis must not be blank")
|
|
86
|
+
if x.key_variables_probe_conjunction_confirmed:
|
|
87
|
+
ensure(x.organized_monitoring_plan and row in _JOINT_ROWS and {"key_process_variables","electrical_resistance_probes"}.issubset(x.methods),"Joint monitoring exception requires qualified conjunction of both methods in applicable row")
|
|
88
|
+
candidates = {m:monitoring_cell(MonitoringCell(monitoring_row=row,monitoring_method=m,
|
|
89
|
+
key_variables_with_probes=x.key_variables_probe_conjunction_confirmed and m == "key_process_variables"))["table_monitoring_factor"] for m in x.methods}
|
|
90
|
+
if not x.organized_monitoring_plan:
|
|
91
|
+
ensure(not override and x.qualification_basis is None,"Unqualified plan cannot claim experience credit or qualification basis")
|
|
92
|
+
factor, reason = 1., "no_organized_plan"
|
|
93
|
+
elif x.methods or override:
|
|
94
|
+
ensure(x.qualification_basis is not None and bool(x.qualification_basis.strip()),"Monitoring credit requires qualified organized-plan basis")
|
|
95
|
+
factor, reason = (x.experience_factor,"qualified_experience_replacement") if override else (max(candidates.values()),"maximum_table_method")
|
|
96
|
+
else:
|
|
97
|
+
factor, reason = 1., "no_monitoring_methods"
|
|
98
|
+
return {**selected,"online_monitoring_factor":factor,"candidate_method_factors":candidates,
|
|
99
|
+
"monitoring_credit_basis":reason,"joint_exception_applied":x.key_variables_probe_conjunction_confirmed and not override}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class InjectionPoint(Model):
|
|
103
|
+
component_scope: Literal["piping","non_piping"]
|
|
104
|
+
injection_or_mix_point_present: bool
|
|
105
|
+
highly_effective_targeted_inspection: bool
|
|
106
|
+
inspection_basis: str | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Deadleg(Model):
|
|
110
|
+
component_scope: Literal["piping","non_piping"]
|
|
111
|
+
deadleg_present: bool
|
|
112
|
+
highly_effective_targeted_inspection: bool
|
|
113
|
+
inspection_basis: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _piping_factor(scope,present,inspected,basis):
|
|
117
|
+
if scope == "non_piping":
|
|
118
|
+
ensure(not present and not inspected and basis is None,"Piping feature and inspection credit cannot be claimed for non-piping component")
|
|
119
|
+
if inspected:
|
|
120
|
+
ensure(present and basis is not None and bool(basis.strip()),"Highly effective targeted inspection requires feature and qualification basis")
|
|
121
|
+
else:
|
|
122
|
+
ensure(basis is None,"Inspection basis cannot accompany unclaimed inspection")
|
|
123
|
+
return 3. if present and not inspected else 1.
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@calculation("damage/thinning-injection-point-factor", "Thinning injection or corrosive mix point adjustment", InjectionPoint,
|
|
127
|
+
["Part2 §4.5.7 STEP15(2); PDF p156"],limitations=[
|
|
128
|
+
"Piping with injection/mix point factor3, reduced1 only by highly effective targeted inspection per source API570 reference. Owner classifies actual mix corrosion: flashing liquid/vapor mixing, water-bearing streams or mixed temperature below combined dew point. Generic inspection does not earn automatic credit. Non-piping factor1."])
|
|
129
|
+
def injection_point(x):
|
|
130
|
+
return {"injection_point_factor":_piping_factor(x.component_scope,x.injection_or_mix_point_present,x.highly_effective_targeted_inspection,x.inspection_basis)}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@calculation("damage/thinning-deadleg-factor", "Thinning intermittent-service or stagnant deadleg adjustment", Deadleg,
|
|
134
|
+
["Part2 §4.5.7 STEP15(3); PDF p156"],limitations=[
|
|
135
|
+
"Piping deadleg factor3, reduced1 only by highly effective inspection specifically addressing localized deadleg corrosion. Owner classifies intermittent/no-significant-flow service; no automatic velocity threshold. Non-piping factor1."])
|
|
136
|
+
def deadleg(x):
|
|
137
|
+
return {"deadleg_factor":_piping_factor(x.component_scope,x.deadleg_present,x.highly_effective_targeted_inspection,x.inspection_basis)}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ThinningConditions(Model):
|
|
141
|
+
monitoring: OnlineMonitoring
|
|
142
|
+
injection: InjectionPoint
|
|
143
|
+
deadleg: Deadleg
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def condition_factors(x):
|
|
147
|
+
ensure(x.injection.component_scope == x.deadleg.component_scope,"Injection and deadleg component scopes must agree")
|
|
148
|
+
return {**online_monitoring(x.monitoring),**injection_point(x.injection),**deadleg(x.deadleg)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ConditionalAdjustment(Model):
|
|
152
|
+
assessment_scope: Literal["non_ast"]
|
|
153
|
+
base_damage_factor: NonNegative
|
|
154
|
+
conditions: ThinningConditions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@calculation("damage/thinning-conditional-adjustment", "Apply qualified non-AST thinning adjustment conditions", ConditionalAdjustment,
|
|
158
|
+
["Part2 §4.5.7 STEP15,eq(2.23),Table4.8; PDF pp155-156,164"],origin="api581_composition",limitations=[
|
|
159
|
+
"Non-AST conditions only: AST construction, maintenance and settlement factors1. FinalDF=max(baseDF*injection*deadleg/monitoring,.1). Monitoring uses maximum qualified method or documented experience replacement; no duplicate monitoring factors. No automatic corrosion/inspection applicability screening."])
|
|
160
|
+
def conditional_adjustment(x):
|
|
161
|
+
factors = condition_factors(x.conditions)
|
|
162
|
+
value = x.base_damage_factor*factors["injection_point_factor"]*factors["deadleg_factor"]/factors["online_monitoring_factor"]
|
|
163
|
+
finite_tree(value)
|
|
164
|
+
return {**factors,"base_damage_factor":x.base_damage_factor,"thinning_damage_factor":max(value,.1)}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class QualifiedUnclad(Model):
|
|
168
|
+
assessment_scope: Literal["non_ast_unclad"]
|
|
169
|
+
qualification_basis: str = Field(min_length=1)
|
|
170
|
+
wall: WallLoss
|
|
171
|
+
strength: PressureStrength | CodeStrength = Field(discriminator="method")
|
|
172
|
+
inspections: Posterior
|
|
173
|
+
conditions: ThinningConditions
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@calculation("damage/thinning-unclad-qualified", "Unclad non-AST thinning with condition-derived adjustment factors", QualifiedUnclad,
|
|
177
|
+
["Part2 §4.5.7,eqs(2.13),(2.17)-(2.23),Table4.8; PDF pp153-156,164"],origin="api581_composition",limitations=[
|
|
178
|
+
"Qualified non-AST unclad component; shared wall/strength baseline required. Governing strength method explicit; code minima and load applicability remain supplied. Conditions derive piping/monitoring adjustments rather than accepting duplicate numeric factors. Table row classification is not corrosion-mechanism applicability screening or inspection validation. No all-mechanism DF or AST corrections."])
|
|
179
|
+
def qualified_unclad(x):
|
|
180
|
+
ensure(bool(x.qualification_basis.strip()),"Applicability basis must not be blank")
|
|
181
|
+
factors = condition_factors(x.conditions)
|
|
182
|
+
args = dict(wall=x.wall,inspections=x.inspections,injection_point_factor=factors["injection_point_factor"],
|
|
183
|
+
deadleg_factor=factors["deadleg_factor"],online_monitoring_factor=factors["online_monitoring_factor"])
|
|
184
|
+
if x.strength.method == "design_thickness_eq2_17":
|
|
185
|
+
from .clad_thinning import DesignStrengthRatio
|
|
186
|
+
result = unclad_design(UncladDesignThinning(**args,assessment_scope="non_ast_unclad_design_thickness_governed",
|
|
187
|
+
qualification_basis=x.qualification_basis,strength=DesignStrengthRatio(**x.strength.model_dump(exclude={"method"}))))
|
|
188
|
+
else:
|
|
189
|
+
from .thinning import StrengthRatio
|
|
190
|
+
result = unclad(UncladThinning(**args,strength=StrengthRatio(**x.strength.model_dump(exclude={"method"}))))
|
|
191
|
+
return {k:v for k,v in {**result,**factors,"strength_method":x.strength.method}.items() if k not in ("governing_minimum_thickness_m","governing_thickness_source")}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class QualifiedClad(Model):
|
|
195
|
+
assessment_scope: Literal["non_ast_pressure_vessel_sequential_cladding"]
|
|
196
|
+
qualification_basis: str = Field(min_length=1)
|
|
197
|
+
wall: CladWall
|
|
198
|
+
strength: PressureStrength | CodeStrength = Field(discriminator="method")
|
|
199
|
+
inspections: Posterior
|
|
200
|
+
conditions: ThinningConditions
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@calculation("damage/thinning-clad-qualified", "Clad pressure-vessel thinning with condition-derived monitoring adjustment", QualifiedClad,
|
|
204
|
+
["Part2 §4.5.7,eqs(2.11),(2.13)-(2.23),Table4.8; PDF pp152-156,164"],origin="api581_composition_with_explicit_zero_rate_extension",limitations=[
|
|
205
|
+
"Non-AST pressure vessel with sequential-cladding scope, owner-qualified corrosion rates and baseline. Injection/deadleg conditions must both be non-piping. Shared monitoring classification drives one applied factor. Reuses clad zero-rate extension and both strength methods; no coating/lining, simultaneous backside corrosion, code-minimum calculation or all-mechanism DF."])
|
|
206
|
+
def qualified_clad(x):
|
|
207
|
+
ensure(x.conditions.injection.component_scope == x.conditions.deadleg.component_scope == "non_piping","Clad pressure-vessel composition requires non-piping condition scope")
|
|
208
|
+
factors = condition_factors(x.conditions)
|
|
209
|
+
result = clad_thinning(CladThinning(assessment_scope="non_ast_pressure_vessel_sequential_cladding",qualification_basis=x.qualification_basis,
|
|
210
|
+
wall=x.wall,strength=x.strength,inspections=x.inspections,injection_point_factor=factors["injection_point_factor"],
|
|
211
|
+
deadleg_factor=factors["deadleg_factor"],online_monitoring_factor=factors["online_monitoring_factor"]))
|
|
212
|
+
return {**result,**factors}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Date-consistent thickness baseline and period-qualified inspection counts."""
|
|
2
|
+
from datetime import timezone
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import AwareDatetime, Field
|
|
5
|
+
from .core import Model, Positive, calculation, ensure
|
|
6
|
+
from .thinning import PriorSelection, Posterior
|
|
7
|
+
from .thinning_selection import ThinningRateSelection, SelectedMechanismThinning, selected_mechanism_thinning
|
|
8
|
+
from .clad_thinning import PressureStrength, CodeStrength
|
|
9
|
+
from .thinning_conditions import ThinningConditions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MeasuredBaseline(Model):
|
|
13
|
+
inspection_id: str = Field(min_length=1,max_length=100)
|
|
14
|
+
measured_at: AwareDatetime
|
|
15
|
+
thickness_m: Positive
|
|
16
|
+
high_quality_inspection: bool
|
|
17
|
+
high_confidence_accuracy: bool
|
|
18
|
+
reset_basis: str = Field(min_length=1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ThinningPeriod(Model):
|
|
22
|
+
installed_at: AwareDatetime
|
|
23
|
+
assessment_at: AwareDatetime
|
|
24
|
+
baseline_type: Literal['furnished','measured']
|
|
25
|
+
furnished_thickness_m: Positive | None = None
|
|
26
|
+
measured_baseline: MeasuredBaseline | None = None
|
|
27
|
+
days_per_year: Positive
|
|
28
|
+
period_and_year_basis: str = Field(min_length=1)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _utc(value):
|
|
32
|
+
return value.astimezone(timezone.utc)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@calculation('damage/thinning-assessment-period','Consistent furnished/measured thickness baseline and elapsed period',ThinningPeriod,
|
|
36
|
+
['Part2 §4.5.5; PDF p151'],limitations=[
|
|
37
|
+
'Timezone-aware timestamps normalized to UTC. Furnished thickness starts at installation; selected measured thickness starts at its qualified inspection time, requiring high-quality inspection, high-confidence accuracy and reset basis. Year conversion is explicit caller convention, not an API581-mandated constant. No process-change detection, measurement uncertainty correction, automatic reset selection or thickness reconciliation.'])
|
|
38
|
+
def thinning_period(x):
|
|
39
|
+
ensure(bool(x.period_and_year_basis.strip()),'Common plan period and year conversion basis required')
|
|
40
|
+
installed,assessment=_utc(x.installed_at),_utc(x.assessment_at)
|
|
41
|
+
ensure(installed<=assessment,'Assessment cannot precede installation')
|
|
42
|
+
if x.baseline_type=='furnished':
|
|
43
|
+
ensure(x.furnished_thickness_m is not None and x.measured_baseline is None,'Furnished baseline requires only furnished thickness')
|
|
44
|
+
baseline,thickness,inspection_id=installed,x.furnished_thickness_m,None
|
|
45
|
+
else:
|
|
46
|
+
ensure(x.measured_baseline is not None and x.furnished_thickness_m is None,'Measured baseline requires only measured baseline record')
|
|
47
|
+
measured=x.measured_baseline
|
|
48
|
+
ensure(measured.high_quality_inspection and measured.high_confidence_accuracy and bool(measured.reset_basis.strip()),
|
|
49
|
+
'Measured thickness reset requires qualified inspection quality, accuracy and basis')
|
|
50
|
+
ensure(bool(measured.inspection_id.strip()),'Baseline inspection ID must be nonblank')
|
|
51
|
+
baseline,thickness,inspection_id=_utc(measured.measured_at),measured.thickness_m,measured.inspection_id
|
|
52
|
+
ensure(installed<=baseline<=assessment,'Measured baseline must lie between installation and assessment')
|
|
53
|
+
days=(assessment-baseline).total_seconds()/86400.
|
|
54
|
+
return dict(baseline_type=x.baseline_type,baseline_at=baseline.isoformat(),assessment_at=assessment.isoformat(),
|
|
55
|
+
baseline_thickness_m=thickness,baseline_inspection_id=inspection_id,elapsed_days=days,
|
|
56
|
+
elapsed_years=days/x.days_per_year,days_per_year=x.days_per_year)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ThinningInspectionRecord(Model):
|
|
60
|
+
inspection_id: str = Field(min_length=1,max_length=100)
|
|
61
|
+
performed_at: AwareDatetime
|
|
62
|
+
effectiveness: Literal['a','b','c','d','e']
|
|
63
|
+
qualified_for_component_mechanism: bool
|
|
64
|
+
inspection_basis: str = Field(min_length=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ThinningInspectionHistory(ThinningPeriod):
|
|
68
|
+
inspections: list[ThinningInspectionRecord] = Field(max_length=10000)
|
|
69
|
+
baseline_boundary_policy: Literal['include_baseline_time','exclude_baseline_time']
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@calculation('damage/thinning-inspection-history','Assessment-period inspection eligibility and direct A-D counts',ThinningInspectionHistory,
|
|
73
|
+
['Part2 §§4.5.5-4.5.6; Tables4.6,2.C inspection guidance; PDF pp151-152'],limitations=[
|
|
74
|
+
'Only performed, qualified inspections in the selected period receive A-D counts; E receives no credit. Before-baseline and after-assessment records remain excluded diagnostics. Exact baseline-time inclusion is explicit owner policy; source does not resolve duplicate use of a baseline-setting inspection. End time is included. Duplicate IDs or a baseline inspection ID with conflicting timestamp fail. No automatic NDE effectiveness grading, equivalent-count conversion, geometry/coverage qualification or credit for planned inspections.'])
|
|
75
|
+
def thinning_inspection_history(x):
|
|
76
|
+
period=thinning_period(ThinningPeriod(**x.model_dump(include=set(ThinningPeriod.model_fields))))
|
|
77
|
+
baseline,assessment=_utc(x.installed_at if x.baseline_type=='furnished' else x.measured_baseline.measured_at),_utc(x.assessment_at)
|
|
78
|
+
ids=[i.inspection_id for i in x.inspections]
|
|
79
|
+
ensure(len(set(ids))==len(ids) and all(s.strip() for s in ids),'Inspection IDs must be nonblank and unique')
|
|
80
|
+
counts={grade:0 for grade in 'abcd'}
|
|
81
|
+
eligible,excluded=[],{}
|
|
82
|
+
for item in x.inspections:
|
|
83
|
+
ensure(bool(item.inspection_basis.strip()),'Inspection effectiveness/eligibility basis required')
|
|
84
|
+
at=_utc(item.performed_at)
|
|
85
|
+
if item.inspection_id==period['baseline_inspection_id']:
|
|
86
|
+
ensure(at==baseline,'Baseline inspection ID has conflicting performed time')
|
|
87
|
+
reason=('before_baseline' if at<baseline else 'after_assessment' if at>assessment else
|
|
88
|
+
'baseline_boundary' if at==baseline and x.baseline_boundary_policy=='exclude_baseline_time' else
|
|
89
|
+
'unqualified' if not item.qualified_for_component_mechanism else 'ineffective_e' if item.effectiveness=='e' else None)
|
|
90
|
+
if reason:
|
|
91
|
+
excluded[item.inspection_id]=reason
|
|
92
|
+
else:
|
|
93
|
+
counts[item.effectiveness]+=1
|
|
94
|
+
eligible.append(item.inspection_id)
|
|
95
|
+
return {**period,**{'inspections_'+grade:value for grade,value in counts.items()},
|
|
96
|
+
'eligible_inspection_ids':sorted(eligible),'excluded_inspection_reasons':excluded,
|
|
97
|
+
'baseline_boundary_policy':x.baseline_boundary_policy}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HistoryThinningAssessment(Model):
|
|
101
|
+
history: ThinningInspectionHistory
|
|
102
|
+
selection: ThinningRateSelection
|
|
103
|
+
prior: PriorSelection
|
|
104
|
+
strength: PressureStrength | CodeStrength = Field(discriminator='method')
|
|
105
|
+
conditions: ThinningConditions
|
|
106
|
+
qualification_basis: str = Field(min_length=1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@calculation('damage/thinning-history-assessment','Thickness/history/rate selection through period-consistent thinning DF',HistoryThinningAssessment,
|
|
110
|
+
['Part2 §§4.5.2-4.5.7; PDF pp150-156'],origin='qualified_thinning_baseline_history_composition',limitations=[
|
|
111
|
+
'Selected baseline derives both wall thickness and elapsed period; eligible dated inspection records derive counts, with independently selected table/custom prior. No competing manually supplied age/counts. Strength baseline must agree with derived wall baseline. Rate-selection common-period and inspection coverage/type qualifications remain external. Generic other monitoring row only, no borrowed mechanism credits. Non-AST unclad scope; no automatic process-change reset, inspection scheduling, cladding or all-mechanism/asset risk.'])
|
|
112
|
+
def thinning_history_assessment(x):
|
|
113
|
+
history=thinning_inspection_history(x.history)
|
|
114
|
+
inspections=Posterior(**x.prior.model_dump(),**{key:history[key] for key in ('inspections_a','inspections_b','inspections_c','inspections_d')})
|
|
115
|
+
result=selected_mechanism_thinning(SelectedMechanismThinning(selection=x.selection,
|
|
116
|
+
starting_thickness_m=history['baseline_thickness_m'],elapsed_years=history['elapsed_years'],strength=x.strength,
|
|
117
|
+
inspections=inspections,conditions=x.conditions,qualification_basis=x.qualification_basis))
|
|
118
|
+
return {**history,**result}
|