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,81 @@
1
+ """Source-wide measured-rate precedence and multi-mechanism thinning selection."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, NonNegative, Positive, calculation, ensure
5
+ from .thinning import WallLoss, Posterior
6
+ from .clad_thinning import PressureStrength, CodeStrength
7
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
8
+
9
+
10
+ class MeasuredThinningRate(Model):
11
+ corrosion_rate_m_year: NonNegative
12
+ representative_for_current_period: bool
13
+ measurement_basis: str = Field(min_length=1)
14
+
15
+
16
+ class FallbackThinningRate(Model):
17
+ corrosion_rate_m_year: NonNegative
18
+ method: Literal['calculated','estimated']
19
+ rate_basis: str = Field(min_length=1)
20
+
21
+
22
+ class MechanismThinningRate(Model):
23
+ mechanism_id: str = Field(min_length=1,max_length=100)
24
+ thinning_type: Literal['general','local']
25
+ measured: MeasuredThinningRate | None = None
26
+ fallback: FallbackThinningRate | None = None
27
+
28
+
29
+ class ThinningRateSelection(Model):
30
+ mechanisms: list[MechanismThinningRate] = Field(min_length=1,max_length=100)
31
+ applicability_and_period_basis: str = Field(min_length=1)
32
+
33
+
34
+ @calculation('damage/thinning-rate-selection','Measured-first and maximum-mechanism rate with governing thinning type',ThinningRateSelection,
35
+ ['Part2 §§4.5.2,4.5.4; PDF pp150-151'],limitations=[
36
+ 'Caller supplies all applicable mechanisms for one component, assessed surface and common current-process plan period. Representative measured CML/history rate wins within each mechanism, including explicit zero. Otherwise exactly the supplied calculated/estimated fallback is used; no invented priority between estimated and calculated rates. Rate is maximum across mechanisms, not sum. Local inspection type governs if any applicable mechanism is local, even when its rate is lower. No missing mechanism assumed absent, automatic mechanism screening, CML regression, rate confidence inference or forecast. Long-term current-process representativeness and rate/type provenance require qualification.'])
37
+ def thinning_rate_selection(x):
38
+ ensure(bool(x.applicability_and_period_basis.strip()),'Applicable mechanisms and common assessed period qualification required')
39
+ ids=[m.mechanism_id for m in x.mechanisms]
40
+ ensure(all(s.strip() for s in ids) and len(set(ids))==len(ids),'Mechanism IDs must be nonblank and unique')
41
+ rates,sources,ignored={},{},{}
42
+ for item in x.mechanisms:
43
+ ensure(item.measured is not None or item.fallback is not None,'Every mechanism requires a measured rate or explicit fallback')
44
+ if item.measured is not None:
45
+ ensure(item.measured.representative_for_current_period and bool(item.measured.measurement_basis.strip()),
46
+ 'Measured rate requires qualified current-process/period representativeness')
47
+ rates[item.mechanism_id]=item.measured.corrosion_rate_m_year
48
+ sources[item.mechanism_id]='measured'
49
+ else:
50
+ ensure(bool(item.fallback.rate_basis.strip()),'Fallback calculated/estimated rate basis required')
51
+ rates[item.mechanism_id]=item.fallback.corrosion_rate_m_year
52
+ sources[item.mechanism_id]=item.fallback.method
53
+ ignored[item.mechanism_id]=item.measured is not None and item.fallback is not None
54
+ maximum=max(rates.values())
55
+ distribution='local' if any(m.thinning_type=='local' for m in x.mechanisms) else 'general'
56
+ return dict(corrosion_rate_m_year=maximum,assessment_distribution=distribution,
57
+ governing_rate_mechanism_ids=sorted(k for k,v in rates.items() if v==maximum),
58
+ governing_type_mechanism_ids=sorted(m.mechanism_id for m in x.mechanisms if m.thinning_type==distribution),
59
+ rate_by_mechanism_m_year=rates,selection_source_by_mechanism=sources,fallback_ignored_by_mechanism=ignored)
60
+
61
+
62
+ class SelectedMechanismThinning(Model):
63
+ selection: ThinningRateSelection
64
+ starting_thickness_m: Positive
65
+ elapsed_years: NonNegative
66
+ strength: PressureStrength | CodeStrength = Field(discriminator='method')
67
+ inspections: Posterior
68
+ conditions: ThinningConditions
69
+ qualification_basis: str = Field(min_length=1)
70
+
71
+
72
+ @calculation('damage/thinning-selected-mechanisms','Selected multi-mechanism rate through qualified non-AST thinning',SelectedMechanismThinning,
73
+ ['Part2 §§4.5.2,4.5.4,4.5.7; Table4.8'],origin='qualified_multi_mechanism_rate_to_thinning',limitations=[
74
+ 'Maximum selected rate drives one common wall/strength baseline; most localized assigned mechanism governs inspection scope. Caller must qualify inspections for that governing type and all applicable loss locations. Generic composition uses other monitoring row with table factor1; mechanism-specific credits cannot be borrowed across mechanisms. Qualified Table4.8 experience replacement is explicit. Custom priors remain supported. No rate summation, multiple-surface loss, all-mechanism DF, AST, cladding, CML history interpretation or whole-asset risk.'])
75
+ def selected_mechanism_thinning(x):
76
+ ensure(x.conditions.monitoring.row.mechanism=='other','Generic multi-mechanism composition requires other monitoring row')
77
+ selection=thinning_rate_selection(x.selection)
78
+ result=qualified_unclad(QualifiedUnclad(assessment_scope='non_ast_unclad',qualification_basis=x.qualification_basis,
79
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=selection['corrosion_rate_m_year'],elapsed_years=x.elapsed_years),
80
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
81
+ return {**selection,**result}
@@ -0,0 +1,34 @@
1
+ """API 581 Annex 2.B thinning-type designation."""
2
+
3
+ from typing import Literal
4
+ from pydantic import Field
5
+ from .core import Model, calculation, ensure
6
+
7
+
8
+ class ThinningTypeSelection(Model):
9
+ effective_inspection_types: list[Literal["general", "localized"]] = Field(default_factory=list, max_length=100)
10
+ applicable_mechanism_types: list[Literal["general", "localized"]] = Field(min_length=1, max_length=100)
11
+ qualification_basis: str = Field(min_length=1, max_length=1000)
12
+
13
+
14
+ @calculation(
15
+ "damage/thinning-type",
16
+ "API 581 Annex 2.B.1.2 general versus localized thinning designation",
17
+ ThinningTypeSelection,
18
+ ["Part 2 Annex B §1.2; Part 2 §4.5.2, PDF p321"],
19
+ origin="api581_annex_2b1_2_thinning_type_selection",
20
+ limitations=[
21
+ "Effective inspection designation takes precedence. If no effective inspection type is supplied, any applicable localized mechanism conservatively selects localized thinning. This API does not screen mechanisms or calculate corrosion rates.",
22
+ "The caller must qualify the mechanism list and inspection effectiveness; an empty inspection list is treated as unknown, not as evidence of general thinning."
23
+ ],
24
+ )
25
+ def thinning_type(x):
26
+ ensure(x.qualification_basis.strip(), "Thinning-type qualification basis must be nonblank")
27
+ observed = x.effective_inspection_types
28
+ source = "effective_inspection_results" if observed else "applicable_mechanism_conservative_default"
29
+ types = observed or x.applicable_mechanism_types
30
+ selected = "localized" if "localized" in types else "general"
31
+ return {"thinning_type": selected, "selection_source": source,
32
+ "effective_inspection_types": observed,
33
+ "applicable_mechanism_types": x.applicable_mechanism_types,
34
+ "qualification_basis": x.qualification_basis}
@@ -0,0 +1,111 @@
1
+ """Explicit historical toxic-criterion priority with exposure-duration matching."""
2
+ import math
3
+ from typing import Literal
4
+ from pydantic import Field, model_validator
5
+ from .core import Model, Positive, Fraction, calculation, ensure, finite_tree
6
+ from .toxic_level2 import ToxicFluid, SpeciesThreshold, impact_table, species_threshold, MixtureLimit, mixture_limit, _ROWS, _TABLE_LIMIT
7
+
8
+ Kind = Literal["probit50","lc50","erpg3","aegl3","teel3","idlh","epa_endpoint"]
9
+ _TIER = dict(probit50=1,lc50=1,erpg3=2,aegl3=2,teel3=2,idlh=3,epa_endpoint=3)
10
+
11
+
12
+ class CriterionCandidate(Model):
13
+ criterion_id: str = Field(min_length=1,max_length=100)
14
+ kind: Kind
15
+ concentration_ppm: Positive = Field(le=1e6)
16
+ exposure_duration_s: Positive
17
+ source: str = Field(min_length=1,max_length=1000)
18
+
19
+ @model_validator(mode="after")
20
+ def source_basis(self):
21
+ if not self.criterion_id.strip() or not self.source.strip():
22
+ raise ValueError("Criterion id and source must be nonblank")
23
+ windows = {"idlh":{1800.},"erpg3":{3600.},"teel3":{900.},"aegl3":{600.,1800.,3600.}}
24
+ if self.kind in windows and self.exposure_duration_s not in windows[self.kind]:
25
+ raise ValueError("Criterion duration conflicts with API5812016 exposure windows")
26
+ return self
27
+
28
+
29
+ class ToxicCriterion(Model):
30
+ fluid_id: str = Field(min_length=1,max_length=100)
31
+ exposure_duration_s: Positive
32
+ use_printed_table: bool = True
33
+ additional_candidates: list[CriterionCandidate] = Field(default_factory=list,max_length=50)
34
+ excluded_candidate_ids: dict[str,str] = Field(default_factory=dict)
35
+ selected_criterion_id: str | None = Field(default=None,min_length=1,max_length=100)
36
+ toxic_mole_fraction: Fraction = 1.
37
+ candidate_availability_basis: str = Field(min_length=1,max_length=1000)
38
+
39
+
40
+ @calculation("consequence/select-toxic-criterion", "Select duration-matched toxic criterion by API581 priority", ToxicCriterion,
41
+ ["Part3 §5.9.2,Table4.14,eqs(3.189)-(3.190); PDF pp531,565-567"],
42
+ origin="api581_explicit_toxic_criterion_selection",limitations=[_TABLE_LIMIT,
43
+ "Only provided candidates and the enabled historical table snapshot are considered, not an external literature/current-limit search. Caller declares candidate availability/applicability; named exclusions require a nonblank basis. Printed probit uses50% fatality and supplied exposure seconds. Same-priority ties require explicit selection, not minimum/maximum of incomparable limits.",
44
+ "Exposure durations match exactly. Printed IDLH uses1800s, ERPG3600s, AEGL600/1800/3600 only where available. LC50/TEEL/EPA ppm must be owner-qualified candidates; EPA mg/L is not silently converted. A lower-priority selection cannot override an eligible higher-priority candidate without explicit exclusion.",
45
+ "Mixture threshold divides pure-component ppm by toxic MOLE fraction once. Zero fraction means absent component; mathematically above1e6ppm is unattainable, not capped. Returned threshold is a criterion, not a field exposure duration or validated damage footprint."])
46
+ def select_criterion(x):
47
+ ensure(x.fluid_id.strip() and x.candidate_availability_basis.strip(),"Fluid and availability bases must be nonblank")
48
+ external_ids = [c.criterion_id for c in x.additional_candidates]
49
+ ensure(len(set(external_ids)) == len(external_ids),"Duplicate external criterion id")
50
+ ensure(all(not key.startswith("printed:") for key in external_ids),"External ids must not use reserved printed: prefix")
51
+ candidates,unavailable = [],{}
52
+ def add(identifier,kind,value,source):
53
+ candidates.append(dict(criterion_id=identifier,kind=kind,concentration_ppm=value,
54
+ exposure_duration_s=x.exposure_duration_s,source=source))
55
+ if x.use_printed_table:
56
+ ensure(x.fluid_id in _ROWS,"Unknown2016 toxic fluid; disable printed table only with qualified external candidates","unsupported_toxic_fluid")
57
+ row = impact_table(ToxicFluid(fluid=x.fluid_id))
58
+ if row["probit_available"]:
59
+ ppm = species_threshold(SpeciesThreshold(fluid=x.fluid_id,exposure_duration_s=x.exposure_duration_s,
60
+ target_fatality_probability=.5))["concentration_ppm"]
61
+ add("printed:probit50","probit50",ppm,"2016 Table4.14/Annex3.A probit50; time seconds")
62
+ else:
63
+ unavailable["printed:probit50"] = "No printed probit coefficients"
64
+ for identifier,kind,value,required in (("printed:idlh","idlh",row["idlh_ppm"],1800.),
65
+ ("printed:erpg3","erpg3",row["erpg3_ppm"],3600.)):
66
+ if value is None:
67
+ unavailable[identifier] = "No printed value"
68
+ elif x.exposure_duration_s != required:
69
+ unavailable[identifier] = f"Requires exposure{required:g}s"
70
+ else:
71
+ add(identifier,kind,value,"2016 Table4.14 historical "+kind)
72
+ aegl = row["aegl3_ppm"].get(str(int(x.exposure_duration_s))) if x.exposure_duration_s.is_integer() else None
73
+ if aegl is None:
74
+ unavailable["printed:aegl3"] = "No printed value at selected exposure duration"
75
+ else:
76
+ add("printed:aegl3","aegl3",aegl,"2016 Table4.14 AEGL3 at selected duration")
77
+ if row["epa_toxic_endpoint_mg_l"] is not None:
78
+ unavailable["printed:epa_endpoint"] = "Published mg/L needs explicit qualified ppm conversion; provide external candidate"
79
+ for candidate in x.additional_candidates:
80
+ if candidate.exposure_duration_s == x.exposure_duration_s:
81
+ candidates.append(candidate.model_dump())
82
+ else:
83
+ unavailable[candidate.criterion_id] = "Exposure duration mismatch"
84
+ ids = [c["criterion_id"] for c in candidates]
85
+ ensure(len(set(ids)) == len(ids),"Duplicate eligible criterion id")
86
+ for identifier,basis in x.excluded_candidate_ids.items():
87
+ ensure(identifier in ids,"Exclusion must name an eligible candidate")
88
+ ensure(basis.strip(),"Criterion exclusion basis must be nonblank")
89
+ eligible = [c for c in candidates if c["criterion_id"] not in x.excluded_candidate_ids]
90
+ ensure(eligible,"No eligible duration-matched toxic criterion","toxic_criterion_unavailable")
91
+ tier = min(_TIER[c["kind"]] for c in eligible)
92
+ first = [c for c in eligible if _TIER[c["kind"]] == tier]
93
+ if x.selected_criterion_id is None:
94
+ ensure(len(first) == 1,"Same-priority criteria require explicit selected_criterion_id","ambiguous_toxic_criterion")
95
+ selected = first[0]
96
+ else:
97
+ matching = [c for c in eligible if c["criterion_id"] == x.selected_criterion_id]
98
+ ensure(matching,"Selected criterion must be eligible")
99
+ selected = matching[0]
100
+ ensure(_TIER[selected["kind"]] == tier,"Eligible higher-priority criterion must be excluded explicitly","toxic_criterion_priority_conflict")
101
+ mixture = mixture_limit(MixtureLimit(pure_component_limit_ppm=selected["concentration_ppm"],toxic_mole_fraction=x.toxic_mole_fraction))
102
+ finite_tree(mixture)
103
+ fraction = mixture["modified_mixture_limit_ppm"]/1e6 if mixture["component_present"] and mixture["attainable_in_mixture"] else None
104
+ ensure(fraction is None or fraction > 0,"Toxic volume fraction underflow","numerical_failure")
105
+ return {"fluid_id":x.fluid_id,"selected_criterion_id":selected["criterion_id"],"selected_kind":selected["kind"],
106
+ "priority_tier":tier,"pure_component_limit_ppm":selected["concentration_ppm"],
107
+ "modified_mixture_limit_ppm":mixture["modified_mixture_limit_ppm"],"threshold_volume_fraction":fraction,
108
+ "component_present":mixture["component_present"],"attainable_in_mixture":mixture["attainable_in_mixture"],
109
+ "exposure_duration_s":x.exposure_duration_s,"criterion_source":selected["source"],
110
+ "eligible_candidate_ids":[c["criterion_id"] for c in eligible],"excluded_candidate_ids":x.excluded_candidate_ids,
111
+ "unavailable_candidate_reasons":unavailable}
api581/toxic_level2.py ADDED
@@ -0,0 +1,233 @@
1
+ """2016 toxic table snapshot, exposure calculations and dispersion-area handoff."""
2
+ import math
3
+ import sys
4
+ from statistics import NormalDist
5
+ from typing import Annotated, Literal
6
+ from pydantic import Field
7
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
8
+
9
+ Fluid = Literal["acrolein","acrylonitrile","aluminum_trichloride","ammonia","benzene","bromine",
10
+ "carbon_monoxide","carbon_tetrachloride","chlorine","ethylene_glycol_monoethyl_ether","ethylene_oxide",
11
+ "formaldehyde","hydrogen_chloride","hydrogen_cyanide","hydrogen_fluoride","hydrogen_sulfide","methanol",
12
+ "methyl_bromide","methyl_isocyanate","nitric_acid","nitrogen_dioxide","phosgene","propylene_oxide",
13
+ "styrene","sulphur_dioxide","toluene","toluene_diisocyanate"]
14
+ # A,B,N,IDLH ppm,AEGL3(10/30/60min) ppm,EPA mg/L,ERPG3 ppm.
15
+ # Dashes remain None. Historical printed values are not current exposure limits.
16
+ _ROWS={
17
+ "acrolein":(-9.93,2.05,1,2,None,None,None,.50,None),
18
+ "acrylonitrile":(-29.42,3.01,1.43,85,None,None,None,.08,75),
19
+ "aluminum_trichloride":(-14.65,2,1,None,None,None,None,None,None),
20
+ "ammonia":(-35.90,1.85,2,10,None,None,None,.14,750),
21
+ "benzene":(-109.8,5.30,2,500,None,None,None,None,1000),
22
+ "bromine":(-9.04,.92,2,3,None,None,None,.01,5),
23
+ "carbon_monoxide":(-37.98,3.70,1,1200,1700,600,330,None,500),
24
+ "carbon_tetrachloride":(-6.29,.41,2.5,200,None,None,None,None,750),
25
+ "chlorine":(-8.29,.92,2,10,None,28,20,.01,20),
26
+ "ethylene_glycol_monoethyl_ether":(-15.54,1,2,None,None,None,None,None,None),
27
+ "ethylene_oxide":(-6.21,1,1,800,None,None,None,None,None),
28
+ "formaldehyde":(-12.24,1.30,2,20,None,None,None,.01,25),
29
+ "hydrogen_chloride":(-16.85,2,1,50,620,210,100,.03,150),
30
+ "hydrogen_cyanide":(-29.42,3.01,1.43,50,27,21,15,None,25),
31
+ "hydrogen_fluoride":(-48.33,4.853,1,30,170,62,44,None,None),
32
+ "hydrogen_sulfide":(-31.42,3.01,1.43,100,76,60,50,None,100),
33
+ "methanol":(None,None,None,None,15000,15000,7900,None,None),
34
+ "methyl_bromide":(-56.81,5.27,1,None,None,None,None,None,200),
35
+ "methyl_isocyanate":(-5.64,1.64,.65,None,1.2,.4,.2,None,5),
36
+ "nitric_acid":(-5.48,1,2,None,None,None,None,None,None),
37
+ "nitrogen_dioxide":(-13.79,1.40,2,20,None,None,None,None,None),
38
+ "phosgene":(-19.27,3.69,1,2,3.6,1.5,.75,None,1),
39
+ "propylene_oxide":(-7.415,.509,2,400,None,None,None,.59,750),
40
+ "styrene":(None,None,None,700,None,None,None,None,1000),
41
+ "sulphur_dioxide":(-15.67,2.10,1,100,None,None,None,None,None),
42
+ "toluene":(-6.79,.41,2.5,500,1600,900,630,None,None),
43
+ "toluene_diisocyanate":(-4.49,1,2,None,None,None,None,None,None),
44
+ }
45
+ _TABLE_LIMIT="Historical2016 Table4.14 snapshot, not current occupational/emergency exposure guidance. Printed ammonia IDLH10ppm retained. Dashes are unavailable, not zero. Probit concentration ppm and time seconds follow Annex3.A nomenclature; caller must qualify coefficient/species applicability. EPA mg/L is not implicitly converted to ppm."
46
+
47
+
48
+ class ToxicFluid(Model):
49
+ fluid: Fluid
50
+
51
+
52
+ @calculation("properties/toxic-impact-table", "2016 Table4.14 toxic criteria and probit parameters", ToxicFluid,
53
+ ["Part3 Table4.14; PDF p531; Annex3.A nomenclature PDF624"],limitations=[_TABLE_LIMIT])
54
+ def impact_table(x):
55
+ a,b,n,idlh,a10,a30,a60,epa,erpg=_ROWS[x.fluid]
56
+ return {"probit_intercept":a,"probit_slope":b,"concentration_exponent":n,"probit_available":a is not None,
57
+ "probit_concentration_unit":"ppm","probit_duration_unit":"s","idlh_ppm":idlh,
58
+ "aegl3_ppm":{duration:value for duration,value in [("600",a10),("1800",a30),("3600",a60)] if value is not None},
59
+ "epa_toxic_endpoint_mg_l":epa,"erpg3_ppm":erpg}
60
+
61
+
62
+ def _coefficients(fluid):
63
+ a,b,n=_ROWS[fluid][:3]
64
+ ensure(a is not None,"No printed probit parameters for selected fluid","toxic_probit_unavailable")
65
+ return a,b,n
66
+
67
+
68
+ def _effect(fluid,logdose):
69
+ a,b,_=_coefficients(fluid)
70
+ if logdose is None:
71
+ return {"probit":None,"fatality_probability":0}
72
+ probit=a+b*logdose
73
+ return {"probit":probit,"fatality_probability":.5*math.erfc(-(probit-5)/math.sqrt(2))}
74
+
75
+
76
+ PPM = Annotated[float,Field(ge=0,le=1e6,strict=True,allow_inf_nan=False)]
77
+
78
+
79
+ class SpeciesProbit(ToxicFluid):
80
+ concentration_ppm: PPM
81
+ exposure_duration_s: NonNegative
82
+
83
+
84
+ @calculation("effects/toxic-species-probit", "Table4.14 constant-exposure toxic probit", SpeciesProbit,
85
+ ["Part3 Table4.14, Annex3.A eq(3.A.5); PDF pp531,620,624"],limitations=[_TABLE_LIMIT,
86
+ "Pr=A+B*ln(C^N*t); probability=Phi(Pr-5). Constant species concentration, not whole-mixture concentration. Zero exposure gives zero probability with no finite probit. No dispersion, escape or dose-response validation for a particular installation."])
87
+ def species_probit(x):
88
+ _,_,n=_coefficients(x.fluid)
89
+ logdose=None if x.concentration_ppm == 0 or x.exposure_duration_s == 0 else n*math.log(x.concentration_ppm)+math.log(x.exposure_duration_s)
90
+ return _effect(x.fluid,logdose)
91
+
92
+
93
+ class SpeciesThreshold(ToxicFluid):
94
+ exposure_duration_s: Positive
95
+ target_fatality_probability: Fraction = .5
96
+
97
+
98
+ @calculation("effects/toxic-species-threshold", "Species concentration for target toxic probit probability", SpeciesThreshold,
99
+ ["Part3 Table4.14, Annex3.A eq(3.A.5) inverse; PDF pp531,620,624"],
100
+ origin="api581_toxic_probit_inverse",limitations=[_TABLE_LIMIT,
101
+ "Default50% fatality corresponds to Pr5. Probability must be strictly between0 and1. Reports whether mathematical threshold is within pure-species1e6ppm limit; values above this are not silently capped."])
102
+ def species_threshold(x):
103
+ a,b,n=_coefficients(x.fluid)
104
+ ensure(0 < x.target_fatality_probability < 1,"Target probability must be strictly between0 and1")
105
+ probit=5+NormalDist().inv_cdf(x.target_fatality_probability)
106
+ concentration=math.exp(((probit-a)/b-math.log(x.exposure_duration_s))/n)
107
+ ensure(concentration > 0,"Toxic threshold underflow","numerical_failure")
108
+ return {"concentration_ppm":concentration,"probit":probit,"within_pure_species_limit":concentration <= 1e6}
109
+
110
+
111
+ class ConcentrationPoint(Model):
112
+ time_s: NonNegative
113
+ concentration_ppm: PPM
114
+
115
+
116
+ class DoseProfile(ToxicFluid):
117
+ profile: list[ConcentrationPoint] = Field(min_length=2,max_length=10000)
118
+ interpolation: Literal["previous","linear"]
119
+ profile_source: str = Field(min_length=1,max_length=1000)
120
+
121
+
122
+ @calculation("effects/toxic-dose-profile", "Time-varying species dose and Table4.14 probit", DoseProfile,
123
+ ["Part3 Annex3.A eq(3.A.5); PDF pp531,620,624"],
124
+ origin="engineering_transient_extension_of_api581_probit",limitations=[_TABLE_LIMIT,
125
+ "Explicit extension: replaces constant C^N*t with integral C(t)^N dt. Previous holds each left concentration; final point only closes last interval. Linear integrates concentration power analytically, not a trapezoid of C^N. Not a printed transient API581 equation or an actual slabx run. No tail outside supplied profile and no automatic exposure cap."])
126
+ def dose_profile(x):
127
+ _,_,n=_coefficients(x.fluid)
128
+ ensure(bool(x.profile_source.strip()),"Profile source must be nonblank")
129
+ ensure(x.profile[0].time_s == 0 and all(b.time_s > a.time_s for a,b in zip(x.profile,x.profile[1:])),
130
+ "Concentration profile must start at0 with strictly increasing times")
131
+ logs=[]
132
+ for a,b in zip(x.profile,x.profile[1:]):
133
+ dt=b.time_s-a.time_s
134
+ if x.interpolation == "previous":
135
+ if a.concentration_ppm == 0:
136
+ continue
137
+ average_log=n*math.log(a.concentration_ppm)
138
+ else:
139
+ high=max(a.concentration_ppm,b.concentration_ppm)
140
+ if high == 0:
141
+ continue
142
+ ratio=min(a.concentration_ppm,b.concentration_ppm)/high
143
+ factor=(1 if ratio == 1 else 1/(n+1) if ratio == 0
144
+ else -math.expm1((n+1)*math.log(ratio))/((n+1)*(1-ratio)))
145
+ average_log=n*math.log(high)+math.log(factor)
146
+ logs.append(math.log(dt)+average_log)
147
+ duration=x.profile[-1].time_s
148
+ if not logs:
149
+ return {**_effect(x.fluid,None),"dose_ppm_power_n_s":0,"log_dose":None,"dose_representable":True,
150
+ "duration_s":duration,"equivalent_constant_concentration_ppm":0}
151
+ largest=max(logs)
152
+ logdose=largest+math.log(math.fsum(math.exp(value-largest) for value in logs))
153
+ dose=math.exp(logdose) if math.log(math.ulp(0.0)) <= logdose <= math.log(sys.float_info.max) else None
154
+ return {**_effect(x.fluid,logdose),"dose_ppm_power_n_s":dose,"log_dose":logdose,"dose_representable":dose is not None,
155
+ "duration_s":duration,"equivalent_constant_concentration_ppm":math.exp((logdose-math.log(duration))/n)}
156
+
157
+
158
+ class ToxicDuration(Model):
159
+ available_mass_kg: NonNegative
160
+ mixture_release_rate_kg_s: NonNegative
161
+ detection_maximum_duration_s: NonNegative
162
+
163
+
164
+ @calculation("release/level2-toxic-duration", "Level2 toxic release duration bound", ToxicDuration,
165
+ ["Part3 eq(3.186), §5.9.3; PDF p566"],
166
+ limitations=["Minimum of3600s, full inventory/full mixture rate, detection/isolation duration. Do not divide full inventory by toxic-component rate. Constant-rate source approximation; no transient depressurization. Release duration is not automatically receptor exposure duration."])
167
+ def toxic_duration(x):
168
+ if x.mixture_release_rate_kg_s == 0 or x.available_mass_kg == 0:
169
+ return {"release_duration_s":0,"governing_limit":"no_release" if x.mixture_release_rate_kg_s == 0 else "no_inventory"}
170
+ values=[(3600,"one_hour"),(x.available_mass_kg/x.mixture_release_rate_kg_s,"inventory"),
171
+ (x.detection_maximum_duration_s,"detection_isolation")]
172
+ duration,limit=min(values,key=lambda item:item[0])
173
+ ensure(duration > 0 or x.detection_maximum_duration_s == 0,
174
+ "Positive toxic release duration underflow","numerical_failure")
175
+ return {"release_duration_s":duration,"governing_limit":limit}
176
+
177
+
178
+ class MixtureLimit(Model):
179
+ pure_component_limit_ppm: Positive
180
+ toxic_mole_fraction: Fraction
181
+
182
+
183
+ @calculation("consequence/toxic-mixture-limit", "Homogeneous mixture cloud toxic concentration criterion", MixtureLimit,
184
+ ["Part3 eqs(3.189)-(3.190); PDF p567"],limitations=["Divides pure-component ppm criterion by toxic mole fraction, not mass fraction. Applies when dispersion concentration represents entire homogeneous mixture using full release rate. Species-resolved concentration must not be divided again. Zero mole fraction means absent component; no finite modified criterion is returned."])
185
+ def mixture_limit(x):
186
+ if x.toxic_mole_fraction == 0:
187
+ return {"modified_mixture_limit_ppm":None,"component_present":False,"attainable_in_mixture":False}
188
+ modified=x.pure_component_limit_ppm/x.toxic_mole_fraction
189
+ return {"modified_mixture_limit_ppm":modified,"component_present":True,"attainable_in_mixture":modified <= 1e6}
190
+
191
+
192
+ class ToxicCloudArea(Model):
193
+ component_id: str = Field(min_length=1,max_length=100)
194
+ selected_criterion_source: str = Field(min_length=1,max_length=1000)
195
+ cloud_area_m2: NonNegative
196
+
197
+
198
+ class ToxicCase(Model):
199
+ components: list[ToxicCloudArea] = Field(min_length=1,max_length=100)
200
+ non_ignition_probability: Fraction
201
+
202
+
203
+ @calculation("consequence/level2-toxic-case", "Selected toxic cloud footprints to conditional toxic consequence", ToxicCase,
204
+ ["Part3 §§5.9.2,5.9.4-5.9.6 eq(3.188); PDF pp565-567"],
205
+ limitations=["Caller supplies one chosen criterion and qualified footprint per toxic component. Criteria hierarchy: probit/LC50, then ERPG3/AEGL3/TEEL3, then IDLH/EPA endpoint. Selection within a tier is caller-qualified, not maximum across incomparable exposure criteria. Takes largest component area and applies non-ignition probability once; equipment toxic damage area is0. Does not run dispersion or sum overlapping clouds."])
206
+ def toxic_case(x):
207
+ ensure(len({c.component_id for c in x.components}) == len(x.components),"Duplicate toxic component")
208
+ ensure(all(c.component_id.strip() and c.selected_criterion_source.strip() for c in x.components),"Component and criterion source must be nonblank")
209
+ critical=max(x.components,key=lambda c:c.cloud_area_m2)
210
+ return {"equipment_area_m2":0,"personnel_area_m2":x.non_ignition_probability*critical.cloud_area_m2,
211
+ "unweighted_cloud_area_m2":critical.cloud_area_m2,"critical_component_id":critical.component_id,
212
+ "component_cloud_areas_m2":{c.component_id:c.cloud_area_m2 for c in x.components}}
213
+
214
+
215
+ class ToxicFrequencyCase(ToxicCase):
216
+ hole: Literal["small","medium","large","rupture"]
217
+ generic_frequency_per_year: NonNegative
218
+
219
+
220
+ class ToxicSummary(Model):
221
+ cases: list[ToxicFrequencyCase] = Field(min_length=4,max_length=4)
222
+
223
+
224
+ @calculation("consequence/level2-toxic-summary", "Four-hole GFF-weighted Level2 toxic consequence", ToxicSummary,
225
+ ["Part3 eqs(3.188),(3.191); PDF p567"],origin="api581_toxic_four_hole_composition",
226
+ limitations=["Requires all four canonical holes once. Takes component maximum and non-ignition weighting within each hole, then generic-frequency average. No DF-adjusted frequency substitution or financial/individual-risk computation. Cloud footprints and selected exposure criteria remain externally qualified inputs."])
227
+ def toxic_summary(x):
228
+ ensure({c.hole for c in x.cases} == {"small","medium","large","rupture"},"Require four distinct canonical hole sizes")
229
+ total=math.fsum(c.generic_frequency_per_year for c in x.cases)
230
+ ensure(math.isfinite(total) and total > 0,"Total generic frequency must be finite and positive")
231
+ areas={c.hole:toxic_case(ToxicCase(**c.model_dump(include=set(ToxicCase.model_fields))))["personnel_area_m2"] for c in x.cases}
232
+ return {"equipment_area_m2":0,"personnel_area_m2":math.fsum(c.generic_frequency_per_year/total*areas[c.hole] for c in x.cases),
233
+ "total_generic_frequency_per_year":total,"by_hole_personnel_area_m2":areas}
@@ -0,0 +1,120 @@
1
+ """Explicit two-phase probability weighting and correlated figure event paths."""
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 .ignition import (TemperatureReference, SinglePhaseIgnition, single_phase_ignition,
7
+ TwoPhaseProbability, two_phase_probability, FlammableRates, flammable_rates)
8
+
9
+
10
+ class FireballConditional(Model):
11
+ phase: Literal["liquid","vapor","two_phase"]
12
+ release_type: Literal["continuous","instantaneous"]
13
+ case_type: Literal["leakage","rupture"]
14
+ applicability_basis: Literal["rupture_only_text","figure53_instantaneous"]
15
+
16
+
17
+ @calculation("events/fireball-given-immediate", "Fireball conditional applicability from text or figure basis", FireballConditional,
18
+ ["Part3 eqs(3.123)-(3.124),Figure5.3; PDF pp549,586"],
19
+ origin="api581_fireball_conditional_with_explicit_applicability",
20
+ limitations=["Conditional on immediate ignition. Text basis requires instantaneous rupture of vapor/two-phase; explicit figure53 basis also allows instantaneous leakage. Liquid and continuous cases give0. Correlation does not establish thermal-model applicability or certify leakage fireballs."])
21
+ def fireball_given_immediate(x):
22
+ allowed=(x.phase in {"vapor","two_phase"} and x.release_type == "instantaneous" and (
23
+ x.case_type == "rupture" or x.applicability_basis == "figure53_instantaneous"))
24
+ return {"fireball_given_immediate":1 if allowed else 0}
25
+
26
+
27
+ class TwoPhaseContext(Model):
28
+ release_type: Literal["continuous","instantaneous"]
29
+ case_type: Literal["leakage","rupture"]
30
+ qualified_rupture_fireball_fraction: Fraction | None = None
31
+ rupture_fireball_source: str | None = Field(default=None,min_length=1,max_length=1000)
32
+
33
+
34
+ class TwoPhaseTree(TwoPhaseContext):
35
+ ignition_probability: Fraction
36
+ immediate_given_ignition: Fraction
37
+ vce_given_delayed: Fraction
38
+ branch_probability_source: str = Field(min_length=1,max_length=1000)
39
+
40
+
41
+ @calculation("events/two-phase-tree", "Two-phase figure paths including one correlated jet/pool event", TwoPhaseTree,
42
+ ["Part3 Figures5.3/5.4,eqs(3.123)-(3.126); PDF pp549,586-587"],
43
+ origin="api581_two_phase_printed_figure_event_tree",
44
+ limitations=["Continuous leakage immediate branch is one jet_pool_max terminal, not independent jet and pool fires. Instantaneous leakage immediate branch is fireball per figure53 despite text conflict. Rupture instantaneous immediate fireball fraction defaults1 per eq3.123; figure54 alternative requires explicit fraction/source, remaining immediate probability goes to pool. Continuous rupture unsupported. Non-ignition still requires toxic/NFNT analysis."])
45
+ def two_phase_tree(x):
46
+ ensure(x.branch_probability_source.strip(),"Branch probability source must be nonblank")
47
+ ensure(x.case_type != "rupture" or x.release_type == "instantaneous","Continuous rupture tree unsupported")
48
+ ensure((x.qualified_rupture_fireball_fraction is None) == (x.rupture_fireball_source is None),
49
+ "Qualified rupture fireball fraction and source must be supplied together")
50
+ if x.qualified_rupture_fireball_fraction is not None:
51
+ ensure(x.case_type == "rupture" and x.rupture_fireball_source.strip(),"Fireball fraction override only for qualified rupture branch")
52
+ basis="figure53_instantaneous" if x.case_type == "leakage" and x.release_type == "instantaneous" else "rupture_only_text"
53
+ conditional=fireball_given_immediate(FireballConditional(phase="two_phase",release_type=x.release_type,
54
+ case_type=x.case_type,applicability_basis=basis))["fireball_given_immediate"]
55
+ if x.qualified_rupture_fireball_fraction is not None:
56
+ conditional=x.qualified_rupture_fireball_fraction
57
+ pi,immediate,vce=x.ignition_probability,x.immediate_given_ignition,x.vce_given_delayed
58
+ probabilities={event:0.0 for event in ["pool_fire","jet_fire","fireball","vce","flash_fire","jet_pool_max"]}
59
+ if x.release_type == "continuous":
60
+ probabilities["jet_pool_max"]=pi*immediate
61
+ else:
62
+ probabilities["fireball"]=pi*immediate*conditional
63
+ probabilities["pool_fire"]=pi*immediate*(1-conditional)
64
+ probabilities["vce"]=pi*(1-immediate)*vce
65
+ probabilities["flash_fire"]=pi*(1-immediate)*(1-vce)
66
+ nonignition=1-pi
67
+ return {"terminal_probabilities":probabilities,"non_ignition_probability":nonignition,
68
+ "probability_sum":math.fsum([*probabilities.values(),nonignition]),"fireball_given_immediate":conditional,
69
+ "fireball_probability_basis":"api581_eq3_123_124" if x.qualified_rupture_fireball_fraction is None else "qualified_figure54_branch",
70
+ "fireball_applicability_basis":basis}
71
+
72
+
73
+ class TwoPhaseIgnition(TwoPhaseContext,TemperatureReference):
74
+ adjusted_mass_rate_kg_s: NonNegative
75
+ flammable_mass_fraction: Fraction
76
+ flash_mass_fraction: Fraction
77
+ liquid_flash_point_temperature_k: Positive
78
+ vapor_flash_point_temperature_k: Positive
79
+ vapor_molecular_weight_kg_kmol: Positive
80
+ storage_temperature_k: Positive
81
+ autoignition_temperature_k: Positive
82
+ correlation_basis_source: str = Field(min_length=1,max_length=1000)
83
+ mixing_basis: Literal["api581_printed_liquid_weight","qualified_vapor_weight"]
84
+ weighting_source: str = Field(min_length=1,max_length=1000)
85
+
86
+
87
+ @calculation("events/two-phase-ignition", "Phase rates and ignition correlations to two-phase terminal paths", TwoPhaseIgnition,
88
+ ["Part3 eqs(3.106)-(3.124),Table5.3,Figures5.3/5.4; PDF pp546-549,583,586-587"],
89
+ origin="api581_two_phase_ignition_composition_with_explicit_bases",
90
+ limitations=["Requires strictly mixed atmospheric flash fraction0<f<1; endpoints belong to single-phase model. Composes phase-specific flammable rates, flash points, vapor MW, common qualified AIT/reference, Table5.3 and explicit phase weighting. Printed liquid-weight basis is preserved as source interpretation, not validated physical phase weighting. Zero fuel gives zero ignition. No EOS, two-phase hydraulics, effect footprints or LH2 validation; correlated jet_pool_max must remain one area branch."])
91
+ def two_phase_ignition(x):
92
+ ensure(0 < x.flash_mass_fraction < 1,"Two-phase ignition requires0<flash fraction<1; use single-phase at endpoints")
93
+ rates=flammable_rates(FlammableRates(adjusted_mass_rate_kg_s=x.adjusted_mass_rate_kg_s,
94
+ flammable_mass_fraction=x.flammable_mass_fraction,flash_mass_fraction=x.flash_mass_fraction))
95
+ setup=x.model_dump(include=set(TemperatureReference.model_fields))
96
+ setup.update(release_type=x.release_type,case_type=x.case_type,storage_temperature_k=x.storage_temperature_k,
97
+ autoignition_temperature_k=x.autoignition_temperature_k,correlation_basis_source=x.correlation_basis_source)
98
+ phase_results={}
99
+ for phase in ["liquid","vapor"]:
100
+ rate=rates[f"flammable_{phase}_rate_kg_s"]
101
+ ensure(rate > 0 or rates["flammable_rate_kg_s"] == 0,"Positive phase fuel rate underflow","numerical_failure")
102
+ phase_results[phase]=single_phase_ignition(SinglePhaseIgnition(**setup,phase=phase,flammable_rate_kg_s=rate,
103
+ flash_point_temperature_k=x.liquid_flash_point_temperature_k if phase == "liquid" else x.vapor_flash_point_temperature_k,
104
+ molecular_weight_kg_kmol=x.vapor_molecular_weight_kg_kmol if phase == "vapor" else None))
105
+ def mix(field):
106
+ return two_phase_probability(TwoPhaseProbability(liquid_probability=phase_results["liquid"][field],
107
+ vapor_probability=phase_results["vapor"][field],flash_mass_fraction=x.flash_mass_fraction,
108
+ mixing_basis=x.mixing_basis,weighting_source=x.weighting_source))["probability"]
109
+ ignition,immediate,vce=mix("ignition_probability"),mix("immediate_given_ignition"),mix("vce_given_delayed")
110
+ tree=two_phase_tree(TwoPhaseTree(**x.model_dump(include=set(TwoPhaseContext.model_fields)),
111
+ ignition_probability=ignition,immediate_given_ignition=immediate,vce_given_delayed=vce,
112
+ branch_probability_source="Composed phase correlations/Table5.3 and explicit mixing basis"))
113
+ return {**tree,**rates,"ignition_probability":ignition,"immediate_given_ignition":immediate,
114
+ "vce_given_delayed":vce,"flash_given_delayed":1-vce,"mixing_basis":x.mixing_basis,
115
+ "liquid_ignition_probability":phase_results["liquid"]["ignition_probability"],
116
+ "vapor_ignition_probability":phase_results["vapor"]["ignition_probability"],
117
+ "liquid_immediate_given_ignition":phase_results["liquid"]["immediate_given_ignition"],
118
+ "vapor_immediate_given_ignition":phase_results["vapor"]["immediate_given_ignition"],
119
+ "reference_temperature_k":phase_results["liquid"]["reference_temperature_k"],
120
+ "temperature_weight":phase_results["liquid"]["temperature_weight"]}
api581/workflow.py ADDED
@@ -0,0 +1,42 @@
1
+ """Bounded declarative composition, with no eval, arbitrary imports or shell commands."""
2
+ from typing import Any
3
+ from pydantic import Field
4
+ from .core import Model, Context, DomainError, evaluate, ensure
5
+
6
+
7
+ class Step(Model):
8
+ step_id: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
9
+ calculation_id: str
10
+ inputs: dict[str, Any]
11
+
12
+
13
+ class Workflow(Model):
14
+ context: Context = Field(default_factory=Context)
15
+ steps: list[Step] = Field(min_length=1, max_length=50)
16
+
17
+
18
+ def run_workflow(workflow: Workflow):
19
+ """References are {"$ref": "previous_step.outputs.field"}. Forward refs are rejected."""
20
+ results = {}
21
+
22
+ def resolve(value, depth=0):
23
+ ensure(depth <= 30, "Workflow input nesting exceeds 30", "invalid_workflow")
24
+ if isinstance(value, dict):
25
+ if "$ref" in value:
26
+ ensure(set(value) == {"$ref"} and isinstance(value["$ref"], str), "Invalid reference", "invalid_workflow")
27
+ path = value["$ref"].split(".")
28
+ ensure(len(path) >= 3 and path[0] in results and path[1] == "outputs", "References must target outputs of earlier steps", "invalid_workflow")
29
+ cursor = results[path[0]]
30
+ for key in path[1:]:
31
+ ensure(isinstance(cursor, dict) and key in cursor, "Missing reference field", "invalid_workflow")
32
+ cursor = cursor[key]
33
+ return cursor
34
+ return {k: resolve(v, depth + 1) for k, v in value.items()}
35
+ if isinstance(value, list):
36
+ return [resolve(v, depth + 1) for v in value]
37
+ return value
38
+
39
+ for step in workflow.steps:
40
+ ensure(step.step_id not in results, "Duplicate step_id", "invalid_workflow")
41
+ results[step.step_id] = evaluate(step.calculation_id, resolve(step.inputs), workflow.context)
42
+ return {"status": "succeeded", "scope": "explicit_user_composed_calculations", "steps": results}