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/catalog.py ADDED
@@ -0,0 +1,442 @@
1
+ """Read-only discovery for Python clients; no service or database required."""
2
+ from dataclasses import dataclass
3
+ from functools import lru_cache
4
+ from importlib.resources import files
5
+ import json
6
+ from copy import deepcopy
7
+ from collections import Counter
8
+ from typing import Literal
9
+ from pydantic import Field
10
+ from .core import Model, REGISTRY, ensure
11
+
12
+
13
+ _NATIVE_VALIDATION_EVIDENCE = {
14
+ "dispersion/native-engine-capabilities": ["verify_native_dispersion.py"],
15
+ "dispersion/native-pool-centreline": ["verify_native_dispersion.py"],
16
+ "dispersion/native-pool-footprint": ["verify_native_footprint.py"],
17
+ "dispersion/native-pool-flash-fire": ["verify_native_footprint.py"],
18
+ "dispersion/native-jet-centreline": ["verify_native_jet.py"],
19
+ "dispersion/native-jet-footprint": ["verify_native_jet.py"],
20
+ "dispersion/native-pool-toxic-consequence": ["verify_native_toxic.py"],
21
+ "dispersion/partitioned-jet-centreline": ["verify_native_jet.py"],
22
+ "dispersion/partitioned-jet-footprint": ["verify_native_jet.py"],
23
+ "dispersion/flashed-boiling-pool-centreline": ["verify_native_flash_pool.py"],
24
+ "dispersion/flashed-boiling-pool-footprint": ["verify_native_flashed_pool_footprint.py"],
25
+ "dispersion/flashed-boiling-pool-flash-fire": ["verify_native_flashed_pool_footprint.py"],
26
+ "risk/flashed-pool-flash-fire-contribution": ["verify_native_flashed_pool_risk.py"],
27
+ "properties/pure-fluid-flash": ["verify_native_flash.py"],
28
+ "release/pure-liquid-flash-partition": ["verify_native_flash.py"],
29
+ "properties/pure-fluid-saturation-pressure": ["verify_native_saturation.py"],
30
+ "properties/pure-fluid-saturation-temperature": ["verify_native_saturation.py"],
31
+ }
32
+
33
+
34
+ def _software_test_evidence() -> dict[str, tuple[str, ...]]:
35
+ try:
36
+ payload = json.loads(files("api581").joinpath("data/software-test-evidence.json").read_text(encoding="utf-8"))
37
+ except (FileNotFoundError, json.JSONDecodeError):
38
+ return {}
39
+ return {identifier: tuple(paths) for identifier, paths in payload.get("calculations", {}).items()}
40
+
41
+
42
+ _SOFTWARE_TEST_EVIDENCE = _software_test_evidence()
43
+
44
+
45
+ def _public_test_evidence() -> dict[str, tuple[str, ...]]:
46
+ try:
47
+ payload = json.loads(files("api581").joinpath("data/software-test-evidence.json").read_text(encoding="utf-8"))
48
+ except (FileNotFoundError, json.JSONDecodeError):
49
+ return {}
50
+ return {identifier: tuple(paths) for identifier, paths in payload.get("direct_public_test_calculations", {}).items()}
51
+
52
+
53
+ _PUBLIC_TEST_EVIDENCE = _public_test_evidence()
54
+
55
+
56
+ def _registry_contract_test_evidence() -> dict[str, tuple[str, ...]]:
57
+ try:
58
+ payload = json.loads(files("api581").joinpath("data/software-test-evidence.json").read_text(encoding="utf-8"))
59
+ except (FileNotFoundError, json.JSONDecodeError):
60
+ return {}
61
+ return {identifier: tuple(paths) for identifier, paths in payload.get("registry_contract_test_calculations", {}).items()}
62
+
63
+
64
+ _REGISTRY_CONTRACT_TEST_EVIDENCE = _registry_contract_test_evidence()
65
+
66
+
67
+ def _reference_fixture_evidence() -> dict[str, tuple[dict, ...]]:
68
+ try:
69
+ payload = json.loads(files("api581").joinpath("data/reference-fixture-evidence.json").read_text(encoding="utf-8"))
70
+ except (FileNotFoundError, json.JSONDecodeError):
71
+ return {}
72
+ return {identifier: tuple(fixtures) for identifier, fixtures in payload.get("fixtures", {}).items()}
73
+
74
+
75
+ _REFERENCE_FIXTURE_EVIDENCE = _reference_fixture_evidence()
76
+
77
+ _SCOPE_BOUNDARIES = (
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class CalculationInfo:
83
+ identifier: str
84
+ python_path: str
85
+ title: str
86
+ input_type: type[Model]
87
+ output_type: type[Model]
88
+ references: tuple[str, ...]
89
+ origin: str
90
+ limitations: tuple[str, ...]
91
+
92
+
93
+ class ScopeBoundary(Model):
94
+ """Typed record for an intentionally unsupported calculation boundary."""
95
+ boundary_id: str = Field(min_length=1, max_length=200)
96
+ status: Literal["not_implemented"]
97
+ category: Literal["external_reference_scope", "native_engine_scope"]
98
+ reason: str = Field(min_length=1, max_length=1000)
99
+ next_evidence_needed: str = Field(min_length=1, max_length=1000)
100
+ related_calculation_ids: tuple[str, ...] = Field(min_length=1, max_length=20)
101
+
102
+
103
+ def describe(identifier: str) -> CalculationInfo:
104
+ from .outputs import OUTPUT_MODELS
105
+ ensure(identifier in REGISTRY, f"Unknown calculation: {identifier}", "unknown_calculation")
106
+ item = REGISTRY[identifier]
107
+ domain, name = identifier.split("/")
108
+ return CalculationInfo(identifier, f"api581.api.{domain}.{name.replace('-', '_')}",
109
+ item.title, item.model, OUTPUT_MODELS[identifier],
110
+ item.references, item.origin, item.limitations)
111
+
112
+
113
+ def list_calculations(domain: str | None = None) -> tuple[CalculationInfo, ...]:
114
+ """Return all calculations, optionally selecting one engineering domain."""
115
+ return tuple(describe(identifier) for identifier in REGISTRY
116
+ if domain is None or identifier.split("/")[0] == domain)
117
+
118
+
119
+ @lru_cache(maxsize=1)
120
+ def _coverage_source() -> dict:
121
+ """Load the immutable packaged ledger once per interpreter."""
122
+ return json.loads(files("api581").joinpath("data/coverage.json").read_text(encoding="utf-8"))
123
+
124
+
125
+ def coverage() -> dict:
126
+ """Return a copy of the source-heading inventory.
127
+
128
+ The packaged ledger is cached for repeated QA/catalog queries, while each
129
+ caller receives an independent copy so mutations cannot leak between
130
+ callers. Partial headings do not imply full compliance.
131
+ """
132
+ return deepcopy(_coverage_source())
133
+
134
+
135
+ @lru_cache(maxsize=1)
136
+ def _coverage_sections() -> tuple[dict, ...]:
137
+ """Return the cached section records for internal read-only queries."""
138
+ return tuple(_coverage_source()["sections"])
139
+
140
+
141
+ def coverage_summary() -> dict:
142
+ """Return a compact immutable-source implementation-status summary.
143
+
144
+ The counts describe registered heading links and review evidence, not a
145
+ certification or a claim that every standard paragraph is implemented.
146
+ """
147
+ data = coverage()
148
+ sections = data["sections"]
149
+ status_counts = dict(Counter(item["status"] for item in sections))
150
+ category_status_counts: dict[str, dict[str, int]] = {}
151
+ scope_status_counts: dict[str, dict[str, int]] = {}
152
+ for item in sections:
153
+ category = item.get("review_category", "uncategorized")
154
+ bucket = category_status_counts.setdefault(category, {})
155
+ status = item["status"]
156
+ bucket[status] = bucket.get(status, 0) + 1
157
+ scope = item["id"].split(" §", 1)[0]
158
+ scope_bucket = scope_status_counts.setdefault(scope, {})
159
+ scope_bucket[status] = scope_bucket.get(status, 0) + 1
160
+ linked_calculations = {identifier for item in sections
161
+ for identifier in item.get("calculation_ids", [])}
162
+ unlinked_calculations = sorted(set(REGISTRY) - linked_calculations)
163
+ unreferenced_calculations = sorted(
164
+ identifier for identifier, calculation in REGISTRY.items()
165
+ if not calculation.references
166
+ )
167
+ unlinked_details = []
168
+ for identifier in unlinked_calculations:
169
+ origin = REGISTRY[identifier].origin
170
+ reason = "adapter_contract" if origin.startswith("optional_native_") else "engineering_utility"
171
+ unlinked_details.append({"calculation_id": identifier, "origin": origin, "reason": reason})
172
+ validation_counts = dict(Counter(
173
+ "native_engine_fixture_verified" if identifier in _NATIVE_VALIDATION_EVIDENCE
174
+ else "software_tested_only"
175
+ for identifier in REGISTRY
176
+ ))
177
+ with_direct_test = sum(bool(_SOFTWARE_TEST_EVIDENCE.get(identifier, ())) for identifier in REGISTRY)
178
+ direct_test_counts = {
179
+ "with_direct_test": with_direct_test,
180
+ "without_direct_test": len(REGISTRY) - with_direct_test,
181
+ }
182
+ direct_public_test_count = sum(bool(_PUBLIC_TEST_EVIDENCE.get(identifier, ())) for identifier in REGISTRY)
183
+ direct_public_test_counts = {
184
+ "with_public_test": direct_public_test_count,
185
+ "without_public_test": len(REGISTRY) - direct_public_test_count,
186
+ }
187
+ validation_by_domain: dict[str, dict[str, int]] = {}
188
+ for identifier in REGISTRY:
189
+ domain = identifier.split("/", 1)[0]
190
+ bucket = validation_by_domain.setdefault(domain, {
191
+ "native_engine_fixture_verified": 0,
192
+ "software_tested_only": 0,
193
+ "with_direct_test": 0,
194
+ "without_direct_test": 0,
195
+ "with_public_test": 0,
196
+ "without_public_test": 0,
197
+ })
198
+ level = "native_engine_fixture_verified" if identifier in _NATIVE_VALIDATION_EVIDENCE else "software_tested_only"
199
+ bucket[level] += 1
200
+ bucket["with_direct_test" if _SOFTWARE_TEST_EVIDENCE.get(identifier, ()) else "without_direct_test"] += 1
201
+ bucket["with_public_test" if _PUBLIC_TEST_EVIDENCE.get(identifier, ()) else "without_public_test"] += 1
202
+ from .standard import documentary_coverage
203
+ documentary = documentary_coverage().model_dump(mode="json")
204
+ reference_fixture_count = sum(len(items) for items in _REFERENCE_FIXTURE_EVIDENCE.values())
205
+ return {"standard_edition": data["standard_edition"],
206
+ "source_sha256": data["source_sha256"],
207
+ "calculation_count": data["calculation_count"],
208
+ "section_count": data["section_count"],
209
+ "status_counts": status_counts,
210
+ "category_status_counts": category_status_counts,
211
+ "scope_status_counts": scope_status_counts,
212
+ "validation_counts": validation_counts,
213
+ "direct_test_counts": direct_test_counts,
214
+ "direct_public_test_count": direct_public_test_count,
215
+ "direct_public_test_counts": direct_public_test_counts,
216
+ "validation_by_domain": validation_by_domain,
217
+ "documentary_coverage": documentary,
218
+ "reference_fixture_count": reference_fixture_count,
219
+ "scope_boundary_count": len(_SCOPE_BOUNDARIES),
220
+ "unreferenced_calculation_ids": unreferenced_calculations,
221
+ "unlinked_calculation_count": len(unlinked_calculations),
222
+ "unlinked_calculation_ids": unlinked_calculations,
223
+ "unlinked_calculation_details": unlinked_details}
224
+
225
+
226
+ def clause_evidence(clause_id: str) -> dict:
227
+ """Return one clause's status and calculation evidence from the source ledger."""
228
+ ensure(bool(clause_id.strip()), "clause_id must not be blank", "invalid_clause_id")
229
+ item = next((section for section in _coverage_sections() if section["id"] == clause_id), None)
230
+ ensure(item is not None, f"Unknown clause: {clause_id}", "unknown_clause")
231
+ result = dict(item)
232
+ result.setdefault("next_evidence_needed",
233
+ "Read the source clause and attach an independent equation/table or engineering validation record.")
234
+ calculation_ids = tuple(item.get("calculation_ids", ()))
235
+ evidence = [calculation_evidence(identifier) for identifier in calculation_ids]
236
+ result["calculation_count"] = len(calculation_ids)
237
+ result["native_engine_calculation_ids"] = [
238
+ record["calculation_id"] for record in evidence
239
+ if record["validation_level"] == "native_engine_fixture_verified"
240
+ ]
241
+ result["software_tested_calculation_ids"] = [
242
+ record["calculation_id"] for record in evidence
243
+ if record["validation_level"] == "software_tested_only"
244
+ ]
245
+ result["direct_test_module_count"] = len({
246
+ module for record in evidence for module in record["software_test_modules"]
247
+ })
248
+ return result
249
+
250
+
251
+ def review_queue(*, status: str | None = None,
252
+ review_category: str | None = None,
253
+ scope: str | None = None) -> tuple[dict, ...]:
254
+ """Return source-clause review items using explicit composable filters.
255
+
256
+ This is a read-only evidence index, not a compliance assertion. Filters
257
+ are validated so misspelled values cannot silently produce an empty queue.
258
+ """
259
+ allowed_statuses = {"partial", "not_implemented"}
260
+ allowed_categories = {"technical_scope_review", "supporting_text_review"}
261
+ if status is not None:
262
+ ensure(status in allowed_statuses, f"Unknown review status: {status}", "invalid_review_status")
263
+ if review_category is not None:
264
+ ensure(review_category in allowed_categories,
265
+ f"Unknown review category: {review_category}", "invalid_review_category")
266
+ if scope is not None:
267
+ ensure(scope.strip(), "scope must not be blank", "invalid_review_scope")
268
+ selected = []
269
+ for item in _coverage_sections():
270
+ if status is not None and item["status"] != status:
271
+ continue
272
+ if review_category is not None and item.get("review_category") != review_category:
273
+ continue
274
+ if scope is not None and not item["id"].startswith(scope):
275
+ continue
276
+ selected.append(dict(item))
277
+ return tuple(selected)
278
+
279
+
280
+ def calculation_evidence(identifier: str) -> dict:
281
+ """Return the reverse traceability record for one registered calculation.
282
+
283
+ Clause links are evidence of source mapping only; they do not assert that
284
+ every paragraph or engineering assumption has been independently
285
+ certified.
286
+ """
287
+ info = describe(identifier)
288
+ linked = [item for item in _coverage_sections()
289
+ if identifier in item.get("calculation_ids", [])]
290
+ direct = [item["id"] for item in linked
291
+ if identifier in item.get("assigned_calculation_ids", [])]
292
+ descendant_only = [item["id"] for item in linked
293
+ if identifier not in item.get("assigned_calculation_ids", [])]
294
+ native_evidence = identifier in _NATIVE_VALIDATION_EVIDENCE
295
+ return {
296
+ "calculation_id": info.identifier,
297
+ "python_path": info.python_path,
298
+ "title": info.title,
299
+ "references": list(info.references),
300
+ "method_origin": info.origin,
301
+ "limitations": list(info.limitations),
302
+ "clause_ids": [item["id"] for item in linked],
303
+ "clause_count": len(linked),
304
+ "direct_clause_ids": direct,
305
+ "descendant_evidence_clause_ids": descendant_only,
306
+ "review_categories": sorted({item.get("review_category", "uncategorized") for item in linked}),
307
+ "validation_evidence": [
308
+ {
309
+ "manifest": "docs/NATIVE_ENGINE_EVIDENCE.json",
310
+ "packaged_manifest": "api581/data/native-engine-evidence.json",
311
+ "verification_scripts": _NATIVE_VALIDATION_EVIDENCE.get(identifier, []),
312
+ }
313
+ ] if native_evidence else [],
314
+ "software_test_modules": list(_SOFTWARE_TEST_EVIDENCE.get(identifier, ())),
315
+ "direct_public_test_modules": list(_PUBLIC_TEST_EVIDENCE.get(identifier, ())),
316
+ "registry_contract_test_modules": list(_REGISTRY_CONTRACT_TEST_EVIDENCE.get(identifier, ())),
317
+ "reference_fixture_evidence": list(_REFERENCE_FIXTURE_EVIDENCE.get(identifier, ())),
318
+ "validation_level": "native_engine_fixture_verified" if native_evidence else "software_tested_only",
319
+ "validation_status": "software_tested_not_independently_certified",
320
+ }
321
+
322
+
323
+ def validation_queue(*, validation_level: str | None = None,
324
+ direct_test: bool | None = None,
325
+ public_test: bool | None = None,
326
+ domain: str | None = None) -> tuple[dict, ...]:
327
+ """Return calculation validation records for QA and release pipelines.
328
+
329
+ ``software_tested_only`` is the default state for calculations without a
330
+ native-engine fixture. ``native_engine_fixture_verified`` means the
331
+ checked-in SLABx/CoolProp fixture and verification script were rerun; it
332
+ still does not constitute independent engineering certification. The
333
+ optional ``domain`` filter narrows the queue to one engineering domain,
334
+ and may be combined with ``validation_level``, ``direct_test`` and
335
+ ``public_test``. ``public_test`` specifically means a test invokes the
336
+ public ``evaluate`` boundary, not merely that the ID appears in a test.
337
+ """
338
+ allowed = {"software_tested_only", "native_engine_fixture_verified"}
339
+ if validation_level is not None:
340
+ ensure(validation_level in allowed,
341
+ f"Unknown validation_level: {validation_level}",
342
+ "invalid_validation_level")
343
+ if direct_test is not None:
344
+ ensure(isinstance(direct_test, bool),
345
+ "direct_test must be boolean or None", "invalid_direct_test_filter")
346
+ if public_test is not None:
347
+ ensure(isinstance(public_test, bool),
348
+ "public_test must be boolean or None", "invalid_public_test_filter")
349
+ if domain is not None:
350
+ ensure(isinstance(domain, str) and bool(domain.strip()),
351
+ "domain must be a non-empty string or None", "invalid_domain_filter")
352
+ records = []
353
+ for identifier in REGISTRY:
354
+ if domain is not None and identifier.split("/", 1)[0] != domain:
355
+ continue
356
+ item = calculation_evidence(identifier)
357
+ if validation_level is None or item["validation_level"] == validation_level:
358
+ has_direct_test = bool(item["software_test_modules"])
359
+ if direct_test is None or has_direct_test == direct_test:
360
+ has_public_test = bool(item["direct_public_test_modules"])
361
+ if public_test is None or has_public_test == public_test:
362
+ records.append(item)
363
+ return tuple(records)
364
+
365
+
366
+ def scope_boundaries() -> tuple[ScopeBoundary, ...]:
367
+ """Return explicit calculation scope boundaries and next evidence needs."""
368
+ return tuple(ScopeBoundary.model_validate(item) for item in _SCOPE_BOUNDARIES)
369
+
370
+
371
+ def audit_coverage() -> dict:
372
+ """Run structural integrity checks over the packaged source ledger.
373
+
374
+ A passing audit proves internal inventory consistency only; it is not a
375
+ claim of API 581 compliance or independent engineering validation.
376
+ """
377
+ data = coverage()
378
+ sections = data["sections"]
379
+ ids = [item["id"] for item in sections]
380
+ linked = {identifier for item in sections for identifier in item.get("calculation_ids", [])}
381
+ technical_missing = [item["id"] for item in sections
382
+ if item["status"] == "not_implemented"
383
+ and item.get("review_category") == "technical_scope_review"]
384
+ technical_without_links = [item["id"] for item in sections
385
+ if item.get("review_category") == "technical_scope_review"
386
+ and not item.get("calculation_ids")]
387
+ valid_categories = {"technical_scope_review", "supporting_text_review"}
388
+ invalid_categories = [item["id"] for item in sections
389
+ if item.get("review_category") not in valid_categories]
390
+ unknown_links = sorted(linked - set(REGISTRY))
391
+ unlinked = sorted(set(REGISTRY) - linked)
392
+ classified_unlinked = all(
393
+ REGISTRY[identifier].origin == "engineering_utility"
394
+ or REGISTRY[identifier].origin.startswith("optional_native_")
395
+ for identifier in unlinked
396
+ )
397
+ native_without_evidence = [
398
+ identifier for identifier, calculation in REGISTRY.items()
399
+ if calculation.origin.startswith("optional_native_")
400
+ and identifier not in _NATIVE_VALIDATION_EVIDENCE
401
+ ]
402
+ calculations_without_limitations = [
403
+ identifier for identifier, calculation in REGISTRY.items()
404
+ if not calculation.limitations
405
+ ]
406
+ unreferenced_nonutilities = [
407
+ identifier for identifier, calculation in REGISTRY.items()
408
+ if not calculation.references and calculation.origin != "engineering_utility"
409
+ ]
410
+ unimplemented_without_next_evidence = [
411
+ item["id"] for item in sections
412
+ if item["status"] == "not_implemented"
413
+ and not str(item.get("next_evidence_needed", "")).strip()
414
+ ]
415
+ checks = {
416
+ "section_count_matches": len(sections) == data["section_count"],
417
+ "calculation_count_matches": len(REGISTRY) == data["calculation_count"],
418
+ "unique_section_ids": len(ids) == len(set(ids)),
419
+ "all_links_registered": not unknown_links,
420
+ "no_unimplemented_technical_scope": not technical_missing,
421
+ "technical_scope_has_calculation_link": not technical_without_links,
422
+ "review_categories_valid": not invalid_categories,
423
+ "unlinked_calculations_classified": classified_unlinked,
424
+ "native_calculations_have_evidence": not native_without_evidence,
425
+ "calculations_have_limitations": not calculations_without_limitations,
426
+ "unreferenced_calculations_are_utilities": not unreferenced_nonutilities,
427
+ "unimplemented_items_have_next_evidence": not unimplemented_without_next_evidence,
428
+ }
429
+ return {"ok": all(checks.values()), "checks": checks,
430
+ "unknown_calculation_links": unknown_links,
431
+ "technical_scope_missing": technical_missing,
432
+ "technical_scope_without_calculation": technical_without_links,
433
+ "invalid_review_categories": invalid_categories,
434
+ "unlinked_calculation_ids": unlinked,
435
+ "native_calculations_without_evidence": native_without_evidence,
436
+ "calculations_without_limitations": calculations_without_limitations,
437
+ "unreferenced_calculation_ids": [
438
+ identifier for identifier, calculation in REGISTRY.items()
439
+ if not calculation.references
440
+ ],
441
+ "unreferenced_nonutilities": unreferenced_nonutilities,
442
+ "unimplemented_without_next_evidence": unimplemented_without_next_evidence}
@@ -0,0 +1,103 @@
1
+ """Caustic SCC material screen and qualified Figure6.2 susceptibility routing."""
2
+ from typing import Literal
3
+ from pydantic import Field
4
+ from .core import Model, Positive, NonNegative, Fraction, calculation, ensure
5
+ from .scc_mechanisms import Assessment, InspectionCounts, assess
6
+
7
+
8
+ class CausticScreen(Model):
9
+ material: Literal['carbon_steel','low_alloy_steel','other_material']
10
+ caustic_present: bool
11
+ screening_basis: str = Field(min_length=1)
12
+
13
+
14
+ @calculation('damage/caustic-screening','Caustic cracking material and any-concentration exposure screen',CausticScreen,
15
+ ['Part2 §6.3; PDF p172'],limitations=[
16
+ 'Carbon/low-alloy steel with caustic at any concentration requires evaluation. Presence includes qualified local/upset/residual exposure; no5wtpct or normal-process-temperature cutoff is used for screening. Screen-out means outside this procedure, not material immunity, zero overall DF or exclusion of other mechanisms.'])
17
+ def caustic_screening(x):
18
+ ensure(bool(x.screening_basis.strip()),'Material and caustic exposure qualification required')
19
+ material=x.material!='other_material'
20
+ return {'evaluate_caustic_cracking':material and x.caustic_present,
21
+ 'screening_reason':'evaluate' if material and x.caustic_present else 'outside_material_scope' if not material else 'caustic_absent'}
22
+
23
+
24
+ class CausticPlotEvidence(Model):
25
+ maximum_metal_temperature_k: Positive
26
+ naoh_mass_fraction: Fraction
27
+ chart_area: Literal['A','B','C']
28
+ chart_unit_basis: Literal['printed_si','printed_us']
29
+ heat_traced: bool
30
+ steamed_out_before_water_flush: bool
31
+ plot_basis: str = Field(min_length=1)
32
+
33
+
34
+ class CausticFigureSusceptibility(Model):
35
+ stress_relieved: bool
36
+ cracking_history: Literal['never_detected','detected_repaired','unrepaired','specialist_reassigned_after_clear_inspection']
37
+ adequate_subsequent_clear_inspection: bool = False
38
+ specialist_reassignment_basis: str | None = None
39
+ fabrication_history_basis: str = Field(min_length=1)
40
+ plot: CausticPlotEvidence | None = None
41
+
42
+
43
+ @calculation('damage/caustic-figure-susceptibility','Qualified caustic graph area through Figure6.2 and crack-history gate',CausticFigureSusceptibility,
44
+ ['Part2 §6.5,Figure6.2; Figures6.1/6.1M; PDF pp172-173,176-178'],limitations=[
45
+ 'Figure6.1/6.1M area is independently qualified from actual maximum metal temperature and effective local NaOH concentration. This API routes the area; it does not digitize/interpolate the graph or certify PWHT. Plot concentration0..50wtpct; exactly5wtpct follows the not-less-than5 branch. Heat tracing and steam-out before water flushing follow Figure6.2.',
46
+ 'Detected/repaired historical cracking stays High until an adequately effective subsequent clear inspection AND specialist reassignment basis. Stress relief cannot erase that history. Unrepaired cracks require separate FFS and fail explicitly. No automatic stress-relief temperature/hold-time qualification, alloy immunity or local boiling concentration model.'])
47
+ def caustic_figure_susceptibility(x):
48
+ # FFS referral must not be obscured by conflicting reassignment metadata.
49
+ ensure(x.cracking_history!='unrepaired','Unrepaired caustic cracks require separate FFS evaluation','ffs_required')
50
+ ensure(bool(x.fabrication_history_basis.strip()),'Qualified fabrication and crack-history basis required')
51
+ reassigned=x.cracking_history=='specialist_reassigned_after_clear_inspection'
52
+ ensure((x.specialist_reassignment_basis is not None)==reassigned,
53
+ 'Specialist reassignment requires a matching basis, unused basis must be omitted')
54
+ if reassigned:
55
+ ensure(x.adequate_subsequent_clear_inspection and bool(x.specialist_reassignment_basis.strip()),
56
+ 'Specialist reassignment requires adequate subsequent clear inspection and nonblank basis')
57
+ retained=x.cracking_history=='detected_repaired'
58
+ if retained:
59
+ level,branch,used='high','historical_cracking_high',False
60
+ elif x.stress_relieved:
61
+ level,branch,used='none','qualified_stress_relief',False
62
+ else:
63
+ plot=x.plot
64
+ ensure(plot is not None,'Non-stress-relieved uncracked branch requires qualified caustic graph area')
65
+ ensure(bool(plot.plot_basis.strip()),'Actual metal temperature/local concentration graph-area basis required')
66
+ ensure(plot.naoh_mass_fraction<=.5,'Caustic graph concentration exceeds50wtpct support')
67
+ low=plot.naoh_mass_fraction<.05
68
+ if plot.chart_area!='A':
69
+ level='medium' if low else 'high'
70
+ branch='outside_area_a_low_concentration' if low else 'outside_area_a_high_concentration'
71
+ elif plot.heat_traced:
72
+ level='medium' if low else 'high'
73
+ branch='area_a_heat_traced'
74
+ elif plot.steamed_out_before_water_flush:
75
+ level='low' if low else 'medium'
76
+ branch='area_a_steamout_before_flush'
77
+ else:
78
+ level,branch='none','area_a_no_heating_or_steamout'
79
+ used=True
80
+ return {'susceptibility':level,'decision_branch':branch,'plot_evidence_used':used,'historical_high_retained':retained}
81
+
82
+
83
+ class QualifiedCausticAssessment(Model):
84
+ screening: CausticScreen
85
+ susceptibility: CausticFigureSusceptibility
86
+ years_since_qualifying_inspection: NonNegative
87
+ inspections: InspectionCounts
88
+ assessment_basis: str = Field(min_length=1)
89
+
90
+
91
+ @calculation('damage/caustic-screened-assessment','Caustic screen and Figure6.2 through SCC severity/inspection/age DF',QualifiedCausticAssessment,
92
+ ['Part2 §§6.3,6.5-6.6; Tables6.2-6.3; Figure6.2; eq(2.25); PDF pp172-178'],
93
+ origin='qualified_caustic_screen_figure_to_existing_scc_df',limitations=[
94
+ 'Material/exposure screen must pass; outside-scope input fails instead of manufacturing DF0. Figure area/PWHT and crack-history/reassignment declarations require qualification. Uses existing caustic SVI, equivalent highest inspection and age escalation unchanged; source ambiguous E/500 cell and counts beyond6 remain unsupported. Inspection coverage and qualifying age are externally established per §6.6.3, not inferred from generic thinning history. No FFS, graph interpolation or all-mechanism risk.'])
95
+ def caustic_screened_assessment(x):
96
+ ensure(bool(x.assessment_basis.strip()),'Caustic inspection period/effectiveness and applicability basis required')
97
+ screen=caustic_screening(x.screening)
98
+ ensure(screen['evaluate_caustic_cracking'],'Caustic material/exposure screen not met','caustic_outside_screen')
99
+ susceptibility=caustic_figure_susceptibility(x.susceptibility)
100
+ result=assess('caustic',Assessment(susceptibility=susceptibility['susceptibility'],
101
+ confirmed_cracking=susceptibility['historical_high_retained'],unrepaired_cracks=False,
102
+ years_since_qualifying_inspection=x.years_since_qualifying_inspection,inspections=x.inspections))
103
+ return {**screen,**susceptibility,**result}