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/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")
|