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,601 @@
1
+ """Inspection effectiveness classification from API 581 Annex 2.C examples."""
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import Field
6
+
7
+ from .core import Model, NonNegative, calculation, ensure
8
+
9
+
10
+ class EffectivenessCategory(Model):
11
+ category: Literal["A", "B", "C", "D", "E"]
12
+ owner_user_basis: str = Field(min_length=1, max_length=1000)
13
+
14
+
15
+ @calculation(
16
+ "inspection/effectiveness-category",
17
+ "API 581 Annex 2.C common inspection effectiveness category metadata",
18
+ EffectivenessCategory,
19
+ ["Part 2 Annex C §2.C.2-§2.C.2.3; PDF pp443-445"],
20
+ origin="api581_annex_2c2_effectiveness_category",
21
+ limitations=[
22
+ "Returns the common qualitative category metadata only. It does not decide whether a particular NDE method satisfies a mechanism-specific table; use the dedicated inspection effectiveness APIs for that decision.",
23
+ "Category E means ineffective or insufficiently evidenced for inspection credit. A-D labels are qualitative and do not by themselves define a numeric damage-factor reduction."
24
+ ],
25
+ )
26
+ def effectiveness_category(x):
27
+ labels = {
28
+ "A": "Highly Effective", "B": "Usually Effective",
29
+ "C": "Fairly Effective", "D": "Poorly Effective", "E": "Ineffective",
30
+ }
31
+ return {
32
+ "category": x.category,
33
+ "ordinal": {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5}[x.category],
34
+ "label": labels[x.category],
35
+ "inspection_credit_allowed": x.category != "E",
36
+ "owner_user_basis": x.owner_user_basis,
37
+ }
38
+
39
+
40
+ class ThinningInspectionEffectiveness(Model):
41
+ damage_type: Literal["general", "local"]
42
+ inspection_mode: Literal["intrusive", "non_intrusive"]
43
+ visual_coverage_percent: NonNegative = Field(default=0, le=100)
44
+ spot_thickness_coverage_percent: NonNegative = Field(default=0, le=100)
45
+ advanced_scan_coverage_percent: NonNegative = Field(default=0, le=100)
46
+ profile_radiography_coverage_percent: NonNegative = Field(default=0, le=100)
47
+ follow_up_coverage_percent: NonNegative = Field(default=0, le=100)
48
+ inspection_quality_high: bool = True
49
+ locations_qualified: bool = True
50
+ technique_effective: bool = True
51
+
52
+
53
+ @calculation(
54
+ "inspection/thinning-effectiveness",
55
+ "API 581 Annex 2.C.8 thinning inspection effectiveness category",
56
+ ThinningInspectionEffectiveness,
57
+ ["Part 2 Annex C §2.C.8.1-§2.C.8.2, Tables 2.C.8.1-2.C.8.2; PDF pp454-455"],
58
+ origin="api581_annex_2c8_inspection_effectiveness",
59
+ limitations=[
60
+ "Implements the printed general/local thinning percentage examples only. It classifies inspection effectiveness; it does not calculate inspection uncertainty, PoF, damage factor or inspection credit.",
61
+ "The caller must qualify inspection quality, CML/location selection and technique effectiveness. A failed qualification returns E rather than manufacturing credit. Thresholds follow the table's percentage bands; ambiguous technique descriptions are not inferred."
62
+ ],
63
+ )
64
+ def thinning_effectiveness(x):
65
+ qualified = x.inspection_quality_high and x.locations_qualified and x.technique_effective
66
+ if not qualified:
67
+ return {
68
+ "effectiveness_category": "E",
69
+ "damage_type": x.damage_type,
70
+ "inspection_mode": x.inspection_mode,
71
+ "qualification_status": "failed",
72
+ "basis": "inspection_quality_or_location_or_technique_not_qualified",
73
+ }
74
+
75
+ visual = x.visual_coverage_percent
76
+ spot = x.spot_thickness_coverage_percent
77
+ scan = x.advanced_scan_coverage_percent
78
+ profile = x.profile_radiography_coverage_percent
79
+ follow = x.follow_up_coverage_percent
80
+
81
+ if x.damage_type == "local":
82
+ if x.inspection_mode == "intrusive":
83
+ if visual >= 100 and follow >= 100:
84
+ category, basis = "A", "100_percent_visual_and_follow_up"
85
+ elif visual > 75 and follow >= 100:
86
+ category, basis = "B", "over_75_percent_visual_and_follow_up"
87
+ elif visual > 50 and follow >= 100:
88
+ category, basis = "C", "over_50_percent_visual_and_follow_up"
89
+ elif visual > 20 and follow >= 100:
90
+ category, basis = "D", "over_20_percent_visual_and_follow_up"
91
+ else:
92
+ category, basis = "E", "local_intrusive_threshold_not_met"
93
+ else:
94
+ coverage = max(spot, scan, profile)
95
+ if coverage >= 100:
96
+ category, basis = "A", "100_percent_suspect_area_coverage"
97
+ elif coverage > 75:
98
+ category, basis = "B", "over_75_percent_suspect_area_coverage"
99
+ elif coverage > 50:
100
+ category, basis = "C", "over_50_percent_suspect_area_coverage"
101
+ elif coverage > 20:
102
+ category, basis = "D", "over_20_percent_suspect_area_coverage"
103
+ else:
104
+ category, basis = "E", "local_non_intrusive_threshold_not_met"
105
+ elif x.inspection_mode == "intrusive":
106
+ if (visual > 50 and spot > 50) or scan >= 10 or profile >= 10:
107
+ category, basis = "A", "general_intrusive_high_coverage"
108
+ elif (visual > 25 and spot > 25) or scan > 5 or profile > 5:
109
+ category, basis = "B", "general_intrusive_usually_effective"
110
+ elif visual > 5 and spot > 5 or profile > 0:
111
+ category, basis = "C", "general_intrusive_fairly_effective"
112
+ elif visual > 0:
113
+ category, basis = "D", "general_intrusive_poorly_effective"
114
+ else:
115
+ category, basis = "E", "general_intrusive_threshold_not_met"
116
+ else:
117
+ if spot >= 100 or scan >= 10 or profile >= 10:
118
+ category, basis = "A", "general_non_intrusive_high_coverage"
119
+ elif spot > 75 or scan > 5 or profile > 5:
120
+ category, basis = "B", "general_non_intrusive_usually_effective"
121
+ elif spot > 50 or profile > 0:
122
+ category, basis = "C", "general_non_intrusive_fairly_effective"
123
+ elif spot > 25:
124
+ category, basis = "D", "general_non_intrusive_poorly_effective"
125
+ else:
126
+ category, basis = "E", "general_non_intrusive_threshold_not_met"
127
+
128
+ ensure(category in {"A", "B", "C", "D", "E"}, "Invalid inspection effectiveness category")
129
+ return {
130
+ "effectiveness_category": category,
131
+ "damage_type": x.damage_type,
132
+ "inspection_mode": x.inspection_mode,
133
+ "qualification_status": "qualified",
134
+ "basis": basis,
135
+ }
136
+
137
+
138
+ class SccInspectionEffectiveness(Model):
139
+ mechanism_family: Literal["weld_scc", "clscc_pta"]
140
+ inspection_mode: Literal["intrusive", "non_intrusive"]
141
+ targeted_coverage_percent: NonNegative = Field(default=0, le=100)
142
+ ultrasonic_scan_percent: NonNegative = Field(default=0, le=100)
143
+ radiography_percent: NonNegative = Field(default=0, le=100)
144
+ acoustic_emission_follow_up_percent: NonNegative = Field(default=0, le=100)
145
+ relevant_indication_follow_up_percent: NonNegative = Field(default=0, le=100)
146
+ inspection_quality_high: bool = True
147
+ locations_qualified: bool = True
148
+ technique_effective: bool = True
149
+
150
+
151
+ @calculation(
152
+ "inspection/scc-effectiveness",
153
+ "API 581 Annex 2.C.9 stress-corrosion-cracking inspection effectiveness category",
154
+ SccInspectionEffectiveness,
155
+ ["Part 2 Annex C §2.C.9.1-§2.C.9.9, Tables 2.C.9.1-2.C.9.9; PDF pp456-464"],
156
+ origin="api581_annex_2c9_inspection_effectiveness",
157
+ limitations=[
158
+ "Implements the common percentage bands in the SCC example tables. It classifies inspection effectiveness only; it does not calculate SCC susceptibility, inspection uncertainty, PoF or damage factor.",
159
+ "For CLSCC/PTA the source provides no non-intrusive A-level technique; this API returns E unless a lower category threshold is met. Caller must qualify inspection quality, suspect-area definition and indication follow-up."
160
+ ],
161
+ )
162
+ def scc_effectiveness(x):
163
+ if not (x.inspection_quality_high and x.locations_qualified and x.technique_effective):
164
+ return {"effectiveness_category": "E", "mechanism_family": x.mechanism_family,
165
+ "inspection_mode": x.inspection_mode, "qualification_status": "failed",
166
+ "basis": "inspection_quality_or_location_or_technique_not_qualified"}
167
+
168
+ targeted = x.targeted_coverage_percent
169
+ scan = x.ultrasonic_scan_percent
170
+ radiography = x.radiography_percent
171
+ follow = x.relevant_indication_follow_up_percent
172
+ ae_follow = x.acoustic_emission_follow_up_percent
173
+
174
+ if x.mechanism_family == "weld_scc":
175
+ if x.inspection_mode == "intrusive":
176
+ if targeted >= 100 and follow >= 100:
177
+ category, basis = "A", "100_percent_targeted_and_indication_follow_up"
178
+ elif (targeted > 75 or ae_follow >= 100) and follow >= 100:
179
+ category, basis = "B", "over_75_percent_targeted_or_AE_with_follow_up"
180
+ elif targeted > 35 and follow >= 100 or radiography > 35:
181
+ category, basis = "C", "over_35_percent_targeted_or_radiography"
182
+ elif targeted > 10 and follow >= 100 or radiography > 10:
183
+ category, basis = "D", "over_10_percent_targeted_or_radiography"
184
+ else:
185
+ category, basis = "E", "weld_intrusive_threshold_not_met"
186
+ else:
187
+ if scan >= 100:
188
+ category, basis = "A", "100_percent_ultrasonic_scan"
189
+ elif scan > 75:
190
+ category, basis = "B", "over_75_percent_ultrasonic_scan"
191
+ elif scan > 35:
192
+ category, basis = "C", "over_35_percent_ultrasonic_scan"
193
+ elif scan > 10:
194
+ category, basis = "D", "over_10_percent_ultrasonic_scan"
195
+ else:
196
+ category, basis = "E", "weld_non_intrusive_threshold_not_met"
197
+ elif x.inspection_mode == "intrusive":
198
+ if targeted >= 100 and follow >= 100:
199
+ category, basis = "A", "100_percent_targeted_and_indication_follow_up"
200
+ elif (targeted > 65 or ae_follow >= 100) and follow >= 100:
201
+ category, basis = "B", "over_65_percent_targeted_or_AE_with_follow_up"
202
+ elif targeted > 35 and follow >= 100 or radiography > 65:
203
+ category, basis = "C", "over_35_percent_targeted_or_over_65_percent_radiography"
204
+ elif targeted > 10 and follow >= 100 or radiography > 35:
205
+ category, basis = "D", "over_10_percent_targeted_or_over_35_percent_radiography"
206
+ else:
207
+ category, basis = "E", "CLSCC_PTA_intrusive_threshold_not_met"
208
+ else:
209
+ if scan >= 100 and x.mechanism_family == "weld_scc":
210
+ category, basis = "A", "100_percent_ultrasonic_scan"
211
+ elif scan > 65:
212
+ category, basis = "B", "over_65_percent_ultrasonic_scan"
213
+ elif scan > 35:
214
+ category, basis = "C", "over_35_percent_ultrasonic_scan"
215
+ elif scan > 10:
216
+ category, basis = "D", "over_10_percent_ultrasonic_scan"
217
+ else:
218
+ category, basis = "E", "SCC_non_intrusive_threshold_not_met_or_A_unavailable"
219
+ return {"effectiveness_category": category, "mechanism_family": x.mechanism_family,
220
+ "inspection_mode": x.inspection_mode, "qualification_status": "qualified",
221
+ "basis": basis}
222
+
223
+
224
+ class ExternalInspectionEffectiveness(Model):
225
+ mechanism_family: Literal["external_corrosion", "external_clscc", "cui", "cui_clscc"]
226
+ insulation_status: Literal["not_applicable", "removed", "not_removed"] = "not_applicable"
227
+ visual_coverage_percent: NonNegative = Field(default=0, le=100)
228
+ targeted_ndt_coverage_percent: NonNegative = Field(default=0, le=100)
229
+ insulation_removed_percent: NonNegative = Field(default=0, le=100)
230
+ follow_up_coverage_percent: NonNegative = Field(default=0, le=100)
231
+ inspection_quality_high: bool = True
232
+ locations_qualified: bool = True
233
+ technique_effective: bool = True
234
+
235
+
236
+ @calculation(
237
+ "inspection/external-effectiveness",
238
+ "API 581 Annex 2.C.10 external-damage inspection effectiveness category",
239
+ ExternalInspectionEffectiveness,
240
+ ["Part 2 Annex C §2.C.10.1-§2.C.10.2, Tables 2.C.10.1-2.C.10.4; PDF pp463-465"],
241
+ origin="api581_annex_2c10_inspection_effectiveness",
242
+ limitations=[
243
+ "Implements the printed external-corrosion, external CLSCC, CUI and CUI-CLSCC percentage examples. It classifies inspection effectiveness only and does not calculate external damage factor or inspection uncertainty.",
244
+ "CUI-CLSCC/non-removed insulation has no source technique for A-D and therefore remains E. Caller must qualify exposed/suspect area definition, inspection quality and follow-up."
245
+ ],
246
+ )
247
+ def external_effectiveness(x):
248
+ if not (x.inspection_quality_high and x.locations_qualified and x.technique_effective):
249
+ return {"effectiveness_category": "E", "mechanism_family": x.mechanism_family,
250
+ "insulation_status": x.insulation_status, "qualification_status": "failed",
251
+ "basis": "inspection_quality_or_location_or_technique_not_qualified"}
252
+
253
+ visual = x.visual_coverage_percent
254
+ ndt = x.targeted_ndt_coverage_percent
255
+ removed = x.insulation_removed_percent
256
+ follow = x.follow_up_coverage_percent
257
+ if x.mechanism_family == "external_corrosion":
258
+ if visual > 95 and follow >= 100: category, basis = "A", "over_95_percent_visual_with_follow_up"
259
+ elif visual > 60 and follow >= 100: category, basis = "B", "over_60_percent_visual_with_follow_up"
260
+ elif visual > 30 and follow >= 100: category, basis = "C", "over_30_percent_visual_with_follow_up"
261
+ elif visual > 5 and follow >= 100: category, basis = "D", "over_5_percent_visual_with_follow_up"
262
+ else: category, basis = "E", "external_corrosion_threshold_not_met"
263
+ elif x.mechanism_family == "external_clscc":
264
+ if ndt >= 100 and follow >= 100: category, basis = "A", "100_percent_dye_or_eddy_with_follow_up"
265
+ elif ndt > 60 and follow >= 100: category, basis = "B", "over_60_percent_dye_or_eddy_with_follow_up"
266
+ elif ndt > 30 and follow >= 100: category, basis = "C", "over_30_percent_dye_or_eddy_with_follow_up"
267
+ elif ndt > 5 and follow >= 100: category, basis = "D", "over_5_percent_dye_or_eddy_with_follow_up"
268
+ else: category, basis = "E", "external_clscc_threshold_not_met"
269
+ elif x.mechanism_family == "cui":
270
+ if visual < 100 or follow < 100:
271
+ category, basis = "E", "cui_requires_full_visual_and_follow_up"
272
+ elif x.insulation_status == "removed":
273
+ if removed >= 100: category, basis = "A", "cui_removed_100_percent_suspect_area"
274
+ elif removed > 50: category, basis = "B", "cui_removed_over_50_percent_suspect_area"
275
+ elif removed > 25: category, basis = "C", "cui_removed_over_25_percent_suspect_area"
276
+ elif removed > 5: category, basis = "D", "cui_removed_over_5_percent_total_area"
277
+ else: category, basis = "E", "cui_removed_threshold_not_met"
278
+ elif x.insulation_status == "not_removed":
279
+ if ndt >= 100: category, basis = "A", "cui_not_removed_100_percent_profile_radiography"
280
+ elif ndt > 65: category, basis = "B", "cui_not_removed_over_65_percent_profile_radiography"
281
+ elif ndt > 35: category, basis = "C", "cui_not_removed_over_35_percent_profile_radiography"
282
+ elif ndt > 5: category, basis = "D", "cui_not_removed_over_5_percent_profile_radiography"
283
+ else: category, basis = "E", "cui_not_removed_threshold_not_met"
284
+ else: category, basis = "E", "cui_insulation_status_required"
285
+ elif x.insulation_status == "removed" and visual >= 100 and follow >= 100:
286
+ if ndt >= 100: category, basis = "A", "cui_clscc_removed_100_percent_targeted_ndt"
287
+ elif ndt > 60: category, basis = "B", "cui_clscc_removed_over_60_percent_targeted_ndt"
288
+ elif ndt > 30: category, basis = "C", "cui_clscc_removed_over_30_percent_targeted_ndt"
289
+ elif ndt > 5: category, basis = "D", "cui_clscc_removed_over_5_percent_targeted_ndt"
290
+ else: category, basis = "E", "cui_clscc_removed_threshold_not_met"
291
+ else:
292
+ category, basis = "E", "cui_clscc_non_removed_or_qualification_not_met"
293
+ return {"effectiveness_category": category, "mechanism_family": x.mechanism_family,
294
+ "insulation_status": x.insulation_status, "qualification_status": "qualified",
295
+ "basis": basis}
296
+
297
+
298
+ class BuriedInspectionEffectiveness(Model):
299
+ inspection_mode: Literal["intrusive", "non_intrusive"]
300
+ coverage_percent: NonNegative = Field(default=0, le=100)
301
+ state_of_art_internal_technology: bool = False
302
+ selected_internal_technology: bool = False
303
+ statistical_or_extreme_value_analysis: bool = False
304
+ partial_smart_pig_or_specialized_crawler: bool = False
305
+ hydrostatic_test: bool = False
306
+ full_excavation_visual_ndt: bool = False
307
+ external_ndt_crawler: bool = False
308
+ cathodic_protection_managed: bool = False
309
+ close_interval_survey: bool = False
310
+ soil_water_sampling: bool = False
311
+ dcvg_coating_survey: bool = False
312
+ guided_wave_and_corrosion_management: bool = False
313
+ spot_check_exposed_areas: bool = False
314
+ inspection_quality_high: bool = True
315
+ qualification_basis: str = Field(min_length=1, max_length=1000)
316
+
317
+
318
+ @calculation(
319
+ "inspection/buried-effectiveness",
320
+ "API 581 Annex 2.C.7 buried-component inspection effectiveness category",
321
+ BuriedInspectionEffectiveness,
322
+ ["Part 2 Annex C §2.C.7.1-§2.C.7.2, Table 2.C.7.1; PDF pp452-453"],
323
+ origin="api581_annex_2c7_buried_inspection_effectiveness",
324
+ limitations=[
325
+ "Classifies the example intrusive/non-intrusive evidence bundles in Table 2.C.7.1. It does not infer corrosion rate, cathodic-protection adequacy, soil corrosivity, PoF or damage factor.",
326
+ "The caller must qualify the evidence flags and coverage. A missing qualification basis or low inspection quality returns E; partial excavation and non-intrusive evidence are not silently promoted to full coverage."
327
+ ],
328
+ )
329
+ def buried_effectiveness(x):
330
+ if not x.inspection_quality_high or not x.qualification_basis.strip():
331
+ return {"effectiveness_category": "E", "inspection_mode": x.inspection_mode,
332
+ "qualification_status": "failed", "basis": "inspection_quality_or_basis_not_qualified"}
333
+ if x.inspection_mode == "intrusive":
334
+ if x.state_of_art_internal_technology and x.coverage_percent >= 100:
335
+ category, basis = "A", "100_percent_state_of_art_internal_inspection"
336
+ elif x.selected_internal_technology and x.statistical_or_extreme_value_analysis:
337
+ category, basis = "B", "selected_internal_inspection_with_statistical_analysis"
338
+ elif x.partial_smart_pig_or_specialized_crawler:
339
+ category, basis = "C", "partial_smart_pig_or_specialized_crawler"
340
+ elif x.hydrostatic_test:
341
+ category, basis = "D", "hydrostatic_test"
342
+ else:
343
+ category, basis = "E", "intrusive_evidence_bundle_not_met"
344
+ elif (
345
+ x.full_excavation_visual_ndt and x.coverage_percent >= 100
346
+ and x.soil_water_sampling and x.cathodic_protection_managed
347
+ ) or (
348
+ x.external_ndt_crawler and x.coverage_percent >= 100
349
+ and x.cathodic_protection_managed
350
+ ):
351
+ category, basis = "A", "qualified_full_external_inspection_and_CP_controls"
352
+ elif (
353
+ x.external_ndt_crawler and x.statistical_or_extreme_value_analysis
354
+ ) or (
355
+ x.close_interval_survey and x.full_excavation_visual_ndt
356
+ and x.cathodic_protection_managed
357
+ ):
358
+ category, basis = "B", "selected_external_inspection_with_statistics_or_CP_guided_excavation"
359
+ elif x.guided_wave_and_corrosion_management and x.spot_check_exposed_areas:
360
+ category, basis = "C", "guided_wave_with_corrosion_management_and_spot_check"
361
+ elif x.coverage_percent > 5 and (x.full_excavation_visual_ndt or x.external_ndt_crawler):
362
+ category, basis = "D", "limited_external_visual_or_NDE_inspection"
363
+ else:
364
+ category, basis = "E", "non_intrusive_evidence_bundle_not_met"
365
+ return {"effectiveness_category": category, "inspection_mode": x.inspection_mode,
366
+ "qualification_status": "qualified", "basis": basis}
367
+
368
+
369
+ class PrdInspectionEffectiveness(Model):
370
+ component_type: Literal["pressure_relief_device", "rupture_disk"]
371
+ as_received_bench_test: bool = False
372
+ leak_pressure_documented: bool = False
373
+ opening_pressure_documented: bool = False
374
+ reseat_pressure_documented: bool = False
375
+ inlet_outlet_examined: bool = False
376
+ cleaned_or_steamed_before_test: bool = False
377
+ detailed_internal_visual: bool = False
378
+ in_situ_actual_process_fluid: bool = False
379
+ removed_and_visually_inspected: bool = False
380
+ in_situ_non_process_fluid: bool = False
381
+ leakage_monitoring: bool = False
382
+ overhaul_without_pop_test: bool = False
383
+ internal_details_documented: bool = False
384
+ qualification_basis: str = Field(min_length=1, max_length=1000)
385
+
386
+
387
+ @calculation(
388
+ "inspection/prd-effectiveness",
389
+ "API 581 Annex 2.C.3 pressure-relief-device inspection effectiveness category",
390
+ PrdInspectionEffectiveness,
391
+ ["Part 2 Annex C §2.C.3-§2.C.3.1, Table 2.C.3.1; PDF pp445-446"],
392
+ origin="api581_annex_2c3_prd_inspection_effectiveness",
393
+ limitations=[
394
+ "Implements the Table 2.C.3.1 evidence examples. It classifies inspection effectiveness only and does not calculate PRD failure frequency, service-age factor, set-pressure factor or leak consequence.",
395
+ "Rupture-disk A-level inspection is explicitly unavailable in the source table. Missing qualification basis or incomplete evidence returns E rather than an optimistic category."
396
+ ],
397
+ )
398
+ def prd_effectiveness(x):
399
+ if not x.qualification_basis.strip():
400
+ return {"effectiveness_category": "E", "component_type": x.component_type,
401
+ "qualification_status": "failed", "basis": "qualification_basis_missing"}
402
+ if x.component_type == "pressure_relief_device":
403
+ if (x.as_received_bench_test and x.leak_pressure_documented
404
+ and x.opening_pressure_documented and x.reseat_pressure_documented
405
+ and x.inlet_outlet_examined):
406
+ category, basis = "A", "as_received_bench_test_pressures_and_piping_exam"
407
+ elif ((x.as_received_bench_test and x.cleaned_or_steamed_before_test and x.detailed_internal_visual)
408
+ or x.in_situ_actual_process_fluid):
409
+ category, basis = "B", "cleaned_bench_test_with_visual_or_actual_fluid_in_situ_test"
410
+ elif x.detailed_internal_visual or x.in_situ_non_process_fluid:
411
+ category, basis = "C", "documented_visual_or_non_process_fluid_in_situ_test"
412
+ elif x.overhaul_without_pop_test:
413
+ category, basis = "D", "overhaul_without_pop_test"
414
+ else:
415
+ category, basis = "E", "PRD_evidence_bundle_not_met"
416
+ else:
417
+ if x.removed_and_visually_inspected:
418
+ category, basis = "B", "rupture_disk_removed_and_visually_inspected"
419
+ elif x.leakage_monitoring:
420
+ category, basis = "C", "rupture_disk_leakage_monitoring"
421
+ elif x.internal_details_documented:
422
+ category, basis = "D", "rupture_disk_internal_details_only"
423
+ else:
424
+ category, basis = "E", "rupture_disk_no_effective_method"
425
+ return {"effectiveness_category": category, "component_type": x.component_type,
426
+ "qualification_status": "qualified", "basis": basis}
427
+
428
+
429
+ class LiningInspectionEffectiveness(Model):
430
+ inspection_mode: Literal["intrusive", "non_intrusive"]
431
+ visual_coverage_percent: NonNegative = Field(default=0, le=100)
432
+ holiday_test_coverage_percent: NonNegative = Field(default=0, le=100)
433
+ disbonding_test_coverage_percent: NonNegative = Field(default=0, le=100)
434
+ ultrasonic_scan_coverage_percent: NonNegative = Field(default=0, le=100)
435
+ inspection_quality_high: bool = True
436
+ locations_qualified: bool = True
437
+ qualification_basis: str = Field(min_length=1, max_length=1000)
438
+
439
+
440
+ @calculation(
441
+ "inspection/lining-effectiveness",
442
+ "API 581 Annex 2.C.6 non-metallic lining inspection effectiveness category",
443
+ LiningInspectionEffectiveness,
444
+ ["Part 2 Annex C §2.C.6.1-§2.C.6.2, Table 2.C.6.1; PDF pp451-452"],
445
+ origin="api581_annex_2c6_lining_inspection_effectiveness",
446
+ limitations=[
447
+ "Implements the example percentage bands for corrosion-resistant non-metallic liners. The source states this inspection effectiveness is not currently used in the lining damage factor calculation; this API does not change that rule.",
448
+ "Non-intrusive A-level inspection is explicitly unavailable in the source table. Inspection quality, suspect-area definition and test suitability remain owner-qualified inputs."
449
+ ],
450
+ )
451
+ def lining_effectiveness(x):
452
+ if not (x.inspection_quality_high and x.locations_qualified and x.qualification_basis.strip()):
453
+ return {"effectiveness_category": "E", "inspection_mode": x.inspection_mode,
454
+ "qualification_status": "failed", "basis": "inspection_quality_location_or_basis_not_qualified"}
455
+ visual = x.visual_coverage_percent
456
+ holiday = x.holiday_test_coverage_percent
457
+ disbonding = x.disbonding_test_coverage_percent
458
+ scan = x.ultrasonic_scan_coverage_percent
459
+ if x.inspection_mode == "intrusive":
460
+ if visual >= 100 and holiday >= 100 and disbonding >= 100:
461
+ category, basis = "A", "100_percent_visual_holiday_and_disbonding_tests"
462
+ elif visual > 65 and holiday > 65 and disbonding > 65:
463
+ category, basis = "B", "over_65_percent_visual_holiday_and_disbonding_tests"
464
+ elif max(visual, holiday, disbonding) > 35:
465
+ category, basis = "C", "over_35_percent_intrusive_liner_test"
466
+ elif max(visual, holiday, disbonding) > 5:
467
+ category, basis = "D", "over_5_percent_intrusive_liner_test"
468
+ else:
469
+ category, basis = "E", "intrusive_liner_threshold_not_met"
470
+ else:
471
+ if scan >= 100:
472
+ category, basis = "B", "100_percent_automated_or_manual_ultrasonic_scan"
473
+ elif scan > 65:
474
+ category, basis = "C", "over_65_percent_ultrasonic_scan"
475
+ elif scan > 35:
476
+ category, basis = "D", "over_35_percent_ultrasonic_scan"
477
+ else:
478
+ category, basis = "E", "non_intrusive_liner_threshold_or_A_unavailable"
479
+ return {"effectiveness_category": category, "inspection_mode": x.inspection_mode,
480
+ "qualification_status": "qualified", "basis": basis}
481
+
482
+
483
+ class TubeBundleInspectionUncertainty(Model):
484
+ inspection_effectiveness: Literal["A", "B", "C", "D", "E"]
485
+ owner_user_basis: str = Field(min_length=1, max_length=1000)
486
+
487
+
488
+ @calculation(
489
+ "inspection/tube-bundle-uncertainty",
490
+ "API 581 Annex 2.C.4.1 default tube-bundle inspection uncertainty",
491
+ TubeBundleInspectionUncertainty,
492
+ ["Part 2 Annex C §2.C.4.1.1-§2.C.4.1.3, Table 2.C.4.1; PDF pp446-448"],
493
+ origin="api581_annex_2c4_tube_bundle_uncertainty",
494
+ limitations=[
495
+ "Returns the recommended additional-uncertainty percentage only: A=5%, B=10%, C=20%, D=30%, E=50%. It does not shift a Weibull curve or calculate tube-bundle PoF by itself.",
496
+ "The source says tube-bundle LoIE tables are owner-user defined; the category and basis remain caller-qualified and must not be inferred from an NDE name alone."
497
+ ],
498
+ )
499
+ def tube_bundle_uncertainty(x):
500
+ values = {"A": 5.0, "B": 10.0, "C": 20.0, "D": 30.0, "E": 50.0}
501
+ percent = values[x.inspection_effectiveness]
502
+ return {"inspection_effectiveness": x.inspection_effectiveness,
503
+ "uncertainty_percent": percent,
504
+ "uncertainty_fraction": percent / 100.0,
505
+ "baseline_no_history_percent": 50.0,
506
+ "owner_user_basis": x.owner_user_basis}
507
+
508
+
509
+ class AstInspectionEffectiveness(Model):
510
+ component_type: Literal["shell_internal", "shell_external", "tank_bottom"]
511
+ inspection_side: Literal["internal", "external", "soil_side", "product_side"]
512
+ insulated: bool = False
513
+ visual_coverage_percent: NonNegative = Field(default=0, le=100)
514
+ internal_video_or_visual: bool = False
515
+ pit_depth_measurement: bool = False
516
+ ut_scan_percent: NonNegative = Field(default=0, le=100)
517
+ ut_follow_up: bool = False
518
+ insulation_removed_percent: NonNegative = Field(default=0, le=100)
519
+ pulse_eddy_percent: NonNegative = Field(default=0, le=100)
520
+ floor_scan_percent: NonNegative = Field(default=0, le=100)
521
+ vacuum_box_percent: NonNegative = Field(default=0, le=100)
522
+ sponge_test_percent: NonNegative = Field(default=0, le=100)
523
+ statistical_floor_analysis: bool = False
524
+ qualification_basis: str = Field(min_length=1, max_length=1000)
525
+ inspection_quality_high: bool = True
526
+
527
+
528
+ @calculation(
529
+ "inspection/ast-effectiveness",
530
+ "API 581 Annex 2.C.5 atmospheric storage tank inspection effectiveness category",
531
+ AstInspectionEffectiveness,
532
+ ["Part 2 Annex C §2.C.5.1-§2.C.5.2, Tables 2.C.5.1-2.C.5.3; PDF pp449-451"],
533
+ origin="api581_annex_2c5_ast_inspection_effectiveness",
534
+ limitations=[
535
+ "Implements the example AST shell-internal, shell-external and tank-bottom evidence bands. It does not calculate tank corrosion rate, minimum thickness, PoF or financial consequence.",
536
+ "The caller must select the correct side and qualify inspection quality, exposed/suspect area and API 653/NDE interpretation. Inputs from multiple sides are not silently combined into a higher category."
537
+ ],
538
+ )
539
+ def ast_effectiveness(x):
540
+ if not (x.inspection_quality_high and x.qualification_basis.strip()):
541
+ return {"effectiveness_category": "E", "component_type": x.component_type,
542
+ "inspection_side": x.inspection_side, "qualification_status": "failed",
543
+ "basis": "inspection_quality_or_basis_not_qualified"}
544
+ if x.component_type == "shell_internal":
545
+ if x.internal_video_or_visual and x.pit_depth_measurement and x.ut_scan_percent >= 100 and x.ut_follow_up:
546
+ category, basis = "A", "internal_visual_pit_and_full_UT_confirmation"
547
+ elif x.ut_scan_percent > 0 and x.internal_video_or_visual and x.ut_follow_up:
548
+ category, basis = "B", "internal_video_with_external_UT_follow_up"
549
+ elif x.ut_scan_percent > 0 and x.internal_video_or_visual:
550
+ category, basis = "C", "external_spot_UT_with_internal_information"
551
+ elif x.ut_scan_percent > 0:
552
+ category, basis = "D", "external_spot_UT_without_internal_information"
553
+ else:
554
+ category, basis = "E", "shell_internal_evidence_bundle_not_met"
555
+ elif x.component_type == "shell_external":
556
+ if x.insulated:
557
+ if x.visual_coverage_percent > 95 and x.insulation_removed_percent > 90 and x.pulse_eddy_percent > 90:
558
+ category, basis = "A", "insulated_shell_full_visual_removal_and_pulse_eddy"
559
+ elif x.visual_coverage_percent > 95 and x.insulation_removed_percent > 50 and x.pulse_eddy_percent > 50:
560
+ category, basis = "B", "insulated_shell_over_50_percent_suspect_inspection"
561
+ elif x.visual_coverage_percent > 95 and x.insulation_removed_percent > 30 and x.pulse_eddy_percent > 30:
562
+ category, basis = "C", "insulated_shell_over_30_percent_suspect_inspection"
563
+ elif x.visual_coverage_percent > 95 and x.insulation_removed_percent > 10 and x.pulse_eddy_percent > 10:
564
+ category, basis = "D", "insulated_shell_over_10_percent_suspect_inspection"
565
+ else:
566
+ category, basis = "E", "insulated_shell_threshold_not_met"
567
+ elif x.visual_coverage_percent > 95 and x.ut_follow_up:
568
+ category, basis = "A", "uninsulated_shell_over_95_percent_visual_with_follow_up"
569
+ elif x.visual_coverage_percent > 50 and x.ut_follow_up:
570
+ category, basis = "B", "uninsulated_shell_over_50_percent_visual_with_follow_up"
571
+ elif x.visual_coverage_percent > 25 and x.ut_follow_up:
572
+ category, basis = "C", "uninsulated_shell_over_25_percent_visual_with_follow_up"
573
+ elif x.visual_coverage_percent > 10 and x.ut_follow_up:
574
+ category, basis = "D", "uninsulated_shell_over_10_percent_visual_with_follow_up"
575
+ else:
576
+ category, basis = "E", "uninsulated_shell_threshold_not_met"
577
+ elif x.inspection_side == "soil_side":
578
+ if x.floor_scan_percent > 90 and x.ut_follow_up:
579
+ category, basis = "A", "soil_side_over_90_percent_floor_scan_and_UT_follow_up"
580
+ elif (x.floor_scan_percent > 50 and x.ut_follow_up) or x.statistical_floor_analysis:
581
+ category, basis = "B", "soil_side_over_50_percent_scan_or_statistical_analysis"
582
+ elif x.floor_scan_percent > 5 and x.ut_follow_up:
583
+ category, basis = "C", "soil_side_partial_floor_scan_and_UT_follow_up"
584
+ elif x.ut_scan_percent > 0 or x.visual_coverage_percent > 50:
585
+ category, basis = "D", "soil_side_spot_or_limited_visual_inspection"
586
+ else:
587
+ category, basis = "E", "soil_side_threshold_not_met"
588
+ else:
589
+ if x.visual_coverage_percent >= 100 and x.vacuum_box_percent >= 100:
590
+ category, basis = "A", "product_side_full_visual_and_vacuum_box"
591
+ elif x.sponge_test_percent > 75 or x.visual_coverage_percent >= 100:
592
+ category, basis = "B", "product_side_full_visual_or_over_75_percent_sponge"
593
+ elif 50 <= x.sponge_test_percent <= 75 or x.visual_coverage_percent > 50:
594
+ category, basis = "C", "product_side_intermediate_liner_or_visual_inspection"
595
+ elif x.sponge_test_percent > 0 or x.visual_coverage_percent > 0:
596
+ category, basis = "D", "product_side_limited_inspection"
597
+ else:
598
+ category, basis = "E", "product_side_threshold_not_met"
599
+ return {"effectiveness_category": category, "component_type": x.component_type,
600
+ "inspection_side": x.inspection_side, "qualification_status": "qualified",
601
+ "basis": basis}
api581/jet_handoff.py ADDED
@@ -0,0 +1,60 @@
1
+ """Qualified API581 rainout/flash partition -> actual native jet source."""
2
+ import math
3
+ from pydantic import Field
4
+ from .core import Model, Positive, REGISTRY, calculation, ensure, finite_tree
5
+ from .source_partition import SourcePartition, source_partition
6
+ from .slabx_adapter import NativeJetGeometry, NativeJet, NativeJetFootprint, native_jet, native_jet_footprint
7
+
8
+
9
+ class PartitionedJet(Model):
10
+ partition: SourcePartition
11
+ jet: NativeJetGeometry
12
+
13
+
14
+ class JetFootprintGeometry(NativeJetGeometry):
15
+ crosswind_half_span_m: Positive
16
+ crosswind_points_per_side: int = Field(default=65,ge=3,le=501,strict=True)
17
+
18
+
19
+ class PartitionedJetFootprint(Model):
20
+ partition: SourcePartition
21
+ jet: JetFootprintGeometry
22
+
23
+
24
+ _LIMITS = [*["Source-partition stage only: "+item for item in REGISTRY["release/level2-source-partition"].limitations],
25
+ *REGISTRY["dispersion/native-jet-centreline"].limitations,
26
+ "Native q is partition jet-total vapor+droplet rate; liquid MASS fraction is partition entrained-liquid fraction. Duration is jet mass/rate from same partition. No independent duplicate source rate/composition/duration accepted. Pool rainout remains a separate source, not added to native jet. Empty/zero-duration jet and pure-liquid jet fail explicitly. Caller still qualifies effective atmospheric exit area, temperature, flash/rainout data and native source applicability."]
27
+
28
+
29
+ def _handoff(x,footprint):
30
+ partition = source_partition(x.partition)
31
+ finite_tree(partition)
32
+ q,mass = partition["jet_total_rate_kg_s"],partition["jet_total_mass_kg"]
33
+ ensure(q > 0 and mass > 0 and partition["jet_fraction_nonzero"],"Partition has no positive finite-duration jet source","empty_jet_source")
34
+ liquid = partition["entrained_liquid_mass_fraction"]
35
+ ensure(liquid is not None and 0 <= liquid < 1,"Native jet cannot accept pure liquid/no-vapor partition","unsupported_engine_source_domain")
36
+ duration = mass/q
37
+ ensure(math.isfinite(duration) and duration > 0,"Jet handoff duration outside numerical range","numerical_failure")
38
+ if x.jet.temperature_basis == "require_consistent":
39
+ boiling = x.jet.substance.normal_boiling_point_k
40
+ ensure(x.jet.source_temperature_k >= boiling and (liquid == 0 or x.jet.source_temperature_k == boiling),
41
+ "Partition composition conflicts with required native source temperature","native_source_state_inconsistent")
42
+ model = NativeJetFootprint if footprint else NativeJet
43
+ request = model(**x.jet.model_dump(),mass_source_rate_kg_s=q,release_duration_s=duration,jet_liquid_mass_fraction=liquid)
44
+ output = (native_jet_footprint if footprint else native_jet)(request)
45
+ return {**output,**partition,"release_duration_s":duration,"integration_scope":"qualified_partition_to_native_jet"}
46
+
47
+
48
+ @calculation("dispersion/partitioned-jet-centreline", "API581 source partition through native jet centreline", PartitionedJet,
49
+ [*REGISTRY["release/level2-source-partition"].references,*REGISTRY["dispersion/native-jet-centreline"].references],
50
+ origin="api581_partition_to_native_jet_composition",limitations=_LIMITS)
51
+ def partitioned_jet(x):
52
+ return _handoff(x,False)
53
+
54
+
55
+ @calculation("dispersion/partitioned-jet-footprint", "API581 source partition through native planar jet footprint", PartitionedJetFootprint,
56
+ [*REGISTRY["release/level2-source-partition"].references,*REGISTRY["dispersion/native-jet-footprint"].references],
57
+ origin="api581_partition_to_native_jet_spatial_composition",limitations=_LIMITS+[
58
+ "Area is sampled-grid triangular interpolation and needs spatial convergence. Source/handoff-edge concentration, outer-grid exceedance or LH2 premise violation censors usable area; diagnostic field/area preserved. No missing near-field completion or source-face extrapolation."])
59
+ def partitioned_jet_footprint(x):
60
+ return _handoff(x,True)