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
@@ -0,0 +1,88 @@
1
+ """ACSCC alkaline-water and FCCU feed-change screening, Part2 section10.3."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, NonNegative, calculation, ensure
5
+ from .scc_mechanisms import AcsccSusceptibility, acscc_susceptibility, Assessment, InspectionCounts, assess
6
+
7
+
8
+ class AcsccScreen(Model):
9
+ material: Literal['carbon_steel','low_alloy_steel','other_material']
10
+ water_present: bool
11
+ water_ph: float | None = Field(default=None,strict=True,allow_inf_nan=False)
12
+ fccu_feed_sulfur_reduced: bool
13
+ fccu_feed_nitrogen_increased: bool
14
+ screening_basis: str = Field(min_length=1)
15
+
16
+
17
+ @calculation('damage/acscc-screening','ACSCC alkaline-water and FCCU feed-change evaluation triggers',AcsccScreen,
18
+ ['Part2 §10.3; PDF p196'],limitations=[
19
+ 'Carbon/low-alloy steel: alkaline water pH>7.5 or reduced-sulfur/increased-nitrogen FCCU feed changes trigger evaluation. Exactly7.5 does not pass the strict water trigger. Feed-change triggers are independent review flags, not proof of carbonate concentration or susceptibility. Water presence requires its qualified pH; absent water must omit pH. No H2S threshold, cyanide exclusion, carbonate estimate, PWHT credit or damage factor is inferred. Outside scope is not overall immunity or zero risk.'])
20
+ def acscc_screening(x):
21
+ ensure(bool(x.screening_basis.strip()),'Qualified material/water chemistry and FCCU feed-change review basis required')
22
+ ensure((x.water_ph is not None)==x.water_present,'Water pH required exactly when water is present')
23
+ triggers=[]
24
+ if x.water_present and x.water_ph>7.5:
25
+ triggers.append('alkaline_water_ph_above_7_5')
26
+ if x.fccu_feed_sulfur_reduced:
27
+ triggers.append('fccu_feed_sulfur_reduced')
28
+ if x.fccu_feed_nitrogen_increased:
29
+ triggers.append('fccu_feed_nitrogen_increased')
30
+ eligible=x.material!='other_material'
31
+ return {'evaluate_acscc':eligible and bool(triggers),'material_in_scope':eligible,'evaluation_triggers':triggers}
32
+
33
+
34
+ class AcsccWaterSusceptibility(Model):
35
+ water_ph: float = Field(strict=True,allow_inf_nan=False)
36
+ carbonate_ppm: NonNegative
37
+ pwht: bool
38
+ shared_ph_boundary_policy: Literal['lower_ph_row','higher_ph_row'] | None = None
39
+ carbonate_measurement_basis: str = Field(min_length=1)
40
+ traditional_alkalinity_only: bool
41
+ service_fabrication_basis: str = Field(min_length=1)
42
+
43
+
44
+ @calculation('damage/acscc-water-susceptibility','Qualified numeric water pH to ACSCC carbonate/PWHT susceptibility',AcsccWaterSusceptibility,
45
+ ['Part2 Table10.2 and note1; PDF p199'],limitations=[
46
+ 'Numeric pH mapped to existing Table10.2 bands. Shared8.0/9.0 endpoints require explicit lower/higher-pH row selection; no automatic susceptibility-maximizing policy.7.5 enters7.5_to_8, independently of screening strict>7.5.100ppm carbonate belongs high-concentration column. Traditional P/M alkalinity alone is rejected per note1; caller supplies qualified carbonate method. No carbonate inference from total alkalinity, PWHT certification, numeric pH rounding allowance or material applicability.'])
47
+ def acscc_water_susceptibility(x):
48
+ ensure(bool(x.carbonate_measurement_basis.strip()) and bool(x.service_fabrication_basis.strip()),
49
+ 'Qualified carbonate analysis and service/fabrication bases required')
50
+ ensure(not x.traditional_alkalinity_only,'Traditional P/M alkalinity alone is ineffective for carbonate in sour water','unsupported_carbonate_measurement')
51
+ ph=x.water_ph
52
+ boundary=ph in (8.,9.)
53
+ ensure((x.shared_ph_boundary_policy is not None)==boundary,
54
+ 'Shared pH8/9 endpoints require a row policy; unused policy must be omitted','ambiguous_acscc_ph_boundary')
55
+ if boundary:
56
+ bands=('7.5_to_8','8_to_9') if ph==8. else ('8_to_9','at_least_9')
57
+ band=bands[x.shared_ph_boundary_policy=='higher_ph_row']
58
+ else:
59
+ band='below_7.5' if ph<7.5 else '7.5_to_8' if ph<8. else '8_to_9' if ph<9. else 'at_least_9'
60
+ result=acscc_susceptibility(AcsccSusceptibility(water_ph_band=band,carbonate_ppm=x.carbonate_ppm,pwht=x.pwht))
61
+ return {**result,'selected_ph_band':band,'shared_ph_boundary_encountered':boundary}
62
+
63
+
64
+ class AcsccQualifiedAssessment(Model):
65
+ screening: AcsccScreen
66
+ water: AcsccWaterSusceptibility
67
+ confirmed_cracking: bool
68
+ unrepaired_cracks: bool
69
+ years_since_qualifying_inspection: NonNegative
70
+ inspections: InspectionCounts
71
+ inspection_basis: str = Field(min_length=1)
72
+
73
+
74
+ @calculation('damage/acscc-qualified-assessment','ACSCC screen and common water chemistry through inspection/age DF',AcsccQualifiedAssessment,
75
+ ['Part2 §§10.3,10.6.3,Table10.2-10.3,Table6.3; eq(2.29); PDF pp196-199'],
76
+ origin='qualified_acscc_water_to_existing_scc_df',limitations=[
77
+ 'Requires positive screening and water-present numeric pH consistent with susceptibility input. FCCU feed change can trigger review but cannot replace water/carbonate data needed by Table10.2. Shared pH8/9 policy, qualified carbonate measurement and PWHT remain explicit. Confirmed cracking forceshigh; unrepaired cracks requireFFS. Independent mechanism-specific inspection age/coverage required; existing six-equivalent-inspection support retained. No chemical speciation, automatic damage detection, FFS or whole-asset risk.'])
78
+ def acscc_qualified_assessment(x):
79
+ ensure(not x.unrepaired_cracks,'Unrepaired ACSCC requires separate FFS evaluation','ffs_required')
80
+ ensure(bool(x.inspection_basis.strip()),'ACSCC-specific inspection age and effectiveness basis required')
81
+ screen=acscc_screening(x.screening)
82
+ ensure(screen['evaluate_acscc'],'ACSCC material/environment review screen not met','acscc_outside_screen')
83
+ ensure(x.screening.water_present,'ACSCC table requires qualified aqueous carbonate conditions','acscc_water_required')
84
+ ensure(x.screening.water_ph==x.water.water_ph,'Screening and susceptibility water pH must agree','acscc_ph_mismatch')
85
+ water=acscc_water_susceptibility(x.water)
86
+ result=assess('acscc',Assessment(susceptibility=water['susceptibility'],confirmed_cracking=x.confirmed_cracking,
87
+ unrepaired_cracks=False,years_since_qualifying_inspection=x.years_since_qualifying_inspection,inspections=x.inspections))
88
+ return {**screen,**water,**result}
@@ -0,0 +1,267 @@
1
+ """2016 Annex2.B section7 NH4HS sour-water corrosion and sour-water routing."""
2
+ from bisect import bisect_left
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure, finite_tree
6
+ from .co2_corrosion import PSI_PA
7
+ from .acid_sour_water import AcidSourRoute, route as acid_route, AcidSourAssessment, assessment as acid_assessment
8
+ from .thinning import Posterior, WallLoss
9
+ from .clad_thinning import PressureStrength, CodeStrength
10
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
11
+
12
+ Units = Literal["printed_si","printed_us_converted"]
13
+ _C = (.02,.05,.1,.15)
14
+ _VSI = (3.05,4.57,6.10,7.62,9.14)
15
+ _VUS = tuple(v*.3048 for v in (10,15,20,25,30))
16
+ _SI = ((.08,.10,.13,.20,.28),(.15,.23,.30,.38,.46),(.51,.69,.89,1.09,1.27),(1.14,1.78,2.54,3.81,5.08))
17
+ _US = ((3,4,5,8,11),(6,9,12,15,18),(20,27,35,43,50),(45,70,100,150,200))
18
+
19
+
20
+ class H2SPartialPressure(Model):
21
+ total_pressure_pa_abs: Positive
22
+ h2s_mole_fraction: Fraction
23
+
24
+
25
+ @calculation("properties/h2s-partial-pressure", "H2S partial pressure from absolute pressure and mole fraction", H2SPartialPressure,
26
+ ["Part2 Table2.B.7.1; PDF p379"],limitations=["Public mole fraction0..1, not printed mole-percent number multiplied without/100. AbsolutePa, not gauge. Product only; no gas-liquid equilibrium, dissolvedH2S or fugacity calculation."])
27
+ def partial_pressure(x):
28
+ p = x.total_pressure_pa_abs*x.h2s_mole_fraction
29
+ ensure(p > 0 or x.h2s_mole_fraction == 0,"H2S partial-pressure underflow","numerical_failure")
30
+ return {"h2s_partial_pressure_pa_abs":p}
31
+
32
+
33
+ class NH4HSEstimate(Model):
34
+ h2s_mass_fraction: Fraction
35
+ nh3_mass_fraction: Fraction
36
+ analytical_basis: str = Field(min_length=1)
37
+ excess_h2s_policy: Literal["reject_disputed_branch","qualified_printed_literal"]
38
+ excess_h2s_qualification_basis: str | None = None
39
+ equality_policy: Literal["reject_unassigned_equality","qualified_lower_branch","qualified_upper_branch"]
40
+ equality_qualification_basis: str | None = None
41
+
42
+
43
+ @calculation("damage/alkaline-sour-water-nh4hs-estimate", "Source NH4HS analytical approximation with disputed branch gate", NH4HSEstimate,
44
+ ["Part2 Table2.B.7.1; PDF p379"],limitations=[
45
+ "Source prefers ionic process model. Printed ifH2S<2*NH3:NH4HS=1.5*H2S; ifH2S>2*NH3:NH4HS=3*H2S, notNH3. Excess branch rejected unless qualified literal use; no undocumented ammonia-limited repair. Equality unassigned, requires explicit qualified lower/upper selection or error. Public mass fractions, same condensed-water analytical basis, H2S+NH3<=1. Literal estimate can exceed1; raw value/flag retained, not clipped or claimed mass-balanced chemistry. Integrated table rejects out-of-range concentration."])
46
+ def nh4hs_estimate(x):
47
+ ensure(bool(x.analytical_basis.strip()) and x.h2s_mass_fraction+x.nh3_mass_fraction <= 1,"Analytical basis or common mass fractions invalid")
48
+ excess = x.excess_h2s_policy == "qualified_printed_literal"
49
+ ensure((x.excess_h2s_qualification_basis is not None and bool(x.excess_h2s_qualification_basis.strip())) if excess else x.excess_h2s_qualification_basis is None,"Disputed excess policy and basis must agree")
50
+ equality = x.equality_policy != "reject_unassigned_equality"
51
+ ensure((x.equality_qualification_basis is not None and bool(x.equality_qualification_basis.strip())) if equality else x.equality_qualification_basis is None,"Equality policy and basis must agree")
52
+ equal = x.h2s_mass_fraction == 2*x.nh3_mass_fraction
53
+ if equal:
54
+ ensure(equality,"ExactH2S=2*NH3 boundary unassigned by source","nh4hs_unassigned_equality")
55
+ upper = x.equality_policy == "qualified_upper_branch"
56
+ branch = "qualified_equality_upper" if upper else "qualified_equality_lower"
57
+ else:
58
+ upper = x.h2s_mass_fraction > 2*x.nh3_mass_fraction
59
+ branch = "printed_excess_h2s" if upper else "printed_lower_h2s"
60
+ ensure(not upper or excess,"Disputed printed excess-H2S formula requires qualified literal policy","nh4hs_disputed_formula")
61
+ value = x.h2s_mass_fraction*(3. if upper else 1.5)
62
+ return {"estimated_nh4hs_mass_fraction":value,"concentration_estimate_branch":branch,"estimated_fraction_exceeds_one":value > 1}
63
+
64
+
65
+ class AlkalineVelocity(Model):
66
+ phase_regime: Literal["two_phase","liquid_full"]
67
+ vapor_velocity_m_s: NonNegative | None = None
68
+ liquid_velocity_m_s: NonNegative | None = None
69
+ qualification_basis: str = Field(min_length=1)
70
+
71
+
72
+ @calculation("damage/alkaline-sour-water-velocity-selection", "Select vapor or liquid velocity for NH4HS table", AlkalineVelocity,
73
+ ["Part2 Table2.B.7.1; PDF p379"],limitations=[
74
+ "Two-phase system uses vapor-phase velocity, liquid-full system uses liquid velocity. Bulk/mixture velocity is not substituted. Two-phase liquid velocity may be supplied as contextual input but is not selected. Vapor input in liquid-full case rejected. No phase fractions, holdup, slip velocity or wall-shear calculation inferred."])
75
+ def velocity_selection(x):
76
+ ensure(bool(x.qualification_basis.strip()),"Phase-specific velocity qualification required")
77
+ if x.phase_regime == "two_phase":
78
+ ensure(x.vapor_velocity_m_s is not None,"Two-phase NH4HS table requires vapor velocity")
79
+ v,phase = x.vapor_velocity_m_s,"vapor"
80
+ else:
81
+ ensure(x.liquid_velocity_m_s is not None and x.vapor_velocity_m_s is None,"Liquid-full case requires liquid velocity and no vapor velocity")
82
+ v,phase = x.liquid_velocity_m_s,"liquid"
83
+ return {"selected_velocity_m_s":v,"selected_velocity_phase":phase}
84
+
85
+
86
+ class AlkalineBase(Model):
87
+ nh4hs_mass_fraction: Fraction
88
+ selected_velocity_m_s: NonNegative
89
+ unit_basis: Units
90
+ lookup_policy: Literal["exact_nodes_only","bilinear","maximum_bracketing_value"]
91
+
92
+
93
+ def _bracket(nodes,value):
94
+ value = next((n for n in nodes if abs(n-value) <= 1e-10),value)
95
+ ensure(nodes[0] <= value <= nodes[-1],"NH4HS concentration/velocity outside source table; no extrapolation")
96
+ i = bisect_left(nodes,value)
97
+ return (i,i,0.) if value == nodes[i] else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
98
+
99
+
100
+ @calculation("damage/alkaline-sour-water-base-rate", "NH4HS and phase-velocity baseline carbon-steel corrosion rate", AlkalineBase,
101
+ ["Part2 Tables2.B.7.2/7.2M; PDF pp379-380"],limitations=[
102
+ "20cells per unit basis:NH4HS2/5/10/15wt%,SI3.05/4.57/6.10/7.62/9.14m/s,US10/15/20/25/30ft/s. Public fractions/m/s/m-year; rounded SI rates/grid versus exactUS conversions explicit. Exact/bilinear/max-bracketing numerical policy required; no concentration or velocity extrapolation/edge defaults. Baseline before pressure correction, not independently modeled temperature/hydrocarbon effects."])
103
+ def base_rate(x):
104
+ a,b,wc = _bracket(_C,x.nh4hs_mass_fraction)
105
+ c,d,wv = _bracket(_VSI if x.unit_basis == "printed_si" else _VUS,x.selected_velocity_m_s)
106
+ ensure(x.lookup_policy != "exact_nodes_only" or (a == b and c == d),"Exact baseline node policy does not interpolate")
107
+ table = _SI if x.unit_basis == "printed_si" else _US
108
+ if x.lookup_policy == "maximum_bracketing_value":
109
+ value = max(table[a][c],table[a][d],table[b][c],table[b][d])
110
+ else:
111
+ lo,hi = table[a][c]+wv*(table[a][d]-table[a][c]),table[b][c]+wv*(table[b][d]-table[b][c])
112
+ value = lo+wc*(hi-lo)
113
+ return {"baseline_corrosion_rate_m_year":value*(.001 if x.unit_basis == "printed_si" else .0000254)}
114
+
115
+
116
+ class AlkalinePressure(Model):
117
+ baseline_corrosion_rate_m_year: NonNegative
118
+ h2s_partial_pressure_pa_abs: NonNegative
119
+ equation_basis: Units
120
+
121
+
122
+ @calculation("damage/alkaline-sour-water-pressure-adjustment", "NH4HS sour-water H2S partial-pressure correction", AlkalinePressure,
123
+ ["Part2 Tables2.B.7.2/7.2M notes1-2; PDF pp379-380"],limitations=[
124
+ "US factor=1+(p_psia-50)/25 below50, /40 above;SI=1+(p_kPa-345)/173 below345,/276 above. At reference both expressions return1. Finalrate=max(baseline*factor,0). Public absolutePa; rounded pressure thresholds/slopes differ, basis explicit. Negative factor/low-pressure zero clamp flagged, not actual immunity to sour-water/wetH2S damage. No extrapolation range is specified by source pressure notes; owner qualifies actual pressure applicability."])
125
+ def pressure_adjustment(x):
126
+ p,ref,low,high = (x.h2s_partial_pressure_pa_abs/1000,345.,173.,276.) if x.equation_basis == "printed_si" else (x.h2s_partial_pressure_pa_abs/PSI_PA,50.,25.,40.)
127
+ branch = "at_reference" if p == ref else "below_reference" if p < ref else "above_reference"
128
+ raw = 1+(p-ref)/(low if p < ref else high)
129
+ factor = max(raw,0.)
130
+ rate = x.baseline_corrosion_rate_m_year*factor
131
+ finite_tree([raw,factor,rate])
132
+ ensure(rate > 0 or factor == 0 or x.baseline_corrosion_rate_m_year == 0,"Pressure-adjusted rate underflow","numerical_failure")
133
+ return {"corrosion_rate_m_year":rate,"unclamped_pressure_factor":raw,"pressure_correction_factor":factor,
134
+ "negative_pressure_factor_clamped":raw < 0,"pressure_correction_branch":branch}
135
+
136
+
137
+ class SuppliedNH4HS(Model):
138
+ method: Literal["supplied"]
139
+ nh4hs_mass_fraction: Fraction
140
+ qualification_basis: str = Field(min_length=1)
141
+
142
+
143
+ class EstimatedNH4HS(NH4HSEstimate):
144
+ method: Literal["source_approximation"]
145
+
146
+
147
+ class AlkalineWet(Model):
148
+ concentration: SuppliedNH4HS | EstimatedNH4HS = Field(discriminator="method")
149
+ velocity: AlkalineVelocity
150
+ h2s_partial_pressure_pa_abs: NonNegative
151
+ baseline_unit_basis: Units
152
+ pressure_equation_basis: Units
153
+ lookup_policy: Literal["exact_nodes_only","bilinear","maximum_bracketing_value"]
154
+ thinning_threshold_basis: Units
155
+ qualification_basis: str = Field(min_length=1)
156
+
157
+
158
+ class AlkalineAssessment(Model):
159
+ material: Literal["carbon_steel"]
160
+ free_water_present: bool
161
+ ph: float | None = Field(default=None,gt=7,le=14,strict=True,allow_inf_nan=False)
162
+ h2s_ammonia_service_qualified: bool
163
+ applicability_basis: str = Field(min_length=1)
164
+ wet_model: AlkalineWet | None = None
165
+
166
+
167
+ @calculation("damage/alkaline-sour-water-assessment", "Qualified alkaline NH4HS carbon-steel corrosion assessment", AlkalineAssessment,
168
+ ["Part2 Annex2.B §7,Tables7.1-7.2M; Table1.2; PDF pp323,378-381"],origin="api581_composition_with_explicit_source_policies",limitations=[
169
+ "Carbon-steel free-water/H2S/NH3 service above neutralpH only; wet concentration preferably supplied from qualified ionic model/analysis. Disputed source approximations retain gates. Phase-specific velocity selected once; pressure correction applied to same baseline. Source table general/local classification uses<=6.1m/s or<=20ft/s as explicit threshold basis. Temperature/HC-water ratio effects are secondary but no quantitative correction supplied; not invented. Dry0 is NH4HS-specific, not zero all-mechanismDF. Low-pressure source clamp0 not validated absence of damage. No wetH2S cracking, material-alloy extension or process equilibrium inferred."])
170
+ def alkaline_assessment(x):
171
+ ensure(bool(x.applicability_basis.strip()),"Alkaline service/water/pH applicability basis required")
172
+ if not x.free_water_present:
173
+ ensure(x.ph is None and x.wet_model is None,"Dry alkaline branch cannot carry unused aqueous pH/model")
174
+ return {"corrosion_rate_m_year":0.,"baseline_corrosion_rate_m_year":None,"nh4hs_mass_fraction_used":None,"concentration_source":None,
175
+ "selected_velocity_m_s":None,"selected_velocity_phase":None,"unclamped_pressure_factor":None,"pressure_correction_factor":None,
176
+ "negative_pressure_factor_clamped":False,"pressure_correction_branch":None,"source_thinning_type":None,"calculation_branch":"no_free_water"}
177
+ ensure(x.ph is not None and x.h2s_ammonia_service_qualified and x.wet_model is not None,"Wet alkaline calculation requires qualifiedpH>7 H2S/NH3 and wet model")
178
+ m = x.wet_model
179
+ ensure(bool(m.qualification_basis.strip()),"Condensed-water concentration and pressure applicability qualification required")
180
+ if m.concentration.method == "supplied":
181
+ ensure(bool(m.concentration.qualification_basis.strip()),"Supplied NH4HS concentration qualification required")
182
+ concentration = m.concentration.nh4hs_mass_fraction
183
+ else:
184
+ concentration = nh4hs_estimate(m.concentration)["estimated_nh4hs_mass_fraction"]
185
+ vel = velocity_selection(m.velocity)
186
+ base = base_rate(AlkalineBase(nh4hs_mass_fraction=concentration,selected_velocity_m_s=vel["selected_velocity_m_s"],unit_basis=m.baseline_unit_basis,lookup_policy=m.lookup_policy))
187
+ adjusted = pressure_adjustment(AlkalinePressure(baseline_corrosion_rate_m_year=base["baseline_corrosion_rate_m_year"],h2s_partial_pressure_pa_abs=m.h2s_partial_pressure_pa_abs,equation_basis=m.pressure_equation_basis))
188
+ threshold = 6.1 if m.thinning_threshold_basis == "printed_si" else 20*.3048
189
+ return {**base,**vel,**adjusted,"nh4hs_mass_fraction_used":concentration,"concentration_source":m.concentration.method,
190
+ "source_thinning_type":"general" if vel["selected_velocity_m_s"] <= threshold else "local","calculation_branch":"wet_nh4hs_model"}
191
+
192
+
193
+ class AlkalineThinning(Model):
194
+ corrosion: AlkalineAssessment
195
+ assessment_distribution: Literal["general","local"]
196
+ distribution_qualification_basis: str = Field(min_length=1)
197
+ starting_thickness_m: Positive
198
+ elapsed_years: NonNegative
199
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
200
+ inspections: Posterior
201
+ conditions: ThinningConditions
202
+ qualification_basis: str = Field(min_length=1)
203
+
204
+
205
+ @calculation("damage/alkaline-sour-water-thinning-assessment", "NH4HS corrosion through qualified non-AST unclad thinning DF", AlkalineThinning,
206
+ ["Part2 Annex2.B §7,Table1.2; Part2 §4.5.7,Table4.8"],origin="api581_composition_with_explicit_source_policies",limitations=[
207
+ "Same selected phase velocity and rate drive sour-water monitoring and wall loss. Explicit source general/local classification retained separately from owner-qualified inspection/distribution; no automatic coverage validation. Source pressure-clamp0 does not force DF0: strength/uncertainty/floor remain. Non-AST unclad only; no ammonia/wetH2S cracking, material extension, two-phase EOS, all-mechanismDF or code/FFS."])
208
+ def alkaline_thinning(x):
209
+ ensure(bool(x.distribution_qualification_basis.strip()),"Actual general/local distribution and inspection basis required")
210
+ corrosion = alkaline_assessment(x.corrosion)
211
+ monitor = x.conditions.monitoring.row
212
+ ensure(monitor.mechanism == "sour_water","NH4HS model requires sour-water monitoring row")
213
+ if corrosion["selected_velocity_m_s"] is not None:
214
+ ensure(monitor.velocity_m_s == corrosion["selected_velocity_m_s"],"Monitoring must use same selected phase velocity as NH4HS rate model")
215
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
216
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
217
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
218
+ return {**corrosion,**result,"assessment_distribution":x.assessment_distribution}
219
+
220
+
221
+ from .hydrochloric_acid import HClAssessment, assessment as hcl_assessment
222
+
223
+
224
+ class SourWaterAssessment(Model):
225
+ screening: AcidSourRoute
226
+ acid_model: AcidSourAssessment | None = None
227
+ alkaline_model: AlkalineAssessment | None = None
228
+ hcl_model: HClAssessment | None = None
229
+ hcl_shared_phase_qualification_basis: str | None = None
230
+
231
+
232
+ @calculation("damage/sour-water-assessment", "Route qualified sour water to acid or alkaline quantitative model", SourWaterAssessment,
233
+ ["Part2 Annex2.B §§2,7,10,Figures2.1,10.1; PDF pp324-330,378-381,392-396"],origin="api581_composition_with_explicit_source_policies",limitations=[
234
+ "Reuses explicit free-water/pH/chloride policies. Executes only matching supplied acid/alkaline/HCl model, never sums them. Acid child screening must match entire shared request; alkaline child must have same free-water/pH. HCl child requires shared separated-phase qualification and matching available pH/chloride; screeningroute alone does not establish dilute HCl applicability. Neutral-unclassified errors, never zero. Returns common selected-rate fields; independent APIs retain detailed diagnostics including HCl999-cell flags. Dry0 source-specific; no all-mechanismDF or immunity statement."])
235
+ def sour_water_assessment(x):
236
+ routed = acid_route(x.screening)
237
+ choice = routed["corrosion_route"]
238
+ if choice != "hydrochloric_acid":
239
+ ensure(x.hcl_model is None and x.hcl_shared_phase_qualification_basis is None,"Non-HCl route cannot carry unused HCl model or basis")
240
+ if choice == "no_free_water":
241
+ ensure(x.acid_model is None and x.alkaline_model is None,"Dry route cannot carry unused quantitative models")
242
+ result = {"corrosion_rate_m_year":0.,"base_corrosion_rate_m_year":None,"selected_rate_is_screening_bound":False,"localized_attack_review_required":False}
243
+ elif choice == "acid_sour_water":
244
+ ensure(x.acid_model is not None and x.alkaline_model is None,"Acid route requires only matching acid model")
245
+ ensure(x.acid_model.screening == x.screening,"Acid child and shared screening requests must match")
246
+ result = acid_assessment(x.acid_model)
247
+ elif choice == "alkaline_sour_water":
248
+ ensure(x.alkaline_model is not None and x.acid_model is None,"Alkaline route requires only matching alkaline model")
249
+ ensure(x.alkaline_model.free_water_present == x.screening.free_water_present and x.alkaline_model.ph == x.screening.ph,"Alkaline model and shared pH/water must match")
250
+ alkaline = alkaline_assessment(x.alkaline_model)
251
+ result = {**alkaline,"base_corrosion_rate_m_year":alkaline["baseline_corrosion_rate_m_year"],"selected_rate_is_screening_bound":False,
252
+ "localized_attack_review_required":alkaline["source_thinning_type"] == "local"}
253
+ elif choice == "hydrochloric_acid":
254
+ ensure(x.hcl_model is not None and x.acid_model is None and x.alkaline_model is None,"HCl route requires only matching HCl model")
255
+ ensure(x.hcl_shared_phase_qualification_basis is not None and bool(x.hcl_shared_phase_qualification_basis.strip()),"HCl child and shared screening need same separated acid-phase qualification")
256
+ ensure(x.hcl_model.free_water_present == x.screening.free_water_present,"HCl and shared free-water state must match")
257
+ hcl = hcl_assessment(x.hcl_model)
258
+ if hcl["ph_used"] is not None:
259
+ ensure(hcl["ph_used"] == x.screening.ph,"HCl model and shared separated-phase pH must match")
260
+ if hcl["chloride_mass_fraction_used"] is not None and x.screening.chloride_concentration_ppm is not None:
261
+ ensure(abs(hcl["chloride_mass_fraction_used"]*1e6-x.screening.chloride_concentration_ppm) <= 1e-9,"HCl and shared chloride concentration must match")
262
+ result = {"corrosion_rate_m_year":hcl["corrosion_rate_m_year"],"base_corrosion_rate_m_year":hcl["corrosion_rate_m_year"],
263
+ "selected_rate_is_screening_bound":False,"localized_attack_review_required":True}
264
+ else:
265
+ ensure(False,"Sour-water route needs neutral-boundary resolution; no automatic zero","different_corrosion_mechanism")
266
+ return {**routed,"corrosion_rate_m_year":result["corrosion_rate_m_year"],"selected_model_base_rate_m_year":result["base_corrosion_rate_m_year"],
267
+ "selected_rate_is_screening_bound":result["selected_rate_is_screening_bound"],"localized_attack_review_required":result["localized_attack_review_required"]}
@@ -0,0 +1,277 @@
1
+ """2016 Annex2.B section8 amine tables, chemistry policies and thinning."""
2
+ from bisect import bisect_left
3
+ from itertools import product
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure, finite_tree
7
+ from ._amine_tables import TABLES
8
+ from .thinning import Posterior, WallLoss
9
+ from .clad_thinning import PressureStrength, CodeStrength
10
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
11
+
12
+ Amine = Literal["mea","dea","mdea"]
13
+ Units = Literal["printed_si","printed_us_converted"]
14
+ Lookup = Literal["exact_nodes_only","linear_loading_temperature","maximum_bracketing_value"]
15
+ _RICH = (.15,.25,.35,.45,.55,.65,.7)
16
+
17
+
18
+ def _qualified(value,message):
19
+ ensure(bool(value.strip()),message)
20
+
21
+
22
+ def _bracket(nodes,value):
23
+ value=next((n for n in nodes if abs(n-value)<=1e-10),value)
24
+ ensure(nodes[0]<=value<=nodes[-1],"Outside amine table; no extrapolation")
25
+ i=bisect_left(nodes,value)
26
+ return (i,i,0.) if value==nodes[i] else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
27
+
28
+
29
+ class AmineAcidGasLoading(Model):
30
+ h2s_amount_mol: NonNegative
31
+ co2_amount_mol: NonNegative
32
+ active_amine_amount_mol: Positive
33
+ common_sample_basis: str = Field(min_length=1)
34
+
35
+
36
+ @calculation("damage/amine-acid-gas-loading", "Combined H2S and CO2 loading per mole of active amine", AmineAcidGasLoading,
37
+ ["Part2 Annex2.B §8.1,Table8.1; PDF pp382-383"],limitations=[
38
+ "Both acid gases on same representative sample basis divided by active, not total or degraded amine. No HSAS-to-active-amine subtraction, equilibrium or rich/lean process-state inference. Zero acid-gas loading valid; denominator strictly positive. Owner process analysis/qualification required."])
39
+ def acid_gas_loading(x):
40
+ _qualified(x.common_sample_basis,"Common sample and active-amine analytical basis required")
41
+ result=(x.h2s_amount_mol+x.co2_amount_mol)/x.active_amine_amount_mol
42
+ finite_tree(result)
43
+ return {"acid_gas_loading_mol_mol":result}
44
+
45
+
46
+ class AmineLoadingSelection(Model):
47
+ acid_gas_loading_mol_mol: NonNegative
48
+ loading_gap_policy: Literal["reject_unassigned_0p10_to_0p15","qualified_lean_row","qualified_first_rich_row"]
49
+ loading_gap_qualification_basis: str | None = None
50
+
51
+
52
+ @calculation("damage/amine-loading-selection", "Amine lean/rich source loading rows and explicit gap selection", AmineLoadingSelection,
53
+ ["Part2 Tables2.B.8.2-8.3M; PDF pp384-387"],limitations=[
54
+ "Carbon tables have lean<0.1 and rich nodes0.15..0.7.0.1<=loading<0.15 is unassigned, default rejected; owner-qualified nearest lean/first-rich mapping flagged, never interpolation across incompatible velocity thresholds. Rich nodes may be numerically bracketed; rate lookup separately selects interpolation. Loading>0.7 rejected. Returned row indices0lean,1..7rich are table coordinates, not process rich/lean designation."])
55
+ def loading_selection(x):
56
+ q=x.loading_gap_policy!="reject_unassigned_0p10_to_0p15"
57
+ ensure((x.loading_gap_qualification_basis is not None and bool(x.loading_gap_qualification_basis.strip())) if q else x.loading_gap_qualification_basis is None,
58
+ "Loading gap policy and qualification basis must agree")
59
+ value=x.acid_gas_loading_mol_mol
60
+ gap=.1<=value<.15
61
+ if value<.1:
62
+ a=b=0; w=0.
63
+ elif gap:
64
+ ensure(q,"Carbon amine loading0.10..0.15 lacks printed row","amine_loading_gap")
65
+ a=b=0 if x.loading_gap_policy=="qualified_lean_row" else 1; w=0.
66
+ else:
67
+ a,b,w=_bracket(_RICH,value)
68
+ a+=1; b+=1
69
+ return {"lower_loading_row_index":a,"upper_loading_row_index":b,"loading_interpolation_weight":w,
70
+ "table_loading_regime":"lean" if a==0 else "rich","loading_gap_mapping_used":gap}
71
+
72
+
73
+ class AmineContaminantSelection(Model):
74
+ amine_type: Amine
75
+ contaminant_mass_fraction: Fraction
76
+ mdea_scale_policy: Literal["not_mdea","qualified_basic_data_organic_ppm_bands","qualified_printed_wt_percent_nodes"]
77
+ mdea_scale_qualification_basis: str | None = None
78
+ four_percent_boundary_policy: Literal["basic_data_middle_band","printed_four_percent_high_row"] | None = None
79
+ four_percent_qualification_basis: str | None = None
80
+
81
+
82
+ @calculation("damage/amine-contaminant-row", "Select amine HSAS or MDEA organic-acid contamination row", AmineContaminantSelection,
83
+ ["Part2 Table2.B.8.1,Tables8.2-8.3M; PDF pp383-387"],limitations=[
84
+ "MEA/DEA basic-data<=2wt%,2..4,>4 maplow/middle/high table rows2/3/4. Exact4 conflicts with printed high-row4: explicit middle/high policy/basis required. MDEA basic-data organic-acid<500,500..4000,>4000wppm differs from tableHSAS0.5/2.25/4wt%; explicit qualified basic-data band mapping or literal exactwt%node policy required. Never treat500ppm as0.5wt%. No hidden HSAS composition or active-amine correction. No interpolation between contaminant categories."])
85
+ def contaminant_row(x):
86
+ c=x.contaminant_mass_fraction
87
+ if x.amine_type=="mdea":
88
+ ensure(x.mdea_scale_policy!="not_mdea" and x.mdea_scale_qualification_basis is not None and bool(x.mdea_scale_qualification_basis.strip()),"MDEA source scale conflict needs explicit policy and basis")
89
+ ensure(x.four_percent_boundary_policy is None and x.four_percent_qualification_basis is None,"MEA/DEA boundary fields do not apply to MDEA")
90
+ if x.mdea_scale_policy=="qualified_basic_data_organic_ppm_bands":
91
+ ppm=c*1e6
92
+ row="low" if ppm<500 else "middle" if ppm<=4000 else "high"
93
+ else:
94
+ index=next((i for i,n in enumerate((.005,.0225,.04)) if abs(c-n)<=1e-12),None)
95
+ ensure(index is not None,"Printed MDEA wt% policy accepts only literal0.5/2.25/4wt% nodes")
96
+ row=("low","middle","high")[index]
97
+ else:
98
+ ensure(x.mdea_scale_policy=="not_mdea" and x.mdea_scale_qualification_basis is None,"MDEA fields unused for MEA/DEA")
99
+ if c==.04:
100
+ ensure(x.four_percent_boundary_policy is not None and x.four_percent_qualification_basis is not None and bool(x.four_percent_qualification_basis.strip()),"Exact4wt% HSAS requires source boundary policy and basis")
101
+ row="middle" if x.four_percent_boundary_policy=="basic_data_middle_band" else "high"
102
+ else:
103
+ ensure(x.four_percent_boundary_policy is None and x.four_percent_qualification_basis is None,"Four-percent boundary policy applies only at4wt%")
104
+ row="low" if c<=.02 else "middle" if c<.04 else "high"
105
+ return {"contaminant_row":row,"printed_hsas_row_mass_fraction":((.005,.0225,.04) if x.amine_type=="mdea" else (.02,.03,.04))[("low","middle","high").index(row)]}
106
+
107
+
108
+ class AmineStrength(Model):
109
+ amine_type: Amine
110
+ amine_mass_fraction: Fraction
111
+
112
+
113
+ @calculation("damage/amine-strength-factor", "Carbon-steel amine concentration multiplier", AmineStrength,
114
+ ["Part2 Table2.B.8.4; PDF p388"],limitations=[
115
+ "MEA<=20wt%:1,21..25:1.5,>25:2;DEA<=30:1,31..40:1.2,>40:1.5;MDEA<=50:1 only. Continuous20..21 and30..31 gaps rejected, no rounding/interpolation. MDEA>50 not supplied. Positive active process solution concentration required. Carbon-table factor, not extra stainless multiplier or concentration derating credit."])
116
+ def strength_factor(x):
117
+ c=x.amine_mass_fraction
118
+ ensure(c>0,"Amine concentration must be positive")
119
+ if x.amine_type=="mea":
120
+ ensure(c<=.2 or c>=.21,"MEA20..21wt% multiplier gap")
121
+ f=1. if c<=.2 else 1.5 if c<=.25 else 2.
122
+ elif x.amine_type=="dea":
123
+ ensure(c<=.3 or c>=.31,"DEA30..31wt% multiplier gap")
124
+ f=1. if c<=.3 else 1.2 if c<=.4 else 1.5
125
+ else:
126
+ ensure(c<=.5,"No MDEA strength factor above50wt%")
127
+ f=1.
128
+ return {"amine_strength_factor":f}
129
+
130
+
131
+ class AmineCarbonBaseRate(Model):
132
+ table_family: Literal["mea_dea","mdea"]
133
+ loading: AmineLoadingSelection
134
+ contaminant_row: Literal["low","middle","high"]
135
+ maximum_temperature_k: Positive
136
+ velocity_m_s: NonNegative
137
+ unit_basis: Units
138
+ lookup_policy: Lookup
139
+
140
+
141
+ @calculation("damage/amine-carbon-base-rate", "Carbon-steel amine loading temperature contamination velocity tables", AmineCarbonBaseRate,
142
+ ["Part2 Tables2.B.8.2-8.3M; PDF pp384-387"],limitations=[
143
+ "1152physical source cells:2families x2units x8loading rows x3contamination rows x6temperatures x2velocity. Base rates before strength factor. Lean velocity low<=6.1m/s or20ft/s,rich<=1.5m/s or5ft/s; equality low, no velocity interpolation. Rich loading/temperature interpolation explicit, not across lean gap. Contaminant row is source table coordinate; process classification independent API. Unit differences preserved, temperature outside table rejected. No local evaporation/metal-temperature prediction, film transient or automatic amineSCC."])
144
+ def carbon_base_rate(x):
145
+ selected=loading_selection(x.loading)
146
+ lean=selected["table_loading_regime"]=="lean"
147
+ threshold=(6.1 if lean else 1.5) if x.unit_basis=="printed_si" else (20 if lean else 5)*.3048
148
+ high=x.velocity_m_s>threshold
149
+ temps=tuple(t+273.15 for t in (88,93,104,116,127,132)) if x.unit_basis=="printed_si" else tuple((t-32)*5/9+273.15 for t in (190,200,220,240,260,270))
150
+ a,b,v=_bracket(temps,x.maximum_temperature_k)
151
+ i,j,w=selected["lower_loading_row_index"],selected["upper_loading_row_index"],selected["loading_interpolation_weight"]
152
+ ensure(x.lookup_policy!="exact_nodes_only" or (a==b and i==j),"Exact amine table policy does not interpolate")
153
+ data=TABLES[x.table_family+("_si" if x.unit_basis=="printed_si" else "_us")]
154
+ row=("low","middle","high").index(x.contaminant_row)
155
+ def cell(l,t):
156
+ return data[3*l+row][2*t+int(high)]
157
+ native=max(cell(l,t) for l,t in product((i,j),(a,b))) if x.lookup_policy=="maximum_bracketing_value" else (1-w)*((1-v)*cell(i,a)+v*cell(i,b))+w*((1-v)*cell(j,a)+v*cell(j,b))
158
+ return {**selected,"base_corrosion_rate_m_year":native*(.001 if x.unit_basis=="printed_si" else .0000254),
159
+ "velocity_band":"high" if high else "low","velocity_threshold_m_s":threshold,
160
+ "source_table_id":"2.B.8."+("2" if x.table_family=="mea_dea" else "3")+("M" if x.unit_basis=="printed_si" else "")}
161
+
162
+
163
+ class AmineStainlessRate(Model):
164
+ acid_gas_loading_mol_mol: NonNegative
165
+ maximum_temperature_k: Positive
166
+ unit_basis: Units
167
+ lookup_policy: Lookup
168
+ low_loading_policy: Literal["reject_below_0p1","qualified_first_row"]
169
+ low_loading_qualification_basis: str | None = None
170
+ normal_service_conditions_qualified: bool
171
+ qualification_basis: str = Field(min_length=1)
172
+
173
+
174
+ @calculation("damage/amine-stainless-rate", "Type300 stainless amine loading corrosion table", AmineStainlessRate,
175
+ ["Part2 Table2.B.8.5,§8.3,Figure8.1; PDF pp382,388-389"],limitations=[
176
+ "8loading x2unit cells,temperature<=149C or300F explicitly selected. Below0.1loading not printed: reject or qualified first-row screening value flagged, not guaranteed bound. No loading>0.7 or high-temperature extrapolation. Normal service qualification required: source says extreme concentration/temperature/degradation can cause up to200x, not fixed multiplier or guaranteed upper bound. No carbon strength factor on stainless and no inferred HSAS/velocity modifier. Source figure Type300 scope; not all chromium/nickel/copper alloys."])
177
+ def stainless_rate(x):
178
+ _qualified(x.qualification_basis,"Stainless material and normal service qualification required")
179
+ ensure(x.normal_service_conditions_qualified,"Extreme stainless conditions require separate qualified corrosion assessment; no automatic200x factor")
180
+ q=x.low_loading_policy=="qualified_first_row"
181
+ ensure((x.low_loading_qualification_basis is not None and bool(x.low_loading_qualification_basis.strip())) if q else x.low_loading_qualification_basis is None,"Stainless low-loading policy and basis must agree")
182
+ threshold=149+273.15 if x.unit_basis=="printed_si" else (300-32)*5/9+273.15
183
+ ensure(x.maximum_temperature_k<=threshold,"Above stainless amine table temperature limit")
184
+ below=x.acid_gas_loading_mol_mol<.1
185
+ ensure(not below or q,"Stainless loading below first printed0.1 node")
186
+ data=TABLES["stainless"]
187
+ a,b,w=_bracket(tuple(r[0] for r in data),max(x.acid_gas_loading_mol_mol,.1))
188
+ ensure(x.lookup_policy!="exact_nodes_only" or a==b,"Exact stainless table policy does not interpolate")
189
+ col=1 if x.unit_basis=="printed_si" else 2
190
+ native=max(data[a][col],data[b][col]) if x.lookup_policy=="maximum_bracketing_value" else (1-w)*data[a][col]+w*data[b][col]
191
+ return {"corrosion_rate_m_year":native*(.001 if x.unit_basis=="printed_si" else .0000254),"low_loading_first_row_used":below,"source_table_id":"2.B.8.5"}
192
+
193
+
194
+ class AmineCarbonService(Model):
195
+ method: Literal["carbon_table"]
196
+ material: Literal["carbon_steel","low_alloy_steel"]
197
+ low_alloy_qualification_basis: str | None = None
198
+ solution: AmineStrength
199
+ contaminants: AmineContaminantSelection
200
+ loading: AmineLoadingSelection
201
+ maximum_temperature_k: Positive
202
+ velocity_m_s: NonNegative
203
+ unit_basis: Units
204
+ lookup_policy: Lookup
205
+ classification_qualification_basis: str = Field(min_length=1)
206
+ qualification_basis: str = Field(min_length=1)
207
+
208
+
209
+ class AmineStainlessService(AmineStainlessRate):
210
+ method: Literal["type300_stainless_table"]
211
+ amine_type: Amine
212
+
213
+
214
+ class AmineAssessment(Model):
215
+ amine_service_present: bool
216
+ applicability_basis: str = Field(min_length=1)
217
+ rate_model: AmineCarbonService | AmineStainlessService | None = Field(default=None,discriminator="method")
218
+
219
+
220
+ @calculation("damage/amine-corrosion-assessment", "Qualified amine chemistry and material corrosion assessment", AmineAssessment,
221
+ ["Part2 Annex2.B §8,Tables8.1-8.5,Figure8.1,Table1.2; PDF pp323,382-389"],origin="api581_composition_with_explicit_source_policies",limitations=[
222
+ "MEA/DEA/MDEA chemical gas-treating amines only; no physical solvent/DIPA extension. Same amine in strength/contaminants required. CS table and qualified figurelow-alloy branch use base*strength once;Type300 stainless uses normal-service table without carbon multiplier. CS classification explicitly qualified to rate-table velocity columns: overviewstrict boundaries/body6.01vs6.1/1.52vs1.5 differ; equality follows rate-table low, not unqualified universal limit. Stainless source type not inferred without velocity:None. No-service0 specific, not totalDF0. Maximum local metal temperature/amine concentration, active loading and contaminant applicability external; no amineSCC."])
223
+ def assessment(x):
224
+ _qualified(x.applicability_basis,"Amine service and process applicability qualification required")
225
+ if not x.amine_service_present:
226
+ ensure(x.rate_model is None,"No-amine branch cannot carry unused rate model")
227
+ return {"corrosion_rate_m_year":0.,"base_corrosion_rate_m_year":None,"amine_strength_factor":None,"contaminant_row":None,
228
+ "printed_hsas_row_mass_fraction":None,"table_loading_regime":None,"loading_gap_mapping_used":False,"velocity_band":None,
229
+ "velocity_threshold_m_s":None,"low_loading_first_row_used":False,"source_table_id":None,"source_thinning_type":None,"calculation_branch":"no_amine_service"}
230
+ ensure(x.rate_model is not None,"Exposed amine service requires qualified rate model")
231
+ m=x.rate_model
232
+ _qualified(m.qualification_basis,"Maximum local conditions and material qualification required")
233
+ if m.method=="carbon_table":
234
+ low=m.material=="low_alloy_steel"
235
+ ensure((m.low_alloy_qualification_basis is not None and bool(m.low_alloy_qualification_basis.strip())) if low else m.low_alloy_qualification_basis is None,"Figure low-alloy/table carbon-steel difference needs qualified material basis")
236
+ ensure(m.solution.amine_type==m.contaminants.amine_type,"Solution and contaminant amine types must match")
237
+ _qualified(m.classification_qualification_basis,"Explicit rate-table velocity classification and inspection basis required")
238
+ contaminant=contaminant_row(m.contaminants)
239
+ base=carbon_base_rate(AmineCarbonBaseRate(table_family="mdea" if m.solution.amine_type=="mdea" else "mea_dea",loading=m.loading,
240
+ contaminant_row=contaminant["contaminant_row"],maximum_temperature_k=m.maximum_temperature_k,velocity_m_s=m.velocity_m_s,unit_basis=m.unit_basis,lookup_policy=m.lookup_policy))
241
+ factor=strength_factor(m.solution)["amine_strength_factor"]
242
+ return {"corrosion_rate_m_year":base["base_corrosion_rate_m_year"]*factor,"base_corrosion_rate_m_year":base["base_corrosion_rate_m_year"],
243
+ "amine_strength_factor":factor,**contaminant,"table_loading_regime":base["table_loading_regime"],"loading_gap_mapping_used":base["loading_gap_mapping_used"],
244
+ "velocity_band":base["velocity_band"],"velocity_threshold_m_s":base["velocity_threshold_m_s"],"low_loading_first_row_used":False,
245
+ "source_table_id":base["source_table_id"],"source_thinning_type":"general" if base["velocity_band"]=="low" else "local","calculation_branch":"carbon_amine_model"}
246
+ result=stainless_rate(m)
247
+ return {**result,"base_corrosion_rate_m_year":result["corrosion_rate_m_year"],"amine_strength_factor":None,"contaminant_row":None,
248
+ "printed_hsas_row_mass_fraction":None,"table_loading_regime":None,"loading_gap_mapping_used":False,"velocity_band":None,
249
+ "velocity_threshold_m_s":None,"source_thinning_type":None,"calculation_branch":"type300_stainless_model"}
250
+
251
+
252
+ class AmineThinning(Model):
253
+ corrosion: AmineAssessment
254
+ assessment_distribution: Literal["general","local"]
255
+ distribution_qualification_basis: str = Field(min_length=1)
256
+ starting_thickness_m: Positive
257
+ elapsed_years: NonNegative
258
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
259
+ inspections: Posterior
260
+ conditions: ThinningConditions
261
+ qualification_basis: str = Field(min_length=1)
262
+
263
+
264
+ @calculation("damage/amine-thinning-assessment", "Amine rate through qualified non-AST unclad thinning DF", AmineThinning,
265
+ ["Part2 Annex2.B §8,Table1.2; §4.5.7,Table4.8"],origin="api581_composition_with_explicit_source_policies",limitations=[
266
+ "Same final base*strength rate drives wall loss; amine monitoring row must match table velocity band when carbon model supplies one. Monitoring regime basis and actual inspection distribution qualified explicitly. Stainless has no rate-table velocity classification; owner monitoring regime remains separately qualified. No-amine0 does not bypass generalDF floor/uncertainty. Non-AST unclad only; no amineSCC, process equilibrium, code/FFS or all-mechanism risk inference."])
267
+ def thinning_assessment(x):
268
+ _qualified(x.distribution_qualification_basis,"Actual general/local distribution and inspection qualification required")
269
+ corrosion=assessment(x.corrosion)
270
+ monitor=x.conditions.monitoring.row
271
+ ensure(monitor.mechanism=="amine","Amine corrosion requires amine monitoring row")
272
+ if corrosion["velocity_band"] is not None:
273
+ ensure(monitor.amine_velocity_regime==corrosion["velocity_band"],"Monitoring and corrosion must use same qualified amine velocity band")
274
+ result=qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
275
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
276
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
277
+ return {**corrosion,**result,"assessment_distribution":x.assessment_distribution}