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/digital_twin.py ADDED
@@ -0,0 +1,491 @@
1
+ """Framework-neutral digital-twin exchange contracts.
2
+
3
+ These models intentionally perform no I/O and do not define a transport
4
+ protocol. A host can serialize them to its event bus, database or adapter,
5
+ then call one of the typed :mod:`api581.api` functions with ``to_context()``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
10
+ from datetime import datetime
11
+ from hashlib import sha256
12
+ import json
13
+ from typing import Any, Literal, Sequence
14
+ from uuid import uuid4
15
+
16
+ from pydantic import ConfigDict, Field, field_validator, model_validator
17
+
18
+ from .core import CODE_FINGERPRINT, VERSION, Context, Model, NonNegative, REGISTRY, ensure, evaluate
19
+ from .evidence import ValidationEvidence
20
+
21
+ __all__ = [
22
+ "TwinMeasurement", "TwinCalculationRequest", "TwinCalculationResponse",
23
+ "TwinBatchShard", "TwinBatchPlan", "partition_requests", "build_batch_plan",
24
+ "merge_batch_responses", "execute_request", "execute_requests", "execute_batch_plan",
25
+ "execute_batch_shard",
26
+ ]
27
+
28
+
29
+ class _FrozenDict(dict):
30
+ """JSON-compatible recursively immutable mapping used by twin envelopes."""
31
+ def _blocked(self, *args, **kwargs):
32
+ raise TypeError("digital-twin envelope mappings are immutable")
33
+
34
+ __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _blocked
35
+
36
+ def __reduce__(self):
37
+ return (_restore_frozen_dict, (tuple(self.items()),))
38
+
39
+
40
+ class _FrozenList(list):
41
+ """JSON-compatible recursively immutable sequence used by twin envelopes."""
42
+ def _blocked(self, *args, **kwargs):
43
+ raise TypeError("digital-twin envelope sequences are immutable")
44
+
45
+ __setitem__ = __delitem__ = append = clear = extend = insert = pop = remove = reverse = sort = _blocked
46
+
47
+ def __reduce__(self):
48
+ return (_restore_frozen_list, (tuple(self),))
49
+
50
+
51
+ def _restore_frozen_dict(items):
52
+ result = dict.__new__(_FrozenDict)
53
+ for key, value in items:
54
+ dict.__setitem__(result, key, value)
55
+ return result
56
+
57
+
58
+ def _restore_frozen_list(items):
59
+ result = list.__new__(_FrozenList)
60
+ list.extend(result, items)
61
+ return result
62
+
63
+
64
+ def _freeze(value: Any) -> Any:
65
+ if isinstance(value, dict):
66
+ return _FrozenDict({key: _freeze(item) for key, item in value.items()})
67
+ if isinstance(value, list):
68
+ return _FrozenList([_freeze(item) for item in value])
69
+ if isinstance(value, tuple):
70
+ return tuple(_freeze(item) for item in value)
71
+ return value
72
+
73
+
74
+ class TwinMeasurement(Model):
75
+ model_config = ConfigDict(frozen=True)
76
+ measurement_id: str = Field(min_length=1, max_length=200)
77
+ quantity: str = Field(min_length=1, max_length=200)
78
+ value: float
79
+ unit: str = Field(min_length=1, max_length=50)
80
+ measured_at: datetime
81
+ source: str = Field(min_length=1, max_length=500)
82
+ quality: Literal["measured", "estimated", "simulated"]
83
+ uncertainty: NonNegative | None = None
84
+
85
+ @model_validator(mode="after")
86
+ def aware_timestamp(self):
87
+ if self.measured_at.utcoffset() is None:
88
+ raise ValueError("measured_at requires a timezone offset")
89
+ return self
90
+
91
+
92
+ class TwinCalculationRequest(Model):
93
+ """Immutable calculation request envelope supplied by a twin host."""
94
+ model_config = ConfigDict(frozen=True)
95
+ request_id: str = Field(min_length=1, max_length=200)
96
+ calculation_id: str = Field(min_length=1, max_length=200)
97
+ asset_id: str = Field(min_length=1, max_length=200)
98
+ component_id: str = Field(min_length=1, max_length=200)
99
+ observed_at: datetime
100
+ source_revision: str = Field(min_length=1, max_length=200)
101
+ correlation_id: str = Field(min_length=1, max_length=200)
102
+ inputs: dict[str, Any]
103
+ measurements: list[TwinMeasurement] = Field(default_factory=list, max_length=10000)
104
+ contract_version: Literal["api581-twin-1"] = "api581-twin-1"
105
+
106
+ @field_validator("inputs", mode="after")
107
+ @classmethod
108
+ def freeze_inputs(cls, value):
109
+ return _freeze(value)
110
+
111
+ @field_validator("measurements", mode="after")
112
+ @classmethod
113
+ def freeze_measurements(cls, value):
114
+ return _freeze(value)
115
+
116
+ @model_validator(mode="after")
117
+ def validate_envelope(self):
118
+ if self.observed_at.utcoffset() is None:
119
+ raise ValueError("observed_at requires a timezone offset")
120
+ ids = [m.measurement_id for m in self.measurements]
121
+ if len(ids) != len(set(ids)):
122
+ raise ValueError("measurement_id values must be unique within a request")
123
+ return self
124
+
125
+ def to_context(self) -> Context:
126
+ """Convert the envelope identity to the engine's calculation context."""
127
+ return Context(asset_id=self.asset_id, component_id=self.component_id,
128
+ observed_at=self.observed_at, correlation_id=self.correlation_id,
129
+ source_revision=self.source_revision)
130
+
131
+ def measurement_snapshot(self) -> dict[str, dict[str, Any]]:
132
+ """Return a deterministic, lossless measurement index for adapters.
133
+
134
+ Calculation inputs remain explicit in ``inputs``; this method does not
135
+ silently inject sensor values into a model. It gives a digital-twin
136
+ adapter a stable lookup keyed by measurement ID while preserving the
137
+ quantity, unit, timestamp, quality and uncertainty metadata.
138
+ """
139
+ return {
140
+ measurement.measurement_id: {
141
+ "quantity": measurement.quantity,
142
+ "value": measurement.value,
143
+ "unit": measurement.unit,
144
+ "measured_at": measurement.measured_at.isoformat(),
145
+ "source": measurement.source,
146
+ "quality": measurement.quality,
147
+ "uncertainty": measurement.uncertainty,
148
+ }
149
+ for measurement in self.measurements
150
+ }
151
+
152
+ def input_hash(self) -> str:
153
+ """Return the engine input snapshot hash expected for this request."""
154
+ ensure(self.calculation_id in REGISTRY,
155
+ f"Unknown calculation: {self.calculation_id}", "unknown_calculation")
156
+ parsed = REGISTRY[self.calculation_id].model.model_validate(self.inputs)
157
+ snapshot = {
158
+ "inputs": parsed.model_dump(mode="json"),
159
+ "context": self.to_context().model_dump(mode="json"),
160
+ "calculation_id": self.calculation_id,
161
+ "engine_version": VERSION,
162
+ "code_fingerprint": CODE_FINGERPRINT,
163
+ }
164
+ return sha256(json.dumps(snapshot, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
165
+
166
+
167
+ class TwinCalculationResponse(Model):
168
+ """Reproducible result envelope returned to a twin host."""
169
+ model_config = ConfigDict(frozen=True)
170
+ contract_version: Literal["api581-twin-1"] = "api581-twin-1"
171
+ request_id: str = Field(min_length=1, max_length=200)
172
+ run_id: str = Field(min_length=1, max_length=200)
173
+ calculation_id: str = Field(min_length=1, max_length=200)
174
+ asset_id: str = Field(min_length=1, max_length=200)
175
+ component_id: str = Field(min_length=1, max_length=200)
176
+ observed_at: datetime
177
+ input_hash: str = Field(min_length=1, max_length=200)
178
+ engine_version: str = Field(min_length=1, max_length=100)
179
+ standard_edition: str = Field(min_length=1, max_length=100)
180
+ code_fingerprint: str = Field(min_length=1, max_length=200)
181
+ outputs: dict[str, Any]
182
+ references: list[str]
183
+ limitations: list[str]
184
+ validation_evidence: list[ValidationEvidence] = Field(default_factory=list)
185
+ software_test_modules: list[str] = Field(default_factory=list)
186
+ direct_public_test_modules: list[str] = Field(default_factory=list)
187
+ validation_level: Literal["software_tested_only", "native_engine_fixture_verified"] = "software_tested_only"
188
+ validation_status: str = Field(min_length=1, max_length=200)
189
+
190
+ @field_validator("outputs", "references", "limitations", "validation_evidence", "software_test_modules", "direct_public_test_modules", mode="after")
191
+ @classmethod
192
+ def freeze_payload(cls, value):
193
+ return _freeze(value)
194
+
195
+ @model_validator(mode="after")
196
+ def aware_timestamp(self):
197
+ if self.observed_at.utcoffset() is None:
198
+ raise ValueError("observed_at requires a timezone offset")
199
+ return self
200
+
201
+ @classmethod
202
+ def from_result(cls, request: TwinCalculationRequest, result: Any) -> "TwinCalculationResponse":
203
+ """Project a typed ``CalculationResult`` into the twin envelope."""
204
+ ensure(result.calculation_id == request.calculation_id,
205
+ "Result calculation_id does not match request", "twin_contract_mismatch")
206
+ from .catalog import calculation_evidence
207
+ evidence = calculation_evidence(request.calculation_id)
208
+ ensure(result.validation_level == evidence["validation_level"],
209
+ "Result validation_level does not match calculation evidence",
210
+ "twin_contract_mismatch")
211
+ ensure(list(result.direct_public_test_modules) == list(evidence["direct_public_test_modules"]),
212
+ "Result direct_public_test_modules do not match calculation evidence",
213
+ "twin_contract_mismatch")
214
+ return cls(request_id=request.request_id, run_id=result.run_id,
215
+ calculation_id=result.calculation_id, asset_id=request.asset_id,
216
+ component_id=request.component_id, observed_at=request.observed_at,
217
+ input_hash=result.input_hash, engine_version=result.engine_version,
218
+ standard_edition=result.standard_edition,
219
+ code_fingerprint=result.code_fingerprint,
220
+ outputs=result.outputs.model_dump(mode="json"),
221
+ references=list(result.references), limitations=list(result.limitations),
222
+ validation_evidence=list(result.validation_evidence),
223
+ software_test_modules=list(result.software_test_modules),
224
+ direct_public_test_modules=list(result.direct_public_test_modules),
225
+ validation_level=result.validation_level,
226
+ validation_status=result.validation_status)
227
+
228
+
229
+ def execute_request(request: TwinCalculationRequest) -> TwinCalculationResponse:
230
+ """Execute a request through the normal typed registry boundary.
231
+
232
+ This is deliberately a local pure-Python bridge: transport, retries,
233
+ authentication and persistence remain the twin host's responsibility.
234
+ """
235
+ ensure(request.calculation_id in REGISTRY,
236
+ f"Unknown calculation: {request.calculation_id}", "unknown_calculation")
237
+ payload = evaluate(request.calculation_id, request.inputs, request.to_context())
238
+ from .catalog import calculation_evidence
239
+ evidence = calculation_evidence(request.calculation_id)
240
+ return TwinCalculationResponse(
241
+ request_id=request.request_id, run_id=payload["run_id"],
242
+ calculation_id=payload["calculation_id"], asset_id=request.asset_id,
243
+ component_id=request.component_id, observed_at=request.observed_at,
244
+ input_hash=payload["input_hash"], engine_version=payload["engine_version"],
245
+ standard_edition=payload["standard_edition"],
246
+ code_fingerprint=payload["code_fingerprint"], outputs=payload["outputs"],
247
+ references=payload["references"], limitations=payload["limitations"],
248
+ validation_evidence=evidence["validation_evidence"],
249
+ software_test_modules=evidence["software_test_modules"],
250
+ direct_public_test_modules=evidence["direct_public_test_modules"],
251
+ validation_level=evidence["validation_level"],
252
+ validation_status=payload["validation_status"])
253
+
254
+
255
+ class TwinBatchShard(Model):
256
+ """Deterministic work partition that a host may submit to an HPC queue."""
257
+ model_config = ConfigDict(frozen=True)
258
+ shard_id: str = Field(min_length=1, max_length=200)
259
+ ordinal: int = Field(ge=0)
260
+ request_ids: tuple[str, ...] = Field(min_length=1, max_length=100000)
261
+ payload_hash: str = Field(min_length=64, max_length=64)
262
+
263
+
264
+ class TwinBatchPlan(Model):
265
+ """Scheduler-neutral manifest for replayable digital-twin batches."""
266
+ model_config = ConfigDict(frozen=True)
267
+ contract_version: Literal["api581-batch-1"] = "api581-batch-1"
268
+ batch_id: str = Field(min_length=1, max_length=200)
269
+ request_count: int = Field(ge=0)
270
+ shard_size: int = Field(gt=0)
271
+ request_ids: tuple[str, ...]
272
+ shards: tuple[TwinBatchShard, ...]
273
+ plan_hash: str = Field(min_length=64, max_length=64)
274
+
275
+ @model_validator(mode="after")
276
+ def validate_manifest_shape(self):
277
+ """Reject structurally inconsistent manifests before scheduler use.
278
+
279
+ The payload digest is checked when the original requests are supplied
280
+ to :meth:`verify_requests`; this validator covers the request-free
281
+ portion of the contract so a worker cannot accept a manifest whose
282
+ shard IDs, ordinals, or request count disagree with one another.
283
+ """
284
+ request_ids = tuple(self.request_ids)
285
+ ensure(self.request_count == len(request_ids),
286
+ "Batch request_count does not match request_ids", "batch_plan_mismatch")
287
+ ensure(len(request_ids) == len(set(request_ids)),
288
+ "Batch request_ids must be unique", "batch_plan_mismatch")
289
+ shard_ids = tuple(shard.shard_id for shard in self.shards)
290
+ ensure(len(shard_ids) == len(set(shard_ids)),
291
+ "Batch shard_id values must be unique", "batch_plan_mismatch")
292
+ ordinals = tuple(shard.ordinal for shard in self.shards)
293
+ ensure(ordinals == tuple(range(len(self.shards))),
294
+ "Batch shard ordinals must be contiguous and ordered", "batch_plan_mismatch")
295
+ flattened = tuple(request_id for shard in self.shards for request_id in shard.request_ids)
296
+ ensure(flattened == request_ids,
297
+ "Batch shards do not cover request_ids in order", "batch_plan_mismatch")
298
+ return self
299
+
300
+ def verify_requests(self, requests: Sequence[TwinCalculationRequest]) -> None:
301
+ """Verify that queued request payloads still match this manifest."""
302
+ groups = partition_requests(requests, shard_size=self.shard_size)
303
+ items = tuple(item for group in groups for item in group)
304
+ actual_ids = tuple(item.request_id for item in items)
305
+ ensure(actual_ids == self.request_ids,
306
+ "Batch requests do not match manifest request order", "batch_plan_mismatch")
307
+ actual_shards = tuple(
308
+ tuple(item.request_id for item in group) for group in groups
309
+ )
310
+ expected_shards = tuple(shard.request_ids for shard in self.shards)
311
+ ensure(actual_shards == expected_shards,
312
+ "Batch requests do not match manifest shards", "batch_plan_mismatch")
313
+ actual_hashes = tuple(
314
+ _request_manifest_hash(group) for group in groups
315
+ )
316
+ expected_hashes = tuple(shard.payload_hash for shard in self.shards)
317
+ ensure(actual_hashes == expected_hashes,
318
+ "Batch request shard payload hash does not match manifest", "batch_plan_mismatch")
319
+ digest = _request_manifest_hash(items)
320
+ ensure(digest == self.plan_hash,
321
+ "Batch request payload hash does not match manifest", "batch_plan_mismatch")
322
+
323
+
324
+ def partition_requests(
325
+ requests: Sequence[TwinCalculationRequest], *, shard_size: int,
326
+ ) -> tuple[tuple[TwinCalculationRequest, ...], ...]:
327
+ """Partition requests without changing order or request-level provenance."""
328
+ ensure(shard_size >= 1, "shard_size must be at least one", "invalid_batch_shard_size")
329
+ items = tuple(requests)
330
+ ids = [item.request_id for item in items]
331
+ ensure(len(ids) == len(set(ids)), "request_id values must be unique within a batch", "duplicate_batch_request")
332
+ return tuple(items[index:index + shard_size] for index in range(0, len(items), shard_size))
333
+
334
+
335
+ def _request_manifest_hash(items: Sequence[TwinCalculationRequest]) -> str:
336
+ manifest = [item.model_dump(mode="json") for item in items]
337
+ return sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
338
+
339
+
340
+ def build_batch_plan(
341
+ requests: Sequence[TwinCalculationRequest], *, shard_size: int,
342
+ batch_id: str | None = None,
343
+ ) -> TwinBatchPlan:
344
+ """Build a deterministic manifest; scheduler submission remains host-owned."""
345
+ groups = partition_requests(requests, shard_size=shard_size)
346
+ items = tuple(item for group in groups for item in group)
347
+ request_ids = tuple(item.request_id for item in items)
348
+ digest = _request_manifest_hash(items)
349
+ resolved_batch_id = batch_id or str(uuid4())
350
+ shards = tuple(
351
+ TwinBatchShard(
352
+ shard_id=f"{resolved_batch_id}-{ordinal:06d}",
353
+ ordinal=ordinal,
354
+ request_ids=tuple(item.request_id for item in group),
355
+ payload_hash=_request_manifest_hash(group),
356
+ )
357
+ for ordinal, group in enumerate(groups)
358
+ )
359
+ return TwinBatchPlan(
360
+ batch_id=resolved_batch_id,
361
+ request_count=len(items),
362
+ shard_size=shard_size,
363
+ request_ids=request_ids,
364
+ shards=shards,
365
+ plan_hash=digest,
366
+ )
367
+
368
+
369
+ def merge_batch_responses(
370
+ responses: Sequence[TwinCalculationResponse], *, request_order: Sequence[str],
371
+ requests: Sequence[TwinCalculationRequest] | None = None,
372
+ ) -> list[TwinCalculationResponse]:
373
+ """Reassemble independently completed shards and reject identity mismatches.
374
+
375
+ When ``requests`` is supplied, response calculation/asset/component and
376
+ observation identity are checked against the original immutable request;
377
+ this prevents a scheduler retry from attaching a valid response from a
378
+ different component to the same request ID.
379
+ """
380
+ order = tuple(request_order)
381
+ ensure(len(order) == len(set(order)), "request_order values must be unique", "duplicate_batch_request")
382
+ response_map: dict[str, TwinCalculationResponse] = {}
383
+ for response in responses:
384
+ ensure(response.request_id not in response_map, "duplicate response request_id", "duplicate_batch_response")
385
+ response_map[response.request_id] = response
386
+ ensure(set(response_map) == set(order), "batch responses do not match request_order", "incomplete_batch_response")
387
+ if requests is not None:
388
+ request_map = {request.request_id: request for request in requests}
389
+ ensure(tuple(request_map) == order,
390
+ "batch requests do not match request_order", "batch_plan_mismatch")
391
+ for request_id, response in response_map.items():
392
+ request = request_map[request_id]
393
+ ensure(
394
+ response.calculation_id == request.calculation_id
395
+ and response.asset_id == request.asset_id
396
+ and response.component_id == request.component_id
397
+ and response.observed_at == request.observed_at,
398
+ "batch response identity does not match request",
399
+ "batch_response_mismatch",
400
+ )
401
+ ensure(response.input_hash == request.input_hash(),
402
+ "batch response input_hash does not match request",
403
+ "batch_response_mismatch")
404
+ return [response_map[request_id] for request_id in order]
405
+
406
+
407
+ def execute_requests(
408
+ requests: Sequence[TwinCalculationRequest], *, max_workers: int = 1,
409
+ execution_mode: Literal["serial", "thread", "process"] = "serial",
410
+ chunksize: int = 1,
411
+ ) -> list[TwinCalculationResponse]:
412
+ """Execute independent twin requests while preserving input order.
413
+
414
+ The helper is transport- and storage-neutral. Serial execution is the
415
+ deterministic default; ``thread`` is useful for I/O-bound adapters and
416
+ ``process`` provides a GIL-independent CPU boundary for large scenario
417
+ batches or an HPC launcher. Input order and request-level provenance are
418
+ preserved in every mode. Native SLABx calls still retain their own process
419
+ isolation inside each request.
420
+ """
421
+ ensure(max_workers >= 1, "max_workers must be at least one", "invalid_batch_workers")
422
+ ensure(chunksize >= 1, "chunksize must be at least one", "invalid_batch_chunksize")
423
+ ensure(execution_mode in {"serial", "thread", "process"},
424
+ "execution_mode must be serial, thread or process", "invalid_batch_execution_mode")
425
+ request_list = list(requests)
426
+ ids = [item.request_id for item in request_list]
427
+ ensure(len(ids) == len(set(ids)), "request_id values must be unique within a batch", "duplicate_batch_request")
428
+ if execution_mode == "serial" or max_workers == 1 or len(request_list) < 2:
429
+ return [execute_request(item) for item in request_list]
430
+ executor_type = ProcessPoolExecutor if execution_mode == "process" else ThreadPoolExecutor
431
+ with executor_type(max_workers=max_workers) as pool:
432
+ # ``chunksize`` materially reduces IPC overhead for large process
433
+ # batches. ThreadPoolExecutor accepts the argument for API
434
+ # compatibility but intentionally ignores it, matching stdlib
435
+ # semantics. Serial execution above avoids creating an executor.
436
+ return list(pool.map(execute_request, request_list, chunksize=chunksize))
437
+
438
+
439
+ def execute_batch_plan(
440
+ plan: TwinBatchPlan,
441
+ requests: Sequence[TwinCalculationRequest], *,
442
+ max_workers: int = 1,
443
+ execution_mode: Literal["serial", "thread", "process"] = "serial",
444
+ chunksize: int = 1,
445
+ ) -> list[TwinCalculationResponse]:
446
+ """Verify and execute a replayable batch plan, returning manifest order.
447
+
448
+ The plan is checked before any calculation starts. This makes scheduler
449
+ retries safe: a worker cannot accidentally run a changed payload under an
450
+ old shard manifest. The returned responses are ordered exactly like the
451
+ plan, regardless of the selected execution backend.
452
+ """
453
+ request_list = tuple(requests)
454
+ ensure(len(request_list) == plan.request_count,
455
+ "Batch request count does not match plan", "batch_plan_mismatch")
456
+ plan.verify_requests(request_list)
457
+ responses = execute_requests(
458
+ request_list, max_workers=max_workers, execution_mode=execution_mode,
459
+ chunksize=chunksize,
460
+ )
461
+ return merge_batch_responses(responses, request_order=plan.request_ids, requests=request_list)
462
+
463
+
464
+ def execute_batch_shard(
465
+ plan: TwinBatchPlan,
466
+ shard_id: str,
467
+ requests: Sequence[TwinCalculationRequest], *,
468
+ max_workers: int = 1,
469
+ execution_mode: Literal["serial", "thread", "process"] = "serial",
470
+ chunksize: int = 1,
471
+ ) -> list[TwinCalculationResponse]:
472
+ """Verify and execute one scheduler-owned shard of a batch plan.
473
+
474
+ Workers need only receive the requests assigned to their shard. The
475
+ shard-local request IDs and payload hash are checked against the immutable
476
+ plan before any calculation starts; the host can merge returned responses
477
+ with :func:`merge_batch_responses` after all workers complete.
478
+ """
479
+ shard = next((item for item in plan.shards if item.shard_id == shard_id), None)
480
+ ensure(shard is not None, "Unknown batch shard", "batch_plan_mismatch")
481
+ request_list = tuple(requests)
482
+ actual_ids = tuple(item.request_id for item in request_list)
483
+ ensure(actual_ids == shard.request_ids,
484
+ "Shard requests do not match manifest order", "batch_plan_mismatch")
485
+ ensure(_request_manifest_hash(request_list) == shard.payload_hash,
486
+ "Shard request payload hash does not match manifest", "batch_plan_mismatch")
487
+ responses = execute_requests(
488
+ request_list, max_workers=max_workers, execution_mode=execution_mode,
489
+ chunksize=chunksize,
490
+ )
491
+ return merge_batch_responses(responses, request_order=shard.request_ids, requests=request_list)
api581/dispersion.py ADDED
@@ -0,0 +1,45 @@
1
+ """External-engine contracts. No simulated dispersion result is returned."""
2
+ from typing import Literal, Protocol
3
+ from pydantic import Field, model_validator
4
+ from .core import Model, Context, NonNegative, Positive, Fraction, DomainError
5
+
6
+
7
+ class SourcePoint(Model):
8
+ time_s: NonNegative
9
+ mass_rate_kg_s: NonNegative
10
+ temperature_k: Positive
11
+ vapor_mass_fraction: Fraction
12
+
13
+
14
+ class DispersionRequest(Model):
15
+ schema_version: Literal["1.0"] = "1.0"
16
+ context: Context = Field(default_factory=Context)
17
+ engine: Literal["slabx", "slabx-lh2"]
18
+ fluid_id: str = Field(min_length=1, max_length=100)
19
+ release_height_m: NonNegative
20
+ wind_speed_m_s: NonNegative
21
+ wind_reference_height_m: Positive
22
+ ambient_temperature_k: Positive
23
+ source_profile: list[SourcePoint] = Field(min_length=1, max_length=10000)
24
+ requested_outputs: list[Literal["concentration_field", "lfl_area", "flammable_cloud_mass"]] = Field(min_length=1, max_length=3)
25
+
26
+ @model_validator(mode="after")
27
+ def times(self):
28
+ if self.source_profile[0].time_s != 0 or any(b.time_s <= a.time_s for a, b in zip(self.source_profile, self.source_profile[1:])):
29
+ raise ValueError("Profile must start at 0 and have strictly increasing times")
30
+ return self
31
+
32
+
33
+ class DispersionProvider(Protocol):
34
+ def capabilities(self) -> dict: ...
35
+ def run(self, request: DispersionRequest) -> dict: ...
36
+
37
+
38
+ def capabilities():
39
+ return [{"engine": name, "status": "not_configured", "available": False,
40
+ "reason": "Draft variable-profile/area contract is unmapped; use separate api581.api.dispersion.native_pool_centreline for the verified constant vapor pool contract",
41
+ "contract_status": "draft_requires_engine_mapping"} for name in ("slabx", "slabx-lh2")]
42
+
43
+
44
+ def execute(request: DispersionRequest):
45
+ raise DomainError("engine_not_configured", f"{request.engine} is not configured; no fallback model was used")