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,245 @@
1
+ """Part 1 §§7.4-7.6 PRD leakage consequences and risk integration."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field, StrictBool
5
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
6
+
7
+ Discharge = Literal["atmosphere", "flare", "closed_system"]
8
+ LeakModel = Literal["two_case_equations", "three_case_table_7_15"]
9
+ LeakRiskBasis = Literal["annual_frequency", "source_cumulative_probability"]
10
+
11
+
12
+ class MoneyBasis(Model):
13
+ currency: str = Field(pattern=r"^[A-Z]{3}$")
14
+ cost_basis_year: int = Field(strict=True,ge=1900,le=2200)
15
+
16
+
17
+ def _money(x):
18
+ return {"currency":x.currency,"cost_basis_year":x.cost_basis_year}
19
+
20
+
21
+ class LeakageRates(Model):
22
+ operating_capacity_kg_s: Positive
23
+
24
+
25
+ @calculation("prd/leakage-rates", "PRD minor/moderate/combined/stuck-open leakage rates", LeakageRates,
26
+ ["Part 1 §7.5.2; eqs(1.40)-(1.41); Table7.15; PDF pp66,88"],
27
+ limitations=["Capacity must be evaluated at normal operating conditions, not silently copied from a relief-condition rating. Eq(1.40) uses 1% for combined mild/moderate; Table7.15 uses 1% minor and 10% moderate. Both are returned explicitly. API520 capacity sizing is not implemented here."])
28
+ def leakage_rates(x):
29
+ return {"minor_rate_kg_s":.01*x.operating_capacity_kg_s,
30
+ "moderate_table_rate_kg_s":.1*x.operating_capacity_kg_s,
31
+ "mild_moderate_equation_rate_kg_s":.01*x.operating_capacity_kg_s,
32
+ "stuck_open_rate_kg_s":.25*x.operating_capacity_kg_s}
33
+
34
+
35
+ class LeakageDuration(Model):
36
+ inlet_size_nps: Positive
37
+ discharge: Discharge
38
+
39
+
40
+ @calculation("prd/leakage-duration", "PRD size/discharge leakage duration", LeakageDuration,
41
+ ["Part 1 Table7.14; eq(1.42); PDF pp67,88"],
42
+ limitations=["NPS is a nominal inlet designation, not a measured diameter in metres. Table duration days are converted exactly to seconds (including printed .33 days). Stuck-open duration uses exact30 minutes=1800s, not rounded .021 days."])
43
+ def leakage_duration(x):
44
+ row = sum(x.inlet_size_nps > b for b in (.75,1.5,3,6))
45
+ days = ((8,4,2,1,.33) if x.discharge == "atmosphere" else (60,30,15,7,2))[row]
46
+ return {"mild_moderate_duration_s":days*86400,"stuck_open_duration_s":1800}
47
+
48
+
49
+ class InventoryRecovery(Model):
50
+ discharge: Discharge
51
+ flare_recovery_installed: StrictBool = False
52
+
53
+
54
+ @calculation("prd/inventory-loss-factor", "PRD residual inventory loss factor after recovery", InventoryRecovery,
55
+ ["Part 1 §7.5.4; PDF p67"],
56
+ limitations=["Factor Fr multiplies lost-inventory cost: closed system0, flare with recovery .5, otherwise1. It does not reduce physical leaked mass or environmental/repair/production costs."])
57
+ def inventory_recovery(x):
58
+ ensure(not x.flare_recovery_installed or x.discharge == "flare", "Flare recovery is only applicable to flare discharge")
59
+ return {"inventory_loss_factor":0 if x.discharge == "closed_system" else (.5 if x.flare_recovery_installed else 1)}
60
+
61
+
62
+ class InventoryLoss(MoneyBasis):
63
+ leakage_rate_kg_s: NonNegative
64
+ duration_s: NonNegative
65
+ fluid_cost_per_kg: NonNegative
66
+ inventory_loss_factor: Fraction
67
+
68
+
69
+ @calculation("prd/inventory-loss", "PRD leakage inventory mass and financial loss", InventoryLoss,
70
+ ["Part 1 §7.5.5; eqs(1.43)-(1.44); PDF p67"],
71
+ limitations=["SI mass rate kg/s times seconds replaces source hourly-rate/day factor24. Fluid value must correspond to the protected equipment at the PRD location. Constant supplied operating-capacity leak; no automatic finite inventory/supply limit."])
72
+ def inventory_loss(x):
73
+ mass = x.leakage_rate_kg_s*x.duration_s
74
+ residual = mass*x.inventory_loss_factor
75
+ return {"leaked_mass_kg":mass,"inventory_loss_equivalent_mass_kg":residual,
76
+ "inventory_loss_cost":residual*x.fluid_cost_per_kg,**_money(x)}
77
+
78
+
79
+ class ReferenceRepair(MoneyBasis):
80
+ inlet_size_nps: Positive
81
+ price_index_multiplier: Positive
82
+ output_currency_per_usd: Positive
83
+
84
+
85
+ @calculation("prd/repair-cost", "PRD reference repair quote adjusted to declared price basis", ReferenceRepair,
86
+ ["Part 1 §7.5.7; PDF p67"],
87
+ limitations=["Printed reference USD1000 below NPS6, USD2000 at/above NPS6. Price quote year is not inferred from edition year; explicit owner-qualified price index and FX required. Actual work-order cost is preferred and can be supplied directly to leakage-consequence."])
88
+ def repair_cost(x):
89
+ reference = 1000 if x.inlet_size_nps < 6 else 2000
90
+ return {"reference_cost_usd":reference,"repair_cost":reference*x.price_index_multiplier*x.output_currency_per_usd,**_money(x)}
91
+
92
+
93
+ class ProductionLoss(MoneyBasis):
94
+ daily_production_margin: NonNegative
95
+ shutdown_duration_days: NonNegative
96
+ leak_case: Literal["mild_moderate", "stuck_open"]
97
+ mild_leak_tolerated_or_online_repair: StrictBool
98
+
99
+
100
+ @calculation("prd/production-loss", "PRD leakage shutdown production loss", ProductionLoss,
101
+ ["Part 1 §7.5.8; eqs(1.45)-(1.47); PDF pp67-68"],
102
+ limitations=["Mild/moderate leak tolerated or isolated/repaired online has zero production loss. Stuck-open case uses shutdown loss per eq(1.47). Caller supplies actual shutdown duration and production margin, including any qualified zero-duration repair."])
103
+ def production_loss(x):
104
+ required = x.leak_case == "stuck_open" or not x.mild_leak_tolerated_or_online_repair
105
+ return {"production_loss_cost":x.daily_production_margin*x.shutdown_duration_days if required else 0,
106
+ "shutdown_loss_applied":required,**_money(x)}
107
+
108
+
109
+ class LeakageConsequence(MoneyBasis):
110
+ operating_capacity_kg_s: Positive
111
+ inlet_size_nps: Positive
112
+ discharge: Discharge
113
+ flare_recovery_installed: StrictBool = False
114
+ fluid_cost_per_kg: NonNegative
115
+ environmental_cost: NonNegative
116
+ repair_cost: NonNegative
117
+ daily_production_margin: NonNegative
118
+ shutdown_duration_days: NonNegative
119
+ mild_leak_tolerated_or_online_repair: StrictBool
120
+ model_basis: LeakModel
121
+
122
+
123
+ @calculation("prd/leakage-consequence", "PRD two/three-case leakage financial consequence", LeakageConsequence,
124
+ ["Part 1 §7.5.10; eqs(1.39)-(1.50); Tables7.14,7.15; PDF pp66-69,88"],
125
+ origin="api581_equations_with_explicit_source_discrepancy_choice",
126
+ limitations=["Caller explicitly selects two_case_equations (.9 at1% capacity + .1 stuck at25%) or three_case_table_7_15 (.5 at1%, .4 at10%, .1 at25%) because source rates conflict. Non-stuck cases share Table7.14 duration. Environmental and repair costs are common conditional case costs as printed. All monetary inputs share declared currency/year; no automatic price-year inference or supply/inventory cap."])
127
+ def leakage_consequence(x):
128
+ rates = leakage_rates(LeakageRates(operating_capacity_kg_s=x.operating_capacity_kg_s))
129
+ duration = leakage_duration(LeakageDuration(inlet_size_nps=x.inlet_size_nps,discharge=x.discharge))
130
+ recovery = inventory_recovery(InventoryRecovery(discharge=x.discharge,flare_recovery_installed=x.flare_recovery_installed))["inventory_loss_factor"]
131
+ cases = ({"mild_moderate":(.9,rates["mild_moderate_equation_rate_kg_s"]),"stuck_open":(.1,rates["stuck_open_rate_kg_s"])}
132
+ if x.model_basis == "two_case_equations" else
133
+ {"minor":(.5,rates["minor_rate_kg_s"]),"moderate":(.4,rates["moderate_table_rate_kg_s"]),"stuck_open":(.1,rates["stuck_open_rate_kg_s"])})
134
+ costs,weights = {},{}
135
+ for name,(weight,rate) in cases.items():
136
+ stuck = name == "stuck_open"
137
+ seconds = duration["stuck_open_duration_s" if stuck else "mild_moderate_duration_s"]
138
+ inv = inventory_loss(InventoryLoss(leakage_rate_kg_s=rate,duration_s=seconds,fluid_cost_per_kg=x.fluid_cost_per_kg,
139
+ inventory_loss_factor=recovery,**_money(x)))
140
+ prod = production_loss(ProductionLoss(daily_production_margin=x.daily_production_margin,
141
+ shutdown_duration_days=x.shutdown_duration_days,leak_case="stuck_open" if stuck else "mild_moderate",
142
+ mild_leak_tolerated_or_online_repair=x.mild_leak_tolerated_or_online_repair,**_money(x)))
143
+ total = inv["inventory_loss_cost"]+prod["production_loss_cost"]+x.environmental_cost+x.repair_cost
144
+ costs[name] = {"leakage_rate_kg_s":rate,"duration_s":seconds,"leaked_mass_kg":inv["leaked_mass_kg"],
145
+ "inventory_loss_cost":inv["inventory_loss_cost"],"production_loss_cost":prod["production_loss_cost"],
146
+ "environmental_cost":x.environmental_cost,"repair_cost":x.repair_cost,"financial_consequence":total}
147
+ weights[name] = weight
148
+ return {"financial_consequence":math.fsum(weights[k]*v["financial_consequence"] for k,v in costs.items()),
149
+ "case_weights":weights,"costs_by_case":costs,"inventory_loss_factor":recovery,"model_basis":x.model_basis,**_money(x)}
150
+
151
+
152
+ class MultipleDevicePressure(Model):
153
+ analyzed_orifice_area_m2: Positive
154
+ total_installed_orifice_area_m2: Positive
155
+ scenario_pressure_pa_gauge: NonNegative
156
+
157
+
158
+ @calculation("prd/multiple-device-pressure", "Multiple PRD area-based protected-equipment pressure adjustment", MultipleDevicePressure,
159
+ ["Part 1 §7.4.4; eqs(1.37)-(1.38); PDF pp64-65"],
160
+ limitations=["Total installed area includes analyzed PRD. Fa=max(.25,sqrt(Aprd/Atotal)). Reduced pressure is used for protected-equipment LOC and consequence calculations only; original scenario pressure must be retained for Fop (PRD POFOD). Source square-root approximation is not an independent parallel-failure probability or a capacity-design verification."])
161
+ def multiple_device_pressure(x):
162
+ ensure(x.analyzed_orifice_area_m2 <= x.total_installed_orifice_area_m2, "Total installed orifice area must include analyzed device")
163
+ factor = max(.25,math.sqrt(x.analyzed_orifice_area_m2/x.total_installed_orifice_area_m2))
164
+ return {"multiple_device_factor":factor,"protected_equipment_pressure_pa_gauge":factor*x.scenario_pressure_pa_gauge,
165
+ "unreduced_pressure_for_fop_pa_gauge":x.scenario_pressure_pa_gauge}
166
+
167
+
168
+ class FailRiskCase(Model):
169
+ case_id: str = Field(min_length=1,max_length=100)
170
+ annual_failure_frequency: NonNegative
171
+ financial_consequence: NonNegative
172
+
173
+
174
+ class ProtectedRisk(Model):
175
+ equipment_id: str = Field(min_length=1,max_length=200)
176
+ cases: list[FailRiskCase] = Field(min_length=1,max_length=100)
177
+
178
+
179
+ class FailRisk(MoneyBasis):
180
+ protected_equipment: list[ProtectedRisk] = Field(min_length=1,max_length=100)
181
+
182
+
183
+ @calculation("prd/fail-risk", "PRD fail-to-open case sum and protected-equipment maximum risk", FailRisk,
184
+ ["Part 1 §7.6.1; eqs(1.51)-(1.52); PDF p69"],
185
+ limitations=["Within each protected item, sum distinct demand-case frequency*conditional consequence. Then take maximum across protected items as prescribed, not their sum. Caller supplies qualified case frequencies and pressure-dependent COF; currency/year must be common."])
186
+ def fail_risk(x):
187
+ ensure(len({e.equipment_id for e in x.protected_equipment}) == len(x.protected_equipment), "Duplicate protected equipment ID")
188
+ values = {}
189
+ for equipment in x.protected_equipment:
190
+ ensure(len({c.case_id for c in equipment.cases}) == len(equipment.cases), "Duplicate demand case ID within protected equipment")
191
+ values[equipment.equipment_id] = math.fsum(c.annual_failure_frequency*c.financial_consequence for c in equipment.cases)
192
+ maximum = max(values.values())
193
+ return {"protected_equipment_risks_per_year":values,"maximum_fail_risk_per_year":maximum,
194
+ "governing_equipment_ids":[k for k,v in values.items() if v == maximum],**_money(x)}
195
+
196
+
197
+ class LeakageRisk(MoneyBasis):
198
+ leakage_measure: NonNegative
199
+ financial_consequence: NonNegative
200
+ measure_basis: LeakRiskBasis
201
+
202
+
203
+ @calculation("prd/leakage-risk", "PRD leakage consequence weighted by declared probability/frequency basis", LeakageRisk,
204
+ ["Part 1 §7.6.2; eq(1.53); PDF p69"],
205
+ limitations=["Source multiplies leakage Weibull cumulative probability by consequence but uses annual-risk terminology. Basis must be explicit: source_cumulative_probability gives monetary probability-weighted consequence; annual_frequency requires an independently qualified annual event frequency. No silent CDF annualization."])
206
+ def leakage_risk(x):
207
+ ensure(x.measure_basis != "source_cumulative_probability" or x.leakage_measure <= 1, "Cumulative leakage probability must not exceed1")
208
+ return {"leakage_risk_value":x.leakage_measure*x.financial_consequence,
209
+ "measure_basis":x.measure_basis,"risk_unit":"currency/year" if x.measure_basis == "annual_frequency" else "currency",**_money(x)}
210
+
211
+
212
+ class TotalRisk(MoneyBasis):
213
+ fail_risk_per_year: NonNegative
214
+ leakage_risk_value: NonNegative
215
+ leakage_measure_basis: LeakRiskBasis
216
+
217
+
218
+ @calculation("prd/total-risk", "PRD combined risk with explicit dimensional basis", TotalRisk,
219
+ ["Part 1 §7.6.3; eq(1.54); PDF p69"],
220
+ origin="api581_aggregation_with_explicit_measure_basis",
221
+ limitations=["Annual leakage-frequency basis permits an annual total. Source cumulative-probability basis reproduces printed addition only as source_total_risk_index and leaves annual_financial_risk unset, because summands have inconsistent dimensions. Source index must not be presented as a certified monetary annual rate."])
222
+ def total_risk(x):
223
+ combined = x.fail_risk_per_year+x.leakage_risk_value
224
+ annual = x.leakage_measure_basis == "annual_frequency"
225
+ return {"annual_financial_risk":combined if annual else None,"source_total_risk_index":None if annual else combined,
226
+ "leakage_measure_basis":x.leakage_measure_basis,**_money(x)}
227
+
228
+
229
+ class RiskAssessment(FailRisk):
230
+ leakage_measure: NonNegative
231
+ leakage_financial_consequence: NonNegative
232
+ leakage_measure_basis: LeakRiskBasis
233
+
234
+
235
+ @calculation("prd/risk-assessment", "Compose protected-item fail risk and PRD leakage risk", RiskAssessment,
236
+ ["Part 1 §7.6.4; eqs(1.51)-(1.54); PDF pp69-70"],
237
+ origin="api581_aggregation_with_explicit_measure_basis",
238
+ limitations=["Integration over supplied case frequencies, protected-item conditional consequences and leakage measure/consequence. It does not automatically derive complete relief design or COF. Fail risk takes max across protected items. Cumulative leakage basis yields source index only; annual total requires qualified leakage annual frequency."])
239
+ def risk_assessment(x):
240
+ failed = fail_risk(FailRisk(protected_equipment=x.protected_equipment,**_money(x)))
241
+ leak = leakage_risk(LeakageRisk(leakage_measure=x.leakage_measure,financial_consequence=x.leakage_financial_consequence,
242
+ measure_basis=x.leakage_measure_basis,**_money(x)))
243
+ total = total_risk(TotalRisk(fail_risk_per_year=failed["maximum_fail_risk_per_year"],leakage_risk_value=leak["leakage_risk_value"],
244
+ leakage_measure_basis=x.leakage_measure_basis,**_money(x)))
245
+ return {**failed,**total,"leakage_risk_value":leak["leakage_risk_value"],"leakage_risk_unit":leak["risk_unit"]}
api581/prd_history.py ADDED
@@ -0,0 +1,44 @@
1
+ """Explicit PRD service-age and inspection-history boundary calculations."""
2
+ from datetime import date
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, Positive, calculation, ensure
6
+
7
+
8
+ class ServiceEvent(Model):
9
+ event_date: date
10
+ event_type: Literal["test", "overhaul", "replacement", "design_change"]
11
+
12
+
13
+ class ServiceAge(Model):
14
+ installation_date: date
15
+ evaluation_date: date
16
+ events: list[ServiceEvent] = Field(default_factory=list,max_length=10000)
17
+ days_per_year: Positive = 365.25
18
+
19
+
20
+ @calculation("prd/service-age", "PRD as-new service age and valid-history start boundary", ServiceAge,
21
+ ["Part 1 §7.1.6, §7.2.4(i)(5), §7.7.2-7.7.3; PDF pp48,57,70"],
22
+ origin="api581_lifecycle_rules_with_explicit_calendar_convention",
23
+ limitations=["Overhaul/replacement reset effective age; test alone does not. Replacement/design change invalidate earlier parameter-updating history. Inspection update window starts at later of last as-new reset and parameter-history validity boundary per §7.2.6 STEP6.4. Design change alone does not assert an as-new condition. Dates/age only, not automatic test/eta updating or new-curve qualification. Default365.25-day year is an explicit engineering calendar convention, not a claimed source mandate. Same-day ordered events are allowed."])
24
+ def service_age(x):
25
+ ensure(x.evaluation_date >= x.installation_date,"Evaluation precedes original installation")
26
+ reset,history = x.installation_date,x.installation_date
27
+ last_test = None
28
+ kind = "installation"
29
+ previous = x.installation_date
30
+ for event in x.events:
31
+ ensure(previous <= event.event_date <= x.evaluation_date,"Service events must be chronological and within installation/evaluation dates")
32
+ previous = event.event_date
33
+ if event.event_type == "test":
34
+ last_test = event.event_date
35
+ elif event.event_type in ("overhaul","replacement"):
36
+ reset,kind = event.event_date,event.event_type
37
+ if event.event_type in ("replacement","design_change"):
38
+ history = event.event_date
39
+ return {"effective_age_years":(x.evaluation_date-reset).days/x.days_per_year,
40
+ "elapsed_since_original_installation_years":(x.evaluation_date-x.installation_date).days/x.days_per_year,
41
+ "last_age_reset_date":reset.isoformat(),"last_age_reset_type":kind,
42
+ "parameter_history_valid_from_date":history.isoformat(),
43
+ "inspection_update_start_date":max(reset,history).isoformat(),
44
+ "last_test_date":None if last_test is None else last_test.isoformat(),"days_per_year":x.days_per_year}
api581/py.typed ADDED
File without changes
api581/release.py ADDED
@@ -0,0 +1,307 @@
1
+ """Single-phase source calculations. Internal units: SI, absolute pressures."""
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
+ R = 8.31446261815324 # J/(mol K); molecular weight API uses kg/kmol, hence R*1000
8
+
9
+
10
+ class HoleSizeSelection(Model):
11
+ """Actual opening to API 581 Table 4.4/4.4M release-hole class."""
12
+ hole_diameter_m: NonNegative
13
+ component_diameter_m: Positive
14
+ diameter_basis: Literal["printed_si", "printed_us_converted"]
15
+
16
+
17
+ @calculation("release/hole-size-selection", "Release hole class and representative diameter",
18
+ HoleSizeSelection,
19
+ ["Part 3 §4.2, Table 4.4/Table 4.4M; PDF p518"],
20
+ limitations=[
21
+ "The measured opening is supplied in metres; diameter_basis selects the printed SI millimetre or US inch boundaries. Exact upper boundaries belong to the lower class. Rupture representative diameter is min(component diameter, 406 mm/16 in); this class selection does not calculate an orifice rate or infer component geometry."])
22
+ def hole_size_selection(x):
23
+ if x.diameter_basis == "printed_si":
24
+ small, medium, large = .0064, .025, .102
25
+ upper_small, upper_medium, upper_large = .0064, .051, .152
26
+ basis_name = "Table 4.4M"
27
+ else:
28
+ small, medium, large = .00635, .0254, .1016
29
+ upper_small, upper_medium, upper_large = .00635, .0508, .1524
30
+ basis_name = "Table 4.4"
31
+ d = x.hole_diameter_m
32
+ if d <= upper_small:
33
+ hole, representative = "small", small
34
+ lower, upper = 0., upper_small
35
+ elif d <= upper_medium:
36
+ hole, representative = "medium", medium
37
+ lower, upper = upper_small, upper_medium
38
+ elif d <= upper_large:
39
+ hole, representative = "large", large
40
+ lower, upper = upper_medium, upper_large
41
+ else:
42
+ hole, representative = "rupture", min(x.component_diameter_m, .4064)
43
+ lower, upper = upper_large, None
44
+ return {"selected_hole": hole, "representative_diameter_m": representative,
45
+ "lower_bound_m": lower, "upper_bound_m": upper,
46
+ "diameter_basis": x.diameter_basis, "source_table": basis_name}
47
+
48
+
49
+ class DetectionIsolationRating(Model):
50
+ """Qualitative Table 4.5 detection and isolation classification."""
51
+ detection_type: Literal[
52
+ "process_instrumentation",
53
+ "outside_envelope_detector",
54
+ "visual_or_marginal"]
55
+ isolation_type: Literal[
56
+ "automatic_instrument_or_detector",
57
+ "remote_operator",
58
+ "manual_valve"]
59
+ qualification_basis: str = Field(min_length=1, max_length=1000)
60
+
61
+
62
+ @calculation("release/detection-isolation-rating", "Table 4.5 detection and isolation system classification",
63
+ DetectionIsolationRating,
64
+ ["Part 3 Table 4.5; PDF p519"],
65
+ limitations=[
66
+ "Maps only the printed qualitative descriptions to A/B/C. It does not inspect plant instrumentation, coverage, response time or proof-test data; those remain owner-qualified evidence in qualification_basis."])
67
+ def detection_isolation_rating(x):
68
+ ensure(x.qualification_basis.strip(), "Detection/isolation qualification basis must be nonblank")
69
+ detection = {
70
+ "process_instrumentation": "A",
71
+ "outside_envelope_detector": "B",
72
+ "visual_or_marginal": "C",
73
+ }[x.detection_type]
74
+ isolation = {
75
+ "automatic_instrument_or_detector": "A",
76
+ "remote_operator": "B",
77
+ "manual_valve": "C",
78
+ }[x.isolation_type]
79
+ return {"detection_rating": detection, "isolation_rating": isolation,
80
+ "detection_type": x.detection_type, "isolation_type": x.isolation_type,
81
+ "qualification_basis": x.qualification_basis}
82
+
83
+
84
+ class Orifice(Model):
85
+ diameter_m: NonNegative
86
+
87
+
88
+ @calculation("release/orifice-area", "Circular release area", Orifice, ["Part 3 eq (3.8)"])
89
+ def orifice(x):
90
+ return {"area_m2": math.pi * x.diameter_m ** 2 / 4}
91
+
92
+
93
+ class Viscosity(Model):
94
+ reynolds_number: Positive
95
+
96
+
97
+ @calculation("release/viscosity-factor", "Liquid viscosity correction", Viscosity,
98
+ ["Part 3 eq (3.4); PDF p485"])
99
+ def viscosity(x):
100
+ re = x.reynolds_number
101
+ return {"viscosity_factor": 1 / (.9935 + 2.878 / math.sqrt(re) + 342.75 / re ** 1.5)}
102
+
103
+
104
+ class Liquid(Model):
105
+ diameter_m: NonNegative
106
+ upstream_pressure_pa_abs: Positive
107
+ downstream_pressure_pa_abs: Positive = 101325
108
+ density_kg_m3: Positive
109
+ discharge_coefficient: Fraction = .61
110
+ viscosity_factor: Positive = 1
111
+
112
+
113
+ @calculation("release/liquid", "Liquid orifice mass release rate", Liquid,
114
+ ["Part 3 §4.3.2 eq (3.3); PDF p485"],
115
+ limitations=["Steady upstream state; no line friction, flash fraction or pool evaporation calculation."])
116
+ def liquid(x):
117
+ ensure(x.upstream_pressure_pa_abs >= x.downstream_pressure_pa_abs, "Upstream pressure must be >= downstream pressure")
118
+ area = orifice(Orifice(diameter_m=x.diameter_m))["area_m2"]
119
+ rate = x.discharge_coefficient * x.viscosity_factor * area * math.sqrt(
120
+ 2 * x.density_kg_m3 * (x.upstream_pressure_pa_abs - x.downstream_pressure_pa_abs))
121
+ return {"mass_rate_kg_s": rate, "area_m2": area, "regime": "liquid"}
122
+
123
+
124
+ class Gas(Model):
125
+ diameter_m: NonNegative
126
+ upstream_pressure_pa_abs: Positive
127
+ downstream_pressure_pa_abs: Positive = 101325
128
+ temperature_k: Positive
129
+ molecular_weight_kg_kmol: Positive
130
+ heat_capacity_ratio: Positive = Field(gt=1)
131
+ discharge_coefficient: Fraction = .9
132
+
133
+
134
+ @calculation("release/gas", "Sonic or subsonic ideal-gas release", Gas,
135
+ ["Part 3 §4.3.3 eqs (3.5)-(3.7); PDF p486"],
136
+ limitations=["Ideal gas, constant upstream state; not a two-phase or real-gas near-critical model."])
137
+ def gas(x):
138
+ p, back, k = x.upstream_pressure_pa_abs, x.downstream_pressure_pa_abs, x.heat_capacity_ratio
139
+ ensure(p >= back, "Upstream pressure must be >= downstream pressure")
140
+ ratio = back / p
141
+ critical = (2 / (k + 1)) ** (k / (k - 1))
142
+ area = orifice(Orifice(diameter_m=x.diameter_m))["area_m2"]
143
+ specific_r = R * 1000 / x.molecular_weight_kg_kmol
144
+ if ratio <= critical:
145
+ factor = k * (2 / (k + 1)) ** ((k + 1) / (k - 1))
146
+ regime = "sonic"
147
+ else:
148
+ # expm1 avoids cancellation as upstream approaches downstream pressure.
149
+ factor = 2 * k / (k - 1) * ratio ** (2 / k) * (-math.expm1((k - 1) / k * math.log(ratio)))
150
+ regime = "subsonic"
151
+ rate = x.discharge_coefficient * area * p * math.sqrt(max(0, factor) / (specific_r * x.temperature_k))
152
+ return {"mass_rate_kg_s": rate, "area_m2": area, "regime": regime,
153
+ "critical_downstream_upstream_ratio": critical, "transition_pressure_pa_abs": back / critical}
154
+
155
+
156
+ class HeatCapacity(Model):
157
+ cp_j_mol_k: Positive
158
+
159
+
160
+ @calculation("properties/heat-capacity-ratio", "Ideal-gas specific heat ratio", HeatCapacity,
161
+ ["Part 3 eq (3.1)"])
162
+ def heat_capacity(x):
163
+ ensure(x.cp_j_mol_k > R, "Molar Cp must exceed R")
164
+ return {"heat_capacity_ratio": x.cp_j_mol_k / (x.cp_j_mol_k - R)}
165
+
166
+
167
+ class AvailableInventory(Model):
168
+ component_mass_kg: NonNegative
169
+ inventory_group_mass_kg: NonNegative
170
+ release_rate_kg_s: NonNegative
171
+ eight_inch_rate_kg_s: NonNegative
172
+
173
+
174
+ @calculation("release/available-inventory", "Maximum available mass", AvailableInventory,
175
+ ["Part 3 §4.4.2 eqs (3.10)-(3.11); PDF p487"])
176
+ def inventory(x):
177
+ ensure(x.inventory_group_mass_kg >= x.component_mass_kg, "Inventory group must include component mass")
178
+ added = 180 * min(x.release_rate_kg_s, x.eight_inch_rate_kg_s)
179
+ return {"added_mass_kg": added, "available_mass_kg": min(x.component_mass_kg + added, x.inventory_group_mass_kg)}
180
+
181
+
182
+ class ReleaseType(Model):
183
+ diameter_m: NonNegative
184
+ theoretical_rate_kg_s: NonNegative
185
+ available_mass_kg: NonNegative
186
+
187
+
188
+ @calculation("release/type", "Continuous or instantaneous release classification", ReleaseType,
189
+ ["Part 3 §4.5.2 eq (3.12); PDF p488"], limitations=["Uses the text's rounded 4536 kg threshold."])
190
+ def release_type(x):
191
+ time = 4536 / x.theoretical_rate_kg_s if x.theoretical_rate_kg_s else None
192
+ instant = x.diameter_m > .00635 and time is not None and time <= 180 and x.available_mass_kg > 4536
193
+ return {"release_type": "instantaneous" if instant else "continuous", "time_to_4536kg_s": time}
194
+
195
+
196
+ class ReleaseMagnitude(Model):
197
+ theoretical_rate_kg_s: NonNegative
198
+ reduction_fraction: Fraction
199
+ available_mass_kg: NonNegative
200
+ maximum_duration_s: NonNegative
201
+
202
+
203
+ @calculation("release/magnitude", "Adjusted release rate, duration and mass", ReleaseMagnitude,
204
+ ["Part 3 §4.7 eqs (3.13)-(3.15); PDF p490"],
205
+ limitations=["Detection reduction and maximum duration supplied by caller, not yet selected from Tables 4.6/4.7."])
206
+ def magnitude(x):
207
+ rate = x.theoretical_rate_kg_s * (1 - x.reduction_fraction)
208
+ duration = min(x.maximum_duration_s, x.available_mass_kg / rate) if rate else 0
209
+ return {"adjusted_rate_kg_s": rate, "duration_s": duration,
210
+ "released_mass_kg": min(x.available_mass_kg, rate * duration)}
211
+
212
+
213
+ class DetectionIsolation(Model):
214
+ """API 581 Table 4.6/4.7M detection and isolation selection."""
215
+ detection_rating: Literal["A", "B", "C"]
216
+ isolation_rating: Literal["A", "B", "C"]
217
+ hole_size: Literal["small", "medium", "large", "rupture"]
218
+ theoretical_rate_kg_s: NonNegative
219
+ available_mass_kg: NonNegative
220
+
221
+
222
+ @calculation("release/detection-isolation", "Detection/isolation release reduction and maximum leak duration",
223
+ DetectionIsolation,
224
+ ["Part 3 §4.6.2-§4.6.6, Tables 4.6/4.7M; PDF pp488-490,520"],
225
+ limitations=[
226
+ "Uses the printed SI Table 4.7M hole classes (6.4, 25 and 102 mm). A rupture hole has no maximum duration in Table 4.7M, so its instantaneous released mass is the available mass and duration is null. Ratings are supplied after the qualitative Table 4.5 screening; no detector/isolation hardware classification is inferred."])
227
+ def detection_isolation(x):
228
+ d, i = x.detection_rating, x.isolation_rating
229
+ # Table 4.6: release magnitude reduction factor fact_di.
230
+ reduction = {
231
+ ("A", "A"): .25, ("A", "B"): .20, ("A", "C"): .10,
232
+ ("B", "A"): .10, ("B", "B"): .15, ("B", "C"): .10,
233
+ ("C", "A"): .00, ("C", "B"): .00, ("C", "C"): .00,
234
+ }[(d, i)]
235
+ # Table 4.7M, seconds; the rupture row intentionally has no duration.
236
+ duration_by_rating = {
237
+ ("A", "A"): {"small": 20*60, "medium": 10*60, "large": 5*60},
238
+ ("A", "B"): {"small": 30*60, "medium": 20*60, "large": 10*60},
239
+ ("A", "C"): {"small": 40*60, "medium": 30*60, "large": 20*60},
240
+ ("B", "A"): {"small": 40*60, "medium": 30*60, "large": 20*60},
241
+ ("B", "B"): {"small": 40*60, "medium": 30*60, "large": 20*60},
242
+ ("B", "C"): {"small": 60*60, "medium": 30*60, "large": 20*60},
243
+ ("C", "A"): {"small": 40*60, "medium": 20*60},
244
+ ("C", "B"): {"small": 40*60, "medium": 20*60},
245
+ ("C", "C"): {"small": 40*60, "medium": 20*60},
246
+ }
247
+ maximum_duration = duration_by_rating[(d, i)].get(x.hole_size)
248
+ adjusted_rate = x.theoretical_rate_kg_s * (1 - reduction)
249
+ if x.hole_size == "rupture":
250
+ duration = None
251
+ released = x.available_mass_kg
252
+ elif adjusted_rate == 0:
253
+ duration = 0.
254
+ released = 0.
255
+ else:
256
+ duration = min(maximum_duration, x.available_mass_kg / adjusted_rate)
257
+ released = min(x.available_mass_kg, adjusted_rate * duration)
258
+ return {"release_reduction_fraction": reduction,
259
+ "adjusted_rate_kg_s": adjusted_rate,
260
+ "maximum_leak_duration_s": maximum_duration,
261
+ "duration_s": duration,
262
+ "released_mass_kg": released,
263
+ "detection_rating": d, "isolation_rating": i, "hole_size": x.hole_size}
264
+
265
+
266
+ class TimePoint(Model):
267
+ time_s: NonNegative
268
+ rate_kg_s: NonNegative
269
+
270
+
271
+ class Transient(Model):
272
+ points: list[TimePoint] = Field(min_length=2, max_length=10000)
273
+ available_mass_kg: NonNegative
274
+
275
+
276
+ @calculation("release/integrate-profile", "Integrate a piecewise-linear release profile", Transient,
277
+ ["Part 3 §4.7.3 Calculation of Release Rate and Mass", "Part 3 §5.7.3 Calculation of Jet Release Rate and Pool Release Rate"],
278
+ origin="engineering_utility", limitations=["Profile supplied externally; not a vessel blowdown solver. Inventory exceedance is rejected, not clipped."])
279
+ def integrate_profile(x):
280
+ ensure(x.points[0].time_s == 0, "Profile must start at time 0")
281
+ intervals = []
282
+ for left, right in zip(x.points, x.points[1:]):
283
+ ensure(right.time_s > left.time_s, "Profile times must strictly increase")
284
+ intervals.append((right.time_s - left.time_s) * (left.rate_kg_s + right.rate_kg_s) / 2)
285
+ mass = math.fsum(intervals)
286
+ ensure(mass <= x.available_mass_kg + max(1e-9, 1e-9 * x.available_mass_kg), "Profile exceeds available inventory")
287
+ return {"released_mass_kg": mass, "duration_s": x.points[-1].time_s}
288
+
289
+
290
+ class MixtureComponent(Model):
291
+ mole_fraction: Fraction
292
+ molecular_weight_kg_kmol: Positive
293
+
294
+
295
+ class Mixture(Model):
296
+ components: list[MixtureComponent] = Field(min_length=1, max_length=100)
297
+
298
+
299
+ @calculation("properties/mixture-molecular-weight", "Mole-weighted mixture and mass fractions", Mixture,
300
+ ["Part 3 §4.1.2, §4.9.4; mixture molecular-weight and mass-fraction inputs"],
301
+ origin="api581_input_preparation",
302
+ limitations=["Mole fractions and component molecular weights are supplied by the caller; this does not select a representative fluid or perform Level 2 thermodynamic flash."])
303
+ def mixture(x):
304
+ ensure(math.isclose(math.fsum(c.mole_fraction for c in x.components), 1, abs_tol=1e-9), "Mole fractions must sum to 1")
305
+ mw = math.fsum(c.mole_fraction * c.molecular_weight_kg_kmol for c in x.components)
306
+ return {"molecular_weight_kg_kmol": mw,
307
+ "mass_fractions": [c.mole_fraction * c.molecular_weight_kg_kmol / mw for c in x.components]}