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
api581/standard.py ADDED
@@ -0,0 +1,281 @@
1
+ """Read-only API 581 Part 1 standard metadata.
2
+
3
+ The metadata is extracted from the supplied 2016 PDF during the source-data
4
+ build and packaged as JSON. These helpers are deliberately documentary: they
5
+ do not infer applicability, replace engineering qualification, or act as
6
+ calculation inputs.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from functools import lru_cache
12
+ from importlib.resources import files
13
+ from typing import Any
14
+
15
+ from pydantic import ConfigDict, Field
16
+
17
+ from .core import Model, ensure
18
+
19
+ __all__ = [
20
+ "StandardDefinition", "StandardAcronym", "StandardReference", "StandardSection", "SupportingTextLocator", "SupportingTextReview", "DocumentaryCoverage", "standard_metadata",
21
+ "definitions", "definition", "acronyms", "acronym",
22
+ "references", "reference", "sections", "section", "supporting_text_review", "documentary_coverage",
23
+ ]
24
+
25
+
26
+ class StandardDefinition(Model):
27
+ model_config = ConfigDict(frozen=True)
28
+ clause: str = Field(min_length=1)
29
+ term: str = Field(min_length=1)
30
+ acronym: str | None = None
31
+ definition: str = Field(min_length=1)
32
+ source_page: int = Field(gt=0)
33
+
34
+
35
+ class StandardAcronym(Model):
36
+ model_config = ConfigDict(frozen=True)
37
+ acronym: str = Field(min_length=1)
38
+ expansion: str = Field(min_length=1)
39
+ source_page: int = Field(gt=0)
40
+
41
+
42
+ class StandardReference(Model):
43
+ model_config = ConfigDict(frozen=True)
44
+ category: str = Field(pattern=r"^(normative|informative)$")
45
+ number: int = Field(gt=0)
46
+ citation: str = Field(min_length=1)
47
+ source_page: int = Field(gt=0)
48
+
49
+
50
+ class StandardSection(Model):
51
+ """Typed view of one source-heading ledger entry."""
52
+ model_config = ConfigDict(extra="ignore", frozen=True)
53
+ id: str = Field(min_length=1)
54
+ title: str = Field(min_length=1)
55
+ status: str = Field(pattern=r"^(partial|not_implemented)$")
56
+ review_category: str = Field(pattern=r"^(technical_scope_review|supporting_text_review)$")
57
+ scope: str = Field(min_length=1)
58
+ calculation_ids: tuple[str, ...] = ()
59
+ next_evidence_needed: str = Field(min_length=1)
60
+
61
+
62
+ class SupportingTextLocator(Model):
63
+ """Immutable source locator and extracted-page fingerprint."""
64
+ model_config = ConfigDict(extra="ignore", frozen=True)
65
+ pdf: str = Field(min_length=1)
66
+ toc_pdf_page: int | None = Field(default=None, ge=1)
67
+ printed_page_in_part: str | None = None
68
+ extracted_page_text_sha256: str = Field(min_length=64, max_length=64)
69
+ extracted_page_text_bytes: int = Field(ge=0)
70
+
71
+
72
+ class SupportingTextReview(Model):
73
+ """Typed provenance record for a non-calculational source heading."""
74
+ model_config = ConfigDict(extra="ignore", frozen=True)
75
+ clause_id: str = Field(min_length=1)
76
+ title: str = Field(min_length=1)
77
+ scope: str = Field(min_length=1)
78
+ source_locator: SupportingTextLocator
79
+ review_category: str = Field(pattern=r"^(technical_scope_review|supporting_text_review)$")
80
+ classification: str = Field(min_length=1)
81
+ evidence_target: str = Field(min_length=1)
82
+ next_action: str = Field(min_length=1)
83
+ calculation_ids: tuple[str, ...] = ()
84
+
85
+
86
+ class DocumentaryCoverage(Model):
87
+ """Typed documentary-evidence boundary for the source heading ledger."""
88
+ model_config = ConfigDict(frozen=True)
89
+ standard_edition: str = Field(min_length=1)
90
+ source_sha256: str = Field(min_length=64, max_length=64)
91
+ metadata_sources: dict[str, str]
92
+ metadata_covered_section_ids: tuple[str, ...]
93
+ metadata_covered_count: int = Field(ge=0)
94
+ supporting_review_section_ids: tuple[str, ...]
95
+ supporting_review_count: int = Field(ge=0)
96
+ unimplemented_supporting_section_ids: tuple[str, ...]
97
+ unimplemented_supporting_count: int = Field(ge=0)
98
+
99
+
100
+ @lru_cache(maxsize=1)
101
+ def _metadata() -> dict[str, Any]:
102
+ resource = files("api581").joinpath("data/standard-metadata.json")
103
+ return json.loads(resource.read_text(encoding="utf-8"))
104
+
105
+
106
+ def standard_metadata() -> dict[str, str | int]:
107
+ """Return edition and source identifiers without exposing mutable tables."""
108
+ payload = _metadata()
109
+ coverage = json.loads(files("api581").joinpath("data/coverage.json").read_text(encoding="utf-8"))
110
+ return {"standard_edition": payload["standard_edition"], "source": payload["source"],
111
+ "source_sha256": payload["source_sha256"],
112
+ "definition_count": len(payload["definitions"]), "acronym_count": len(payload["acronyms"]),
113
+ "reference_count": len(payload["references"]), "section_count": len(coverage["sections"])}
114
+
115
+
116
+ @lru_cache(maxsize=1)
117
+ def _definitions() -> tuple[StandardDefinition, ...]:
118
+ return tuple(StandardDefinition.model_validate(item) for item in _metadata()["definitions"])
119
+
120
+
121
+ @lru_cache(maxsize=1)
122
+ def _acronyms() -> tuple[StandardAcronym, ...]:
123
+ return tuple(StandardAcronym.model_validate(item) for item in _metadata()["acronyms"])
124
+
125
+
126
+ @lru_cache(maxsize=1)
127
+ def _references() -> tuple[StandardReference, ...]:
128
+ return tuple(StandardReference.model_validate(item) for item in _metadata()["references"])
129
+
130
+
131
+ @lru_cache(maxsize=1)
132
+ def _sections() -> tuple[StandardSection, ...]:
133
+ resource = files("api581").joinpath("data/coverage.json")
134
+ payload = json.loads(resource.read_text(encoding="utf-8"))
135
+ normalized = []
136
+ for item in payload["sections"]:
137
+ row = dict(item)
138
+ row["scope"] = row["id"].split(" §", 1)[0]
139
+ normalized.append(row)
140
+ return tuple(StandardSection.model_validate(item) for item in normalized)
141
+
142
+
143
+ @lru_cache(maxsize=1)
144
+ def _supporting_text_reviews() -> tuple[SupportingTextReview, ...]:
145
+ resource = files("api581").joinpath("data/supporting-text-review.json")
146
+ payload = json.loads(resource.read_text(encoding="utf-8"))
147
+ return tuple(SupportingTextReview.model_validate(item) for item in payload["clauses"])
148
+
149
+
150
+ def definitions(query: str | None = None) -> tuple[StandardDefinition, ...]:
151
+ """List definitions, optionally matching term, acronym, clause, or text."""
152
+ items = _definitions()
153
+ if query is None:
154
+ return items
155
+ needle = query.strip().casefold()
156
+ ensure(needle, "query must not be blank", "invalid_standard_query")
157
+ return tuple(item for item in items if needle in " ".join(
158
+ value for value in (item.term, item.acronym or "", item.clause, item.definition)
159
+ ).casefold())
160
+
161
+
162
+ def definition(term_or_clause: str) -> StandardDefinition:
163
+ """Return one exact definition or raise a typed documentary error."""
164
+ needle = term_or_clause.strip().casefold()
165
+ ensure(needle, "term_or_clause must not be blank", "invalid_standard_query")
166
+ matches = tuple(item for item in _definitions()
167
+ if item.term.casefold() == needle or item.clause.casefold() == needle)
168
+ ensure(len(matches) == 1, "No unique API 581 definition matched the query", "standard_definition_not_found")
169
+ return matches[0]
170
+
171
+
172
+ def acronyms(prefix: str | None = None) -> tuple[StandardAcronym, ...]:
173
+ """List acronyms, optionally by case-insensitive prefix."""
174
+ items = _acronyms()
175
+ if prefix is None:
176
+ return items
177
+ needle = prefix.strip().casefold()
178
+ ensure(needle, "prefix must not be blank", "invalid_standard_query")
179
+ return tuple(item for item in items if item.acronym.casefold().startswith(needle))
180
+
181
+
182
+ def acronym(key: str) -> StandardAcronym:
183
+ """Return one exact acronym expansion or raise a typed documentary error."""
184
+ needle = key.strip().casefold()
185
+ ensure(needle, "key must not be blank", "invalid_standard_query")
186
+ matches = tuple(item for item in _acronyms() if item.acronym.casefold() == needle)
187
+ ensure(len(matches) == 1, "No unique API 581 acronym matched the query", "standard_acronym_not_found")
188
+ return matches[0]
189
+
190
+
191
+ def references(category: str | None = None) -> tuple[StandardReference, ...]:
192
+ """List Part 1 §2 references, optionally filtered by category."""
193
+ items = _references()
194
+ if category is None:
195
+ return items
196
+ needle = category.strip().casefold()
197
+ ensure(needle in {"normative", "informative"},
198
+ "category must be normative or informative", "invalid_standard_query")
199
+ return tuple(item for item in items if item.category == needle)
200
+
201
+
202
+ def reference(number: int, *, category: str = "informative") -> StandardReference:
203
+ """Return one Part 1 §2 reference by category and source number."""
204
+ matches = tuple(item for item in references(category) if item.number == number)
205
+ ensure(len(matches) == 1, "No unique API 581 reference matched the query", "standard_reference_not_found")
206
+ return matches[0]
207
+
208
+
209
+ def sections(*, status: str | None = None, review_category: str | None = None,
210
+ scope: str | None = None) -> tuple[StandardSection, ...]:
211
+ """List source headings from the packaged coverage ledger with filters."""
212
+ if status is not None:
213
+ ensure(status in {"partial", "not_implemented"},
214
+ "status must be partial or not_implemented", "invalid_standard_query")
215
+ if review_category is not None:
216
+ ensure(review_category in {"technical_scope_review", "supporting_text_review"},
217
+ "review_category is not recognized", "invalid_standard_query")
218
+ if scope is not None:
219
+ ensure(isinstance(scope, str) and bool(scope.strip()),
220
+ "scope must be a non-empty string or None", "invalid_standard_query")
221
+ return tuple(item for item in _sections()
222
+ if (status is None or item.status == status)
223
+ and (review_category is None or item.review_category == review_category)
224
+ and (scope is None or item.scope.casefold() == scope.strip().casefold()))
225
+
226
+
227
+ def section(section_id: str) -> StandardSection:
228
+ """Return one source heading and its next evidence requirement."""
229
+ needle = section_id.strip().casefold()
230
+ ensure(needle, "section_id must not be blank", "invalid_standard_query")
231
+ matches = tuple(item for item in _sections() if item.id.casefold() == needle)
232
+ ensure(len(matches) == 1, "No unique API 581 source section matched the query", "standard_section_not_found")
233
+ return matches[0]
234
+
235
+
236
+ def supporting_text_review(clause_id: str | None = None) -> tuple[SupportingTextReview, ...]:
237
+ """Return source-locator and evidence-routing records for supporting headings."""
238
+ items = _supporting_text_reviews()
239
+ if clause_id is None:
240
+ return items
241
+ needle = clause_id.strip().casefold()
242
+ ensure(needle, "clause_id must not be blank", "invalid_standard_query")
243
+ matches = tuple(item for item in items if item.clause_id.casefold() == needle)
244
+ ensure(matches, "No supporting-text review matched the clause", "standard_section_not_found")
245
+ return matches
246
+
247
+
248
+ def documentary_coverage() -> DocumentaryCoverage:
249
+ """Summarize source headings covered by extracted documentary metadata.
250
+
251
+ Part 1 §2 (references) and Part 1 §3 (definitions/acronyms) are not
252
+ numerical calculation clauses, but their source data is packaged and
253
+ queryable through this module. The remaining supporting-text headings
254
+ stay explicitly listed so metadata extraction is not mistaken for full
255
+ clause interpretation.
256
+ """
257
+ source_sections = _sections()
258
+ metadata_prefixes = ("Part 1 §2", "Part 1 §3")
259
+ covered = tuple(item.id for item in source_sections
260
+ if item.id.startswith(metadata_prefixes))
261
+ supporting_review = tuple(item.id for item in source_sections
262
+ if item.review_category == "supporting_text_review"
263
+ and item.id not in covered)
264
+ unimplemented = tuple(item.id for item in source_sections
265
+ if item.status == "not_implemented")
266
+ metadata = standard_metadata()
267
+ return DocumentaryCoverage.model_validate({
268
+ "standard_edition": metadata["standard_edition"],
269
+ "source_sha256": metadata["source_sha256"],
270
+ "metadata_sources": {
271
+ "references": "standard-metadata.json:references",
272
+ "definitions": "standard-metadata.json:definitions",
273
+ "acronyms": "standard-metadata.json:acronyms",
274
+ },
275
+ "metadata_covered_section_ids": covered,
276
+ "metadata_covered_count": len(covered),
277
+ "supporting_review_section_ids": supporting_review,
278
+ "supporting_review_count": len(supporting_review),
279
+ "unimplemented_supporting_section_ids": unimplemented,
280
+ "unimplemented_supporting_count": len(unimplemented),
281
+ })
api581/storage.py ADDED
@@ -0,0 +1,42 @@
1
+ """Local immutable result archive; create one repository per application instance."""
2
+ import json
3
+ from pathlib import Path
4
+ try:
5
+ import sqlite3
6
+ except ImportError as exc: # pragma: no cover - exercised on stripped HPC images
7
+ # Some cluster Python builds ship the sqlite3 stdlib wrapper without the
8
+ # compiled `_sqlite3` extension. A staged pysqlite3-binary wheel can
9
+ # provide the same DB-API surface without changing the public RunStore API.
10
+ if getattr(exc, "name", None) != "_sqlite3":
11
+ raise
12
+ try:
13
+ import pysqlite3 as sqlite3
14
+ except ImportError:
15
+ raise exc
16
+
17
+
18
+ class RunStore:
19
+ def __init__(self, path: str):
20
+ self.path = path
21
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
22
+ with self.connect() as db:
23
+ db.execute("CREATE TABLE IF NOT EXISTS runs (run_id TEXT PRIMARY KEY, asset_id TEXT, calculated_at TEXT NOT NULL, payload TEXT NOT NULL)")
24
+ db.execute("CREATE INDEX IF NOT EXISTS runs_asset ON runs(asset_id, calculated_at)")
25
+
26
+ def connect(self):
27
+ return sqlite3.connect(self.path, timeout=30)
28
+
29
+ def save(self, result):
30
+ with self.connect() as db:
31
+ db.execute("INSERT INTO runs VALUES (?, ?, ?, ?)", (result["run_id"], result["context"].get("asset_id"),
32
+ result["calculated_at"], json.dumps(result, allow_nan=False)))
33
+
34
+ def get(self, run_id):
35
+ with self.connect() as db:
36
+ row = db.execute("SELECT payload FROM runs WHERE run_id = ?", (run_id,)).fetchone()
37
+ return json.loads(row[0]) if row else None
38
+
39
+ def list(self, asset_id, limit):
40
+ with self.connect() as db:
41
+ rows = db.execute("SELECT payload FROM runs WHERE asset_id = ? ORDER BY calculated_at DESC LIMIT ?", (asset_id, limit)).fetchall()
42
+ return [json.loads(row[0]) for row in rows]
@@ -0,0 +1,197 @@
1
+ """2016 modified McConomy/naphthenic source tables and explicit edge policies."""
2
+ from bisect import bisect_left
3
+ from itertools import product
4
+ from typing import Literal
5
+ from pydantic import Field
6
+ from .core import Model, NonNegative, Positive, Fraction, calculation, ensure
7
+ from ._sulfidic_tables import TABLES
8
+ from .thinning import Posterior, WallLoss
9
+ from .clad_thinning import PressureStrength, CodeStrength
10
+ from .thinning_conditions import ThinningConditions, QualifiedUnclad, qualified_unclad
11
+
12
+ Material=Literal['carbon_steel','cr1_0p2mo','cr1_0p5mo','cr1p25_0p5mo','cr2p25_1mo','cr3_1mo',
13
+ 'cr5_0p5mo','cr7_1mo','cr9_1mo','cr12','austenitic_without_mo','ss_304','ss_304l','ss_321','ss_347','ss_316','ss_316l','ss_316h','ss_317']
14
+ Group=Literal['carbon_steel','low_alloy','cr5','cr7','cr9','cr12','stainless_no_mo','stainless_low_mo','stainless_high_mo']
15
+ Units=Literal['printed_si','printed_us_converted']
16
+ _GROUPS=Group.__args__
17
+
18
+
19
+ class SulfidicMaterial(Model):
20
+ material: Material
21
+ molybdenum_mass_fraction: Fraction | None=None
22
+
23
+
24
+ @calculation('damage/sulfidic-material-selection','Sulfidic/naphthenic material and316 molybdenum source group',SulfidicMaterial,
25
+ ['Part2 Table2.B.3.1,Tables3.2-3.10M; PDF pp333-351'],limitations=[
26
+ 'Named alloys only, no continuousCr interpolation.316/316L/316H withMo>=2.5wt% use high-Mo group, below use low; unknownMo assumes<2.5wt% per source and flags assumption.317source high-Mo group;Mo input only used for316family and rejected elsewhere. Austenitic-withoutMo includes304/304L/321/347. Actual composition/material suitability remains owner-qualified.'])
27
+ def material_selection(x):
28
+ family=x.material in ('ss_316','ss_316l','ss_316h')
29
+ ensure(family or x.molybdenum_mass_fraction is None,'Mo concentration input applies only to316-family selection')
30
+ assumed=family and x.molybdenum_mass_fraction is None
31
+ if family:
32
+ group='stainless_low_mo' if assumed or x.molybdenum_mass_fraction<.025 else 'stainless_high_mo'
33
+ elif x.material=='ss_317': group='stainless_high_mo'
34
+ elif x.material.startswith('ss_') or x.material=='austenitic_without_mo': group='stainless_no_mo'
35
+ else: group={'carbon_steel':'carbon_steel','cr5_0p5mo':'cr5','cr7_1mo':'cr7','cr9_1mo':'cr9','cr12':'cr12'}.get(x.material,'low_alloy')
36
+ return {'table_group':group,'unknown_molybdenum_low_assumed':assumed}
37
+
38
+
39
+ class SulfidicAxes(SulfidicMaterial):
40
+ unit_basis: Units
41
+
42
+
43
+ @calculation('damage/sulfidic-table-axes','Sulfidic/naphthenic sulfur TAN and temperature-bin axes',SulfidicAxes,
44
+ ['Part2 Tables2.B.3.2-3.10M; PDF pp334-351'],limitations=[
45
+ 'Material-specific sulfur andTAN grids, not common axes. CS sulfur0.6wt% versus5Cr0.75wt% and most others0.8wt% preserved. Lower temperature column<=232C/450F is interval, upper>399C/750F interval; neither invented finite node. Interior six temperature nodes exposed separately. Interpolation near first bin needs explicit threshold-anchor qualification. SI/US rates/temperatures retain rounding.'])
46
+ def table_axes(x):
47
+ selected=material_selection(x)
48
+ data=TABLES[selected['table_group']+'_'+x.unit_basis]
49
+ temps=[t+273.15 for t in (232,260,288,315,343,371,399)] if x.unit_basis=='printed_si' else [(t-32)*5/9+273.15 for t in (450,500,550,600,650,700,750)]
50
+ return {**selected,'sulfur_mass_fraction_nodes':list(data['sulfur_mass_fractions']),'tan_nodes_mg_koh_g':list(data['tan_mg_koh_g']),
51
+ 'lower_temperature_bin_upper_k':temps[0],'interior_temperature_nodes_k':temps[1:],'upper_temperature_bin_lower_exclusive_k':temps[-1],
52
+ 'source_table_id':'2.B.3.'+str(_GROUPS.index(selected['table_group'])+2)+('M' if x.unit_basis=='printed_si' else '')}
53
+
54
+
55
+ def _bracket(nodes,value):
56
+ tolerance=1e-14 if nodes[-1]<=.1 else 1e-10
57
+ value=next((n for n in nodes if abs(n-value)<=tolerance),value)
58
+ ensure(nodes[0]<=value<=nodes[-1],'Outside sulfidic sulfur/TAN/table range; no extrapolation')
59
+ i=bisect_left(nodes,value)
60
+ return (i,i,0.) if value==nodes[i] else (i-1,i,(value-nodes[i-1])/(nodes[i]-nodes[i-1]))
61
+
62
+
63
+ class SulfidicTableRate(SulfidicAxes):
64
+ sulfur_mass_fraction: Fraction
65
+ tan_mg_koh_g: NonNegative
66
+ maximum_temperature_k: Positive
67
+ lookup_policy: Literal['exact_nodes_only','trilinear','maximum_bracketing_value']
68
+ temperature_edge_policy: Literal['reject_edge_bins_and_first_gap','qualified_source_bins_and_threshold_anchor']
69
+ temperature_edge_qualification_basis: str | None=None
70
+ low_tan_policy: Literal['reject_below_first_tan','qualified_first_tan_row']
71
+ low_tan_qualification_basis: str | None=None
72
+
73
+
74
+ @calculation('damage/sulfidic-table-rate','Sulfidic/naphthenic sulfur TAN temperature base rate',SulfidicTableRate,
75
+ ['Part2 Annex2.B §3.3,Tables3.2-3.10M; PDF pp332-351'],limitations=[
76
+ '3840physical SI/US cells before velocity correction. Low/high temperature bins require qualified applicability and flag;232..260C/450..500F first-gap interpolation additionally uses qualified lower-bin threshold anchor, not invented source point. No interpolation from750F into open-ended>750bin; exact750 is interior last node, immediatelyabove uses upper bin. FirstTAN rows are literal nodes, not<=:below-first requires owner-qualified first-row extension/flag;above-last and sulfur outside table rejected. Concentration/TAN nonmonotonicities retained, including12Cr rate declines: no smoothing. Explicit trilinear/max-corner policy, not validated physical correlation. Naphthenic-influenced values only order-of-magnitude per source. No automatic gas-only acidity suppression or unknown-sulfur default.'])
77
+ def table_rate(x):
78
+ axes=table_axes(x)
79
+ data=TABLES[axes['table_group']+'_'+x.unit_basis]
80
+ tq=x.temperature_edge_policy=='qualified_source_bins_and_threshold_anchor'
81
+ ensure((x.temperature_edge_qualification_basis is not None and bool(x.temperature_edge_qualification_basis.strip())) if tq else x.temperature_edge_qualification_basis is None,'Temperature policy and qualification basis must agree')
82
+ lq=x.low_tan_policy=='qualified_first_tan_row'
83
+ ensure((x.low_tan_qualification_basis is not None and bool(x.low_tan_qualification_basis.strip())) if lq else x.low_tan_qualification_basis is None,'Below-first TAN policy and basis must agree')
84
+ s0,s1,ws=_bracket(axes['sulfur_mass_fraction_nodes'],x.sulfur_mass_fraction)
85
+ below=x.tan_mg_koh_g<axes['tan_nodes_mg_koh_g'][0]
86
+ ensure(not below or lq,'TAN below first printed material-specific row')
87
+ a,b,wa=_bracket(axes['tan_nodes_mg_koh_g'],max(x.tan_mg_koh_g,axes['tan_nodes_mg_koh_g'][0]))
88
+ temp=x.maximum_temperature_k
89
+ nodes=[axes['lower_temperature_bin_upper_k']]+axes['interior_temperature_nodes_k']
90
+ bin_used=None; anchor=False
91
+ if temp<=nodes[0]:
92
+ ensure(tq,'Lower source temperature bin requires qualified high-temperature applicability')
93
+ i=j=0; wt=0.; bin_used='lower'
94
+ elif temp>nodes[-1]:
95
+ ensure(tq,'Open-ended upper temperature bin requires qualified applicability')
96
+ i=j=7; wt=0.; bin_used='upper'
97
+ else:
98
+ anchor=temp<nodes[1]
99
+ ensure(not anchor or tq,'First-bin-to-interior gap requires qualified threshold anchor')
100
+ i,j,wt=_bracket(nodes,temp)
101
+ ensure(x.lookup_policy!='exact_nodes_only' or (s0==s1 and a==b and i==j),'Exact sulfidic policy does not interpolate')
102
+ nt=len(axes['tan_nodes_mg_koh_g'])
103
+ def cell(s,t,c): return data['rates'][s*nt+t][c]
104
+ if x.lookup_policy=='maximum_bracketing_value':
105
+ native=max(cell(s,t,c) for s,t,c in product((s0,s1),(a,b),(i,j)))
106
+ else:
107
+ native=sum(fs*ft*fc*cell(s,t,c) for (s,fs),(t,ft),(c,fc) in product(((s0,1-ws),(s1,ws)),((a,1-wa),(b,wa)),((i,1-wt),(j,wt))))
108
+ return {'base_corrosion_rate_m_year':native*(.001 if x.unit_basis=='printed_si' else .0000254),'source_table_id':axes['source_table_id'],
109
+ 'table_group':axes['table_group'],'unknown_molybdenum_low_assumed':axes['unknown_molybdenum_low_assumed'],
110
+ 'source_temperature_bin_used':bin_used,'first_temperature_threshold_anchor_used':anchor,'below_first_tan_row_used':below}
111
+
112
+
113
+ class SulfidicVelocity(Model):
114
+ velocity_m_s: NonNegative
115
+ boundary_interpretation: Literal['body_strictly_above_100ft_s','figure_at_or_above_100ft_s']
116
+ qualification_basis: str=Field(min_length=1)
117
+
118
+
119
+ @calculation('damage/sulfidic-velocity-factor','Sulfidic/naphthenic high-velocity factor with100ft/s boundary policy',SulfidicVelocity,
120
+ ['Part2 Annex2.B §3.3,Figure3.1; PDF pp332,352'],limitations=[
121
+ 'Factor5 above30.48m/s(100ft/s),1below. Body saysstrictly> while figure< test sends equality to5: explicit interpretation/basis determines exact boundary and conflict flag. No continuous velocity interpolation,hydraulic phase-velocity calculation or repeated5x in composition. Maximum nozzle/stream velocity applicability external.'])
122
+ def velocity_factor(x):
123
+ ensure(bool(x.qualification_basis.strip()),'Velocity basis and source boundary interpretation qualification required')
124
+ boundary=x.velocity_m_s==30.48
125
+ high=x.velocity_m_s>30.48 or (boundary and x.boundary_interpretation=='figure_at_or_above_100ft_s')
126
+ return {'velocity_factor':5. if high else 1.,'velocity_boundary_conflict_encountered':boundary}
127
+
128
+
129
+ class SulfidicThinningClassification(Model):
130
+ tan_mg_koh_g: NonNegative
131
+
132
+
133
+ @calculation('damage/sulfidic-thinning-classification','Sulfidic/naphthenic TAN general/local source classification',SulfidicThinningClassification,
134
+ ['Part2 Table2.B.1.2; PDF p323'],limitations=[
135
+ 'TAN<=0.5mgKOH/g general,>0.5local. Actual liquid-phaseTAN required,not whole-crude average or acid fraction. Classification does not establish mechanism presence or actual pit/inspection distribution; no velocity boundary from another table substituted.'])
136
+ def thinning_classification(x):
137
+ return {'source_thinning_type':'general' if x.tan_mg_koh_g<=.5 else 'local'}
138
+
139
+
140
+ class SulfidicService(Model):
141
+ table: SulfidicTableRate
142
+ velocity: SulfidicVelocity
143
+ liquid_phase_present: bool
144
+ hydrogen_free_sulfidation_and_tan_basis_qualified: bool
145
+ qualification_basis: str=Field(min_length=1)
146
+
147
+
148
+ class SulfidicAssessment(Model):
149
+ sulfidic_service_present: bool
150
+ applicability_basis: str=Field(min_length=1)
151
+ service_model: SulfidicService | None=None
152
+
153
+
154
+ @calculation('damage/sulfidic-corrosion-assessment','Qualified high-temperature sulfidic/naphthenic corrosion assessment',SulfidicAssessment,
155
+ ['Part2 Annex2.B §3,Tables3.1-3.10M,Figure3.1,Table1.2; PDF pp323,331-352'],origin='api581_composition_with_explicit_source_policies',limitations=[
156
+ 'Same table rate*velocity factor once. Source hydrogen-free sulfidation and actual liquid-phaseTAN/material/process qualification required; highTAN>0.5 gas-only rejected,naphthenic attack needs liquid. Explicit working high-temperature scope>=204C or400F;below rejected,not immunity/zero. Above399C table upper bin retained with qualification,not automatic naphthenic decomposition discount. Source lowTAN first-row/Mo assumptions and edge flags retained. No-service0 specific,not totalDF0. No catalyticH2S/H2 substitution,phase equilibrium,McConomy curve digitization beyond printed tables or model certification.'])
157
+ def assessment(x):
158
+ ensure(bool(x.applicability_basis.strip()),'Sulfidic mechanism and service applicability basis required')
159
+ if not x.sulfidic_service_present:
160
+ ensure(x.service_model is None,'No-sulfidic branch cannot carry unused source model')
161
+ return {'corrosion_rate_m_year':0.,'base_corrosion_rate_m_year':None,'source_table_id':None,'table_group':None,'unknown_molybdenum_low_assumed':False,
162
+ 'source_temperature_bin_used':None,'first_temperature_threshold_anchor_used':False,'below_first_tan_row_used':False,
163
+ 'velocity_factor':None,'velocity_boundary_conflict_encountered':False,'source_thinning_type':None,'calculation_branch':'no_sulfidic_service'}
164
+ ensure(x.service_model is not None,'Sulfidic service requires source model')
165
+ m=x.service_model
166
+ ensure(m.hydrogen_free_sulfidation_and_tan_basis_qualified and bool(m.qualification_basis.strip()),'Qualified hydrogen-free environment,material and actual phaseTAN required')
167
+ onset=204+273.15 if m.table.unit_basis=='printed_si' else (400-32)*5/9+273.15
168
+ ensure(m.table.maximum_temperature_k>=onset,'Below declared high-temperature sulfidic scope; no source-specific zero inferred')
169
+ ensure(m.liquid_phase_present or m.table.tan_mg_koh_g<=.5,'High-TAN naphthenic model needs liquid hydrocarbon phase')
170
+ base=table_rate(m.table); factor=velocity_factor(m.velocity)
171
+ return {**base,**factor,**thinning_classification(SulfidicThinningClassification(tan_mg_koh_g=m.table.tan_mg_koh_g)),
172
+ 'corrosion_rate_m_year':base['base_corrosion_rate_m_year']*factor['velocity_factor'],'calculation_branch':'qualified_sulfidic_naphthenic_service'}
173
+
174
+
175
+ class SulfidicThinning(Model):
176
+ corrosion: SulfidicAssessment
177
+ assessment_distribution: Literal['general','local']
178
+ distribution_qualification_basis: str=Field(min_length=1)
179
+ starting_thickness_m: Positive
180
+ elapsed_years: NonNegative
181
+ strength: PressureStrength | CodeStrength=Field(discriminator='method')
182
+ inspections: Posterior
183
+ conditions: ThinningConditions
184
+ qualification_basis: str=Field(min_length=1)
185
+
186
+
187
+ @calculation('damage/sulfidic-thinning-assessment','Sulfidic/naphthenic rate through qualified unclad thinning DF',SulfidicThinning,
188
+ ['Part2 Annex2.B §3,Table1.2; §4.5.7,Table4.8'],origin='api581_composition_with_explicit_source_policies',limitations=[
189
+ 'Same base*velocity-corrected rate drives wall loss once,dedicated high-temperature sulfidic/naphthenic monitoring row required. Actual distribution/inspection coverage owner-qualified independently of sourceTAN classification. Non-AST unclad only,no additional5x or unrelated H2S/H2 model. No-service0 retains generalDF floor/uncertainty;no all-mechanismDF,code/FFS acceptance or source-condition certification.'])
190
+ def thinning_assessment(x):
191
+ ensure(bool(x.distribution_qualification_basis.strip()),'Actual distribution/inspection qualification required')
192
+ corrosion=assessment(x.corrosion)
193
+ ensure(x.conditions.monitoring.row.mechanism=='high_temperature_sulfidic_naphthenic','Sulfidic source requires dedicated monitoring row')
194
+ result=qualified_unclad(QualifiedUnclad(assessment_scope='non_ast_unclad',qualification_basis=x.qualification_basis,
195
+ wall=WallLoss(starting_thickness_m=x.starting_thickness_m,corrosion_rate_m_year=corrosion['corrosion_rate_m_year'],elapsed_years=x.elapsed_years),
196
+ strength=x.strength,inspections=x.inspections,conditions=x.conditions))
197
+ return {**corrosion,**result,'assessment_distribution':x.assessment_distribution}