api581-engine 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. api581/__init__.py +80 -0
  2. api581/_amine_tables.py +105 -0
  3. api581/_h2sh2_tables.py +161 -0
  4. api581/_slabx_worker.py +174 -0
  5. api581/_sulfidic_tables.py +517 -0
  6. api581/_sulfuric_tables.py +328 -0
  7. api581/acid_sour_water.py +238 -0
  8. api581/acscc_screening.py +88 -0
  9. api581/alkaline_sour_water.py +267 -0
  10. api581/amine_corrosion.py +277 -0
  11. api581/amine_screening.py +103 -0
  12. api581/api/__init__.py +4 -0
  13. api581/api/api520.py +88 -0
  14. api581/api/bundle.py +121 -0
  15. api581/api/consequence.py +102 -0
  16. api581/api/damage.py +1831 -0
  17. api581/api/dispersion.py +121 -0
  18. api581/api/effects.py +318 -0
  19. api581/api/events.py +96 -0
  20. api581/api/financial.py +115 -0
  21. api581/api/frequency.py +40 -0
  22. api581/api/inspection.py +96 -0
  23. api581/api/level1.py +238 -0
  24. api581/api/level2.py +74 -0
  25. api581/api/planning.py +17 -0
  26. api581/api/prd.py +190 -0
  27. api581/api/properties.py +130 -0
  28. api581/api/release.py +252 -0
  29. api581/api/reliability.py +40 -0
  30. api581/api/risk.py +71 -0
  31. api581/api/tank.py +490 -0
  32. api581/api520.py +205 -0
  33. api581/area_contribution.py +72 -0
  34. api581/blast.py +232 -0
  35. api581/brittle.py +205 -0
  36. api581/bundle.py +300 -0
  37. api581/catalog.py +442 -0
  38. api581/caustic_screening.py +103 -0
  39. api581/clad_thinning.py +146 -0
  40. api581/clscc_screening.py +55 -0
  41. api581/co2_corrosion.py +313 -0
  42. api581/co2_screening.py +205 -0
  43. api581/consequences.py +250 -0
  44. api581/contracts.py +1038 -0
  45. api581/cooling_selection.py +204 -0
  46. api581/cooling_water.py +305 -0
  47. api581/core.py +126 -0
  48. api581/corrosion_rate.py +38 -0
  49. api581/cost_tables.py +175 -0
  50. api581/cracking.py +74 -0
  51. api581/data/coverage.json +21361 -0
  52. api581/data/digital-twin.schema.json +538 -0
  53. api581/data/direct-test-queue.json +7 -0
  54. api581/data/native-engine-evidence.json +44 -0
  55. api581/data/python-api.schema.json +92706 -0
  56. api581/data/reference-fixture-evidence.json +2956 -0
  57. api581/data/scope-boundaries.schema.json +58 -0
  58. api581/data/software-test-evidence.json +4278 -0
  59. api581/data/standard-metadata.json +1328 -0
  60. api581/data/standard.schema.json +233 -0
  61. api581/data/supporting-text-review.json +1319 -0
  62. api581/digital_twin.py +491 -0
  63. api581/dispersion.py +45 -0
  64. api581/effects.py +232 -0
  65. api581/evaporation.py +181 -0
  66. api581/evidence.py +81 -0
  67. api581/external_corrosion.py +148 -0
  68. api581/external_cracking.py +63 -0
  69. api581/fatigue.py +56 -0
  70. api581/fireball.py +163 -0
  71. api581/flash_pool.py +111 -0
  72. api581/flash_solver.py +122 -0
  73. api581/groundwater.py +101 -0
  74. api581/h2sh2_corrosion.py +128 -0
  75. api581/hic_screened.py +89 -0
  76. api581/htha.py +45 -0
  77. api581/hydrochloric_acid.py +272 -0
  78. api581/hydrofluoric_acid.py +236 -0
  79. api581/ignition.py +204 -0
  80. api581/inspection_effectiveness.py +601 -0
  81. api581/jet_handoff.py +60 -0
  82. api581/level1.py +311 -0
  83. api581/level1_summary.py +35 -0
  84. api581/level1_toxic.py +313 -0
  85. api581/level1_type1.py +91 -0
  86. api581/level2_summary.py +237 -0
  87. api581/lining.py +173 -0
  88. api581/native_toxic.py +67 -0
  89. api581/outputs.py +5672 -0
  90. api581/oxidation.py +145 -0
  91. api581/pascc_screening.py +78 -0
  92. api581/physical_explosion.py +190 -0
  93. api581/pof.py +207 -0
  94. api581/pool_fire.py +273 -0
  95. api581/portfolio.py +68 -0
  96. api581/prd.py +204 -0
  97. api581/prd_consequences.py +245 -0
  98. api581/prd_history.py +44 -0
  99. api581/py.typed +0 -0
  100. api581/release.py +307 -0
  101. api581/representative_fluids.py +172 -0
  102. api581/result.py +50 -0
  103. api581/risk.py +191 -0
  104. api581/saturation.py +66 -0
  105. api581/scc_mechanisms.py +242 -0
  106. api581/schema.py +45 -0
  107. api581/slabx_adapter.py +261 -0
  108. api581/soil_corrosion.py +191 -0
  109. api581/source_partition.py +169 -0
  110. api581/spatial.py +87 -0
  111. api581/ssc_screening.py +77 -0
  112. api581/standard.py +281 -0
  113. api581/storage.py +42 -0
  114. api581/sulfidic_corrosion.py +197 -0
  115. api581/sulfuric_acid.py +209 -0
  116. api581/tank.py +260 -0
  117. api581/tank_corrosion.py +256 -0
  118. api581/tank_environment.py +186 -0
  119. api581/tank_financial.py +202 -0
  120. api581/tank_integrated.py +51 -0
  121. api581/tank_shell.py +141 -0
  122. api581/tank_source.py +152 -0
  123. api581/tank_thinning.py +204 -0
  124. api581/temper.py +105 -0
  125. api581/thinning.py +172 -0
  126. api581/thinning_conditions.py +212 -0
  127. api581/thinning_history.py +118 -0
  128. api581/thinning_selection.py +81 -0
  129. api581/thinning_type.py +34 -0
  130. api581/toxic_criteria.py +111 -0
  131. api581/toxic_level2.py +233 -0
  132. api581/two_phase_events.py +120 -0
  133. api581/workflow.py +42 -0
  134. api581_engine-0.1.4.dist-info/METADATA +419 -0
  135. api581_engine-0.1.4.dist-info/RECORD +139 -0
  136. api581_engine-0.1.4.dist-info/WHEEL +5 -0
  137. api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
  138. api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
  139. api581_engine-0.1.4.dist-info/top_level.txt +1 -0
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}