api581-engine 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api581/__init__.py +80 -0
- api581/_amine_tables.py +105 -0
- api581/_h2sh2_tables.py +161 -0
- api581/_slabx_worker.py +174 -0
- api581/_sulfidic_tables.py +517 -0
- api581/_sulfuric_tables.py +328 -0
- api581/acid_sour_water.py +238 -0
- api581/acscc_screening.py +88 -0
- api581/alkaline_sour_water.py +267 -0
- api581/amine_corrosion.py +277 -0
- api581/amine_screening.py +103 -0
- api581/api/__init__.py +4 -0
- api581/api/api520.py +88 -0
- api581/api/bundle.py +121 -0
- api581/api/consequence.py +102 -0
- api581/api/damage.py +1831 -0
- api581/api/dispersion.py +121 -0
- api581/api/effects.py +318 -0
- api581/api/events.py +96 -0
- api581/api/financial.py +115 -0
- api581/api/frequency.py +40 -0
- api581/api/inspection.py +96 -0
- api581/api/level1.py +238 -0
- api581/api/level2.py +74 -0
- api581/api/planning.py +17 -0
- api581/api/prd.py +190 -0
- api581/api/properties.py +130 -0
- api581/api/release.py +252 -0
- api581/api/reliability.py +40 -0
- api581/api/risk.py +71 -0
- api581/api/tank.py +490 -0
- api581/api520.py +205 -0
- api581/area_contribution.py +72 -0
- api581/blast.py +232 -0
- api581/brittle.py +205 -0
- api581/bundle.py +300 -0
- api581/catalog.py +442 -0
- api581/caustic_screening.py +103 -0
- api581/clad_thinning.py +146 -0
- api581/clscc_screening.py +55 -0
- api581/co2_corrosion.py +313 -0
- api581/co2_screening.py +205 -0
- api581/consequences.py +250 -0
- api581/contracts.py +1038 -0
- api581/cooling_selection.py +204 -0
- api581/cooling_water.py +305 -0
- api581/core.py +126 -0
- api581/corrosion_rate.py +38 -0
- api581/cost_tables.py +175 -0
- api581/cracking.py +74 -0
- api581/data/coverage.json +21361 -0
- api581/data/digital-twin.schema.json +538 -0
- api581/data/direct-test-queue.json +7 -0
- api581/data/native-engine-evidence.json +44 -0
- api581/data/python-api.schema.json +92706 -0
- api581/data/reference-fixture-evidence.json +2956 -0
- api581/data/scope-boundaries.schema.json +58 -0
- api581/data/software-test-evidence.json +4278 -0
- api581/data/standard-metadata.json +1328 -0
- api581/data/standard.schema.json +233 -0
- api581/data/supporting-text-review.json +1319 -0
- api581/digital_twin.py +491 -0
- api581/dispersion.py +45 -0
- api581/effects.py +232 -0
- api581/evaporation.py +181 -0
- api581/evidence.py +81 -0
- api581/external_corrosion.py +148 -0
- api581/external_cracking.py +63 -0
- api581/fatigue.py +56 -0
- api581/fireball.py +163 -0
- api581/flash_pool.py +111 -0
- api581/flash_solver.py +122 -0
- api581/groundwater.py +101 -0
- api581/h2sh2_corrosion.py +128 -0
- api581/hic_screened.py +89 -0
- api581/htha.py +45 -0
- api581/hydrochloric_acid.py +272 -0
- api581/hydrofluoric_acid.py +236 -0
- api581/ignition.py +204 -0
- api581/inspection_effectiveness.py +601 -0
- api581/jet_handoff.py +60 -0
- api581/level1.py +311 -0
- api581/level1_summary.py +35 -0
- api581/level1_toxic.py +313 -0
- api581/level1_type1.py +91 -0
- api581/level2_summary.py +237 -0
- api581/lining.py +173 -0
- api581/native_toxic.py +67 -0
- api581/outputs.py +5672 -0
- api581/oxidation.py +145 -0
- api581/pascc_screening.py +78 -0
- api581/physical_explosion.py +190 -0
- api581/pof.py +207 -0
- api581/pool_fire.py +273 -0
- api581/portfolio.py +68 -0
- api581/prd.py +204 -0
- api581/prd_consequences.py +245 -0
- api581/prd_history.py +44 -0
- api581/py.typed +0 -0
- api581/release.py +307 -0
- api581/representative_fluids.py +172 -0
- api581/result.py +50 -0
- api581/risk.py +191 -0
- api581/saturation.py +66 -0
- api581/scc_mechanisms.py +242 -0
- api581/schema.py +45 -0
- api581/slabx_adapter.py +261 -0
- api581/soil_corrosion.py +191 -0
- api581/source_partition.py +169 -0
- api581/spatial.py +87 -0
- api581/ssc_screening.py +77 -0
- api581/standard.py +281 -0
- api581/storage.py +42 -0
- api581/sulfidic_corrosion.py +197 -0
- api581/sulfuric_acid.py +209 -0
- api581/tank.py +260 -0
- api581/tank_corrosion.py +256 -0
- api581/tank_environment.py +186 -0
- api581/tank_financial.py +202 -0
- api581/tank_integrated.py +51 -0
- api581/tank_shell.py +141 -0
- api581/tank_source.py +152 -0
- api581/tank_thinning.py +204 -0
- api581/temper.py +105 -0
- api581/thinning.py +172 -0
- api581/thinning_conditions.py +212 -0
- api581/thinning_history.py +118 -0
- api581/thinning_selection.py +81 -0
- api581/thinning_type.py +34 -0
- api581/toxic_criteria.py +111 -0
- api581/toxic_level2.py +233 -0
- api581/two_phase_events.py +120 -0
- api581/workflow.py +42 -0
- api581_engine-0.1.4.dist-info/METADATA +419 -0
- api581_engine-0.1.4.dist-info/RECORD +139 -0
- api581_engine-0.1.4.dist-info/WHEEL +5 -0
- api581_engine-0.1.4.dist-info/licenses/LICENSE +161 -0
- api581_engine-0.1.4.dist-info/licenses/NOTICE.md +18 -0
- api581_engine-0.1.4.dist-info/top_level.txt +1 -0
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}
|