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,236 @@
1
+ """2016 HF tables with explicit concentration, residual and source-conflict policies."""
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
6
+ from .thinning import Posterior, WallLoss
7
+ from .clad_thinning import PressureStrength, CodeStrength
8
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
9
+
10
+ Units = Literal["printed_si", "printed_us_converted"]
11
+ Lookup = Literal["exact_nodes_only", "bilinear", "maximum_bracketing_value"]
12
+ _C = (.01,.02,.05,.06,.63,.64,.80,.81)
13
+ # Paired low/high velocity rows; last two columns are81wt% low/high residual.
14
+ _CS_US = (
15
+ (2,150,150,800,800,5,5,2,6),(20,999,999,999,999,50,50,20,60),
16
+ (10,500,500,999,999,30,30,5,15),(200,999,999,999,999,300,300,50,150),
17
+ (10,500,500,999,999,30,30,10,30),(100,999,999,999,999,300,300,100,300),
18
+ (100,999,999,999,999,500,500,20,60),(999,999,999,999,999,999,999,200,600),
19
+ (100,999,999,999,999,500,500,50,150),(999,999,999,999,999,999,999,500,999),
20
+ (100,999,999,999,999,500,500,70,210),(999,999,999,999,999,999,999,700,999),
21
+ (500,999,999,999,999,999,999,100,300),(999,999,999,999,999,999,999,999,999))
22
+ _CS_SI = (
23
+ (.05,3.81,3.81,20.32,20.32,.13,.13,.05,.15),(.51,25.37,25.37,25.37,25.37,1.27,1.27,.51,1.52),
24
+ (.25,12.7,12.7,25.37,25.37,.76,.76,.13,.38),(5.08,25.37,25.37,25.37,25.37,7.62,7.62,1.27,3.81),
25
+ (.25,12.7,12.7,25.37,25.37,.76,.76,.25,.76),(2.54,25.37,25.37,25.37,25.37,7.62,7.62,2.54,7.62),
26
+ (2.54,25.37,25.37,25.37,25.37,12.7,12.7,.51,1.52),(25.37,25.37,25.37,25.37,25.37,25.37,25.37,5.08,15.24),
27
+ (2.54,25.37,25.37,25.37,25.37,12.7,12.7,1.27,3.81),(25.37,25.37,25.37,25.37,25.37,25.37,25.37,12.7,25.37),
28
+ (2.54,25.37,25.37,25.37,25.37,12.7,12.7,1.78,5.33),(25.37,25.37,25.37,25.37,25.37,25.37,25.37,17.78,25.37),
29
+ (12.7,25.37,25.37,25.37,25.37,25.37,25.37,2.54,7.62),(25.37,25.37,25.37,25.37,25.37,25.37,25.37,25.37,25.37))
30
+ # Paired no/yes aeration rows.
31
+ _A400_US = ((1,1,1,10,10,1,1,2),(10,10,10,25,25,10,10,15),
32
+ (1,1,1,15,15,5,5,3),(10,10,10,30,30,20,20,15),
33
+ (5,5,5,20,20,10,10,5),(20,20,20,100,100,50,50,20),
34
+ (10,10,10,20,20,20,20,10),(100,100,100,200,200,200,200,100))
35
+ _A400_SI = ((.03,.03,.03,.25,.25,.03,.03,.05),(.25,.25,.25,.64,.64,.25,.25,.38),
36
+ (.03,.03,.03,.38,.38,.13,.13,.08),(.25,.25,.25,.76,.76,.51,.51,.38),
37
+ (.13,.13,.13,.51,.51,.25,.25,.13),(.51,.51,.51,2.54,2.54,1.27,1.27,.51),
38
+ (.25,.25,.25,.51,.51,.51,.51,.25),(2.54,2.54,2.54,5.08,5.08,5.08,5.08,2.54))
39
+
40
+
41
+ def _qualified(value,message):
42
+ ensure(bool(value.strip()),message)
43
+
44
+
45
+ class HFConcentration(Model):
46
+ hf_mass_fraction: Fraction
47
+ concentrated_extension_policy: Literal["reject_above_81wt_percent", "qualified_81wt_percent_node"]
48
+ concentrated_extension_qualification_basis: str | None = None
49
+
50
+
51
+ @calculation("damage/hf-concentration-selection", "HF table concentration with explicit above81wt% extension policy", HFConcentration,
52
+ ["Part2 Annex2.B §6.1,Tables6.2-6.3M; PDF pp374,376-377"],limitations=[
53
+ "HF-in-water mass fraction, not water mass fraction. Source body says>=80wt% equivalent to anhydrous but tables distinguish80/81wt% and stop81. Values>81 default reject; qualified81-node extension requires owner basis and flag, never silent extrapolation or81>= label. Below1wt% rejected.80..81 handled by explicit table interpolation policy. No-free-water is not HF immunity: anhydrous acid remains in scope."])
54
+ def concentration_selection(x):
55
+ qualified = x.concentrated_extension_policy == "qualified_81wt_percent_node"
56
+ ensure((x.concentrated_extension_qualification_basis is not None and bool(x.concentrated_extension_qualification_basis.strip())) if qualified else x.concentrated_extension_qualification_basis is None,
57
+ "Concentrated HF extension policy and qualification basis must agree")
58
+ ensure(x.hf_mass_fraction >= .01,"HF concentration below first1wt% table node")
59
+ extended = x.hf_mass_fraction > .81
60
+ ensure(not extended or qualified,"Above81wt% HF requires qualified source-body/table extension","hf_concentrated_extension_required")
61
+ return {"hf_mass_fraction_for_lookup":min(x.hf_mass_fraction,.81),"concentrated_81_node_extension_used":extended}
62
+
63
+
64
+ class HFVelocity(Model):
65
+ velocity_m_s: NonNegative
66
+ velocity_basis: Units
67
+
68
+
69
+ @calculation("damage/hf-carbon-velocity-band", "HF carbon-steel low/high velocity band", HFVelocity,
70
+ ["Part2 Tables2.B.6.2/6.2M; PDF p376"],limitations=[
71
+ "PrintedSI threshold3.05m/s versus10ft/s=3.048m/s explicitly selected. Low strictly below; equality high. No continuous velocity interpolation or generic erosion/film-stripping rate. This rate-table threshold differs from other mechanisms and is not a universal process limit."])
72
+ def velocity_band(x):
73
+ threshold = 3.05 if x.velocity_basis == "printed_si" else 10*.3048
74
+ return {"velocity_band": "low" if x.velocity_m_s < threshold else "high","velocity_threshold_m_s":threshold}
75
+
76
+
77
+ def _bracket(nodes,value):
78
+ value = next((n for n in nodes if abs(n-value) <= 1e-10),value)
79
+ ensure(nodes[0] <= value <= nodes[-1],"Outside printed HF table; no extrapolation")
80
+ i = bisect_left(nodes,value)
81
+ return (i,i,0.) if nodes[i] == value else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
82
+
83
+
84
+ def _lookup(table,us,temps,c,temp,branch,units,policy,residual_high=False):
85
+ a,b,w = _bracket(_C,c)
86
+ i,j,v = _bracket(temps,temp)
87
+ ensure(policy != "exact_nodes_only" or (a == b and i == j),"Exact-node HF policy does not interpolate")
88
+ def column(k):
89
+ return 8 if residual_high and k == 7 else k
90
+ corners = [table[2*t+branch][column(k)] for t in (i,j) for k in (a,b)]
91
+ if policy == "maximum_bracketing_value":
92
+ native = max(corners)
93
+ else:
94
+ lower = (1-w)*table[2*i+branch][column(a)]+w*table[2*i+branch][column(b)]
95
+ upper = (1-w)*table[2*j+branch][column(a)]+w*table[2*j+branch][column(b)]
96
+ native = (1-v)*lower+v*upper
97
+ return {"corrosion_rate_m_year":native*(.001 if units == "printed_si" else .0000254),
98
+ "source_999_corner_used":any(us[2*t+branch][column(k)] == 999 for t in (i,j) for k in (a,b))}
99
+
100
+
101
+ class HFCarbonRate(HFConcentration,HFVelocity):
102
+ maximum_temperature_k: Positive
103
+ residual_category: Literal["low", "high"] | None = None
104
+ residual_qualification_basis: str | None = None
105
+ unit_basis: Units
106
+ lookup_policy: Lookup
107
+
108
+
109
+ @calculation("damage/hf-carbon-rate", "HF carbon-steel concentration temperature velocity residual rate table", HFCarbonRate,
110
+ ["Part2 Tables2.B.6.2/6.2M; PDF p376"],limitations=[
111
+ "252SI/US cells at7temperatures x2velocity x9columns. Concentrations1,2,5,6,63,64,80,81wt%;81haslow/high residual. Any lookup using81corner needs qualified residual classification; source supplies no numeric Cu/Ni/Cr cutoff, none inferred. Residual fields outside81-corner region rejected as unused. All999mpy/25.37mm/y literal with flag. Source SI/US rounding explicit, no temperature extrapolation. Initial high corrosion before protective fluoride scale, galvanic coupling and disturbed scale not quantified by table. Figure6.1 swapped table numbers resolved by actual table titles, not treated as official erratum."])
112
+ def carbon_rate(x):
113
+ concentration = concentration_selection(x)
114
+ c = concentration["hf_mass_fraction_for_lookup"]
115
+ a,b,_ = _bracket(_C,c)
116
+ residual_used = b == 7
117
+ ensure((x.residual_category is not None and x.residual_qualification_basis is not None and bool(x.residual_qualification_basis.strip())) if residual_used else x.residual_category is None and x.residual_qualification_basis is None,
118
+ "Qualified residual category needed only when lookup touches81wt% column")
119
+ velocity = velocity_band(x)
120
+ temps = tuple(t+273.15 for t in (27,41,60,66,75,86,93)) if x.unit_basis == "printed_si" else tuple((t-32)*5/9+273.15 for t in (80,105,140,150,167.5,187.5,200))
121
+ table = _CS_SI if x.unit_basis == "printed_si" else _CS_US
122
+ return {**concentration,**velocity,**_lookup(table,_CS_US,temps,c,x.maximum_temperature_k,int(velocity["velocity_band"] == "high"),x.unit_basis,x.lookup_policy,x.residual_category == "high"),
123
+ "residual_column_used":x.residual_category if residual_used else None,"source_table_id":"2.B.6.2"+("M" if x.unit_basis == "printed_si" else "")}
124
+
125
+
126
+ class HFAlloy400Rate(HFConcentration):
127
+ maximum_temperature_k: Positive
128
+ aerated_or_oxidants_present: bool
129
+ unit_basis: Units
130
+ lookup_policy: Lookup
131
+
132
+
133
+ @calculation("damage/hf-alloy400-rate", "HF Alloy400 concentration temperature aeration rate table", HFAlloy400Rate,
134
+ ["Part2 Tables2.B.6.3/6.3M,Table6.1; PDF pp375,377"],limitations=[
135
+ "128SI/US cells at4temperatures x2aeration x8HF concentrations. Explicit aeration/oxidants bool, no dissolved-oxygen cutoff supplied or inferred. No blanket aeration multiplier or velocity correction. Actual material and applicable solution conditions external. No extrapolation except flagged qualified above81 extension. Figure6.1 swapped table references resolved using actual Alloy400 table title."])
136
+ def alloy400_rate(x):
137
+ concentration = concentration_selection(x)
138
+ temps = tuple(t+273.15 for t in (27,52,79,93)) if x.unit_basis == "printed_si" else tuple((t-32)*5/9+273.15 for t in (80,125,175,200))
139
+ table = _A400_SI if x.unit_basis == "printed_si" else _A400_US
140
+ return {**concentration,**_lookup(table,_A400_US,temps,concentration["hf_mass_fraction_for_lookup"],x.maximum_temperature_k,
141
+ int(x.aerated_or_oxidants_present),x.unit_basis,x.lookup_policy),"source_table_id":"2.B.6.3"+("M" if x.unit_basis == "printed_si" else "")}
142
+
143
+
144
+ class HFCarbonService(HFCarbonRate):
145
+ method: Literal["carbon_steel_table"]
146
+ established_scale_and_stable_operation_qualified: bool
147
+ material_residual_content_qualified: bool
148
+ qualification_basis: str = Field(min_length=1)
149
+
150
+
151
+ class HFAlloy400Service(HFAlloy400Rate):
152
+ method: Literal["alloy400_table"]
153
+ material_and_aeration_qualified: bool
154
+ qualification_basis: str = Field(min_length=1)
155
+
156
+
157
+ class HFPublishedRate(Model):
158
+ method: Literal["qualified_published_rate"]
159
+ material_description: str = Field(min_length=1)
160
+ hf_mass_fraction: Fraction
161
+ maximum_temperature_k: Positive
162
+ velocity_m_s: NonNegative
163
+ corrosion_rate_m_year: NonNegative
164
+ published_source_reference: str = Field(min_length=1)
165
+ qualification_basis: str = Field(min_length=1)
166
+
167
+
168
+ class HFAssessment(Model):
169
+ hf_exposure_present: bool
170
+ applicability_basis: str = Field(min_length=1)
171
+ rate_model: HFCarbonService | HFAlloy400Service | HFPublishedRate | None = Field(default=None,discriminator="method")
172
+ galvanic_coupling_present: bool | None = None
173
+ thinning_classification_policy: Literal["overview_local", "detailed_general_without_galvanic"] | None = None
174
+ classification_qualification_basis: str | None = None
175
+
176
+
177
+ @calculation("damage/hf-assessment", "Qualified HF exposure material and source-classification corrosion assessment", HFAssessment,
178
+ ["Part2 Annex2.B §6,Tables6.1-6.3M,Figure6.1,Table1.2; PDF pp323,374-378"],origin="api581_composition_with_explicit_source_policies",limitations=[
179
+ "HF exposure, not free-water presence controls branch: no free water cannot imply zero because anhydrous HF corrodes. No-HF0 source-specific only. CS table requires stable established fluoride scale/material-residual qualification; table paths reject galvanic coupling whose acceleration is unquantified. Other materials/conditions may use explicit published rate with provenance and applicability qualification per source figure, not an API581 table prediction. Source overviewLocal versus detailedGeneral absent galvanic conflict requires explicit policy/basis; general policy disallowed with galvanic coupling. No HSC/HIC/blistering, SCC, film growth or transient dilution model."])
180
+ def assessment(x):
181
+ _qualified(x.applicability_basis,"HF exposure and service applicability qualification required")
182
+ if not x.hf_exposure_present:
183
+ ensure(x.rate_model is None and x.galvanic_coupling_present is None and x.thinning_classification_policy is None and x.classification_qualification_basis is None,
184
+ "No-HF branch cannot carry unused service model or classification")
185
+ return {"corrosion_rate_m_year":0.,"source_table_id":None,"rate_source":None,"source_999_corner_used":False,
186
+ "hf_mass_fraction_for_lookup":None,"concentrated_81_node_extension_used":False,"velocity_band":None,
187
+ "velocity_threshold_m_s":None,"residual_column_used":None,"source_thinning_type":None,"calculation_branch":"no_hf_exposure"}
188
+ ensure(x.rate_model is not None and x.galvanic_coupling_present is not None and x.thinning_classification_policy is not None,
189
+ "HF service requires model, galvanic review and explicit source classification")
190
+ ensure(x.classification_qualification_basis is not None and bool(x.classification_qualification_basis.strip()),"Source classification conflict requires qualified policy basis")
191
+ general = x.thinning_classification_policy == "detailed_general_without_galvanic"
192
+ ensure(not general or not x.galvanic_coupling_present,"Detailed general-thinning branch excludes potential galvanic attack")
193
+ m = x.rate_model
194
+ _qualified(m.qualification_basis,"Material/conditions and source applicability qualification required")
195
+ if m.method == "carbon_steel_table":
196
+ ensure(m.established_scale_and_stable_operation_qualified and m.material_residual_content_qualified,
197
+ "Carbon table requires qualified established scale, stable service and residual metallurgy")
198
+ ensure(not x.galvanic_coupling_present,"HF table does not quantify galvanic acceleration")
199
+ result = carbon_rate(m)
200
+ elif m.method == "alloy400_table":
201
+ ensure(m.material_and_aeration_qualified,"Alloy400 material and aeration qualification required")
202
+ ensure(not x.galvanic_coupling_present,"HF table does not quantify galvanic acceleration")
203
+ result = {**alloy400_rate(m),"velocity_band":None,"velocity_threshold_m_s":None,"residual_column_used":None}
204
+ else:
205
+ _qualified(m.published_source_reference,"Published HF rate reference required")
206
+ _qualified(m.material_description,"Published rate material identification required")
207
+ ensure(m.hf_mass_fraction > 0,"Exposed HF service cannot use zero HF concentration")
208
+ result = {"corrosion_rate_m_year":m.corrosion_rate_m_year,"source_table_id":None,"source_999_corner_used":False,
209
+ "hf_mass_fraction_for_lookup":None,"concentrated_81_node_extension_used":False,"velocity_band":None,
210
+ "velocity_threshold_m_s":None,"residual_column_used":None}
211
+ return {**result,"rate_source":m.method,"source_thinning_type":"general" if general else "local","calculation_branch":"qualified_hf_service"}
212
+
213
+
214
+ class HFThinning(Model):
215
+ corrosion: HFAssessment
216
+ assessment_distribution: Literal["general","local"]
217
+ distribution_qualification_basis: str = Field(min_length=1)
218
+ starting_thickness_m: Positive
219
+ elapsed_years: NonNegative
220
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
221
+ inspections: Posterior
222
+ conditions: ThinningConditions
223
+ qualification_basis: str = Field(min_length=1)
224
+
225
+
226
+ @calculation("damage/hf-thinning-assessment", "Qualified HF rate through non-AST unclad thinning DF", HFThinning,
227
+ ["Part2 Annex2.B §6,Table1.2; §4.5.7,Table4.8"],origin="api581_composition_with_explicit_source_policies",limitations=[
228
+ "Same selected HF rate drives wall loss and qualified HF monitoring row. Source class conflict policy and owner-qualified actual distribution retained separately. No-HF0 not DF0, uncertainty/floor remain. Non-AST unclad only; no added residual/aeration multipliers, no automatic cracking/totalDF/FFS acceptance. Published rate retains supplied provenance in input snapshot, not relabeled API581 table data."])
229
+ def thinning_assessment(x):
230
+ _qualified(x.distribution_qualification_basis,"Actual general/local inspection distribution qualification required")
231
+ corrosion = assessment(x.corrosion)
232
+ ensure(x.conditions.monitoring.row.mechanism == "hydrofluoric_acid","HF calculation requires hydrofluoric-acid monitoring row")
233
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
234
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
235
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
236
+ return {**corrosion,**result,"assessment_distribution":x.assessment_distribution}
api581/ignition.py ADDED
@@ -0,0 +1,204 @@
1
+ """Level2 ignition correlations, explicit temperature reference, figure trees."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+
7
+ Phase=Literal["liquid","vapor"]
8
+ ReleaseType=Literal["continuous","instantaneous"]
9
+ _COEFFICIENTS={
10
+ "liquid":(-.1368924,-.007598764,8.282163e-6,-6.124231e-9,.06876128,1.193736e-4,2.081034e-7,-4.057289e-11),
11
+ "vapor":(-.06053124,-.009958413,1.518603e-5,-1.386705e-8,.04564953,7.912392e-4,-6.489157e-7,7.159409e-10),
12
+ }
13
+ _TABLE={
14
+ ("continuous","liquid"):(.2,.25), ("continuous","vapor"):(.5,.5),
15
+ ("instantaneous","liquid"):(.2,.125), ("instantaneous","vapor"):(.1,.25),
16
+ }
17
+
18
+
19
+ class FlammableRates(Model):
20
+ adjusted_mass_rate_kg_s: NonNegative
21
+ flammable_mass_fraction: Fraction
22
+ flash_mass_fraction: Fraction
23
+
24
+
25
+ @calculation("release/flammable-phase-rates", "Flammable total/liquid/vapor rates for ignition", FlammableRates,
26
+ ["Part3 eqs(3.106)-(3.108); PDF p546"],
27
+ limitations=["Flash fraction supplied by qualified thermodynamics. Phase liquid rate is not pool rainout rate; aerosol/rainout partition is separate. Flammable mass fraction, not mole fraction, multiplies total adjusted rate."])
28
+ def flammable_rates(x):
29
+ total=x.adjusted_mass_rate_kg_s*x.flammable_mass_fraction
30
+ vapor=total*x.flash_mass_fraction
31
+ liquid=total*(1-x.flash_mass_fraction)
32
+ ensure(total > 0 or x.adjusted_mass_rate_kg_s == 0 or x.flammable_mass_fraction == 0,
33
+ "Positive flammable rate underflow","numerical_failure")
34
+ ensure(vapor > 0 or total == 0 or x.flash_mass_fraction == 0,
35
+ "Positive flammable vapor rate underflow","numerical_failure")
36
+ ensure(liquid > 0 or total == 0 or x.flash_mass_fraction == 1,
37
+ "Positive flammable liquid rate underflow","numerical_failure")
38
+ return {"flammable_rate_kg_s":total,"flammable_vapor_rate_kg_s":vapor,"flammable_liquid_rate_kg_s":liquid}
39
+
40
+
41
+ class AmbientIgnition(Model):
42
+ phase: Phase
43
+ flammable_rate_kg_s: NonNegative
44
+ flash_point_temperature_k: Positive
45
+ correlation_basis_source: str = Field(min_length=1,max_length=1000)
46
+
47
+
48
+ @calculation("events/ambient-ignition", "Flash-point/rate ambient ignition correlation", AmbientIgnition,
49
+ ["Part3 eqs(3.109)-(3.110),C4B/C5/C12; PDF pp546-547,647"],
50
+ limitations=["Printed C12=1.8 converts Kelvin flash point to Rankine; C4B=2.205 converts capped kg/s to lb/s inside natural log. Uses C5=25.2kg/s even though adjacent text also says25.22. Fit range is not certified by accepting source text: requires positive rate exponent and probability<=1, rejects nonphysical extrapolation. Zero rate uses limiting probability0. No LH2 ignition validation."])
51
+ def ambient_ignition(x):
52
+ ensure(x.correlation_basis_source.strip(),"Ignition correlation basis must be nonblank")
53
+ t=1.8*x.flash_point_temperature_k
54
+ c=_COEFFICIENTS[x.phase]
55
+ a=((c[3]*t+c[2])*t+c[1])*t+c[0]
56
+ b=((c[7]*t+c[6])*t+c[5])*t+c[4]
57
+ ensure(math.isfinite(a) and math.isfinite(b) and b > 0,"Ignition polynomial outside finite increasing-rate domain")
58
+ rate=min(25.2,x.flammable_rate_kg_s)
59
+ if rate == 0:
60
+ probability=0
61
+ else:
62
+ logp=a+b*(math.log(2.205)+math.log(rate))
63
+ ensure(logp <= 0,"Ambient ignition correlation exceeds probability1; no implicit clamp")
64
+ probability=math.exp(logp)
65
+ ensure(probability > 0,"Ambient ignition probability underflow","numerical_failure")
66
+ return {"ambient_ignition_probability":probability,"evaluated_rate_kg_s":rate,"rate_capped":x.flammable_rate_kg_s > 25.2}
67
+
68
+
69
+ class MaximumIgnition(Model):
70
+ phase: Phase
71
+ molecular_weight_kg_kmol: Positive | None = None
72
+
73
+
74
+ @calculation("events/ait-ignition-maximum", "Ignition probability at autoignition temperature", MaximumIgnition,
75
+ ["Part3 eqs(3.111)-(3.112); PDF pp547-548"],
76
+ limitations=["Liquid maximum1; vapor follows printed molecular-weight interpolation with lower floor.7. MW2 gives.9 and MW>=170 gives.7. This is a release ignition probability correlation, not deterministic autoignition physics or a hydrogen certification."])
77
+ def ignition_maximum(x):
78
+ if x.phase == "liquid":
79
+ return {"maximum_ignition_probability":1}
80
+ ensure(x.molecular_weight_kg_kmol is not None,"Vapor maximum requires molecular weight")
81
+ return {"maximum_ignition_probability":max(.7,.7+.2*(170-x.molecular_weight_kg_kmol)/168)}
82
+
83
+
84
+ class TemperatureReference(Model):
85
+ reference_basis: Literal["api581_printed_c16","qualified_reference"]
86
+ qualified_reference_temperature_k: Positive | None = None
87
+ reference_source: str = Field(min_length=1,max_length=1000)
88
+
89
+
90
+ def _reference(x):
91
+ ensure(x.reference_source.strip(),"Temperature reference source must be nonblank")
92
+ ensure((x.reference_basis == "qualified_reference") == (x.qualified_reference_temperature_k is not None),
93
+ "Provide temperature only for qualified_reference basis")
94
+ return 30.89 if x.reference_basis == "api581_printed_c16" else x.qualified_reference_temperature_k
95
+
96
+
97
+ class TemperatureProbability(TemperatureReference):
98
+ probability_at_reference: Fraction
99
+ probability_at_ait: Fraction
100
+ storage_temperature_k: Positive
101
+ autoignition_temperature_k: Positive
102
+
103
+
104
+ @calculation("events/probability-temperature", "Reference-to-AIT probability interpolation", TemperatureProbability,
105
+ ["Part3 eqs(3.113)-(3.114),(3.116)-(3.117),C16; PDF pp548,648"],
106
+ origin="api581_interpolation_with_explicit_temperature_reference",
107
+ limitations=["Printed C16=30.89K and US70R conflict with ambient-temperature discussion; no silent conversion to70F. Required basis chooses literal C16 or a caller-qualified reference. Ts below reference and AIT<=reference rejected; Ts>=AIT returns AIT limit. AIT probability must be at least reference probability. Qualified reference is not an authorized erratum."])
108
+ def temperature_probability(x):
109
+ reference=_reference(x)
110
+ ensure(x.autoignition_temperature_k > reference and x.storage_temperature_k >= reference,
111
+ "Temperature interpolation requires AIT>reference and Ts>=reference")
112
+ ensure(x.probability_at_ait >= x.probability_at_reference,"AIT maximum is below reference probability")
113
+ weight=min(1,(x.storage_temperature_k-reference)/(x.autoignition_temperature_k-reference))
114
+ probability=x.probability_at_ait if weight == 1 else x.probability_at_reference+(x.probability_at_ait-x.probability_at_reference)*weight
115
+ return {"probability":probability,"temperature_weight":weight,"reference_temperature_k":reference}
116
+
117
+
118
+ class ConditionalTable(Model):
119
+ phase: Phase
120
+ release_type: ReleaseType
121
+
122
+
123
+ @calculation("events/conditional-table", "Table5.3 conditional event probabilities", ConditionalTable,
124
+ ["Part3 Table5.3; PDF p583"],limitations=["Historical expert-opinion defaults by phase/release type. Immediate probability at reference, not automatically at current storage temperature. VCE and flash columns are conditional on delayed ignition, not unconditional outcomes."])
125
+ def conditional_table(x):
126
+ immediate,vce=_TABLE[x.release_type,x.phase]
127
+ return {"immediate_at_reference":immediate,"immediate_at_ait":1,"vce_given_delayed":vce,"flash_given_delayed":1-vce}
128
+
129
+
130
+ class TwoPhaseProbability(Model):
131
+ liquid_probability: Fraction
132
+ vapor_probability: Fraction
133
+ flash_mass_fraction: Fraction
134
+ mixing_basis: Literal["api581_printed_liquid_weight","qualified_vapor_weight"]
135
+ weighting_source: str = Field(min_length=1,max_length=1000)
136
+
137
+
138
+ @calculation("events/two-phase-probability", "Explicit-basis liquid/vapor conditional probability mixing", TwoPhaseProbability,
139
+ ["Part3 eqs(3.115),(3.118)-(3.119),(3.122); PDF pp548-549"],
140
+ origin="api581_with_explicit_two_phase_probability_weight",
141
+ limitations=["Printed equations weight LIQUID by flash fraction and VAPOR by its complement, despite flash-as-vapor wording. Literal and separately qualified vapor weighting are explicit alternatives; neither is silently selected or asserted as an erratum. Inputs must be probabilities of the same conditional quantity. No complete two-phase terminal tree or correlated max(jet,pool) consequence representation here."])
142
+ def two_phase_probability(x):
143
+ ensure(x.weighting_source.strip(),"Two-phase probability weighting source must be nonblank")
144
+ liquid_weight=x.flash_mass_fraction if x.mixing_basis == "api581_printed_liquid_weight" else 1-x.flash_mass_fraction
145
+ return {"probability":liquid_weight*x.liquid_probability+(1-liquid_weight)*x.vapor_probability,"mixing_basis":x.mixing_basis}
146
+
147
+
148
+ class SinglePhaseTree(Model):
149
+ phase: Phase
150
+ release_type: ReleaseType
151
+ case_type: Literal["leakage","rupture"]
152
+ ignition_probability: Fraction
153
+ immediate_given_ignition: Fraction
154
+ vce_given_delayed: Fraction
155
+ branch_probability_source: str = Field(min_length=1,max_length=1000)
156
+
157
+
158
+ @calculation("events/single-phase-tree", "Single-phase terminal paths from Figures5.3/5.4", SinglePhaseTree,
159
+ ["Part3 Figures5.3/5.4; PDF pp586-587; conflicting example eq(3.125) PDF549"],
160
+ origin="api581_printed_figure_event_tree",
161
+ limitations=["Follows figure VCE path factor pvcedi, not conflicting printed example1-pvcedi. Figure5.3 labels instantaneous vapor leakage as fireball, although text restricts fireballs to rupture; output explicitly reports figure53_instantaneous applicability when needed. Rupture tree supported only for instantaneous release. Non-ignition is not automatically safe: toxic/NFNT must be evaluated separately."])
162
+ def single_phase_tree(x):
163
+ ensure(x.branch_probability_source.strip(),"Branch probability source must be nonblank")
164
+ ensure(x.case_type != "rupture" or x.release_type == "instantaneous","Continuous rupture tree not specified by this implementation")
165
+ pi,immediate,vce=x.ignition_probability,x.immediate_given_ignition,x.vce_given_delayed
166
+ probabilities={event:0.0 for event in ["pool_fire","jet_fire","fireball","vce","flash_fire"]}
167
+ fire="pool_fire" if x.phase == "liquid" else "jet_fire" if x.release_type == "continuous" else "fireball"
168
+ probabilities[fire]=pi*immediate
169
+ probabilities["vce"]=pi*(1-immediate)*vce
170
+ probabilities["flash_fire"]=pi*(1-immediate)*(1-vce)
171
+ nonignition=1-pi
172
+ return {"terminal_probabilities":probabilities,"non_ignition_probability":nonignition,
173
+ "probability_sum":math.fsum([*probabilities.values(),nonignition]),
174
+ "fireball_applicability_basis":"figure53_instantaneous" if x.case_type == "leakage" and fire == "fireball" else "rupture_only_text"}
175
+
176
+
177
+ class SinglePhaseIgnition(AmbientIgnition,TemperatureReference):
178
+ release_type: ReleaseType
179
+ case_type: Literal["leakage","rupture"]
180
+ molecular_weight_kg_kmol: Positive | None = None
181
+ storage_temperature_k: Positive
182
+ autoignition_temperature_k: Positive
183
+
184
+
185
+ @calculation("events/single-phase-ignition", "Rate/flash-point/temperature to single-phase terminal probabilities", SinglePhaseIgnition,
186
+ ["Part3 eqs(3.109)-(3.117),Table5.3,Figures5.3/5.4; PDF pp547-549,583,586-587"],
187
+ origin="api581_single_phase_ignition_composition",
188
+ limitations=["Composes supplied phase flammable rate and qualified flash point, printed or qualified temperature reference, AIT maximum and Table5.3. Does not infer release phase, flash properties, two-phase mixture or probability validation from process data. Zero source has zero ignition even above AIT. Figure/text fireball conflict is reported via applicability basis. Continuous rupture and below-reference temperature unsupported."])
189
+ def single_phase_ignition(x):
190
+ ambient=ambient_ignition(AmbientIgnition(**x.model_dump(include=set(AmbientIgnition.model_fields))))
191
+ maximum=ignition_maximum(MaximumIgnition(phase=x.phase,molecular_weight_kg_kmol=x.molecular_weight_kg_kmol))
192
+ reference=x.model_dump(include=set(TemperatureReference.model_fields))
193
+ setup=dict(**reference,storage_temperature_k=x.storage_temperature_k,autoignition_temperature_k=x.autoignition_temperature_k)
194
+ corrected=temperature_probability(TemperatureProbability(**setup,probability_at_reference=ambient["ambient_ignition_probability"],
195
+ probability_at_ait=maximum["maximum_ignition_probability"]))
196
+ table=conditional_table(ConditionalTable(phase=x.phase,release_type=x.release_type))
197
+ immediate=temperature_probability(TemperatureProbability(**setup,probability_at_reference=table["immediate_at_reference"],probability_at_ait=1))["probability"]
198
+ pi=corrected["probability"] if x.flammable_rate_kg_s > 0 else 0
199
+ tree=single_phase_tree(SinglePhaseTree(phase=x.phase,release_type=x.release_type,case_type=x.case_type,
200
+ ignition_probability=pi,immediate_given_ignition=immediate,vce_given_delayed=table["vce_given_delayed"],
201
+ branch_probability_source="Composed source correlations/Table5.3 with explicit reference basis"))
202
+ return {**ambient,**maximum,**tree,"ignition_probability":pi,"immediate_given_ignition":immediate,
203
+ "vce_given_delayed":table["vce_given_delayed"],"flash_given_delayed":table["flash_given_delayed"],
204
+ "reference_temperature_k":corrected["reference_temperature_k"],"temperature_weight":corrected["temperature_weight"]}