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,172 @@
|
|
|
1
|
+
"""API 581 Part 3 Table 4.2/4.2M representative-fluid properties.
|
|
2
|
+
|
|
3
|
+
The table is intentionally kept as source data rather than hidden inside a
|
|
4
|
+
consequence calculation. This makes the selection reusable by release,
|
|
5
|
+
phase, flash and consequence APIs. The printed Table 4.2M heat-capacity
|
|
6
|
+
polynomials and the printed note 2 hyperbolic form are evaluated directly
|
|
7
|
+
from the table. No silently substituted property package is used.
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from .core import Model, Positive, Fraction, calculation, ensure
|
|
13
|
+
|
|
14
|
+
FluidId = Literal[
|
|
15
|
+
"C1-C2", "C3-C4", "C5", "C6-C8", "C9-C12", "C13-C16", "C17-C25", "C25+",
|
|
16
|
+
"Water", "Steam", "Acid", "H2", "H2S", "HF", "CO", "DEE", "HCL",
|
|
17
|
+
"Nitric Acid", "ALCL3", "NO2", "Phosgene", "TDI", "Methanol", "PO",
|
|
18
|
+
"Styrene", "EEA", "EE", "EG", "EO", "Pyrophoric"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AcidCausticSelection(Model):
|
|
23
|
+
"""Table 4.1.3 decision for acid/caustic nonflammable releases."""
|
|
24
|
+
is_acid_or_caustic: bool
|
|
25
|
+
is_flammable: bool
|
|
26
|
+
is_toxic: bool
|
|
27
|
+
personnel_contact_hazard: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@calculation(
|
|
31
|
+
"properties/acid-caustic-representative-fluid",
|
|
32
|
+
"API 581 acid/caustic representative-fluid selection",
|
|
33
|
+
AcidCausticSelection,
|
|
34
|
+
["Part 3 §4.1.3, §4.1.6; PDF p483-484"],
|
|
35
|
+
limitations=[
|
|
36
|
+
"Implements only the printed screening rule: ACID is selected for a nonflammable, nontoxic acid/caustic that still presents personnel contact hazard. It does not determine chemistry, concentration, toxicity, flammability or splash hazard from plant data."
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
def acid_caustic_representative_fluid(x):
|
|
40
|
+
selected = x.is_acid_or_caustic and not x.is_flammable and not x.is_toxic and x.personnel_contact_hazard
|
|
41
|
+
return {
|
|
42
|
+
"selection": "acid_reference" if selected else "not_applicable",
|
|
43
|
+
"representative_fluid": "Acid" if selected else None,
|
|
44
|
+
"release_phase": "liquid" if selected else None,
|
|
45
|
+
"consequence_model": "acid_splash" if selected else None,
|
|
46
|
+
"selection_basis": "Part 3 §4.1.3 printed ACID rule" if selected else "caller must select another representative fluid or consequence route",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MixturePropertyComponent(Model):
|
|
51
|
+
fluid: FluidId
|
|
52
|
+
mole_fraction: Fraction
|
|
53
|
+
consequence_relevant: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MixtureRepresentativeFluid(Model):
|
|
57
|
+
components: list[MixturePropertyComponent] = Field(min_length=1, max_length=100)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@calculation(
|
|
61
|
+
"properties/mixture-representative-fluid",
|
|
62
|
+
"Annex 3.A mole-weighted representative-fluid selection",
|
|
63
|
+
MixtureRepresentativeFluid,
|
|
64
|
+
["Part 3 Annex A §3.1.2.1, §3.1.2.3-§3.1.2.4, eq (3.A.1); PDF pp614-615"],
|
|
65
|
+
limitations=[
|
|
66
|
+
"Uses Table 4.2M properties and mole-fraction weighting for MW, NBP, density and AIT. Inert/non-consequence-relevant components are excluded as the printed conservative Level 1 rule directs. Hydrocarbon pseudo-components are selected only from the hydrocarbon rows; selection then prioritizes the highest candidate NBP that does not exceed the weighted NBP. Chemistry outside the table remains Level 2 recommended and is not silently mapped."
|
|
67
|
+
],
|
|
68
|
+
)
|
|
69
|
+
def mixture_representative_fluid(x):
|
|
70
|
+
ensure(abs(math.fsum(c.mole_fraction for c in x.components) - 1.0) <= 1e-9, "Mixture mole fractions must sum to 1")
|
|
71
|
+
relevant = [c for c in x.components if c.consequence_relevant and c.mole_fraction > 0]
|
|
72
|
+
ensure(relevant, "At least one consequence-relevant mixture component is required")
|
|
73
|
+
relevant_total = math.fsum(c.mole_fraction for c in relevant)
|
|
74
|
+
weighted = {
|
|
75
|
+
"molecular_weight_kg_kmol": math.fsum(c.mole_fraction * TABLE_4_2M[c.fluid][0] for c in relevant) / relevant_total,
|
|
76
|
+
"normal_boiling_point_k": 273.15 + math.fsum(c.mole_fraction * TABLE_4_2M[c.fluid][2] for c in relevant) / relevant_total,
|
|
77
|
+
"liquid_density_kg_m3": math.fsum(c.mole_fraction * TABLE_4_2M[c.fluid][1] for c in relevant) / relevant_total,
|
|
78
|
+
}
|
|
79
|
+
ait_values = [(c, TABLE_4_2M[c.fluid][6]) for c in relevant]
|
|
80
|
+
ensure(all(value is not None for _, value in ait_values), "AIT is unavailable for a relevant mixture component")
|
|
81
|
+
weighted["autoignition_temperature_k"] = 273.15 + math.fsum(c.mole_fraction * value for c, value in ait_values) / relevant_total
|
|
82
|
+
weighted_nbp_c = weighted["normal_boiling_point_k"] - 273.15
|
|
83
|
+
# Annex 3.A examples are pseudo-component mixtures. Do not select an
|
|
84
|
+
# unrelated chemical merely because its boiling point happens to be close.
|
|
85
|
+
hydrocarbon_rows = {"C1-C2", "C3-C4", "C5", "C6-C8", "C9-C12", "C13-C16", "C17-C25", "C25+"}
|
|
86
|
+
relevant_fluids = {c.fluid for c in relevant}
|
|
87
|
+
if relevant_fluids <= hydrocarbon_rows:
|
|
88
|
+
candidate_fluids = hydrocarbon_rows
|
|
89
|
+
elif len(relevant_fluids) == 1:
|
|
90
|
+
candidate_fluids = relevant_fluids
|
|
91
|
+
else:
|
|
92
|
+
candidate_fluids = set(TABLE_4_2M)
|
|
93
|
+
candidates = [(fluid, TABLE_4_2M[fluid]) for fluid in candidate_fluids if TABLE_4_2M[fluid][2] <= weighted_nbp_c]
|
|
94
|
+
ensure(candidates, "No Table 4.2M representative fluid has NBP at or below the weighted mixture NBP", "unsupported_representative_fluid")
|
|
95
|
+
selected, _ = max(candidates, key=lambda item: (item[1][2], -abs(item[1][0] - weighted["molecular_weight_kg_kmol"])))
|
|
96
|
+
return {**weighted, "selected_fluid": selected,
|
|
97
|
+
"relevant_mole_fraction": relevant_total,
|
|
98
|
+
"selection_basis": "highest_same_family_table_NBP_not_exceeding_weighted_NBP",
|
|
99
|
+
"level2_recommended": False}
|
|
100
|
+
|
|
101
|
+
# MW kg/kmol, liquid density kg/m3, NBP °C, ambient state, Cp note,
|
|
102
|
+
# A..E as printed, AIT °C. N/A is represented by None.
|
|
103
|
+
TABLE_4_2M = {
|
|
104
|
+
"C1-C2": (23, 250.512, -125, "Gas", 1, (12.3, .115, -2.87e-5, -1.30e-9, None), 558),
|
|
105
|
+
"C3-C4": (51, 538.379, -21, "Gas", 1, (2.632, .3188, -1.35e-4, 1.47e-8, None), 369),
|
|
106
|
+
"C5": (72, 625.199, 36, "Liquid", 1, (-3.626, .4873, -2.60e-4, 5.30e-8, None), 284),
|
|
107
|
+
"C6-C8": (100, 684.018, 99, "Liquid", 1, (-5.146, .676, -3.65e-4, 7.66e-8, None), 223),
|
|
108
|
+
"C9-C12": (149, 734.012, 184, "Liquid", 1, (-8.5, 1.01, -5.56e-4, 1.18e-7, None), 208),
|
|
109
|
+
"C13-C16": (205, 764.527, 261, "Liquid", 1, (-11.7, 1.39, -7.72e-4, 1.67e-7, None), 202),
|
|
110
|
+
"C17-C25": (280, 775.019, 344, "Liquid", 1, (-22.4, 1.94, -1.12e-3, -2.53e-7, None), 202),
|
|
111
|
+
"C25+": (422, 900.026, 527, "Liquid", 1, (-22.4, 1.94, -1.12e-3, -2.53e-7, None), 202),
|
|
112
|
+
"Water": (18, 997.947, 100, "Liquid", 3, (2.76e5, -2.09e3, 8.125, -1.41e-2, 9.37e-6), None),
|
|
113
|
+
"Steam": (18, 997.947, 100, "Gas", 3, (3.34e4, 2.68e4, 2.61e3, 8.90e3, 1.17e3), None),
|
|
114
|
+
"Acid": (18, 997.947, 100, "Liquid", 3, (2.76e5, -2.09e3, 8.125, -1.41e-2, 9.37e-6), None),
|
|
115
|
+
"H2": (2, 71.010, -253, "Gas", 1, (27.1, 9.27e-3, -1.38e-5, 7.65e-9, None), 400),
|
|
116
|
+
"H2S": (34, 993.029, -59, "Gas", 1, (31.9, 1.44e-3, 2.43e-5, -1.18e-8, None), 260),
|
|
117
|
+
"HF": (20, 967.031, 20, "Gas", 1, (29.1, 6.61e-4, -2.03e-6, 2.50e-9, None), 17760),
|
|
118
|
+
"CO": (28, 800.920, -191, "Gas", 2, (2.91e4, 8.77e3, 3.09e3, 8.46e3, 1.54e3), 609),
|
|
119
|
+
"DEE": (74, 720.828, 35, "Liquid", 2, (8.62e4, 2.55e5, 1.54e3, 1.44e5, -6.89e2), 160),
|
|
120
|
+
"HCL": (36, 1185.362, -85, "Gas", None, None, None),
|
|
121
|
+
"Nitric Acid": (63, 1521.749, 121, "Liquid", None, None, None),
|
|
122
|
+
"ALCL3": (133.5, 2434.798, 194, "Powder", 1, (4.34e4, 3.97e4, 4.17e2, 2.40e4, None), 558),
|
|
123
|
+
"NO2": (90, 929.068, 135, "Liquid", None, None, None),
|
|
124
|
+
"Phosgene": (99, 1377.583, 83, "Liquid", None, None, None),
|
|
125
|
+
"TDI": (174, 1217.399, 251, "Liquid", None, None, 620),
|
|
126
|
+
"Methanol": (32, 800.920, 65, "Liquid", 2, (3.93e4, 8.79e4, 1.92e3, 5.37e4, 8.97e2), 464),
|
|
127
|
+
"PO": (58, 832.957, 34, "Liquid", 2, (4.95e4, 1.74e5, 1.56e3, 1.15e5, 7.02e2), 449),
|
|
128
|
+
"Styrene": (104, 683.986, 145, "Liquid", 2, (8.93e4, 2.15e5, 7.72e2, 9.99e4, 2.44e3), 490),
|
|
129
|
+
"EEA": (132, 977.123, 156, "Liquid", 2, (1.06e5, 2.40e5, 6.59e2, 1.50e5, 1.97e3), 379),
|
|
130
|
+
"EE": (90, 929.068, 135, "Liquid", 2, (3.25e4, 3.00e5, 1.17e3, 2.08e5, 4.73e2), 235),
|
|
131
|
+
"EG": (62, 1105.270, 197, "Liquid", 2, (6.30e4, 1.46e5, 1.67e3, 9.73e4, 7.74e2), 396),
|
|
132
|
+
"EO": (44, 881.013, 11, "Gas", 2, (3.35e4, 1.21e5, 1.61e3, 8.24e4, 7.37e2), 429),
|
|
133
|
+
"Pyrophoric": (149, 734.012, 184, "Liquid", 1, (-8.5, 1.01, -5.56e-4, 1.18e-7, None), None),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class RepresentativeFluid(Model):
|
|
138
|
+
fluid: FluidId
|
|
139
|
+
temperature_k: Positive
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@calculation(
|
|
143
|
+
"properties/representative-fluid",
|
|
144
|
+
"API 581 Table 4.2M representative-fluid properties",
|
|
145
|
+
RepresentativeFluid,
|
|
146
|
+
["Part 3 §4.1.1-§4.1.2, Table 4.2/Table 4.2M; PDF pp515-517"],
|
|
147
|
+
limitations=[
|
|
148
|
+
"Returns tabulated representative properties, not a real-fluid property package. Cp note 1 and note 3 are evaluated as printed polynomials; note 2 uses Cp=A+B((C/T)/sinh(C/T))²+D((E/T)/cosh(E/T))². Rows with --- have no tabulated Cp or AIT. Ambient-state and phase-selection rules remain a separate Table 4.3 decision."
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
def representative_fluid(x):
|
|
152
|
+
mw, density, nbp_c, ambient_state, note, coefficients, ait_c = TABLE_4_2M[x.fluid]
|
|
153
|
+
cp = None
|
|
154
|
+
cp_basis = "not_available"
|
|
155
|
+
t = x.temperature_k
|
|
156
|
+
if coefficients is not None and note in (1, 3):
|
|
157
|
+
a, b, c, d, e = coefficients
|
|
158
|
+
cp = a + b*t + c*t*t + d*t*t*t + ((e or 0.0)*t*t*t*t if note == 3 else 0.0)
|
|
159
|
+
cp_basis = f"table_note_{note}_polynomial"
|
|
160
|
+
elif coefficients is not None and note == 2:
|
|
161
|
+
a, b, c, d, e = coefficients
|
|
162
|
+
cp = a + b*((c/t)/math.sinh(c/t))**2 + d*((e/t)/math.cosh(e/t))**2
|
|
163
|
+
cp_basis = "table_note_2_hyperbolic"
|
|
164
|
+
ensure(cp is None or cp == cp and abs(cp) < 1e12, "Representative-fluid Cp is non-finite or outside supported range")
|
|
165
|
+
return {
|
|
166
|
+
"fluid": x.fluid, "molecular_weight_kg_kmol": mw,
|
|
167
|
+
"liquid_density_kg_m3": density, "normal_boiling_point_k": nbp_c + 273.15,
|
|
168
|
+
"ambient_state": ambient_state, "cp_note": note,
|
|
169
|
+
"cp_value_table_units": cp, "cp_basis": cp_basis,
|
|
170
|
+
"autoignition_temperature_k": None if ait_c is None else ait_c + 273.15,
|
|
171
|
+
"source_table": "Table 4.2M",
|
|
172
|
+
}
|
api581/result.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Typed calculation results and the shared Python execution boundary."""
|
|
2
|
+
from typing import Any, Generic, TypeVar
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Context, Model, REGISTRY, ensure, evaluate
|
|
5
|
+
from .evidence import ValidationEvidence
|
|
6
|
+
|
|
7
|
+
OutputT = TypeVar("OutputT", bound=Model)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CalculationResult(Model, Generic[OutputT]):
|
|
11
|
+
run_id: str
|
|
12
|
+
calculation_id: str
|
|
13
|
+
engine_version: str
|
|
14
|
+
standard_edition: str
|
|
15
|
+
code_fingerprint: str
|
|
16
|
+
calculated_at: str
|
|
17
|
+
context: Context
|
|
18
|
+
input_hash: str
|
|
19
|
+
normalized_inputs: dict[str, Any]
|
|
20
|
+
outputs: OutputT
|
|
21
|
+
method_origin: str
|
|
22
|
+
references: list[str]
|
|
23
|
+
limitations: list[str]
|
|
24
|
+
validation_evidence: list[ValidationEvidence] = Field(default_factory=list)
|
|
25
|
+
software_test_modules: list[str] = Field(default_factory=list)
|
|
26
|
+
direct_public_test_modules: list[str] = Field(default_factory=list)
|
|
27
|
+
validation_level: str = Field(
|
|
28
|
+
default="software_tested_only",
|
|
29
|
+
pattern=r"^(software_tested_only|native_engine_fixture_verified)$",
|
|
30
|
+
)
|
|
31
|
+
validation_status: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def calculate(identifier: str, inputs: Model, *, output_type: type[OutputT],
|
|
35
|
+
context: Context | None = None) -> CalculationResult[OutputT]:
|
|
36
|
+
"""Execute without I/O. Domain functions supply the matching output type."""
|
|
37
|
+
ensure(identifier in REGISTRY, f"Unknown calculation: {identifier}", "unknown_calculation")
|
|
38
|
+
if not isinstance(inputs, REGISTRY[identifier].model):
|
|
39
|
+
raise TypeError(f"{identifier} requires {REGISTRY[identifier].model.__name__}")
|
|
40
|
+
from .outputs import OUTPUT_MODELS
|
|
41
|
+
if OUTPUT_MODELS[identifier] is not output_type:
|
|
42
|
+
raise TypeError("output_type must match the registered calculation")
|
|
43
|
+
payload = evaluate(identifier, inputs.model_dump(mode="python"), context)
|
|
44
|
+
from .catalog import calculation_evidence
|
|
45
|
+
evidence = calculation_evidence(identifier)
|
|
46
|
+
payload["validation_evidence"] = evidence["validation_evidence"]
|
|
47
|
+
payload["software_test_modules"] = evidence["software_test_modules"]
|
|
48
|
+
payload["direct_public_test_modules"] = evidence["direct_public_test_modules"]
|
|
49
|
+
payload["validation_level"] = evidence["validation_level"]
|
|
50
|
+
return CalculationResult[output_type].model_validate(payload)
|
api581/risk.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Consequence aggregation, risk and planning utilities with explicit units."""
|
|
2
|
+
import math
|
|
3
|
+
from typing import Literal
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
|
|
6
|
+
from .pof import Component, generic, Management, management, GoverningDamage, combine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Outcome(Model):
|
|
10
|
+
event_id: str = Field(min_length=1, max_length=100)
|
|
11
|
+
probability: Fraction
|
|
12
|
+
equipment_area_m2: NonNegative
|
|
13
|
+
personnel_area_m2: NonNegative
|
|
14
|
+
financial_consequence: NonNegative
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Consequences(Model):
|
|
18
|
+
outcomes: list[Outcome] = Field(min_length=1, max_length=100)
|
|
19
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@calculation("consequence/expected", "Conditional event-weighted consequence", Consequences,
|
|
23
|
+
["Part 3 §5.8.7, §5.11"], limitations=["Inputs must be mutually exclusive complete terminal outcomes, already conditional on the same release/weather case."])
|
|
24
|
+
def expected(x):
|
|
25
|
+
ensure(len({o.event_id for o in x.outcomes}) == len(x.outcomes), "Duplicate event_id")
|
|
26
|
+
ensure(math.isclose(math.fsum(o.probability for o in x.outcomes), 1, abs_tol=1e-9), "Terminal probabilities must sum to 1")
|
|
27
|
+
return {"equipment_area_m2": math.fsum(o.probability * o.equipment_area_m2 for o in x.outcomes),
|
|
28
|
+
"personnel_area_m2": math.fsum(o.probability * o.personnel_area_m2 for o in x.outcomes),
|
|
29
|
+
"financial_consequence": math.fsum(o.probability * o.financial_consequence for o in x.outcomes), "currency": x.currency}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RiskScenario(Model):
|
|
33
|
+
scenario_id: str = Field(min_length=1, max_length=100)
|
|
34
|
+
annual_failure_frequency: NonNegative
|
|
35
|
+
equipment_area_m2: NonNegative
|
|
36
|
+
personnel_area_m2: NonNegative
|
|
37
|
+
financial_consequence: NonNegative
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Risk(Model):
|
|
41
|
+
scenarios: list[RiskScenario] = Field(min_length=1, max_length=1000)
|
|
42
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@calculation("risk/aggregate", "Aggregate disjoint release-scenario risks", Risk,
|
|
46
|
+
["Part 1 §4.3.1 eqs (1.6)-(1.8)"],
|
|
47
|
+
limitations=["Scenarios must be disjoint; each consequence is conditional and already event/weather weighted. No individual/societal risk calculation."])
|
|
48
|
+
def aggregate(x):
|
|
49
|
+
ensure(len({s.scenario_id for s in x.scenarios}) == len(x.scenarios), "Duplicate scenario_id")
|
|
50
|
+
total = math.fsum(s.annual_failure_frequency for s in x.scenarios)
|
|
51
|
+
equip = math.fsum(s.annual_failure_frequency * s.equipment_area_m2 for s in x.scenarios)
|
|
52
|
+
person = math.fsum(s.annual_failure_frequency * s.personnel_area_m2 for s in x.scenarios)
|
|
53
|
+
financial = math.fsum(s.annual_failure_frequency * s.financial_consequence for s in x.scenarios)
|
|
54
|
+
return {"annual_failure_frequency": total, "equipment_area_risk_m2_per_year": equip,
|
|
55
|
+
"personnel_area_risk_m2_per_year": person, "area_risk_m2_per_year": max(equip, person),
|
|
56
|
+
"financial_risk_per_year": financial, "currency": x.currency,
|
|
57
|
+
"frequency_weighted_equipment_area_m2": equip / total if total else None,
|
|
58
|
+
"frequency_weighted_personnel_area_m2": person / total if total else None}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HoleConsequence(Model):
|
|
62
|
+
hole: Literal["small", "medium", "large", "rupture"]
|
|
63
|
+
equipment_area_m2: NonNegative
|
|
64
|
+
personnel_area_m2: NonNegative
|
|
65
|
+
financial_consequence: NonNegative
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Assessment(Model):
|
|
69
|
+
component_type: str
|
|
70
|
+
management_score: NonNegative = Field(le=1000)
|
|
71
|
+
total_damage_factor: NonNegative
|
|
72
|
+
damage_factor_source: str = Field(min_length=1, max_length=500)
|
|
73
|
+
consequences: list[HoleConsequence] = Field(min_length=4, max_length=4)
|
|
74
|
+
consequence_source: str = Field(min_length=1, max_length=500)
|
|
75
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@calculation("risk/assessment", "Compose GFF, Fms, supplied DF and supplied hole consequences", Assessment,
|
|
79
|
+
["Part 1 §4.1, §4.3; Part 2 §3.3, §3.5.4"],
|
|
80
|
+
limitations=["Integration boundary: DF and COF supplied externally; not a complete asset damage/COF assessment. All four holes required."])
|
|
81
|
+
def assessment(x):
|
|
82
|
+
ensure({c.hole for c in x.consequences} == {"small", "medium", "large", "rupture"}, "Exactly one consequence for each of the four holes is required")
|
|
83
|
+
g = generic(Component(component_type=x.component_type))
|
|
84
|
+
fms = management(Management(score=x.management_score))["management_factor"]
|
|
85
|
+
scenarios = [RiskScenario(scenario_id=c.hole, annual_failure_frequency=g["by_hole_per_year"][c.hole] * x.total_damage_factor * fms,
|
|
86
|
+
equipment_area_m2=c.equipment_area_m2, personnel_area_m2=c.personnel_area_m2,
|
|
87
|
+
financial_consequence=c.financial_consequence) for c in x.consequences]
|
|
88
|
+
return {"integration_scope": "supplied_damage_and_consequences", "management_factor": fms,
|
|
89
|
+
"generic_frequencies": g, "scenarios": [s.model_dump() for s in scenarios],
|
|
90
|
+
"risk": aggregate(Risk(scenarios=scenarios, currency=x.currency))}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class DamageAssessment(Model):
|
|
94
|
+
component_type: str
|
|
95
|
+
management_score: NonNegative = Field(le=1000)
|
|
96
|
+
damage: GoverningDamage
|
|
97
|
+
damage_factor_source: str = Field(min_length=1, max_length=500)
|
|
98
|
+
consequences: list[HoleConsequence] = Field(min_length=4, max_length=4)
|
|
99
|
+
consequence_source: str = Field(min_length=1, max_length=500)
|
|
100
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@calculation(
|
|
104
|
+
"risk/assessment-from-damage",
|
|
105
|
+
"Compose damage mechanisms, GFF, management factor and hole consequences",
|
|
106
|
+
DamageAssessment,
|
|
107
|
+
["Part 1 §§4.1,4.3; Part 2 §§3.4.2,3.5.4; Part 3 consequence inputs"],
|
|
108
|
+
limitations=[
|
|
109
|
+
"Damage mechanism inputs are still owner-qualified outputs from the individual Part 2 APIs. This composition performs the governing/total damage-factor combination and risk weighting; it does not infer applicability, inspection effectiveness, consequence areas or currency assumptions."
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
def assessment_from_damage(x):
|
|
113
|
+
combined = combine(x.damage)
|
|
114
|
+
result = assessment(
|
|
115
|
+
Assessment(
|
|
116
|
+
component_type=x.component_type,
|
|
117
|
+
management_score=x.management_score,
|
|
118
|
+
total_damage_factor=combined["total_damage_factor"],
|
|
119
|
+
damage_factor_source=x.damage_factor_source,
|
|
120
|
+
consequences=x.consequences,
|
|
121
|
+
consequence_source=x.consequence_source,
|
|
122
|
+
currency=x.currency,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return {**result, "integration_scope": "combined_damage_and_supplied_consequences",
|
|
126
|
+
"damage_factor": combined}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Financial(Model):
|
|
130
|
+
component_repair_cost: NonNegative
|
|
131
|
+
surrounding_equipment_cost: NonNegative
|
|
132
|
+
business_interruption_cost: NonNegative
|
|
133
|
+
injury_cost: NonNegative
|
|
134
|
+
environmental_cost: NonNegative
|
|
135
|
+
currency: str = Field(pattern=r"^[A-Z]{3}$")
|
|
136
|
+
cost_basis_year: int = Field(ge=1900, le=2200)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@calculation("consequence/financial-total", "Sum non-overlapping financial consequence categories", Financial,
|
|
140
|
+
["Part 3 §4.12"], origin="engineering_utility",
|
|
141
|
+
limitations=["Category costs supplied by caller; repair/downtime/cleanup submodels not yet implemented."])
|
|
142
|
+
def financial(x):
|
|
143
|
+
return {"financial_consequence": math.fsum([x.component_repair_cost, x.surrounding_equipment_cost,
|
|
144
|
+
x.business_interruption_cost, x.injury_cost, x.environmental_cost]), "currency": x.currency,
|
|
145
|
+
"cost_basis_year": x.cost_basis_year}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Matrix(Model):
|
|
149
|
+
frequency_per_year: NonNegative
|
|
150
|
+
consequence: NonNegative
|
|
151
|
+
frequency_boundaries: list[Positive] = Field(min_length=1, max_length=20)
|
|
152
|
+
consequence_boundaries: list[Positive] = Field(min_length=1, max_length=20)
|
|
153
|
+
risk_cells: list[list[str]]
|
|
154
|
+
policy_id: str = Field(min_length=1, max_length=200)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@calculation("risk/matrix", "Owner-defined risk matrix classification", Matrix,
|
|
158
|
+
["Part 1 §4.3.2"], limitations=["No built-in universal acceptance limits; equality is assigned to the higher category."])
|
|
159
|
+
def matrix(x):
|
|
160
|
+
for bounds in (x.frequency_boundaries, x.consequence_boundaries):
|
|
161
|
+
ensure(all(b > a for a, b in zip(bounds, bounds[1:])), "Boundaries must strictly increase")
|
|
162
|
+
ensure(len(x.risk_cells) == len(x.frequency_boundaries) + 1 and
|
|
163
|
+
all(len(row) == len(x.consequence_boundaries) + 1 for row in x.risk_cells), "risk_cells dimensions must match boundaries")
|
|
164
|
+
i = sum(x.frequency_per_year >= b for b in x.frequency_boundaries)
|
|
165
|
+
j = sum(x.consequence >= b for b in x.consequence_boundaries)
|
|
166
|
+
return {"frequency_category": i + 1, "consequence_category": j + 1, "risk_label": x.risk_cells[i][j], "policy_id": x.policy_id}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class RiskPoint(Model):
|
|
170
|
+
time_years: NonNegative
|
|
171
|
+
value: NonNegative
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Crossing(Model):
|
|
175
|
+
points: list[RiskPoint] = Field(min_length=2, max_length=10000)
|
|
176
|
+
target: Positive
|
|
177
|
+
metric: Literal["financial_per_year", "area_m2_per_year", "frequency_per_year", "damage_factor"]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@calculation("planning/target-crossing", "First target crossing on a supplied risk curve", Crossing,
|
|
181
|
+
["Part 1 §4.4"], origin="engineering_utility",
|
|
182
|
+
limitations=["Linear interpolation between supplied points; does not generate DF forecasts or select NDE effectiveness. Unresolved crossings between samples remain possible."])
|
|
183
|
+
def crossing(x):
|
|
184
|
+
ensure(all(b.time_years > a.time_years for a, b in zip(x.points, x.points[1:])), "Curve times must strictly increase")
|
|
185
|
+
if x.points[0].value >= x.target:
|
|
186
|
+
return {"status": "already_exceeded", "crossing_years": x.points[0].time_years}
|
|
187
|
+
for a, b in zip(x.points, x.points[1:]):
|
|
188
|
+
if b.value >= x.target:
|
|
189
|
+
time = a.time_years + (b.time_years - a.time_years) * (x.target - a.value) / (b.value - a.value)
|
|
190
|
+
return {"status": "crossing", "crossing_years": time}
|
|
191
|
+
return {"status": "not_crossed_within_horizon", "crossing_years": None, "horizon_years": x.points[-1].time_years}
|
api581/saturation.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Optional pure-fluid equilibrium properties for Level2 pool source calculations."""
|
|
2
|
+
import math
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from .core import Model, Positive, DomainError, calculation, ensure
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PureFluidIdentity(Model):
|
|
8
|
+
fluid: str = Field(pattern=r'^[A-Za-z][A-Za-z0-9_-]{0,79}$')
|
|
9
|
+
qualification_basis: str = Field(min_length=1)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SaturationPressure(PureFluidIdentity):
|
|
13
|
+
pressure_pa_abs: Positive
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SaturationTemperature(PureFluidIdentity):
|
|
17
|
+
temperature_k: Positive
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _saturation(inputs, coordinate):
|
|
21
|
+
ensure(bool(inputs.qualification_basis.strip()),'Fluid identity and EOS applicability qualification required')
|
|
22
|
+
try:
|
|
23
|
+
import CoolProp
|
|
24
|
+
from CoolProp.CoolProp import PropsSI, get_fluid_param_string
|
|
25
|
+
except ImportError:
|
|
26
|
+
ensure(False,'Pure-fluid saturation requires optional CoolProp8.0.0','optional_engine_missing')
|
|
27
|
+
ensure(CoolProp.__version__=='8.0.0','Saturation backend requires tested CoolProp8.0.0','engine_version_mismatch')
|
|
28
|
+
fluid=inputs.fluid
|
|
29
|
+
try:
|
|
30
|
+
ensure(get_fluid_param_string(fluid,'pure')=='true','Pseudopure fluids and mixtures are outside pure-fluid saturation scope','unsupported_saturation_fluid')
|
|
31
|
+
value=inputs.pressure_pa_abs if coordinate=='P' else inputs.temperature_k
|
|
32
|
+
lower=PropsSI('ptriple' if coordinate=='P' else 'Ttriple',fluid)
|
|
33
|
+
upper=PropsSI('pcrit' if coordinate=='P' else 'Tcrit',fluid)
|
|
34
|
+
ensure(lower<=value<upper,'Saturation coordinate must be at/above triple point and below critical point','unsupported_saturation_state')
|
|
35
|
+
def prop(name, quality):
|
|
36
|
+
return PropsSI(name,coordinate,value,'Q',quality,fluid)
|
|
37
|
+
temperature=prop('T',0.)
|
|
38
|
+
latent=prop('Hmass',1.)-prop('Hmass',0.)
|
|
39
|
+
output=dict(saturation_pressure_pa_abs=prop('P',0.),bubble_temperature_k=temperature,
|
|
40
|
+
dew_temperature_k=prop('T',1.),liquid_density_kg_m3=prop('Dmass',0.),vapor_density_kg_m3=prop('Dmass',1.),
|
|
41
|
+
liquid_specific_heat_j_kg_k=prop('Cpmass',0.),latent_heat_j_kg=latent,
|
|
42
|
+
vapor_specific_heat_j_kg_k=prop('Cpmass',1.),
|
|
43
|
+
molecular_weight_kg_kmol=PropsSI('molar_mass',fluid)*1000.)
|
|
44
|
+
ensure(all(math.isfinite(v) and v>0 for v in output.values()),'Nonfinite or nonpositive saturation property','invalid_engine_result')
|
|
45
|
+
return {**output,'engine_version':CoolProp.__version__}
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
if isinstance(exc,DomainError):
|
|
48
|
+
raise
|
|
49
|
+
ensure(False,'CoolProp could not solve supplied pure-fluid saturation state','saturation_solver_failure')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_LIMITS=['Optional CoolProp8.0.0 HEOS pure fluids only. Triple-point to subcritical equilibrium saturation; no metastable, solid, pseudopure or mixture states. Saturated liquid cp/densities and latent heat are evaluated on the coexistence curve, not at storage or arbitrary flash temperature. Pure-fluid bubble/dew temperatures coincide. No heat of combustion, flammability, transient evaporation or fluid-wide/LH2 validation. Caller qualifies EOS and physical applicability.']
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@calculation('properties/pure-fluid-saturation-pressure','Pure-fluid bubble/dew and saturated properties at pressure',SaturationPressure,
|
|
56
|
+
['Part3 §5.1.3-5.1.4 STEP1.4; §5.7.4.2; PDF pp540-541,544'],
|
|
57
|
+
origin='optional_coolprop_8_0_0_pure_fluid_saturation',limitations=_LIMITS)
|
|
58
|
+
def saturation_pressure(inputs):
|
|
59
|
+
return _saturation(inputs,'P')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@calculation('properties/pure-fluid-saturation-temperature','Pure-fluid vapor pressure and saturated properties at temperature',SaturationTemperature,
|
|
63
|
+
['Part3 §5.7.4.3 eq(3.105); PDF p544'],
|
|
64
|
+
origin='optional_coolprop_8_0_0_pure_fluid_saturation',limitations=_LIMITS)
|
|
65
|
+
def saturation_temperature(inputs):
|
|
66
|
+
return _saturation(inputs,'T')
|