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.
- api581/__init__.py +80 -0
- api581/_amine_tables.py +105 -0
- api581/_h2sh2_tables.py +161 -0
- api581/_slabx_worker.py +174 -0
- api581/_sulfidic_tables.py +517 -0
- api581/_sulfuric_tables.py +328 -0
- api581/acid_sour_water.py +238 -0
- api581/acscc_screening.py +88 -0
- api581/alkaline_sour_water.py +267 -0
- api581/amine_corrosion.py +277 -0
- api581/amine_screening.py +103 -0
- api581/api/__init__.py +4 -0
- api581/api/api520.py +88 -0
- api581/api/bundle.py +121 -0
- api581/api/consequence.py +102 -0
- api581/api/damage.py +1831 -0
- api581/api/dispersion.py +121 -0
- api581/api/effects.py +318 -0
- api581/api/events.py +96 -0
- api581/api/financial.py +115 -0
- api581/api/frequency.py +40 -0
- api581/api/inspection.py +96 -0
- api581/api/level1.py +238 -0
- api581/api/level2.py +74 -0
- api581/api/planning.py +17 -0
- api581/api/prd.py +190 -0
- api581/api/properties.py +130 -0
- api581/api/release.py +252 -0
- api581/api/reliability.py +40 -0
- api581/api/risk.py +71 -0
- api581/api/tank.py +490 -0
- api581/api520.py +205 -0
- api581/area_contribution.py +72 -0
- api581/blast.py +232 -0
- api581/brittle.py +205 -0
- api581/bundle.py +300 -0
- api581/catalog.py +442 -0
- api581/caustic_screening.py +103 -0
- api581/clad_thinning.py +146 -0
- api581/clscc_screening.py +55 -0
- api581/co2_corrosion.py +313 -0
- api581/co2_screening.py +205 -0
- api581/consequences.py +250 -0
- api581/contracts.py +1038 -0
- api581/cooling_selection.py +204 -0
- api581/cooling_water.py +305 -0
- api581/core.py +126 -0
- api581/corrosion_rate.py +38 -0
- api581/cost_tables.py +175 -0
- api581/cracking.py +74 -0
- api581/data/coverage.json +21361 -0
- api581/data/digital-twin.schema.json +538 -0
- api581/data/direct-test-queue.json +7 -0
- api581/data/native-engine-evidence.json +44 -0
- api581/data/python-api.schema.json +92706 -0
- api581/data/reference-fixture-evidence.json +2956 -0
- api581/data/scope-boundaries.schema.json +58 -0
- api581/data/software-test-evidence.json +4278 -0
- api581/data/standard-metadata.json +1328 -0
- api581/data/standard.schema.json +233 -0
- api581/data/supporting-text-review.json +1319 -0
- api581/digital_twin.py +491 -0
- api581/dispersion.py +45 -0
- api581/effects.py +232 -0
- api581/evaporation.py +181 -0
- api581/evidence.py +81 -0
- api581/external_corrosion.py +148 -0
- api581/external_cracking.py +63 -0
- api581/fatigue.py +56 -0
- api581/fireball.py +163 -0
- api581/flash_pool.py +111 -0
- api581/flash_solver.py +122 -0
- api581/groundwater.py +101 -0
- api581/h2sh2_corrosion.py +128 -0
- api581/hic_screened.py +89 -0
- api581/htha.py +45 -0
- api581/hydrochloric_acid.py +272 -0
- api581/hydrofluoric_acid.py +236 -0
- api581/ignition.py +204 -0
- api581/inspection_effectiveness.py +601 -0
- api581/jet_handoff.py +60 -0
- api581/level1.py +311 -0
- api581/level1_summary.py +35 -0
- api581/level1_toxic.py +313 -0
- api581/level1_type1.py +91 -0
- api581/level2_summary.py +237 -0
- api581/lining.py +173 -0
- api581/native_toxic.py +67 -0
- api581/outputs.py +5672 -0
- api581/oxidation.py +145 -0
- api581/pascc_screening.py +78 -0
- api581/physical_explosion.py +190 -0
- api581/pof.py +207 -0
- api581/pool_fire.py +273 -0
- api581/portfolio.py +68 -0
- api581/prd.py +204 -0
- api581/prd_consequences.py +245 -0
- api581/prd_history.py +44 -0
- api581/py.typed +0 -0
- api581/release.py +307 -0
- api581/representative_fluids.py +172 -0
- api581/result.py +50 -0
- api581/risk.py +191 -0
- api581/saturation.py +66 -0
- api581/scc_mechanisms.py +242 -0
- api581/schema.py +45 -0
- api581/slabx_adapter.py +261 -0
- api581/soil_corrosion.py +191 -0
- api581/source_partition.py +169 -0
- api581/spatial.py +87 -0
- api581/ssc_screening.py +77 -0
- api581/standard.py +281 -0
- api581/storage.py +42 -0
- api581/sulfidic_corrosion.py +197 -0
- api581/sulfuric_acid.py +209 -0
- api581/tank.py +260 -0
- api581/tank_corrosion.py +256 -0
- api581/tank_environment.py +186 -0
- api581/tank_financial.py +202 -0
- api581/tank_integrated.py +51 -0
- api581/tank_shell.py +141 -0
- api581/tank_source.py +152 -0
- api581/tank_thinning.py +204 -0
- api581/temper.py +105 -0
- api581/thinning.py +172 -0
- api581/thinning_conditions.py +212 -0
- api581/thinning_history.py +118 -0
- api581/thinning_selection.py +81 -0
- api581/thinning_type.py +34 -0
- api581/toxic_criteria.py +111 -0
- api581/toxic_level2.py +233 -0
- api581/two_phase_events.py +120 -0
- api581/workflow.py +42 -0
- api581_engine-0.1.4.dist-info/METADATA +419 -0
- api581_engine-0.1.4.dist-info/RECORD +139 -0
- api581_engine-0.1.4.dist-info/WHEEL +5 -0
- api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
- api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
- 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]}
|