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,204 @@
1
+ """Cooling-water figure11.1 source selection with measured-rate precedence."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, NonNegative, Positive, calculation, ensure
5
+ from .cooling_water import CoolingFresh, fresh_assessment, CoolingSeawater, seawater_rate, CoolingCoupon, coupon_rate
6
+ from .thinning import Posterior, WallLoss
7
+ from .clad_thinning import PressureStrength, CodeStrength
8
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
9
+
10
+ Distribution = Literal["general","local"]
11
+ Material = Literal["carbon_steel","low_alloy_steel","other_material"]
12
+
13
+
14
+ class CoolingSystemType(Model):
15
+ system: Literal["once_through", "closed_recirculated", "open_recirculated"]
16
+ water_source: Literal["fresh", "seawater", "brackish", "unknown"]
17
+ reuses_heated_water: bool
18
+ continual_makeup_required: bool
19
+ blowdown_or_evaporation_loss: bool
20
+ qualification_basis: str = Field(min_length=1)
21
+
22
+
23
+ @calculation("damage/cooling-water-system-type", "Cooling-water system type and operating premise", CoolingSystemType,
24
+ ["Part2 Annex2.B §§11.1.2-11.1.5; PDF pp397-398"], limitations=[
25
+ "System classification contract only. Once-through, closed-recirculated and open-recirculated definitions are validated from supplied operating facts; water treatment, corrosion rate, material suitability and source chemistry are not inferred."])
26
+ def system_type(x):
27
+ ensure(bool(x.qualification_basis.strip()), "Cooling-system qualification basis must not be blank")
28
+ if x.system == "once_through":
29
+ ensure(not x.reuses_heated_water and not x.blowdown_or_evaporation_loss,
30
+ "Once-through system must not reuse heated water or claim recirculating loss")
31
+ elif x.system == "closed_recirculated":
32
+ ensure(x.reuses_heated_water and not x.continual_makeup_required,
33
+ "Closed recirculated system requires reuse and no continual makeup")
34
+ else:
35
+ ensure(x.reuses_heated_water and x.continual_makeup_required and x.blowdown_or_evaporation_loss,
36
+ "Open recirculated system requires reuse, continual makeup and evaporation/blowdown loss")
37
+ return {"system": x.system, "water_source": x.water_source,
38
+ "requires_continual_makeup": x.continual_makeup_required,
39
+ "has_evaporation_or_blowdown_loss": x.blowdown_or_evaporation_loss,
40
+ "classification_basis": "supplied_operating_premise"}
41
+
42
+
43
+ class TreatedCooling(Model):
44
+ material: Literal["carbon_steel","low_alloy_steel"]
45
+ system: Literal["open_recirculating","closed_recirculating","once_through_fresh"]
46
+ qualified_effective_treatment_program: bool
47
+ actual_ph: float = Field(ge=6.5,le=9.5,strict=True,allow_inf_nan=False)
48
+ effective_microbiological_control: bool
49
+ no_deposits_or_local_low_flow: bool
50
+ qualification_basis: str = Field(min_length=1)
51
+ once_through_interpretation: Literal["body_untreated_only","qualified_figure_treatment"] | None = None
52
+
53
+
54
+ @calculation("damage/cooling-water-treatment-bounds", "Qualified treated cooling-water strict corrosion bounds", TreatedCooling,
55
+ ["Part2 §11.1.10,§11.3.2.2,Figure2.B.11.1; PDF pp401,404,414"],limitations=[
56
+ "Figure treated branch gives general<3mpy and pitting<15mpy, not exact measured rates. Bounds converted exactly to m/year and returned with strict-less relation. Qualified effective treatment, MIC control, no deposits/local low flow and pH6.5..9.5 required. Figure allows once-through fresh to enter treatment branch but detailed body assumes no chemical treatment except biocide: explicit qualified-figure interpretation required for treated once-through. No automatic program-efficacy or alloy-degradation assessment."])
57
+ def treatment_bounds(x):
58
+ ensure(x.qualified_effective_treatment_program and x.effective_microbiological_control and x.no_deposits_or_local_low_flow,
59
+ "Source treatment/MIC/deposit assumptions not met")
60
+ ensure(bool(x.qualification_basis.strip()),"Treatment qualification basis must not be blank")
61
+ if x.system == "once_through_fresh":
62
+ ensure(x.once_through_interpretation == "qualified_figure_treatment","Treated once-through requires explicit qualified figure interpretation","ambiguous_source")
63
+ else:
64
+ ensure(x.once_through_interpretation is None,"Once-through interpretation cannot accompany recirculating system")
65
+ return {"general_upper_bound_m_year":3*.0000254,"pitting_upper_bound_m_year":15*.0000254,"source_rate_relation":"strictly_less_than"}
66
+
67
+
68
+ class OtherCoolingMaterial(Model):
69
+ material: Literal["other_material"]
70
+ qualified_for_resistant_material_nominal_estimate: bool
71
+ other_degradation_assessed: bool
72
+ applicability_basis: str = Field(min_length=1)
73
+
74
+
75
+ @calculation("damage/cooling-water-other-material-estimate", "Cooling-water non-carbon/low-alloy nominal figure estimate", OtherCoolingMaterial,
76
+ ["Part2 §11.1.10-11.1.11,Figure2.B.11.1; PDF pp401,414"],limitations=[
77
+ "Figure non-carbon/non-low-alloy branch estimates.5mpy=.0000127m/year. This is not treated closed-system rate. Detailed text has no quantitative SCC/pitting/dealloying model for other alloys; explicit owner qualification and separate assessment of other degradation required. No automatic assertion stainless/copper/titanium or any nonmetal is immune, and no selection of this estimate solely from material name."])
78
+ def other_material(x):
79
+ ensure(x.qualified_for_resistant_material_nominal_estimate and x.other_degradation_assessed and bool(x.applicability_basis.strip()),
80
+ "Nominal other-material estimate requires applicability and other-degradation assessment")
81
+ return {"nominal_corrosion_rate_m_year":.5*.0000254}
82
+
83
+
84
+ class CoolingMeasurement(Model):
85
+ measurement_type: Literal["component_general","component_local","coupon_weight_loss_general"]
86
+ measured_rate_m_year: NonNegative
87
+ representative_for_assessed_component: bool
88
+ representativeness_basis: str = Field(min_length=1)
89
+ pitting_multiplier: Positive | None = Field(default=None,ge=5,le=10)
90
+
91
+
92
+ class MeasuredCoolingSelection(Model):
93
+ measurement: CoolingMeasurement
94
+ assessment_distribution: Distribution
95
+
96
+
97
+ def _measurement(measurement,distribution):
98
+ ensure(measurement.representative_for_assessed_component and bool(measurement.representativeness_basis.strip()),
99
+ "Measured-rate priority requires qualified component representativeness")
100
+ if measurement.measurement_type == "coupon_weight_loss_general" and distribution == "local":
101
+ ensure(measurement.pitting_multiplier is not None,"Local coupon estimate requires explicit5-10 pitting multiplier")
102
+ rate = coupon_rate(CoolingCoupon(coupon_general_corrosion_rate_m_year=measurement.measured_rate_m_year,
103
+ pitting_multiplier=measurement.pitting_multiplier,representativeness_basis=measurement.representativeness_basis))["pitting_corrosion_rate_m_year"]
104
+ return rate,"coupon_pitting_rule"
105
+ ensure(measurement.pitting_multiplier is None,"Pit multiplier applies only to local estimate from general weight-loss coupon")
106
+ if measurement.measurement_type == "coupon_weight_loss_general":
107
+ return measurement.measured_rate_m_year,"coupon_general"
108
+ ensure(measurement.measurement_type == "component_"+distribution,"Component measurement general/local distribution must match assessment")
109
+ return measurement.measured_rate_m_year,"component_measurement"
110
+
111
+
112
+ @calculation("damage/cooling-water-measured-rate", "Qualified representative measured cooling-water rate", MeasuredCoolingSelection,
113
+ ["Part2 §11.1.10(c),Figure2.B.11.1; PDF pp401,414"],limitations=[
114
+ "Representative component/coupon measurements take priority over model. General/local component rate must match assessment. General weight-loss coupon used for local assessment requires explicit5-10 multiplier; already-local component measurements must never be multiplied again. Coupon coverage of deposits/stagnant/MIC areas is not inferred. Zero measured rate remains a valid explicit input, not treated as missing."])
115
+ def measured_rate(x):
116
+ rate,source = _measurement(x.measurement,x.assessment_distribution)
117
+ return {"corrosion_rate_m_year":rate,"selection_source":source}
118
+
119
+
120
+ class FreshChoice(Model):
121
+ kind: Literal["freshwater_model"]
122
+ model: CoolingFresh
123
+
124
+
125
+ class SeawaterChoice(Model):
126
+ kind: Literal["seawater_model"]
127
+ model: CoolingSeawater
128
+
129
+
130
+ class TreatmentChoice(Model):
131
+ kind: Literal["treated"]
132
+ treatment: TreatedCooling
133
+ rate_policy: Literal["screen_at_strict_upper_bound","qualified_rate_below_bound"]
134
+ owner_rate_m_year: NonNegative | None = None
135
+ rate_selection_basis: str = Field(min_length=1)
136
+
137
+
138
+ class OtherChoice(Model):
139
+ kind: Literal["other_material_nominal"]
140
+ model: OtherCoolingMaterial
141
+
142
+
143
+ class CoolingRateSelection(Model):
144
+ material: Material
145
+ assessment_distribution: Distribution
146
+ selection_basis: str = Field(min_length=1)
147
+ measurement: CoolingMeasurement | None = None
148
+ fallback: FreshChoice | SeawaterChoice | TreatmentChoice | OtherChoice | None = Field(default=None,discriminator="kind")
149
+
150
+
151
+ @calculation("damage/cooling-water-rate-selection", "Select measured treated freshwater seawater or other-material cooling rate", CoolingRateSelection,
152
+ ["Part2 §11.1.10,§11.3,Figure2.B.11.1; PDF pp401-405,414"],origin="api581_composition_with_explicit_source_policies",limitations=[
153
+ "Qualified measured rate always selected before fallback; malformed/unrepresentative measurement errors instead of silently switching to model. Supplied fallback input structure is validated, but fallback calculation/assumption gates are not executed when measurement selected. Without measurement, explicit fallback required; no material/water/treatment guess. Model general/local representativeness must be owner-qualified. Treated strict bound is either an explicitly labeled conservative screening rate at the bound or an owner rate strictly below it, not automatic exact prediction. Main quantitative fresh/seawater models remain carbon-steel-only; other-material nominal path needs separate qualification. No all-mechanism/whole-component immunity conclusion."])
154
+ def select_rate(x):
155
+ ensure(bool(x.selection_basis.strip()),"Rate-selection/distribution qualification basis must not be blank")
156
+ if x.measurement is not None:
157
+ rate,source = _measurement(x.measurement,x.assessment_distribution)
158
+ bound,screen = None,False
159
+ else:
160
+ choice = x.fallback
161
+ ensure(choice is not None,"No representative measurement or explicit model fallback supplied")
162
+ bound,screen = None,False
163
+ if choice.kind in ("freshwater_model","seawater_model"):
164
+ ensure(x.material == "carbon_steel","Quantitative water models require carbon-steel material scope")
165
+ result = fresh_assessment(choice.model) if choice.kind == "freshwater_model" else seawater_rate(choice.model)
166
+ rate,source = result["corrosion_rate_m_year"],choice.kind
167
+ elif choice.kind == "other_material_nominal":
168
+ ensure(x.material == "other_material","Other-material nominal branch cannot replace carbon/low-alloy model")
169
+ rate,source = other_material(choice.model)["nominal_corrosion_rate_m_year"],choice.kind
170
+ else:
171
+ ensure(x.material == choice.treatment.material,"Treatment and overall material scopes must agree")
172
+ bounds = treatment_bounds(choice.treatment)
173
+ bound = bounds["general_upper_bound_m_year" if x.assessment_distribution == "general" else "pitting_upper_bound_m_year"]
174
+ ensure(bool(choice.rate_selection_basis.strip()),"Treated numerical rate selection requires owner basis")
175
+ if choice.rate_policy == "screen_at_strict_upper_bound":
176
+ ensure(choice.owner_rate_m_year is None,"Screening bound cannot have competing owner rate")
177
+ rate,source,screen = bound,"treated_screening_bound",True
178
+ else:
179
+ ensure(choice.owner_rate_m_year is not None and choice.owner_rate_m_year < bound,"Owner treated rate must be strictly below source bound")
180
+ rate,source = choice.owner_rate_m_year,"treated_owner_rate"
181
+ return {"corrosion_rate_m_year":rate,"selection_source":source,"assessment_distribution":x.assessment_distribution,
182
+ "source_strict_upper_bound_m_year":bound,"selected_rate_is_screening_bound":screen,
183
+ "fallback_ignored_due_to_measurement":x.measurement is not None and x.fallback is not None}
184
+
185
+
186
+ class SelectedCoolingThinning(Model):
187
+ selection: CoolingRateSelection
188
+ starting_thickness_m: Positive
189
+ elapsed_years: NonNegative
190
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
191
+ inspections: Posterior
192
+ conditions: ThinningConditions
193
+ qualification_basis: str = Field(min_length=1)
194
+
195
+
196
+ @calculation("damage/cooling-water-selected-thinning", "Selected cooling-water measured or model rate through non-AST thinning DF", SelectedCoolingThinning,
197
+ ["Part2 Annex2.B §11,Figure2.B.11.1; Part2 §4.5.7"],origin="api581_composition_with_explicit_source_policies",limitations=[
198
+ "Same selected measured/model/bound rate drives unclad non-AST thinning; general/local classification and inspection coverage are qualified inputs. Retains screening-bound flag through final DF, never presents it as a measured rate. No second pitting multiplier, treatment credit or duplicated monitoring adjustment. Either strength-ratio method supported with same thickness baseline. Other-material nominal rate does not exclude SCC/pitting/dealloying DFs; apply other applicable mechanisms separately. No AST, cladding, bundle or all-mechanism/whole-system risk assessment."])
199
+ def selected_thinning(x):
200
+ rate = select_rate(x.selection)
201
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
202
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=rate["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
203
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
204
+ return {**rate,**result}
@@ -0,0 +1,305 @@
1
+ """2016 Annex2.B section11 cooling-water corrosion, with explicit source policies."""
2
+ from bisect import bisect_left
3
+ import math
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, calculation, ensure, finite_tree
7
+ from .thinning import Posterior, WallLoss
8
+ from .clad_thinning import PressureStrength, CodeStrength
9
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
10
+
11
+ Lookup = Literal["exact_rows_only","linear_between_rows","maximum_bracketing_value"]
12
+ Units = Literal["printed_si","printed_us_converted"]
13
+ _C2C = (1,4,8,12,16,19,24,29,34,41,47,53,60,68,77)
14
+ _C2F = (33,39,46,53,60,67,76,85,94,105,117,128,140,154,170)
15
+ _C2 = tuple(2.6-i*.1 for i in range(15))
16
+ _CA = (10.5,12.5,15.5,25,39,49.5,62.5,78.5,99,124.5,156.5,197.5,250,310,390,495,625,785,940)
17
+ _C3 = (.6,.7,.8,1.,1.2,1.3,1.4,1.5,1.6,1.7,1.8,1.9,2.,2.1,2.2,2.3,2.4,2.5,2.6)
18
+ _MOA = (10.5,12.5,15.5,25,40,50,62.5,79,99.5,125,158,197.5,250,315,400,500,625,790,945)
19
+ _C4 = (10.5,12.5,1.2,1.4,1.6,1.7,1.8,1.9,2.,2.1,2.2,2.3,2.4,2.5,2.6,2.7,2.8,2.9,3.)
20
+ _CL = (5,10,50,100,250,500,750,1000,2000,3000,5000,10000)
21
+ _BASE_US = ((1,2,4,6,9,13,15,17,17,16,15,13),(.3,.6,1.4,2,3,4.3,5,5.7,5.6,5.4,4.9,4.3))
22
+ _BASE_SI = ((.03,.05,.1,.15,.23,.33,.38,.43,.43,.41,.38,.33),(.01,.02,.04,.05,.08,.11,.13,.14,.14,.14,.12,.11))
23
+ _FTC = (24,27,29,32,35,38,41,43,46,49,52,54,57,60,63,66,68,71,74,77,79,82,85,88,91,93,99)
24
+ _FTF = tuple(range(75,205,5))+(210,)
25
+ _CLOSED = (.1,.3,.4,.6,.8,.9,1.1,1.2,1.4,1.6,1.7,1.9,2.1,2.2,2.4,2.5,2.7,2.9,3.,3.2,3.4,3.5,3.7,3.8,4.,4.2,4.5)
26
+ _OPEN = _CLOSED[:20]+(3.3,3.3,3.3,3.3,3.1,2.9,1.7)
27
+
28
+
29
+ class CoolingRateFactors(Model):
30
+ """Qualified qualitative factors described in Annex 2.B §11.1.6."""
31
+ process_temperature_k: Positive
32
+ impurities_in_makeup_water: bool = False
33
+ deposits_present: bool = False
34
+ corrosion_products_present: bool = False
35
+ biomass_present: bool = False
36
+ hydrocarbon_process_leak: bool = False
37
+ low_flow_or_stagnant_area: bool = False
38
+
39
+
40
+ @calculation("damage/cooling-water-rate-factors", "Cooling-water qualitative corrosion-rate factor flags", CoolingRateFactors,
41
+ ["Part2 Annex2.B §11.1.6; PDF pp398-399"], limitations=[
42
+ "Qualitative screening only: the source describes impurities, scale, deposits, biomass and low-flow effects but supplies no universal multipliers. Scale potential is flagged when process temperature exceeds60C. No flag is converted into a corrosion rate, damage factor or treatment credit; owner qualification remains required."])
43
+ def rate_factors(x):
44
+ scale = x.process_temperature_k > 333.15
45
+ deposit = x.deposits_present or x.corrosion_products_present or x.biomass_present
46
+ flags = []
47
+ if x.impurities_in_makeup_water:
48
+ flags.append("makeup_impurities")
49
+ if scale:
50
+ flags.append("scale_potential_above_60C")
51
+ if x.deposits_present:
52
+ flags.append("mud_silt_or_other_deposit")
53
+ if x.corrosion_products_present:
54
+ flags.append("corrosion_products_deposit")
55
+ if x.biomass_present:
56
+ flags.append("biomass_or_mic_environment")
57
+ if x.hydrocarbon_process_leak:
58
+ flags.append("hydrocarbon_leak_biomass_acceleration")
59
+ if x.low_flow_or_stagnant_area:
60
+ flags.append("low_flow_or_stagnant_area")
61
+ return {"scale_potential": scale, "deposit_risk": deposit,
62
+ "biological_or_mic_risk": x.biomass_present or x.hydrocarbon_process_leak,
63
+ "underdeposit_risk": deposit or x.low_flow_or_stagnant_area,
64
+ "factor_flags": flags}
65
+
66
+
67
+ def _lookup(nodes,values,value,policy,holes=()):
68
+ value = next((n for n in nodes if math.isclose(n,value,rel_tol=0,abs_tol=1e-9)),value)
69
+ ensure(nodes[0] <= value <= nodes[-1],"Argument outside source table; no extrapolation","cooling_table_range")
70
+ i = bisect_left(nodes,value)
71
+ if nodes[i] == value:
72
+ return float(values[i])
73
+ ensure(policy != "exact_rows_only","Argument between source rows; numerical policy required","cooling_between_rows")
74
+ a,b = nodes[i-1],nodes[i]
75
+ ensure(not any(a < h < b for h in holes),"Interpolation crosses missing or unresolved source row","ambiguous_source")
76
+ return max(values[i-1],values[i]) if policy == "maximum_bracketing_value" else values[i-1]+(value-a)/(b-a)*(values[i]-values[i-1])
77
+
78
+
79
+ class CoolingCoefficients(Model):
80
+ total_dissolved_solids_mg_l: NonNegative
81
+ water_temperature_k: Positive
82
+ temperature_basis: Units
83
+ calcium_hardness_mg_caco3_l: Positive
84
+ methyl_orange_alkalinity_mg_caco3_l: Positive
85
+ lookup_policy: Lookup
86
+ c4_source_policy: Literal["reject_ambiguous_cells","qualified_printed_literal"]
87
+ c4_literal_basis: str | None = None
88
+
89
+
90
+ @calculation("damage/cooling-water-ph-coefficients", "Cooling water saturation-pH coefficient table", CoolingCoefficients,
91
+ ["Part2 Table2.B.11.3; PDF p408"],limitations=[
92
+ "C1 TDS50..400=.1,>400..1000=.2. C2 uses explicit printed C/F temperature grid; pairs rounded inconsistently. C3/C4 rows20/31 have no printed factors: exact use or interpolation crossing those rows rejected. C4 first two factors print10.5/12.5, unlike following1.2..3: reject those cells/adjacent interpolation unless qualified printed-literal policy and basis chosen. No inferred1.0/1.1 repair. Interpolation/maximum bracketing is explicit numerical policy, not a source mandate."])
93
+ def coefficients(x):
94
+ ensure(50 <= x.total_dissolved_solids_mg_l <= 1000,"TDS outside50..1000mg/L coefficient table")
95
+ literal = x.c4_source_policy == "qualified_printed_literal"
96
+ ensure((x.c4_literal_basis is not None and bool(x.c4_literal_basis.strip())) if literal else x.c4_literal_basis is None,"C4 literal policy requires matching qualification basis")
97
+ c = x.water_temperature_k-273.15
98
+ temp,grid = (c,_C2C) if x.temperature_basis == "printed_si" else (c*9/5+32,_C2F)
99
+ # Missing printed20/31 nodes cannot be bypassed through a smooth interpolation.
100
+ ca,moa = x.calcium_hardness_mg_caco3_l,x.methyl_orange_alkalinity_mg_caco3_l
101
+ ensure(ca not in (20,31) and moa not in (20,31),"Missing C3/C4 source coefficient","ambiguous_source")
102
+ ensure(literal or moa >= 15.5,"Ambiguous first C4 cells require qualified printed-literal policy","ambiguous_source")
103
+ return {"c1":.1 if x.total_dissolved_solids_mg_l <= 400 else .2,
104
+ "c2":_lookup(grid,_C2,temp,x.lookup_policy),"c3":_lookup(_CA,_C3,ca,x.lookup_policy,(20,31)),
105
+ "c4":_lookup(_MOA,_C4,moa,x.lookup_policy,(20,31)),"c4_printed_literal_used":literal and moa < 15.5}
106
+
107
+
108
+ class CoolingIndices(Model):
109
+ actual_ph: NonNegative = Field(le=14)
110
+ saturation_ph: NonNegative = Field(le=14)
111
+
112
+
113
+ @calculation("damage/cooling-water-stability-indices", "Langelier and Ryznar cooling-water indices", CoolingIndices,
114
+ ["Part2 eqs(2.B.9)-(2.B.10); PDF p402"],limitations=[
115
+ "LSI=pHa-pHs; RSI=2*pHs-pHa. Index alone is not quantitative corrosion rate or water-treatment effectiveness. Main cooling model assumes pH6.5..9.5; this independent index arithmetic does not screen that applicability."])
116
+ def indices(x):
117
+ return {"langelier_saturation_index":x.actual_ph-x.saturation_ph,"ryznar_stability_index":2*x.saturation_ph-x.actual_ph}
118
+
119
+
120
+ class CoolingSaturationPH(Model):
121
+ c1: NonNegative
122
+ c2: NonNegative
123
+ c3: NonNegative
124
+ c4: NonNegative
125
+
126
+
127
+ @calculation("damage/cooling-water-saturation-ph", "Cooling water saturation pH from four coefficients", CoolingSaturationPH,
128
+ ["Part2 eq(2.B.11); PDF p403"],limitations=[
129
+ "pHs=(9.3+C1+C2)-(C3+C4). Coefficients supplied or obtained from separate table API. Signed raw pHs retained diagnostically; no clamp of anomalous literal C4 results into a physical range. Main freshwater model requires qualified pHs0..14."])
130
+ def saturation_ph(x):
131
+ value = 9.3+x.c1+x.c2-x.c3-x.c4
132
+ finite_tree(value)
133
+ return {"saturation_ph":value}
134
+
135
+
136
+ class CoolingBase(Model):
137
+ chloride_ppm: NonNegative
138
+ ryznar_stability_index: NonNegative
139
+ velocity_m_s: NonNegative
140
+ condition_interpretation: Literal["body_rsi6_velocity2_44","us_header_rsi6_velocity8ft"]
141
+ rate_unit_basis: Units
142
+ lookup_policy: Lookup
143
+
144
+
145
+ @calculation("damage/cooling-water-base-rate", "Cooling water chloride and scaling base corrosion rate", CoolingBase,
146
+ ["Part2 §11.3.1.2,Tables2.B.11.4/11.4M; PDF pp402-403,409"],limitations=[
147
+ "SI table both headers repeat RSI<=1.8 and velocity<=2.4; unusable for automatic column choice. Explicit interpretation follows detailed-body RSI6/2.44m/s or US header RSI6/8ft/s; no official SI erratum asserted. High column if RSI>6 OR velocity>threshold, otherwise scaling low column. Printed SI rates and exact US-mpy conversion differ. Chloride5..10000ppm only, explicit interpolation policy; maximum bracketing stays conservative on declining high-chloride rows. Not automatic seawater/freshwater classification."])
148
+ def base_rate(x):
149
+ threshold = 2.44 if x.condition_interpretation == "body_rsi6_velocity2_44" else 8*.3048
150
+ col = 0 if x.ryznar_stability_index > 6 or x.velocity_m_s > threshold else 1
151
+ native = _lookup(_CL,(_BASE_SI if x.rate_unit_basis == "printed_si" else _BASE_US)[col],x.chloride_ppm,x.lookup_policy)
152
+ return {"base_corrosion_rate_m_year":native*(.001 if x.rate_unit_basis == "printed_si" else .0000254),
153
+ "base_column":"unprotected_high" if col == 0 else "scaling_low","base_velocity_threshold_m_s":threshold}
154
+
155
+
156
+ class CoolingTemperature(Model):
157
+ metal_temperature_k: Positive
158
+ system: Literal["open","closed"]
159
+ temperature_basis: Units
160
+ argument_policy: Literal["table_operating_temperature","text_delta_from_reference"]
161
+ duplicate_row_policy: Literal["reject_unresolved_row","qualified_ignore_misordered_row"]
162
+ duplicate_row_basis: str | None = None
163
+ lookup_policy: Lookup
164
+
165
+
166
+ @calculation("damage/cooling-water-temperature-factor", "Cooling water open or closed system temperature factor", CoolingTemperature,
167
+ ["Part2 eq(2.B.12),Table2.B.11.5; PDF pp403,410"],limitations=[
168
+ "Text says use deltaT from24C/75F, table labels operating temperature. Explicit argument policy selects interpretation. Source penultimate row repeats41C/105F with factors4.3/2.5 after93C/200F; no invented96C/205F fix. Default rejects repeated node/adjacent interpolation and upper gap crossing misordered row. Qualified ignore policy omits row explicitly, with owner basis; remaining printed rows interpolate by selected policy. No table-range extrapolation; temperature basis is rounded SI or converted US grid."])
169
+ def temperature_factor(x):
170
+ ignore = x.duplicate_row_policy == "qualified_ignore_misordered_row"
171
+ ensure((x.duplicate_row_basis is not None and bool(x.duplicate_row_basis.strip())) if ignore else x.duplicate_row_basis is None,"Ignoring misordered temperature row requires qualification basis")
172
+ c = x.metal_temperature_k-273.15
173
+ value,grid,duplicate = (c,_FTC,41.) if x.temperature_basis == "printed_si" else (c*9/5+32,_FTF,105.)
174
+ if x.argument_policy == "text_delta_from_reference":
175
+ value -= 24. if x.temperature_basis == "printed_si" else 75.
176
+ if not ignore:
177
+ i = bisect_left(grid,value)
178
+ if i < len(grid) and grid[i] == value:
179
+ ensure(value != duplicate,"Conflicting repeated temperature source row","ambiguous_source")
180
+ elif 0 < i < len(grid):
181
+ ensure(duplicate not in (grid[i-1],grid[i]) and i != len(grid)-1,"Temperature interpolation touches unresolved row","ambiguous_source")
182
+ factor = _lookup(grid,_OPEN if x.system == "open" else _CLOSED,value,x.lookup_policy)
183
+ return {"temperature_factor":factor,"temperature_table_argument":value,"misordered_row_ignored":ignore}
184
+
185
+
186
+ class CoolingVelocity(Model):
187
+ velocity_m_s: NonNegative
188
+ equation_basis: Units
189
+
190
+
191
+ @calculation("damage/cooling-water-velocity-factor", "Cooling water velocity adjustment equation", CoolingVelocity,
192
+ ["Part2 eqs(2.B.13)-(2.B.18),Table2.B.11.6; PDF pp403-404,411"],limitations=[
193
+ "Printed SI uses boundaries.914/2.44m/s and slopes1.64/.82; US3/8ft/s and slopes.50/.25. Explicit basis preserves rounding difference. Public velocitym/s, qualified source application up to6.1m/s(20ft/s rounded), no extrapolation beyond. Equation evaluated directly, not interpolated rounded table values."])
194
+ def velocity_factor(x):
195
+ ensure(x.velocity_m_s <= 6.1,"Velocity beyond stated cooling-system assumption6.1m/s")
196
+ v,lo,hi,a,b = (x.velocity_m_s,.914,2.44,1.64,.82) if x.equation_basis == "printed_si" else (x.velocity_m_s/.3048,3.,8.,.5,.25)
197
+ return {"velocity_factor":1+a*(lo-v) if v < lo else 1+b*(v-hi) if v > hi else 1.}
198
+
199
+
200
+ class CoolingRateEquation(Model):
201
+ base_corrosion_rate_m_year: NonNegative
202
+ temperature_factor: Positive
203
+ velocity_factor: Positive
204
+
205
+
206
+ @calculation("damage/cooling-water-rate-equation", "Cooling water base rate with temperature and velocity factors", CoolingRateEquation,
207
+ ["Part2 eq(2.B.8); PDF p402"],limitations=["Constant qualified carbon-steel cooling-water factors only; this equation does not screen treatment, MIC, deposits or material applicability."])
208
+ def rate_equation(x):
209
+ rate = x.base_corrosion_rate_m_year*x.temperature_factor*x.velocity_factor
210
+ ensure(math.isfinite(rate) and (rate > 0 or x.base_corrosion_rate_m_year == 0),"Corrosion product outside numeric range","numerical_failure")
211
+ return {"corrosion_rate_m_year":rate}
212
+
213
+
214
+ class CoolingSeawater(Model):
215
+ material: Literal["carbon_steel"]
216
+ velocity_m_s: NonNegative
217
+ equation_basis: Units
218
+ applicability_basis: str = Field(min_length=1)
219
+
220
+
221
+ @calculation("damage/cooling-water-seawater-rate", "Once-through seawater carbon-steel corrosion correlation", CoolingSeawater,
222
+ ["Part2 §11.3.2.3,eqs(2.B.19)-(2.B.20); PDF p405"],limitations=[
223
+ "Correlation reference about21C and dissolved oxygen6-8ppm; owner qualifies seawater, untreated carbon steel, oxygen/temperature and absence of deposit/MIC exceptions. Public velocitym/s, limit6.1m/s source cooling assumption; erosion-corrosion above about12.2m/s is outside. SI polynomialmm/y and US polynomialmpy are separately rounded and selectable. No temperature/oxygen/treatment correction invented."])
224
+ def seawater_rate(x):
225
+ ensure(bool(x.applicability_basis.strip()),"Seawater applicability basis must not be blank")
226
+ ensure(x.velocity_m_s <= 6.1,"Seawater velocity exceeds stated cooling-system assumption")
227
+ v = x.velocity_m_s if x.equation_basis == "printed_si" else x.velocity_m_s/.3048
228
+ native = .1318+.3108*v-.0579*v*v+.01208*v**2.5 if x.equation_basis == "printed_si" else 5.1885+3.7293*v-.21181*v*v+.02439*v**2.5
229
+ return {"corrosion_rate_m_year":native*(.001 if x.equation_basis == "printed_si" else .0000254)}
230
+
231
+
232
+ class CoolingCoupon(Model):
233
+ coupon_general_corrosion_rate_m_year: NonNegative
234
+ pitting_multiplier: Positive = Field(ge=5,le=10)
235
+ representativeness_basis: str = Field(min_length=1)
236
+
237
+
238
+ @calculation("damage/cooling-water-coupon-pitting-rate", "Qualified cooling-water coupon general to pitting estimate", CoolingCoupon,
239
+ ["Part2 §11.1.10(c),Table2.B.11.1; PDF pp401,407"],limitations=[
240
+ "Source prefers representative measured coupon rates over model; rule-of-thumb pitting5-10 times weight-loss general rate. Explicit multiplier and representativeness basis required. Flowing coupon may miss deposits/stagnant/MIC attack; no implied qualification of those cases or universal pitting multiplier."])
241
+ def coupon_rate(x):
242
+ ensure(bool(x.representativeness_basis.strip()),"Coupon representativeness basis must not be blank")
243
+ rate = x.coupon_general_corrosion_rate_m_year*x.pitting_multiplier
244
+ finite_tree(rate)
245
+ return {"pitting_corrosion_rate_m_year":rate}
246
+
247
+
248
+ class CoolingFresh(Model):
249
+ material: Literal["carbon_steel"]
250
+ applicability_basis: str = Field(min_length=1)
251
+ actual_ph: float = Field(ge=6.5,le=9.5,strict=True,allow_inf_nan=False)
252
+ saturation_ph: NonNegative = Field(le=14)
253
+ saturation_ph_basis: str = Field(min_length=1)
254
+ chloride_ppm: NonNegative
255
+ velocity_m_s: NonNegative
256
+ system: Literal["open_recirculating","closed_recirculating","once_through_fresh"]
257
+ effective_microbiological_control: bool
258
+ no_deposits_or_local_low_flow: bool
259
+ no_corrosion_inhibitor_model_credit: Literal[True]
260
+ condition_interpretation: Literal["body_rsi6_velocity2_44","us_header_rsi6_velocity8ft"]
261
+ rate_unit_basis: Units
262
+ velocity_equation_basis: Units
263
+ temperature: CoolingTemperature
264
+ chloride_lookup_policy: Lookup
265
+
266
+
267
+ @calculation("damage/cooling-water-fresh-assessment", "Untreated freshwater cooling corrosion with qualified supplied saturation pH", CoolingFresh,
268
+ ["Part2 §11.1.10,§11.3.1-11.3.2.2,eqs(2.B.8)-(2.B.18); PDF pp401-404,409-410"],origin="api581_composition_with_explicit_source_policies",limitations=[
269
+ "Carbon-steel representative rate; qualified supplied pHs can bypass unresolved coefficient table cells without inventing corrections. Measured representative rates preferred. Assumes pH6.5..9.5, effective MIC control and no deposits/local low flow; no inhibitor credit in this model path. Once-through fresh requires chloride<1000ppm; exactly1000 is unclassified by source strict inequalities and rejected. Temperature table and actual system must agree, once-through fresh uses open factors. Return rate is not automatically general/pitting classified; detailed body says pitting dominates, Annex2.B Table1.2 lists general at specified velocity. No5-10 coupon multiplier automatically applied to modeled rate."])
270
+ def fresh_assessment(x):
271
+ ensure(bool(x.applicability_basis.strip()) and bool(x.saturation_ph_basis.strip()),"Applicability and saturation-pH basis required")
272
+ ensure(x.effective_microbiological_control and x.no_deposits_or_local_low_flow,"Cooling-water MIC/deposit/low-flow assumptions not met")
273
+ ensure(x.temperature.system == ("closed" if x.system == "closed_recirculating" else "open"),"Temperature system factor must match water system")
274
+ if x.system == "once_through_fresh":
275
+ ensure(x.chloride_ppm < 1000,"Once-through fresh classification requires chloride<1000ppm; boundary not inferred")
276
+ stability = indices(CoolingIndices(actual_ph=x.actual_ph,saturation_ph=x.saturation_ph))
277
+ ensure(stability["ryznar_stability_index"] >= 0,"RSI outside source expected positive domain")
278
+ base = base_rate(CoolingBase(chloride_ppm=x.chloride_ppm,ryznar_stability_index=stability["ryznar_stability_index"],velocity_m_s=x.velocity_m_s,
279
+ condition_interpretation=x.condition_interpretation,rate_unit_basis=x.rate_unit_basis,lookup_policy=x.chloride_lookup_policy))
280
+ temp = temperature_factor(x.temperature)
281
+ vel = velocity_factor(CoolingVelocity(velocity_m_s=x.velocity_m_s,equation_basis=x.velocity_equation_basis))
282
+ rate = rate_equation(CoolingRateEquation(base_corrosion_rate_m_year=base["base_corrosion_rate_m_year"],temperature_factor=temp["temperature_factor"],velocity_factor=vel["velocity_factor"]))
283
+ return {**stability,**base,**temp,**vel,**rate}
284
+
285
+
286
+ class CoolingThinning(Model):
287
+ cooling: CoolingFresh
288
+ starting_thickness_m: Positive
289
+ elapsed_years: NonNegative
290
+ strength: PressureStrength | CodeStrength = Field(discriminator="method")
291
+ inspections: Posterior
292
+ conditions: ThinningConditions
293
+ thinning_type: Literal["general","local"]
294
+ qualification_basis: str = Field(min_length=1)
295
+
296
+
297
+ @calculation("damage/cooling-water-thinning-assessment", "Freshwater cooling rate through qualified non-AST thinning DF", CoolingThinning,
298
+ ["Part2 Annex2.B §11; Part2 §4.5.7"],origin="api581_composition_with_explicit_source_policies",limitations=[
299
+ "Unclad non-AST component only. Same representative cooling model rate drives loss and either strength method; conditions derive monitoring/piping adjustments. Owner must qualify general/local thinning and actual inspection coverage despite source body/summary classification difference. No automatic pit multiplier on modeled rate; coupon-pitting estimate is a separate API. No treatment-credit path, MIC/deposit modeling, all-mechanism DF, heat-exchanger bundle rupture model or AST use."])
300
+ def cooling_thinning(x):
301
+ rate = fresh_assessment(x.cooling)
302
+ result = qualified_unclad(QualifiedUnclad(assessment_scope="non_ast_unclad",qualification_basis=x.qualification_basis,
303
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=rate["corrosion_rate_m_year"],elapsed_years=x.elapsed_years),
304
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
305
+ return {**rate,**result,"thinning_type":x.thinning_type}
api581/core.py ADDED
@@ -0,0 +1,126 @@
1
+ """Registry shared by typed Python, batch and composed calculations."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from hashlib import sha256
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+ from typing import Annotated, Any, Callable, Literal
11
+ from uuid import uuid4
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
14
+
15
+ VERSION = "0.1.0"
16
+ EDITION = "API-RP-581-2016-3rd-base"
17
+
18
+
19
+ def code_fingerprint():
20
+ digest = sha256()
21
+ root = Path(__file__).parent
22
+ for path in sorted(root.rglob("*.py")):
23
+ digest.update(path.relative_to(root).as_posix().encode())
24
+ digest.update(path.read_bytes())
25
+ return digest.hexdigest()
26
+
27
+
28
+ CODE_FINGERPRINT = code_fingerprint()
29
+ NonNegative = Annotated[float, Field(ge=0, allow_inf_nan=False, strict=True)]
30
+ Positive = Annotated[float, Field(gt=0, allow_inf_nan=False, strict=True)]
31
+ Fraction = Annotated[float, Field(ge=0, le=1, allow_inf_nan=False, strict=True)]
32
+
33
+
34
+ class Model(BaseModel):
35
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False, validate_default=True)
36
+
37
+
38
+ class Context(Model):
39
+ asset_id: str | None = Field(default=None, max_length=200)
40
+ component_id: str | None = Field(default=None, max_length=200)
41
+ observed_at: datetime | None = None
42
+ correlation_id: str | None = Field(default=None, max_length=200)
43
+ source_revision: str | None = Field(default=None, max_length=200)
44
+ standard_edition: Literal["API-RP-581-2016-3rd-base"] = EDITION
45
+
46
+ @field_validator("observed_at")
47
+ @classmethod
48
+ def aware(cls, value):
49
+ if value is not None and value.utcoffset() is None:
50
+ raise ValueError("observed_at requires a timezone offset")
51
+ return value
52
+
53
+
54
+ class DomainError(ValueError):
55
+ def __init__(self, code: str, message: str):
56
+ self.code = code
57
+ super().__init__(message)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Calculation:
62
+ id: str
63
+ title: str
64
+ model: type[Model]
65
+ function: Callable
66
+ references: tuple[str, ...]
67
+ origin: str
68
+ limitations: tuple[str, ...]
69
+
70
+
71
+ REGISTRY: dict[str, Calculation] = {}
72
+
73
+
74
+ def calculation(id, title, model, references=(), origin="api581_equation", limitations=()):
75
+ def register(fn):
76
+ if id in REGISTRY:
77
+ raise RuntimeError(f"duplicate calculation: {id}")
78
+ resolved_limitations = tuple(limitations) or (
79
+ "Primitive or utility calculation with no additional clause-specific limitation; "
80
+ "caller must qualify API 581 applicability, input basis and engineering use.",
81
+ )
82
+ REGISTRY[id] = Calculation(id, title, model, fn, tuple(references), origin, resolved_limitations)
83
+ return fn
84
+ return register
85
+
86
+
87
+ def ensure(condition: bool, message: str, code="outside_model_domain"):
88
+ if not condition:
89
+ raise DomainError(code, message)
90
+
91
+
92
+ def finite_tree(value):
93
+ if isinstance(value, float):
94
+ ensure(math.isfinite(value), "Non-finite calculation result", "numerical_failure")
95
+ elif isinstance(value, dict):
96
+ for x in value.values():
97
+ finite_tree(x)
98
+ elif isinstance(value, (list, tuple)):
99
+ for x in value:
100
+ finite_tree(x)
101
+
102
+
103
+ def evaluate(id: str, inputs: dict[str, Any], context: Context | None = None):
104
+ ensure(id in REGISTRY, f"Unknown calculation: {id}", "unknown_calculation")
105
+ c = REGISTRY[id]
106
+ parsed = c.model.model_validate(inputs)
107
+ try:
108
+ output = c.function(parsed)
109
+ finite_tree(output)
110
+ except (OverflowError, ZeroDivisionError) as exc:
111
+ raise DomainError("numerical_failure", "Inputs exceed numerical domain") from exc
112
+ from .contracts import OUTPUT_MODELS
113
+ try:
114
+ output = OUTPUT_MODELS[id].model_validate(output).model_dump(mode="json", exclude_unset=True)
115
+ except ValidationError as exc:
116
+ raise DomainError("output_contract_violation", f"Calculation {id} returned an invalid output contract") from exc
117
+ ctx = context or Context()
118
+ snapshot = {"inputs": parsed.model_dump(mode="json"), "context": ctx.model_dump(mode="json"),
119
+ "calculation_id": id, "engine_version": VERSION, "code_fingerprint": CODE_FINGERPRINT}
120
+ digest = sha256(json.dumps(snapshot, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
121
+ return {"run_id": str(uuid4()), "calculation_id": id, "engine_version": VERSION,
122
+ "standard_edition": EDITION, "code_fingerprint": snapshot["code_fingerprint"], "calculated_at": datetime.now(timezone.utc).isoformat(),
123
+ "context": ctx.model_dump(mode="json"), "input_hash": digest,
124
+ "normalized_inputs": snapshot["inputs"], "outputs": output,
125
+ "method_origin": c.origin, "references": list(c.references),
126
+ "limitations": list(c.limitations), "validation_status": "software_tested_not_independently_certified"}