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,146 @@
1
+ """Cladding/overlay depletion and code-thickness thinning strength ratio, 2016."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, Positive, NonNegative, Fraction, calculation, ensure, finite_tree
6
+ from .thinning import (WallLoss, wall_loss, StrengthRatio, strength_ratio, Posterior, posterior,
7
+ ThinningBase, thinning_base, ThinningAdjustment, thinning_adjust)
8
+
9
+
10
+ class CladdingLife(Model):
11
+ starting_total_thickness_m: Positive
12
+ original_base_material_thickness_m: Positive
13
+ cladding_corrosion_rate_m_year: NonNegative
14
+
15
+
16
+ @calculation("damage/cladding-remaining-life", "Cladding or weld overlay depletion time from thickness baseline", CladdingLife,
17
+ ["Part2 §4.5.7 STEP4,eq(2.11); PDF p152"],origin="api581_equation_with_explicit_zero_rate_extension",limitations=[
18
+ "Remaining cladding=max(starting total thickness-original base thickness,0); depletion age divides this by cladding corrosion rate. Base reference thickness is owner-qualified, not auto-inferred from a measured total. No coating/lining equivalence. Zero-rate extension: intact noncorroding cladding has null depletion age, already-depleted cladding has age0; no numeric infinity or division by zero."])
19
+ def cladding_life(x):
20
+ remaining = max(x.starting_total_thickness_m-x.original_base_material_thickness_m,0.)
21
+ if remaining == 0:
22
+ age, state = 0., "already_depleted"
23
+ elif x.cladding_corrosion_rate_m_year == 0:
24
+ age, state = None, "noncorroding_present"
25
+ else:
26
+ age, state = remaining/x.cladding_corrosion_rate_m_year, "finite_depletion"
27
+ ensure(math.isfinite(age) and age > 0,"Cladding depletion age outside finite positive range","numerical_failure")
28
+ return {"remaining_cladding_at_baseline_m":remaining,"cladding_depletion_age_years":age,"cladding_baseline_state":state}
29
+
30
+
31
+ class CladWall(CladdingLife):
32
+ base_corrosion_rate_m_year: NonNegative
33
+ elapsed_years: NonNegative
34
+
35
+
36
+ @calculation("damage/thinning-clad-wall-loss", "Sequential cladding and base-material fractional wall loss", CladWall,
37
+ ["Part2 §4.5.7 STEP6(2)-(4),eqs(2.13)-(2.15); PDF pp152-153"],origin="api581_equations_with_explicit_zero_rate_extension",limitations=[
38
+ "Cladding/overlay is assumed to corrode before base material, as source procedure states. Before depletion: cladding rate*elapsed. At/after depletion: cladding rate*depletion age+base rate*(elapsed-depletion age). Already depleted uses base-only eq2.13. Divide by starting TOTAL thickness. Constant rates from same qualified baseline; no simultaneous backside/external corrosion, debonding, coating life or variable process history. Noncorroding intact cladding uses explicit zero-rate analytic extension. Predicted penetration is diagnostic, not FFS acceptance."])
39
+ def clad_wall_loss(x):
40
+ life = cladding_life(x)
41
+ age = life["cladding_depletion_age_years"]
42
+ if age == 0:
43
+ clad_loss, base_age, branch = 0., x.elapsed_years, "base_only_eq2_13"
44
+ elif age is None:
45
+ clad_loss, base_age, branch = 0., 0., "noncorroding_cladding_extension"
46
+ elif x.elapsed_years < age:
47
+ clad_loss, base_age, branch = x.cladding_corrosion_rate_m_year*x.elapsed_years, 0., "cladding_only_eq2_14"
48
+ else:
49
+ # Numerically exact boundary amount from eq2.11 avoids multiplying a rounded age back by rate.
50
+ clad_loss, base_age, branch = life["remaining_cladding_at_baseline_m"], x.elapsed_years-age, "cladding_then_base_eq2_15"
51
+ base_loss = x.base_corrosion_rate_m_year*base_age
52
+ loss = clad_loss+base_loss
53
+ remaining = x.starting_total_thickness_m-loss
54
+ fraction = loss/x.starting_total_thickness_m
55
+ finite_tree([clad_loss,base_age,base_loss,loss,remaining,fraction])
56
+ return {**life,"wall_loss_fraction":fraction,"cladding_wall_loss_m":clad_loss,"base_material_wall_loss_m":base_loss,
57
+ "base_material_exposure_years":base_age,"predicted_remaining_thickness_m":remaining,
58
+ "predicted_penetration":remaining <= 0,"wall_loss_branch":branch}
59
+
60
+
61
+ class DesignStrengthRatio(Model):
62
+ allowable_stress_pa: Positive
63
+ weld_efficiency: Fraction = Field(gt=0)
64
+ flow_stress_pa: Positive
65
+ starting_thickness_m: Positive
66
+ minimum_required_thickness_m: NonNegative
67
+ minimum_structural_thickness_m: NonNegative
68
+ design_thickness_basis: str = Field(min_length=1)
69
+
70
+
71
+ @calculation("damage/thinning-design-strength-ratio", "Thinning strength ratio from required and structural thickness", DesignStrengthRatio,
72
+ ["Part2 §4.5.7 STEP5,STEP8,eq(2.17); PDF pp152-154"],limitations=[
73
+ "SR=(allowable stress*weld efficiency/flow stress)*max(code minimum,structural minimum)/starting thickness. Owner supplies qualified code/design thickness including governing internal/external pressure and structural loads. This API does not calculate those code minima or certify buckling strength. Zero explicitly denotes a non-governing term; both minima cannot be zero. Flow stress, allowable stress and efficiency must have consistent design-temperature/material basis; no automatic cladding structural-strength credit."])
74
+ def design_strength_ratio(x):
75
+ ensure(bool(x.design_thickness_basis.strip()),"Code/design thickness basis must not be blank")
76
+ governing = max(x.minimum_required_thickness_m,x.minimum_structural_thickness_m)
77
+ ensure(governing > 0,"At least one qualified minimum thickness must be positive")
78
+ ratio = x.allowable_stress_pa/x.flow_stress_pa*x.weld_efficiency*(governing/x.starting_thickness_m)
79
+ ensure(math.isfinite(ratio) and ratio > 0,"Design strength ratio outside finite positive range","numerical_failure")
80
+ return {"strength_ratio":ratio,"governing_minimum_thickness_m":governing,
81
+ "governing_thickness_source":"equal_minima" if x.minimum_required_thickness_m == x.minimum_structural_thickness_m else
82
+ "structural_minimum" if x.minimum_structural_thickness_m > x.minimum_required_thickness_m else "required_minimum"}
83
+
84
+
85
+ class PressureStrength(StrengthRatio):
86
+ method: Literal["internal_pressure_eq2_18"]
87
+
88
+
89
+ class CodeStrength(DesignStrengthRatio):
90
+ method: Literal["design_thickness_eq2_17"]
91
+
92
+
93
+ class CladThinning(Model):
94
+ assessment_scope: Literal["non_ast_pressure_vessel_sequential_cladding"]
95
+ qualification_basis: str = Field(min_length=1)
96
+ wall: CladWall
97
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
98
+ inspections: Posterior
99
+ injection_point_factor: Positive
100
+ deadleg_factor: Positive
101
+ online_monitoring_factor: Positive
102
+
103
+
104
+ @calculation("damage/thinning-clad", "Compose clad non-AST thinning with selected strength-ratio method", CladThinning,
105
+ ["Part2 §4.5.7,eqs(2.11),(2.13)-(2.23); PDF pp152-156"],origin="api581_composition_with_explicit_zero_rate_extension",limitations=[
106
+ "Explicit non-AST pressure-vessel sequential-cladding scope; owner qualifies baseline, reference base thickness, corrosion rates, inspection eligibility, monitoring and applicability. Select internal-hoop-pressure eq2.18 only when governing; structural/external-pressure cases require qualified code-minimum eq2.17. Same total starting thickness must drive wall-loss and strength equations. Standard three damage states and conservative default COVs are reused. AST welded/maintenance/settlement factors1; not AST-bottom table, lining protection, simultaneous corrosion or all-mechanism DF. Grade counts must be eligible since same baseline. Zero-rate intact cladding extension is explicit in branch output."])
107
+ def clad_thinning(x):
108
+ ensure(bool(x.qualification_basis.strip()),"Cladding/baseline applicability basis must not be blank")
109
+ ensure(x.wall.starting_total_thickness_m == x.strength.starting_thickness_m,"Wall-loss and strength total starting thickness must agree")
110
+ wall = clad_wall_loss(x.wall)
111
+ strength = design_strength_ratio(x.strength) if x.strength.method == "design_thickness_eq2_17" else strength_ratio(x.strength)
112
+ finite_tree(strength)
113
+ post = posterior(x.inspections)
114
+ base = thinning_base(ThinningBase(wall_loss_fraction=wall["wall_loss_fraction"],strength_ratio=strength["strength_ratio"],posterior=post["posterior_probabilities"]))
115
+ final = thinning_adjust(ThinningAdjustment(base_damage_factor=base["base_damage_factor"],injection_point_factor=x.injection_point_factor,
116
+ deadleg_factor=x.deadleg_factor,online_monitoring_factor=x.online_monitoring_factor,welded_construction_factor=1.,maintenance_factor=1.,settlement_factor=1.))
117
+ finite_tree(final)
118
+ return {**wall,"strength_ratio":strength["strength_ratio"],"strength_method":x.strength.method,**post,**base,**final}
119
+
120
+
121
+ class UncladDesignThinning(Model):
122
+ assessment_scope: Literal["non_ast_unclad_design_thickness_governed"]
123
+ qualification_basis: str = Field(min_length=1)
124
+ wall: WallLoss
125
+ strength: DesignStrengthRatio
126
+ inspections: Posterior
127
+ injection_point_factor: Positive
128
+ deadleg_factor: Positive
129
+ online_monitoring_factor: Positive
130
+
131
+
132
+ @calculation("damage/thinning-unclad-design", "Compose unclad thinning with code and structural minimum strength ratio", UncladDesignThinning,
133
+ ["Part2 §4.5.7,eqs(2.13),(2.17),(2.19)-(2.23); PDF pp153-156"],origin="api581_composition",limitations=[
134
+ "Non-AST unclad component with owner-qualified governing code/structural minima. Eq2.17 replaces internal-hoop-only eq2.18; source code minima and load applicability are external inputs, not computed FFS. Starting thickness must agree. No AST construction factors, cladding/lining credit, automatic corrosion-rate selection or all-mechanism DF. Inspection counts and monitoring factors require same baseline and applicable mechanism qualification."])
135
+ def unclad_design(x):
136
+ ensure(bool(x.qualification_basis.strip()),"Applicability qualification basis must not be blank")
137
+ ensure(x.wall.starting_thickness_m == x.strength.starting_thickness_m,"Wall-loss and strength starting thickness must agree")
138
+ wall = wall_loss(x.wall)
139
+ finite_tree(wall)
140
+ strength = design_strength_ratio(x.strength)
141
+ post = posterior(x.inspections)
142
+ base = thinning_base(ThinningBase(wall_loss_fraction=wall["wall_loss_fraction"],strength_ratio=strength["strength_ratio"],posterior=post["posterior_probabilities"]))
143
+ final = thinning_adjust(ThinningAdjustment(base_damage_factor=base["base_damage_factor"],injection_point_factor=x.injection_point_factor,
144
+ deadleg_factor=x.deadleg_factor,online_monitoring_factor=x.online_monitoring_factor,welded_construction_factor=1.,maintenance_factor=1.,settlement_factor=1.))
145
+ finite_tree(final)
146
+ return {**wall,**strength,**post,**base,**final}
@@ -0,0 +1,55 @@
1
+ """CLSCC potential wet-chloride and printed-temperature screening."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, Positive, NonNegative, calculation, ensure
5
+ from .scc_mechanisms import ClsccSusceptibility, clscc_susceptibility, Assessment, InspectionCounts, assess
6
+
7
+ class ClsccScreen(Model):
8
+ material: Literal['austenitic_stainless_steel','other_material']
9
+ actual_or_potential_wet_chloride_exposure: bool
10
+ operating_temperature_k: Positive
11
+ temperature_basis: Literal['printed_si','printed_us_converted']
12
+ exposure_basis: str = Field(min_length=1)
13
+
14
+
15
+ @calculation('damage/clscc-screening','CLSCC material/potential wet-chloride and operating-temperature screen',ClsccScreen,
16
+ ['Part2 §12.3; PDF p207'],limitations=[
17
+ 'Austenitic stainless steel, actual/potential chlorides AND water and operating temperature strictly above selected38C or100F threshold. Paired source thresholds differ; unit basis explicit. Exposure qualification includes upsets, retained hydrotest water, cooling tower drift and insulation/process conditions. No concentration cutoff, bulk-to-local enrichment, nickel-content exemption, material immunity or DF0 inferred. A negative screen does not invalidate observed cracks or replace FFS.'])
18
+ def clscc_screening(x):
19
+ ensure(bool(x.exposure_basis.strip()),'Qualified material and actual/potential wet-chloride exposure review required')
20
+ threshold=311.15 if x.temperature_basis=='printed_si' else (100.-32)*5/9+273.15
21
+ material=x.material=='austenitic_stainless_steel'
22
+ hot=x.operating_temperature_k>threshold
23
+ return {'evaluate_clscc':material and x.actual_or_potential_wet_chloride_exposure and hot,
24
+ 'material_in_scope':material,'temperature_screen_passed':hot,'screening_temperature_threshold_k':threshold,
25
+ 'selected_temperature_basis':x.temperature_basis}
26
+
27
+
28
+ class QualifiedClsccAssessment(Model):
29
+ screening: ClsccScreen
30
+ ph_over_10: bool
31
+ chloride_band_ppm: Literal['1-10','11-100','101-1000','>1000']
32
+ chemistry_basis: str = Field(min_length=1)
33
+ confirmed_cracking: bool
34
+ unrepaired_cracks: bool
35
+ years_since_qualifying_inspection: NonNegative
36
+ inspections: InspectionCounts
37
+ inspection_basis: str = Field(min_length=1)
38
+
39
+
40
+ @calculation('damage/clscc-qualified-assessment','CLSCC wet-chloride screen through SI susceptibility and inspection DF',QualifiedClsccAssessment,
41
+ ['Part2 §§12.3,12.6.3,Tables12.2M/12.3,Table6.3; eq(2.31); PDF pp207-211'],
42
+ origin='qualified_clscc_screen_si_table_to_df',limitations=[
43
+ 'Uses the selected printed SI Table12.2M or US Table12.2 and the same operating temperature for susceptibility. Qualified chloride ion band and pH>10 classification supplied; no bulk-to-local chloride enrichment or interpolation through concentration gaps. No synthetic DF0 on screen-out. Confirmed cracking forces high; unrepaired cracks require FFS. Inspection period/coverage are mechanism-specific external qualifications. Source pH>10/93C overlap in the SI table and existing equivalent count support are retained; no whole-asset risk.'])
44
+ def clscc_qualified_assessment(x):
45
+ ensure(not x.unrepaired_cracks,'Unrepaired CLSCC requires separate FFS evaluation','ffs_required')
46
+ ensure(bool(x.chemistry_basis.strip()) and bool(x.inspection_basis.strip()),'Qualified local chloride/pH and CLSCC inspection bases required')
47
+ screen=clscc_screening(x.screening)
48
+ ensure(screen['evaluate_clscc'],'CLSCC material/wet-chloride/temperature screen not met','clscc_outside_screen')
49
+ selected=clscc_susceptibility(ClsccSusceptibility(
50
+ temperature_k=x.screening.operating_temperature_k,
51
+ temperature_basis=x.screening.temperature_basis,
52
+ ph_over_10=x.ph_over_10, chloride_band_ppm=x.chloride_band_ppm))
53
+ result=assess('clscc',Assessment(susceptibility=selected['susceptibility'],confirmed_cracking=x.confirmed_cracking,
54
+ unrepaired_cracks=False,years_since_qualifying_inspection=x.years_since_qualifying_inspection,inspections=x.inspections))
55
+ return {**screen,**selected,**result}
@@ -0,0 +1,313 @@
1
+ """2016 Annex2.B section13 carbonic-acid corrosion equations and table."""
2
+ from bisect import bisect_left
3
+ import math
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure, finite_tree
7
+ from .thinning import Posterior, WallLoss
8
+ from .clad_thinning import PressureStrength, CodeStrength
9
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
10
+
11
+ PSI_PA = 6894.757293168
12
+ _TEMPS = tuple(c+273.15 for c in range(20,151,10))
13
+ _PH = (3.5,4.,4.5,5.,5.5,6.,6.5)
14
+ _TPH = ((6,5.45,4.9,3.72,2.55,1.55,.72),(8.52,7.77,7.02,5.16,3.4,2.,.91),
15
+ (10.98,10.06,9.13,6.49,4.08,2.3,1.02),(11.92,10.96,10.01,6.86,4.1,2.2,.94),
16
+ (12.83,11.86,10.89,7.18,4.05,2.03,.84),(13.42,12.01,10.6,6.58,3.61,1.86,.87),
17
+ (13.93,12.12,10.31,6.01,3.2,1.7,.9),(9.37,7.91,6.45,2.44,.82,.49,.32),
18
+ (9.23,8.04,6.38,2.19,.94,.62,.42),(8.96,8.09,6.22,1.87,1.07,.77,.53),
19
+ (8.55,8.06,5.98,1.48,1.2,.92,.65),(7.38,6.39,3.98,.96,.8,.63,.47),
20
+ (6.26,4.91,2.31,.53,.46,.39,.32),(5.2,3.62,.98,.19,.19,.19,.19))
21
+
22
+
23
+ class CO2Humidity(Model):
24
+ water_mass_fraction: Fraction = Field(lt=1)
25
+ total_pressure_pa_abs: Positive
26
+ water_saturation_pressure_pa_abs: Positive
27
+
28
+
29
+ @calculation("damage/co2-relative-humidity", "CO2-service source relative-humidity approximation", CO2Humidity,
30
+ ["Part2 eq(2.B.24); PDF pp423-424"],limitations=[
31
+ "x=water mass fraction/(1-water mass fraction); RH=x*P/Psat/(.622+x). Source uses air-based MW ratio.622 while discussing natural gas; preserve literal approximation, not an EOS or general natural-gas dew-point calculation. RH may exceed1, not clamped. Strictly greater than1 indicates source condensation condition; equality reported saturated, not automatically free liquid water."])
32
+ def humidity(x):
33
+ ratio = x.water_mass_fraction/(1-x.water_mass_fraction)
34
+ rh = ratio/(.622+ratio)*(x.total_pressure_pa_abs/x.water_saturation_pressure_pa_abs)
35
+ finite_tree([ratio,rh])
36
+ return {"water_dry_gas_mass_ratio":ratio,"relative_humidity_fraction":rh,"supersaturated":rh > 1,"saturated":rh == 1}
37
+
38
+
39
+ class CO2DewPoint(Model):
40
+ water_mass_fraction: float = Field(gt=0,le=.3,strict=True,allow_inf_nan=False)
41
+ total_pressure_pa_abs: Positive
42
+
43
+
44
+ @calculation("damage/co2-dew-point", "Approximate source dew point for water-containing gas", CO2DewPoint,
45
+ ["Part2 eq(2.B.25),Table2.B.13.1; PDF pp424,427"],limitations=[
46
+ "log10(Td_F)=2.0866+.2088*log10(water mass fraction)+.2242*log10(P_psia). Source water0..30wt%; zero has undefined logarithm and is rejected, not a fabricated dew point. ReturnsK; guidance should not be assumed accurate within±10F. No automatic dry-service screening from this approximate value."])
47
+ def dew_point(x):
48
+ fahrenheit = 10**(2.0866+.2088*math.log10(x.water_mass_fraction)+.2242*math.log10(x.total_pressure_pa_abs/PSI_PA))
49
+ return {"dew_point_temperature_k":(fahrenheit-32)*5/9+273.15}
50
+
51
+
52
+ class CO2TemperaturePH(Model):
53
+ temperature_k: Positive
54
+ ph: float = Field(ge=3.5,le=6.5,strict=True,allow_inf_nan=False)
55
+ lookup_policy: Literal["exact_nodes_only","bilinear"]
56
+ jump_policy: Literal["reject_cross_80_90","qualified_linear_across_jump"]
57
+ jump_qualification_basis: str | None = None
58
+
59
+
60
+ def _bracket(nodes,value):
61
+ value = next((v for v in nodes if math.isclose(v,value,rel_tol=0,abs_tol=1e-10)),value)
62
+ ensure(nodes[0] <= value <= nodes[-1],"Outside temperature-pH table; no extrapolation")
63
+ i = bisect_left(nodes,value)
64
+ return (i,i,0.) if value == nodes[i] else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
65
+
66
+
67
+ @calculation("damage/co2-temperature-ph-factor", "CO2 corrosion temperature-pH table function", CO2TemperaturePH,
68
+ ["Part2 Tables2.B.13.2/13.2M; PDF p428"],limitations=[
69
+ "All98 cells,20..150C andpH3.5..6.5; exact or explicitly selected bilinear interpolation. Detailed source scope says temperatures above140C not considered: standalone table includes150C printed row, integrated model rejects>140C. Sharp80..90C change is not silently smoothed: interpolation across that interval requires explicit qualified policy/basis. No clamped/extrapolated pH or temperature; table function is not corrosion rate alone."])
70
+ def temperature_ph(x):
71
+ qualified = x.jump_policy == "qualified_linear_across_jump"
72
+ ensure((x.jump_qualification_basis is not None and bool(x.jump_qualification_basis.strip())) if qualified else x.jump_qualification_basis is None,"Jump policy and qualification basis must agree")
73
+ a,b,wt = _bracket(_TEMPS,x.temperature_k)
74
+ c,d,wp = _bracket(_PH,x.ph)
75
+ ensure(x.lookup_policy == "bilinear" or (a == b and c == d),"Exact-node policy does not interpolate")
76
+ crossing = a == 6 and b == 7
77
+ ensure(not crossing or qualified,"Interpolation across sharp80-90C change requires qualified policy","co2_temperature_jump")
78
+ low = _TPH[a][c]+wp*(_TPH[a][d]-_TPH[a][c])
79
+ high = _TPH[b][c]+wp*(_TPH[b][d]-_TPH[b][c])
80
+ return {"temperature_ph_factor":low+wt*(high-low),"sharp_temperature_interval_interpolated":crossing}
81
+
82
+
83
+ class CO2PHEstimate(Model):
84
+ temperature_k: Positive
85
+ co2_partial_pressure_pa_abs: Positive
86
+ water_case: Literal["condensate","ferrous_saturated","salinity_46_g_l"]
87
+ approximation_basis: str = Field(min_length=1)
88
+
89
+
90
+ @calculation("damage/co2-ph-estimate", "Carbonic-acid RBI pH approximation by water case", CO2PHEstimate,
91
+ ["Part2 eqs(2.B.27)-(2.B.29); PDF p424"],limitations=[
92
+ "Uses temperatureF and CO2 partial pressurepsi as printed; publicK/Pa converted first. Supplied known pH preferred. Owner qualifies simple water/Fe++/saline case, negligible bicarbonate buffering and salinity limitations. Source saline case says46g/L but following paragraph cautions>45g/L: no automatic resolution or general electrolyte equilibrium model. PositiveF required for logarithm; result not forced into lookup range."])
93
+ def ph_estimate(x):
94
+ ensure(bool(x.approximation_basis.strip()),"pH approximation applicability basis required")
95
+ f = (x.temperature_k-273.15)*9/5+32
96
+ ensure(f > 0,"Printed pH approximation requires positive Fahrenheit logarithm argument")
97
+ a,b,c = {"condensate":(2.8686,.7931,.57),"ferrous_saturated":(2.5907,.8668,.49),"salinity_46_g_l":(2.7137,.8002,.57)}[x.water_case]
98
+ return {"estimated_ph":a+b*math.log10(f)-c*math.log10(x.co2_partial_pressure_pa_abs/PSI_PA)}
99
+
100
+
101
+ class CO2Fugacity(Model):
102
+ temperature_k: Positive
103
+ co2_partial_pressure_pa_abs: Positive
104
+ temperature_denominator_basis: Literal["printed_celsius_plus_273","kelvin_exact"]
105
+
106
+
107
+ @calculation("damage/co2-fugacity", "CO2 fugacity and coefficient from partial pressure", CO2Fugacity,
108
+ ["Part2 eqs(2.B.30)-(2.B.32); PDF p425"],limitations=[
109
+ "Literal log10(f_bar)=log10(p_bar)+min(250,p_bar)*(.0031-1.4/(T_C+273)). PublicPa/K; explicit printed273 versus exactK denominator basis, no hidden273.15 repair. Primitive evaluates printed250bar exponent cap; integrated model separately limits partial pressure4MPa perTable13.1. Not a general-mixture EOS, total-pressure substitution or phase-equilibrium calculation."])
110
+ def fugacity(x):
111
+ denominator = x.temperature_k-.15 if x.temperature_denominator_basis == "printed_celsius_plus_273" else x.temperature_k
112
+ ensure(denominator > 0,"Printed fugacity temperature denominator must be positive")
113
+ bar = x.co2_partial_pressure_pa_abs/1e5
114
+ alpha = 10**(min(250.,bar)*(.0031-1.4/denominator))
115
+ f = x.co2_partial_pressure_pa_abs*alpha
116
+ ensure(math.isfinite(alpha) and math.isfinite(f) and alpha > 0 and f > 0,"Fugacity outside finite positive range","numerical_failure")
117
+ return {"fugacity_pa":f,"fugacity_coefficient":alpha}
118
+
119
+
120
+ class CO2Reynolds(Model):
121
+ diameter_m: Positive
122
+ mixture_density_kg_m3: Positive
123
+ mixture_velocity_m_s: NonNegative
124
+ mixture_viscosity_pa_s: Positive
125
+
126
+
127
+ @calculation("damage/co2-reynolds", "Mixture Reynolds number for CO2 flow correlation", CO2Reynolds,
128
+ ["Part2 eq(2.B.35); PDF p425"],limitations=["D*rho*velocity/viscosity, all SI. Mixture properties externally qualified; no two-phase holdup/viscosity/density or gas-in-oil solubility model inferred."])
129
+ def reynolds(x):
130
+ value = x.diameter_m*x.mixture_density_kg_m3*x.mixture_velocity_m_s/x.mixture_viscosity_pa_s
131
+ finite_tree(value)
132
+ return {"reynolds_number":value}
133
+
134
+
135
+ class CO2Friction(Model):
136
+ reynolds_number: Positive
137
+ relative_roughness: Fraction
138
+
139
+
140
+ @calculation("damage/co2-friction-factor", "Source turbulent mixture friction factor", CO2Friction,
141
+ ["Part2 eq(2.B.34); PDF p425"],limitations=[
142
+ "f=.001375*[1+(20000*relative_roughness+1e6/Re)^.33]. Printed.33 exponent retained, not1/3. Valid only Re>2300; no laminar/transitional fallback or automatic Darcy/Fanning conversion."])
143
+ def friction(x):
144
+ ensure(x.reynolds_number > 2300,"Source turbulent correlation requiresRe>2300")
145
+ return {"friction_factor":.001375*(1+(20000*x.relative_roughness+1e6/x.reynolds_number)**.33)}
146
+
147
+
148
+ class CO2Shear(Model):
149
+ friction_factor: Positive
150
+ mixture_density_kg_m3: Positive
151
+ mixture_velocity_m_s: NonNegative
152
+
153
+
154
+ @calculation("damage/co2-shear-stress", "CO2 mixture wall shear stress", CO2Shear,
155
+ ["Part2 eq(2.B.33); PDF p425"],limitations=["RawS=f*rho*velocity^2/2; no hidden factor conversion. Separate corrosion base-rate API caps applied shear150Pa, preserves requested shear separately. Not CFD or two-phase property prediction."])
156
+ def shear(x):
157
+ value = x.friction_factor*x.mixture_density_kg_m3*x.mixture_velocity_m_s**2/2
158
+ finite_tree(value)
159
+ return {"shear_stress_pa":value}
160
+
161
+
162
+ class CO2BaseRate(Model):
163
+ temperature_ph_factor: Positive
164
+ fugacity_pa: Positive
165
+ shear_stress_pa: NonNegative
166
+
167
+
168
+ @calculation("damage/co2-base-rate", "CO2 carbonic-acid base corrosion equation", CO2BaseRate,
169
+ ["Part2 eq(2.B.26),§13.3.6; PDF pp424-425"],limitations=[
170
+ "CR_mm/y=f(T,pH)*f_bar^.62*(min(S,150)/19)^(.146+.0324*f_bar), literal exponent uses fugacity itself, not log(fugacity). PublicPa/m-year conversions explicit. Applied shear capped150Pa. Zero shear yields literal equation zero; this is not proof stagnant wet steel is safe or outside-mechanism corrosion absent. Positive numeric under/overflow rejected. No automatic applicability or pH-table screening in this primitive."])
171
+ def base_rate(x):
172
+ applied = min(x.shear_stress_pa,150.)
173
+ if applied == 0:
174
+ rate = 0.
175
+ else:
176
+ f = x.fugacity_pa/1e5
177
+ log_rate = math.log(x.temperature_ph_factor)+.62*math.log(f)+(.146+.0324*f)*math.log(applied/19)-math.log(1000)
178
+ rate = math.exp(log_rate)
179
+ ensure(rate > 0 and math.isfinite(rate),"CO2 rate outside finite positive range","numerical_failure")
180
+ return {"base_corrosion_rate_m_year":rate,"applied_shear_stress_pa":applied,"shear_cap_applied":x.shear_stress_pa > 150}
181
+
182
+
183
+ class CO2Glycol(Model):
184
+ composition_percent: float = Field(ge=0,le=100,strict=True,allow_inf_nan=False)
185
+ composition_basis: Literal["equation_g_percent","table_water_weight_percent"]
186
+
187
+
188
+ @calculation("damage/co2-glycol-factor", "CO2 glycol reduction with explicit composition basis", CO2Glycol,
189
+ ["Part2 eq(2.B.36),Table2.B.13.1; PDF pp426-427"],limitations=[
190
+ "Equation definesG glycol percent while data table requests water weight percent of glycol/water mix. Explicit basis either uses literalG orG=100-water%; no inference of arbitrary glycol-to-water mass ratio. Factor=max(.008,((100-G)/100)^1.6), algebraically equivalent to printed base10 logs. G100 uses continuous limiting value with source.008 floor, not log0. Absent glycol isG0,factor1."])
191
+ def glycol(x):
192
+ g = x.composition_percent if x.composition_basis == "equation_g_percent" else 100-x.composition_percent
193
+ raw = ((100-g)/100)**1.6
194
+ return {"glycol_factor":max(.008,raw),"glycol_floor_applied":raw < .008}
195
+
196
+
197
+ class CO2Reductions(Model):
198
+ glycol: CO2Glycol | None = None
199
+ inhibitor_factor: Fraction | None = None
200
+ inhibitor_effectiveness_basis: str | None = None
201
+
202
+
203
+ class CO2Adjustment(CO2Reductions):
204
+ base_corrosion_rate_m_year: NonNegative
205
+
206
+
207
+ @calculation("damage/co2-rate-adjustment", "Apply minimum of glycol and independently qualified inhibitor factors", CO2Adjustment,
208
+ ["Part2 eq(2.B.23),§13.3.7; PDF pp423,426"],limitations=[
209
+ "FinalCR=baseCR*min(Fglycol,Finhibitor), never product or additive credit. No glycol/inhibitor defaults factor1. Any supplied inhibitor factor requires independently determined effectiveness basis, including explicitly supplied1. Factor0 is retained if independently qualified; glycol.008 floor does not impose an invented floor on the inhibitor. This calculation does not validate real inhibitor deployment or uptime."])
210
+ def adjustment(x):
211
+ supplied = x.inhibitor_factor is not None
212
+ ensure((x.inhibitor_effectiveness_basis is not None and bool(x.inhibitor_effectiveness_basis.strip())) if supplied else x.inhibitor_effectiveness_basis is None,"Inhibitor factor requires matching independent-effectiveness basis")
213
+ fg = glycol(x.glycol)["glycol_factor"] if x.glycol is not None else 1.
214
+ fi = x.inhibitor_factor if supplied else 1.
215
+ factor = min(fg,fi)
216
+ rate = x.base_corrosion_rate_m_year*factor
217
+ ensure(math.isfinite(rate) and (rate > 0 or x.base_corrosion_rate_m_year == 0 or factor == 0),"Adjusted CO2 rate outside numeric range","numerical_failure")
218
+ return {"glycol_factor":fg,"inhibitor_factor":fi,"applied_reduction_factor":factor,"corrosion_rate_m_year":rate}
219
+
220
+
221
+ class SuppliedPH(Model):
222
+ method: Literal["supplied"]
223
+ ph: float = Field(ge=0,le=14,strict=True,allow_inf_nan=False)
224
+ qualification_basis: str = Field(min_length=1)
225
+
226
+
227
+ class EstimatedPH(Model):
228
+ method: Literal["condensate","ferrous_saturated","salinity_46_g_l"]
229
+ qualification_basis: str = Field(min_length=1)
230
+
231
+
232
+ class SuppliedShear(Model):
233
+ method: Literal["supplied"]
234
+ shear_stress_pa: NonNegative
235
+ qualification_basis: str = Field(min_length=1)
236
+
237
+
238
+ class TurbulentShear(CO2Reynolds):
239
+ method: Literal["turbulent_flow"]
240
+ relative_roughness: Fraction
241
+ qualification_basis: str = Field(min_length=1)
242
+
243
+
244
+ class CO2WetModel(CO2Reductions):
245
+ ph: SuppliedPH | EstimatedPH = Field(discriminator="method")
246
+ flow: SuppliedShear | TurbulentShear = Field(discriminator="method")
247
+ lookup_policy: Literal["exact_nodes_only","bilinear"]
248
+ jump_policy: Literal["reject_cross_80_90","qualified_linear_across_jump"]
249
+ jump_qualification_basis: str | None = None
250
+ temperature_denominator_basis: Literal["printed_celsius_plus_273","kelvin_exact"]
251
+ simple_upstream_mixture_and_wetting_basis: str = Field(min_length=1)
252
+
253
+
254
+ class CO2Assessment(Model):
255
+ material: Literal["carbon_steel","low_alloy_steel"]
256
+ temperature_k: Positive
257
+ co2_partial_pressure_pa_abs: NonNegative
258
+ free_liquid_water_present: bool
259
+ applicability_basis: str = Field(min_length=1)
260
+ wet_model: CO2WetModel | None = None
261
+
262
+
263
+ @calculation("damage/co2-corrosion-assessment", "Qualified free-water CO2 corrosion from pH fugacity flow and reductions", CO2Assessment,
264
+ ["Part2 Annex2.B §13,eqs(2.B.23)-(2.B.36),Table2.B.13.1; PDF pp423-429"],origin="api581_composition_with_explicit_source_policies",limitations=[
265
+ "Carbon/low-alloy steel only, integrated wet model20..140C and CO2 partial pressure<=4MPa; table150C remains standalone only. No free liquid water or CO2 gives CO2-specific rate0, not zero total corrosion/damage. Free-water and upstream simple crude/water/natural-gas wetting applicability are externally qualified; oil-water<20%/velocity>1m/s figure screen and approximate dew point are not automatically used to assert dry metal. pH and shear supplied or estimated with explicit bases; no two-phase EOS, buffering/salinity chemistry, inhibition efficacy or material immunity inferred. Positive wet case uses same pressure/temperature through all primitives; table interpolation and273 denominator explicit. Standalone RH/dew-point approximations do not substitute for free-water evidence."])
266
+ def assessment(x):
267
+ ensure(bool(x.applicability_basis.strip()),"Free-water/material/CO2 applicability basis must not be blank")
268
+ if not x.free_liquid_water_present or x.co2_partial_pressure_pa_abs == 0:
269
+ ensure(x.wet_model is None,"Dry/no-CO2 branch cannot carry unused wet-model inputs")
270
+ return {"calculation_branch":"no_free_water" if not x.free_liquid_water_present else "no_co2","corrosion_rate_m_year":0.,
271
+ "base_corrosion_rate_m_year":0.,"model_ph":None,"temperature_ph_factor":None,"fugacity_pa":None,
272
+ "requested_shear_stress_pa":None,"applied_shear_stress_pa":None,"shear_cap_applied":False,
273
+ "glycol_factor":1.,"inhibitor_factor":1.,"applied_reduction_factor":1.,"sharp_temperature_interval_interpolated":False}
274
+ ensure(293.15 <= x.temperature_k <= 413.15,"Integrated CO2 scope20..140C")
275
+ ensure(x.co2_partial_pressure_pa_abs <= 4e6,"Integrated CO2 partial pressure exceeds4MPa")
276
+ m = x.wet_model
277
+ ensure(m is not None,"Positive wet CO2 case requires wet-model inputs")
278
+ ensure(all(s.strip() for s in (m.ph.qualification_basis,m.flow.qualification_basis,m.simple_upstream_mixture_and_wetting_basis)),"pH/flow/upstream-mixture qualification bases must not be blank")
279
+ ph = m.ph.ph if m.ph.method == "supplied" else ph_estimate(CO2PHEstimate(temperature_k=x.temperature_k,co2_partial_pressure_pa_abs=x.co2_partial_pressure_pa_abs,
280
+ water_case=m.ph.method,approximation_basis=m.ph.qualification_basis))["estimated_ph"]
281
+ table = temperature_ph(CO2TemperaturePH(temperature_k=x.temperature_k,ph=ph,lookup_policy=m.lookup_policy,jump_policy=m.jump_policy,jump_qualification_basis=m.jump_qualification_basis))
282
+ fug = fugacity(CO2Fugacity(temperature_k=x.temperature_k,co2_partial_pressure_pa_abs=x.co2_partial_pressure_pa_abs,temperature_denominator_basis=m.temperature_denominator_basis))
283
+ if m.flow.method == "supplied":
284
+ stress = m.flow.shear_stress_pa
285
+ else:
286
+ re = reynolds(m.flow)["reynolds_number"]
287
+ f = friction(CO2Friction(reynolds_number=re,relative_roughness=m.flow.relative_roughness))["friction_factor"]
288
+ stress = shear(CO2Shear(friction_factor=f,mixture_density_kg_m3=m.flow.mixture_density_kg_m3,mixture_velocity_m_s=m.flow.mixture_velocity_m_s))["shear_stress_pa"]
289
+ base = base_rate(CO2BaseRate(temperature_ph_factor=table["temperature_ph_factor"],fugacity_pa=fug["fugacity_pa"],shear_stress_pa=stress))
290
+ reduced = adjustment(CO2Adjustment(base_corrosion_rate_m_year=base["base_corrosion_rate_m_year"],glycol=m.glycol,inhibitor_factor=m.inhibitor_factor,
291
+ inhibitor_effectiveness_basis=m.inhibitor_effectiveness_basis))
292
+ return {**base,**reduced,**table,"calculation_branch":"wet_carbonic_acid_model","model_ph":ph,"fugacity_pa":fug["fugacity_pa"],"requested_shear_stress_pa":stress}
293
+
294
+
295
+ class CO2Thinning(Model):
296
+ corrosion: CO2Assessment
297
+ starting_thickness_m: Positive
298
+ elapsed_years: NonNegative
299
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
300
+ inspections: Posterior
301
+ conditions: ThinningConditions
302
+ qualification_basis: str = Field(min_length=1)
303
+
304
+
305
+ @calculation("damage/co2-thinning-assessment", "CO2 corrosion through qualified non-AST unclad thinning DF", CO2Thinning,
306
+ ["Part2 Annex2.B §13; Part2 §4.5.7"],origin="api581_composition_with_explicit_source_policies",limitations=[
307
+ "Same CO2 rate drives constant-baseline unclad non-AST wall loss and either strength method. Owner qualifies localized/general inspection coverage, free-water/material/mixture scope and conditions. Zero CO2 rate does not force DF0: baseline strength, uncertainty and.1 thinning floor remain. No AST, cladding, total all-mechanism DF, FFS or automatic moisture/material/wetting/inhibitor qualification."])
308
+ def co2_thinning(x):
309
+ rate = assessment(x.corrosion)
310
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
311
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=rate["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
312
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
313
+ return {**rate,**result}