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,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')