ANYfeSolver-results 2.2.0a1__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.
- anyfesolver_results/__init__.py +42 -0
- anyfesolver_results/capsule.py +517 -0
- anyfesolver_results/errors.py +18 -0
- anyfesolver_results/formulas.py +145 -0
- anyfesolver_results/principals.py +90 -0
- anyfesolver_results/registry.py +114 -0
- anyfesolver_results/storage_validation.py +66 -0
- anyfesolver_results-2.2.0a1.dist-info/METADATA +87 -0
- anyfesolver_results-2.2.0a1.dist-info/RECORD +14 -0
- anyfesolver_results-2.2.0a1.dist-info/WHEEL +5 -0
- anyfesolver_results-2.2.0a1.dist-info/licenses/COPYRIGHT +1 -0
- anyfesolver_results-2.2.0a1.dist-info/licenses/LICENSE +373 -0
- anyfesolver_results-2.2.0a1.dist-info/licenses/THIRD_PARTY_NOTICES.md +6 -0
- anyfesolver_results-2.2.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""NumPy-only runtime for ANYfeSolver recovery capsules (contracts v1 and v2).
|
|
2
|
+
|
|
3
|
+
This package reconstructs ANYfeSolver response fields offline. It is plain
|
|
4
|
+
data in, arrays out: no executable or pickle payloads, and no imports from
|
|
5
|
+
``anyfesolver``, scipy, ANYfem or ANYpost. ANYpost resolves dependencies and
|
|
6
|
+
calls this runtime; the closed derived-operation registry and the physics
|
|
7
|
+
live here.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .capsule import (SCHEMA, VERSION, RUNTIME_ID, SUPPORTED_RUNTIME_IDS,
|
|
12
|
+
SUPPORTED_COMBINATIONS, PreparedResponse, prepare_response,
|
|
13
|
+
reconstruct_prepared_response, reconstruct_response,
|
|
14
|
+
validate_capsule)
|
|
15
|
+
from .errors import (CapsuleError, CapsuleValidationError, DerivationError,
|
|
16
|
+
ReconstructionError)
|
|
17
|
+
from .registry import OPERATIONS, DERIVATION_EXECUTION_API, derive_field
|
|
18
|
+
from .storage_validation import STORAGE_PREDICTION_API, validate_storage_prediction
|
|
19
|
+
from .formulas import shell_surface_component
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
'shell_surface_component',
|
|
23
|
+
'STORAGE_PREDICTION_API',
|
|
24
|
+
'validate_storage_prediction',
|
|
25
|
+
'RUNTIME_ID',
|
|
26
|
+
'SUPPORTED_RUNTIME_IDS',
|
|
27
|
+
'SUPPORTED_COMBINATIONS',
|
|
28
|
+
'SCHEMA',
|
|
29
|
+
'VERSION',
|
|
30
|
+
'OPERATIONS',
|
|
31
|
+
'DERIVATION_EXECUTION_API',
|
|
32
|
+
'PreparedResponse',
|
|
33
|
+
'prepare_response',
|
|
34
|
+
'reconstruct_prepared_response',
|
|
35
|
+
'reconstruct_response',
|
|
36
|
+
'derive_field',
|
|
37
|
+
'validate_capsule',
|
|
38
|
+
'CapsuleError',
|
|
39
|
+
'CapsuleValidationError',
|
|
40
|
+
'ReconstructionError',
|
|
41
|
+
'DerivationError',
|
|
42
|
+
]
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Capsule validation and response reconstruction (NumPy only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .errors import CapsuleValidationError, ReconstructionError
|
|
10
|
+
|
|
11
|
+
SCHEMA = 'anysolver.recovery_capsule'
|
|
12
|
+
VERSION = 2
|
|
13
|
+
RUNTIME_ID = 'anysolver-results/2'
|
|
14
|
+
SUPPORTED_RUNTIME_IDS = ('anysolver-results/1','anysolver-results/2')
|
|
15
|
+
SUPPORTED_COMBINATIONS = ('legacy_blas/1','ordered_float64/1')
|
|
16
|
+
AVAILABILITIES = ('stored', 'reconstructable', 'unavailable')
|
|
17
|
+
# The completed-status set ANYpost admits for certified cases; a certified
|
|
18
|
+
# failure status is never part of it.
|
|
19
|
+
COMPLETED_STATUSES = ('completed', 'complete', 'success', 'succeeded', 'ok', 'converged')
|
|
20
|
+
GEOMETRY_NAMES = ('positions', 'triangles', 'lines', 'triangle_to_element',
|
|
21
|
+
'line_to_element', 'node_ids', 'element_ids')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _require(condition: bool, message: str) -> None:
|
|
25
|
+
if not condition:
|
|
26
|
+
raise CapsuleValidationError(message)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_plain_json(value: Any) -> bool:
|
|
30
|
+
if value is None or type(value) in (bool, str):
|
|
31
|
+
return True
|
|
32
|
+
if type(value) is int:
|
|
33
|
+
return True
|
|
34
|
+
if type(value) is float:
|
|
35
|
+
# JSON has no NaN or infinity; a non-finite float is not plain data.
|
|
36
|
+
return math.isfinite(value)
|
|
37
|
+
# Solver snapshots can use JSON-compatible read-only dict/list
|
|
38
|
+
# subclasses. Their contents still obey the same finite plain-data
|
|
39
|
+
# admission; no arbitrary objects or executable archive values enter.
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return all(_is_plain_json(item) for item in value)
|
|
42
|
+
if isinstance(value, dict):
|
|
43
|
+
return all(type(key) is str and _is_plain_json(item) for key, item in value.items())
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_capsule(capsule: Mapping[str, Any]) -> None:
|
|
48
|
+
"""Lightweight shape/identity/status checks for a recovery capsule.
|
|
49
|
+
|
|
50
|
+
This is a structural admission check, not a certification: it refuses
|
|
51
|
+
malformed capsules so consumers never guess at layout, and it admits
|
|
52
|
+
failed cases (they stay discoverable) while reconstruction itself only
|
|
53
|
+
ever admits explicitly completed certified cases.
|
|
54
|
+
"""
|
|
55
|
+
if not isinstance(capsule, Mapping):
|
|
56
|
+
raise CapsuleValidationError('capsule must be a mapping')
|
|
57
|
+
_require(capsule.get('schema') == SCHEMA, 'capsule schema is not ' + SCHEMA)
|
|
58
|
+
version = capsule.get('version')
|
|
59
|
+
_require(type(version) is int and version in (1,2), 'unsupported capsule version')
|
|
60
|
+
_require(capsule.get('runtime') == f'anysolver-results/{version}', 'capsule runtime identity mismatch')
|
|
61
|
+
expected = 'legacy_blas/1' if version == 1 else 'ordered_float64/1'
|
|
62
|
+
_require(capsule.get('combination',expected if version == 1 else None) == expected,
|
|
63
|
+
'capsule combination identity mismatch')
|
|
64
|
+
for name in ('model_identity', 'mesh_identity'):
|
|
65
|
+
identity = capsule.get(name)
|
|
66
|
+
_require(type(identity) is str and identity, f'{name} must be a nonempty string')
|
|
67
|
+
_require(isinstance(capsule.get('solver_identity'), Mapping) and _is_plain_json(dict(capsule['solver_identity'])),
|
|
68
|
+
'solver_identity must be a JSON mapping')
|
|
69
|
+
provenance = capsule.get('model_provenance')
|
|
70
|
+
if provenance is not None:
|
|
71
|
+
_require(isinstance(provenance, Mapping) and _is_plain_json(dict(provenance)),
|
|
72
|
+
'model_provenance must be a JSON mapping when present')
|
|
73
|
+
digest = provenance.get('snapshot_content_digest')
|
|
74
|
+
_require(type(digest) is str and digest,
|
|
75
|
+
'model_provenance needs a nonempty snapshot_content_digest')
|
|
76
|
+
recovery_metadata = capsule.get('model_recovery_metadata')
|
|
77
|
+
if recovery_metadata is not None:
|
|
78
|
+
# Shared once-per-model frames/sampling tables: plain JSON only.
|
|
79
|
+
_require(isinstance(recovery_metadata, Mapping)
|
|
80
|
+
and _is_plain_json(dict(recovery_metadata)),
|
|
81
|
+
'model_recovery_metadata must be a JSON mapping when present')
|
|
82
|
+
constants = capsule.get('element_constants')
|
|
83
|
+
if constants is not None:
|
|
84
|
+
_require(isinstance(constants, Mapping), 'element_constants must be a mapping when present')
|
|
85
|
+
for element_id, values in constants.items():
|
|
86
|
+
_require(type(element_id) is str, 'element_constants keys must be strings')
|
|
87
|
+
_require(isinstance(values, Mapping) and _is_plain_json(dict(values)),
|
|
88
|
+
f'element_constants[{element_id!r}] must be a JSON mapping')
|
|
89
|
+
geometry = capsule.get('geometry')
|
|
90
|
+
_require(isinstance(geometry, Mapping), 'geometry must be a mapping')
|
|
91
|
+
for name, value in geometry.items():
|
|
92
|
+
_require(type(name) is str, 'geometry names must be strings')
|
|
93
|
+
_require(isinstance(value, np.ndarray) and value.dtype.kind in 'biuf',
|
|
94
|
+
f'geometry array {name!r} must be a numeric array')
|
|
95
|
+
if version == 2:
|
|
96
|
+
positions=geometry.get('positions')
|
|
97
|
+
if positions is not None:
|
|
98
|
+
_require(positions.ndim == 2 and positions.shape[1] == 3
|
|
99
|
+
and np.all(np.isfinite(positions)),'positions must be finite (node_count,3)')
|
|
100
|
+
for name in ('element_ids','node_ids'):
|
|
101
|
+
if name in geometry:
|
|
102
|
+
value=geometry[name]
|
|
103
|
+
_require(value.dtype.kind in 'iu' and value.ndim == 1,
|
|
104
|
+
f'{name} must be a one-dimensional integer identity table')
|
|
105
|
+
_require(len(np.unique(value)) == len(value),f'{name} must be unique')
|
|
106
|
+
if name == 'node_ids':
|
|
107
|
+
_require(positions is not None and len(value) == len(positions),'node_ids must match positions')
|
|
108
|
+
for topology,width in (('triangles',3),('lines',2)):
|
|
109
|
+
if topology in geometry:
|
|
110
|
+
indices=geometry[topology]
|
|
111
|
+
_require(positions is not None and indices.dtype.kind in 'iu'
|
|
112
|
+
and indices.ndim == 2 and indices.shape[1] == width,
|
|
113
|
+
f'{topology} must be dense node indices with width {width}')
|
|
114
|
+
_require(np.all(indices >= 0) and np.all(indices < len(positions)),
|
|
115
|
+
f'{topology} node indices are out of bounds')
|
|
116
|
+
for name, topology in (('triangle_to_element','triangles'),('line_to_element','lines')):
|
|
117
|
+
if name in geometry:
|
|
118
|
+
value=geometry[name]
|
|
119
|
+
_require(value.dtype.kind in 'iu' and value.ndim == 1,
|
|
120
|
+
f'{name} must be a one-dimensional integer mapping')
|
|
121
|
+
_require(topology in geometry and len(value) == len(geometry[topology]),
|
|
122
|
+
f'{name} must match {topology} rows')
|
|
123
|
+
_require('element_ids' in geometry,'dense element mappings require element_ids')
|
|
124
|
+
_require(np.all(value >= 0) and np.all(value < len(geometry['element_ids'])),
|
|
125
|
+
f'{name} must index element_ids')
|
|
126
|
+
patterns = capsule.get('patterns')
|
|
127
|
+
_require(type(patterns) is tuple, 'patterns must be a tuple')
|
|
128
|
+
_require(all(type(name) is str for name in patterns), 'pattern names must be strings')
|
|
129
|
+
_require(len(set(patterns)) == len(patterns), 'pattern names must be unique')
|
|
130
|
+
pattern_count = len(patterns)
|
|
131
|
+
coefficients = capsule.get('coefficients')
|
|
132
|
+
_require(isinstance(coefficients, np.ndarray) and coefficients.dtype == np.float64
|
|
133
|
+
and coefficients.ndim == 2 and coefficients.shape[1] == pattern_count,
|
|
134
|
+
'coefficients must be float64 (case_count, pattern_count)')
|
|
135
|
+
_require(np.all(np.isfinite(coefficients)), 'coefficients must be finite')
|
|
136
|
+
case_count = coefficients.shape[0]
|
|
137
|
+
cases = capsule.get('cases')
|
|
138
|
+
_require(type(cases) is tuple and len(cases) == case_count,
|
|
139
|
+
'cases must be a tuple matching the coefficient rows')
|
|
140
|
+
seen_cases = set()
|
|
141
|
+
for case in cases:
|
|
142
|
+
_require(isinstance(case, Mapping) and _is_plain_json(dict(case)),
|
|
143
|
+
'each case must be a JSON mapping')
|
|
144
|
+
case_id = case.get('case_id')
|
|
145
|
+
_require(type(case_id) is str and case_id, 'each case needs a nonempty case_id')
|
|
146
|
+
_require(case_id not in seen_cases, 'case_id values must be unique')
|
|
147
|
+
seen_cases.add(case_id)
|
|
148
|
+
_require(type(case.get('status')) is str, f'case {case_id!r} needs a status string')
|
|
149
|
+
_require(type(case.get('certified')) is bool, f'case {case_id!r} needs a certified flag')
|
|
150
|
+
_require(type(case.get('route')) is str and case['route'],
|
|
151
|
+
f'case {case_id!r} needs a route string')
|
|
152
|
+
if case['certified']:
|
|
153
|
+
_require(case['status'] in COMPLETED_STATUSES,
|
|
154
|
+
f'case {case_id!r} is certified but its status is not a completed status')
|
|
155
|
+
active_indices = case.get('active_indices')
|
|
156
|
+
if active_indices is not None:
|
|
157
|
+
# One-based original-order pattern positions, unique and in range,
|
|
158
|
+
# validated exactly like a reconstruction request.
|
|
159
|
+
try:
|
|
160
|
+
_integer_active_indices(active_indices, pattern_count + 1)
|
|
161
|
+
except ReconstructionError as error:
|
|
162
|
+
raise CapsuleValidationError(str(error)) from error
|
|
163
|
+
portable = case.get('portable')
|
|
164
|
+
if portable is not None:
|
|
165
|
+
_require(type(portable) is bool, f'case {case_id!r} needs a boolean portable flag')
|
|
166
|
+
if portable:
|
|
167
|
+
_require(case['certified'],
|
|
168
|
+
f'case {case_id!r} is portable but not certified')
|
|
169
|
+
if 'displacement_portable' in case:
|
|
170
|
+
_require(case['displacement_portable'] is None or type(case['displacement_portable']) is bool,
|
|
171
|
+
'displacement_portable must be boolean or null')
|
|
172
|
+
if 'unavailable_components' in case:
|
|
173
|
+
value=case['unavailable_components']
|
|
174
|
+
_require(isinstance(value,list) and all(type(key) is str for key in value)
|
|
175
|
+
and len(value)==len(set(value)), 'unavailable_components must contain unique strings')
|
|
176
|
+
if 'correction_reasons' in case:
|
|
177
|
+
reasons=case['correction_reasons']
|
|
178
|
+
_require(isinstance(reasons,Mapping) and all(type(key) is str and type(reason) is str
|
|
179
|
+
for key,reason in reasons.items()),'correction_reasons must map strings to strings')
|
|
180
|
+
descriptors = capsule.get('descriptors')
|
|
181
|
+
_require(type(descriptors) is tuple, 'descriptors must be a tuple')
|
|
182
|
+
keys = set()
|
|
183
|
+
pairs = set()
|
|
184
|
+
for descriptor in descriptors:
|
|
185
|
+
_require(isinstance(descriptor, Mapping) and _is_plain_json(dict(descriptor)),
|
|
186
|
+
'each descriptor must be a JSON mapping')
|
|
187
|
+
key = descriptor.get('key')
|
|
188
|
+
_require(type(key) is str and key, 'each descriptor needs a nonempty key')
|
|
189
|
+
_require(key not in keys, 'descriptor keys must be unique')
|
|
190
|
+
keys.add(key)
|
|
191
|
+
for name in ('quantity_id', 'component', 'location', 'unit', 'basis',
|
|
192
|
+
'sampling'):
|
|
193
|
+
_require(type(descriptor.get(name)) is str,
|
|
194
|
+
f'descriptor {key!r} needs a string {name}')
|
|
195
|
+
# `reconstruction` is an optional v1 extension: absent is admitted,
|
|
196
|
+
# but a present value must be a string (None is not a string).
|
|
197
|
+
if 'reconstruction' in descriptor:
|
|
198
|
+
_require(type(descriptor['reconstruction']) is str,
|
|
199
|
+
f'descriptor {key!r} needs a string reconstruction')
|
|
200
|
+
pair = (descriptor['quantity_id'], descriptor['component'])
|
|
201
|
+
_require(pair not in pairs,
|
|
202
|
+
'descriptor (quantity_id, component) pairs must be unique')
|
|
203
|
+
pairs.add(pair)
|
|
204
|
+
_require(descriptor.get('availability') in AVAILABILITIES,
|
|
205
|
+
f'descriptor {key!r} has an unknown availability')
|
|
206
|
+
if version == 2:
|
|
207
|
+
for case in cases:
|
|
208
|
+
_require(type(case.get('portable')) is bool,'runtime2 cases require a portable flag')
|
|
209
|
+
_require(set(case.get('unavailable_components',())) <= keys,
|
|
210
|
+
'unavailable_components reference unknown descriptors')
|
|
211
|
+
_require(set(case.get('correction_reasons',{})) <= keys,
|
|
212
|
+
'correction_reasons reference unknown descriptors')
|
|
213
|
+
associations = capsule.get('associations')
|
|
214
|
+
_require(isinstance(associations, Mapping), 'associations must be a mapping')
|
|
215
|
+
for key, value in associations.items():
|
|
216
|
+
_require(type(key) is str, 'association keys must be strings')
|
|
217
|
+
_require(isinstance(value, np.ndarray) and value.ndim == 1 and value.dtype.kind in 'iu',
|
|
218
|
+
f'association {key!r} must be a 1-D integer array')
|
|
219
|
+
responses = capsule.get('responses')
|
|
220
|
+
_require(isinstance(responses, Mapping), 'responses must be a mapping')
|
|
221
|
+
spatial = {}
|
|
222
|
+
for key, value in responses.items():
|
|
223
|
+
_require(type(key) is str, 'response keys must be strings')
|
|
224
|
+
_require(isinstance(value, np.ndarray) and value.dtype == np.float64 and value.ndim == 2
|
|
225
|
+
and value.shape[1] == pattern_count + 1,
|
|
226
|
+
f'response {key!r} must be float64 (spatial_count, pattern_count+1)')
|
|
227
|
+
_require(np.all(np.isfinite(value)), f'response {key!r} must be finite')
|
|
228
|
+
_require(key in associations, f'response {key!r} has no association array')
|
|
229
|
+
_require(key in keys, f'response {key!r} has no descriptor')
|
|
230
|
+
association = associations[key]
|
|
231
|
+
_require(association.shape[0] == value.shape[0],
|
|
232
|
+
f'response {key!r} disagrees with its association length')
|
|
233
|
+
spatial[key] = value.shape[0]
|
|
234
|
+
stored = capsule.get('stored')
|
|
235
|
+
_require(isinstance(stored, Mapping), 'stored must be a mapping')
|
|
236
|
+
for key, value in stored.items():
|
|
237
|
+
_require(type(key) is str, 'stored keys must be strings')
|
|
238
|
+
_require(isinstance(value, Mapping), f'stored {key!r} must be a mapping')
|
|
239
|
+
indices = value.get('case_indices')
|
|
240
|
+
values = value.get('values')
|
|
241
|
+
_require(isinstance(indices, np.ndarray) and indices.ndim == 1 and indices.dtype.kind in 'iu',
|
|
242
|
+
f'stored {key!r} needs integer case_indices')
|
|
243
|
+
_require(isinstance(values, np.ndarray) and values.dtype == np.float64 and values.ndim == 2,
|
|
244
|
+
f'stored {key!r} needs float64 values (stored_case_count, spatial_count)')
|
|
245
|
+
_require(np.all(np.isfinite(values)), f'stored {key!r} values must be finite')
|
|
246
|
+
_require(indices.shape[0] == values.shape[0],
|
|
247
|
+
f'stored {key!r} case_indices and values disagree')
|
|
248
|
+
_require(np.all((indices >= 0) & (indices < case_count)),
|
|
249
|
+
f'stored {key!r} case_indices are out of range')
|
|
250
|
+
_require(len(set(indices.tolist())) == indices.shape[0],
|
|
251
|
+
f'stored {key!r} case_indices must be unique')
|
|
252
|
+
_require(key in associations, f'stored {key!r} has no association array')
|
|
253
|
+
_require(key in keys, f'stored {key!r} has no descriptor')
|
|
254
|
+
_require(associations[key].shape[0] == values.shape[1],
|
|
255
|
+
f'stored {key!r} disagrees with its association length')
|
|
256
|
+
if key in spatial:
|
|
257
|
+
_require(spatial[key] == values.shape[1],
|
|
258
|
+
f'stored {key!r} disagrees with the response spatial count')
|
|
259
|
+
derived = capsule.get('derived')
|
|
260
|
+
_require(isinstance(derived, Mapping), 'derived must be a mapping')
|
|
261
|
+
for key, specification in derived.items():
|
|
262
|
+
_require(type(key) is str, 'derived keys must be strings')
|
|
263
|
+
_require(isinstance(specification, Mapping) and _is_plain_json(dict(specification)),
|
|
264
|
+
f'derived {key!r} must be a JSON mapping')
|
|
265
|
+
_require(type(specification.get('operation')) is str,
|
|
266
|
+
f'derived {key!r} needs an operation')
|
|
267
|
+
inputs = specification.get('inputs')
|
|
268
|
+
_require(isinstance(inputs, Mapping), f'derived {key!r} needs an inputs mapping')
|
|
269
|
+
_require(key in keys, f'derived {key!r} has no descriptor')
|
|
270
|
+
for input_name, target in inputs.items():
|
|
271
|
+
_require(type(input_name) is str and type(target) is str,
|
|
272
|
+
f'derived {key!r} inputs must map names to component keys')
|
|
273
|
+
_require(target in keys,
|
|
274
|
+
f'derived {key!r} input {input_name!r} references '
|
|
275
|
+
f'unknown component {target!r}')
|
|
276
|
+
_require_acyclical_derived(derived)
|
|
277
|
+
if 'storage_prediction' in capsule:
|
|
278
|
+
_require(version == 2, 'storage prediction requires capsule2')
|
|
279
|
+
from .storage_validation import validate_storage_prediction
|
|
280
|
+
validate_storage_prediction(capsule['storage_prediction'], patterns=patterns,
|
|
281
|
+
descriptors=descriptors, associations=associations)
|
|
282
|
+
for key in capsule['storage_prediction']['targets']:
|
|
283
|
+
_require(key not in responses and key not in derived,
|
|
284
|
+
'storage-only target cannot enter engineering replay')
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _require_acyclical_derived(derived: Mapping[str, Any]) -> None:
|
|
288
|
+
"""Refuse derived specifications whose dependencies form a cycle."""
|
|
289
|
+
targets = {key: {target for target in specification.get('inputs', {}).values()
|
|
290
|
+
if target in derived}
|
|
291
|
+
for key, specification in derived.items()
|
|
292
|
+
if isinstance(specification, Mapping)}
|
|
293
|
+
state: dict = {}
|
|
294
|
+
|
|
295
|
+
def visit(key: str) -> None:
|
|
296
|
+
if state.get(key) == 1:
|
|
297
|
+
raise CapsuleValidationError(f'derived components form a cycle at {key!r}')
|
|
298
|
+
if state.get(key) == 2:
|
|
299
|
+
return
|
|
300
|
+
state[key] = 1
|
|
301
|
+
for target in targets.get(key, ()):
|
|
302
|
+
visit(target)
|
|
303
|
+
state[key] = 2
|
|
304
|
+
|
|
305
|
+
for key in targets:
|
|
306
|
+
visit(key)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _as_float_array(values: Any, message: str) -> np.ndarray:
|
|
310
|
+
"""Float64 conversion whose failure is the documented typed refusal."""
|
|
311
|
+
try:
|
|
312
|
+
return np.asarray(values, dtype=np.float64)
|
|
313
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
314
|
+
raise ReconstructionError(message) from error
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _integer_active_indices(active_indices: Any, column_count: int) -> np.ndarray:
|
|
318
|
+
"""Strict 1-D integer pattern-column positions, unique and in range.
|
|
319
|
+
|
|
320
|
+
Fractional, boolean and duplicate indices are refused instead of being
|
|
321
|
+
silently coerced; the original sparse order of valid indices is preserved.
|
|
322
|
+
Range and uniqueness are validated on the original values before any
|
|
323
|
+
fixed-width conversion, so oversized plain integers (e.g. ``2**100``)
|
|
324
|
+
surface as the documented typed refusal instead of a raw OverflowError.
|
|
325
|
+
"""
|
|
326
|
+
if isinstance(active_indices, (list, tuple)):
|
|
327
|
+
if any(type(index) is not int for index in active_indices):
|
|
328
|
+
raise ReconstructionError('active_indices must be integer indices')
|
|
329
|
+
if any(not 1 <= index < column_count for index in active_indices):
|
|
330
|
+
raise ReconstructionError('active_indices must reference pattern columns')
|
|
331
|
+
if len(set(active_indices)) != len(active_indices):
|
|
332
|
+
raise ReconstructionError('active_indices must be unique')
|
|
333
|
+
return np.asarray(active_indices, dtype=np.intp)
|
|
334
|
+
try:
|
|
335
|
+
active = np.asarray(active_indices)
|
|
336
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
337
|
+
raise ReconstructionError('active_indices must be an integer array') from error
|
|
338
|
+
if active.ndim != 1:
|
|
339
|
+
raise ReconstructionError('active_indices must be one dimensional')
|
|
340
|
+
if active.size and active.dtype.kind not in 'iu':
|
|
341
|
+
raise ReconstructionError('active_indices must be integer indices')
|
|
342
|
+
if active.size:
|
|
343
|
+
if np.any(active < 1) or np.any(active >= column_count):
|
|
344
|
+
raise ReconstructionError('active_indices must reference pattern columns')
|
|
345
|
+
if np.unique(active).shape[0] != active.shape[0]:
|
|
346
|
+
raise ReconstructionError('active_indices must be unique')
|
|
347
|
+
try:
|
|
348
|
+
active = active.astype(np.intp, copy=False)
|
|
349
|
+
except OverflowError as error:
|
|
350
|
+
raise ReconstructionError('active_indices must be integer indices') from error
|
|
351
|
+
return active
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _validated_combination(combination):
|
|
355
|
+
if type(combination) is not str or combination not in SUPPORTED_COMBINATIONS:
|
|
356
|
+
raise ReconstructionError('unsupported response combination')
|
|
357
|
+
return combination
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _combine_response(baseline: np.ndarray, selected: np.ndarray,
|
|
361
|
+
weights: np.ndarray, combination='legacy_blas/1') -> np.ndarray:
|
|
362
|
+
"""Replay the named legacy BLAS or ordered binary64 accumulation.
|
|
363
|
+
|
|
364
|
+
Both the ordinary reconstruction and the prepared replay delegate to
|
|
365
|
+
this helper, so there is a single numerical implementation. An empty
|
|
366
|
+
active order reduces to the baseline alone.
|
|
367
|
+
"""
|
|
368
|
+
combination = _validated_combination(combination)
|
|
369
|
+
if selected.shape[1] == 0:
|
|
370
|
+
result = np.array(baseline, dtype=np.float64, copy=True)
|
|
371
|
+
elif combination == 'ordered_float64/1':
|
|
372
|
+
accumulated = np.zeros(baseline.shape,dtype=np.float64)
|
|
373
|
+
term = np.empty(baseline.shape,dtype=np.float64)
|
|
374
|
+
with np.errstate(over='ignore',invalid='ignore'):
|
|
375
|
+
for column in range(selected.shape[1]):
|
|
376
|
+
np.multiply(selected[:,column],weights[column],out=term)
|
|
377
|
+
np.add(accumulated,term,out=accumulated)
|
|
378
|
+
result = np.add(baseline,accumulated)
|
|
379
|
+
if not np.all(np.isfinite(result)):
|
|
380
|
+
raise ReconstructionError('ordered response replay is nonfinite')
|
|
381
|
+
else:
|
|
382
|
+
result = baseline + selected @ weights
|
|
383
|
+
if combination == 'ordered_float64/1':
|
|
384
|
+
result=np.frombuffer(result.tobytes(),dtype=np.float64).reshape(result.shape)
|
|
385
|
+
result.setflags(write=False)
|
|
386
|
+
return result
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _validated_response_columns(responses: Any, active_indices: Any) -> tuple:
|
|
390
|
+
"""Admitted float64 response matrix and its validated active columns."""
|
|
391
|
+
values = _as_float_array(responses, 'responses must be 2-D with a baseline column')
|
|
392
|
+
if values.ndim != 2 or values.shape[1] < 1:
|
|
393
|
+
raise ReconstructionError('responses must be 2-D with a baseline column')
|
|
394
|
+
if active_indices is None:
|
|
395
|
+
active = np.arange(1, values.shape[1], dtype=np.intp)
|
|
396
|
+
else:
|
|
397
|
+
active = _integer_active_indices(active_indices, values.shape[1])
|
|
398
|
+
if not np.all(np.isfinite(values)):
|
|
399
|
+
raise ReconstructionError('response columns must be finite')
|
|
400
|
+
return values, active
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _validated_weights(coefficients: Any, active_count: int) -> np.ndarray:
|
|
404
|
+
"""Admitted 1-D finite weights matching the active pattern count."""
|
|
405
|
+
weights = _as_float_array(coefficients, 'coefficients must match the active pattern count')
|
|
406
|
+
if weights.ndim != 1 or weights.shape[0] != active_count:
|
|
407
|
+
raise ReconstructionError('coefficients must match the active pattern count')
|
|
408
|
+
if not np.all(np.isfinite(weights)):
|
|
409
|
+
raise ReconstructionError('coefficients must be finite')
|
|
410
|
+
return weights
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def reconstruct_response(responses: Any, coefficients: Any, *, active_indices=None,
|
|
414
|
+
combination='legacy_blas/1') -> np.ndarray:
|
|
415
|
+
"""Reconstruct one scalar spatial field from capsule response columns.
|
|
416
|
+
|
|
417
|
+
``responses`` is a ``(spatial_count, pattern_count+1)`` float64 array whose
|
|
418
|
+
column 0 is the baseline (added exactly once). ``active_indices`` are
|
|
419
|
+
positions in the response columns (column 0 excluded); ``None`` means all
|
|
420
|
+
pattern columns. The default ``legacy_blas/1`` retains the original
|
|
421
|
+
``responses[:, 0] + responses[:, active] @ coefficients`` expression.
|
|
422
|
+
``ordered_float64/1`` materializes each multiply/add in active-column order
|
|
423
|
+
and adds the baseline last. Capsule2 consumers must explicitly select it;
|
|
424
|
+
the source exporter separately certifies exact bits or stores corrections.
|
|
425
|
+
"""
|
|
426
|
+
values, active = _validated_response_columns(responses, active_indices)
|
|
427
|
+
weights = _validated_weights(coefficients, active.shape[0])
|
|
428
|
+
return _combine_response(values[:, 0], values[:, active], weights,combination)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
_CREATE = object()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class PreparedResponse:
|
|
435
|
+
"""Immutable owned baseline and selected response columns for replay.
|
|
436
|
+
|
|
437
|
+
Instances are created only by :func:`prepare_response`; the construction
|
|
438
|
+
token keeps archives from inventing a prepared object. ``baseline`` is
|
|
439
|
+
the copied baseline column, ``selected`` the copied original-order active
|
|
440
|
+
columns (exactly the array the ordinary reconstruction expression
|
|
441
|
+
materializes), both read-only float64 without any alias to caller-mutable
|
|
442
|
+
memory. ``nbytes`` is the exact total retained numeric bytes
|
|
443
|
+
(baseline + selected) as a Python int for archive cache accounting.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
__slots__ = ('_baseline', '_selected', '_nbytes', '_combination')
|
|
447
|
+
|
|
448
|
+
def __setattr__(self,name,value):
|
|
449
|
+
if hasattr(self,name):
|
|
450
|
+
raise AttributeError('prepared responses are immutable')
|
|
451
|
+
object.__setattr__(self,name,value)
|
|
452
|
+
|
|
453
|
+
def __init__(self, baseline: np.ndarray, selected: np.ndarray, _create: Any = None,
|
|
454
|
+
combination='legacy_blas/1') -> None:
|
|
455
|
+
if _create is not _CREATE:
|
|
456
|
+
raise ReconstructionError('prepared responses are created by prepare_response')
|
|
457
|
+
self._baseline = baseline
|
|
458
|
+
self._selected = selected
|
|
459
|
+
self._nbytes = int(baseline.nbytes + selected.nbytes)
|
|
460
|
+
self._combination = _validated_combination(combination)
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def combination(self) -> str:
|
|
464
|
+
return self._combination
|
|
465
|
+
|
|
466
|
+
@property
|
|
467
|
+
def baseline(self) -> np.ndarray:
|
|
468
|
+
return self._baseline
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def selected(self) -> np.ndarray:
|
|
472
|
+
return self._selected
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def nbytes(self) -> int:
|
|
476
|
+
return self._nbytes
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def prepare_response(responses: Any, *, active_indices=None,
|
|
480
|
+
combination='legacy_blas/1') -> PreparedResponse:
|
|
481
|
+
"""Prepare one response matrix for repeated reconstruction.
|
|
482
|
+
|
|
483
|
+
Validates the same response matrix and original one-based active column
|
|
484
|
+
indices as :func:`reconstruct_response` and owns immutable float64 copies
|
|
485
|
+
of the baseline column and the original-order selected matrix
|
|
486
|
+
(``responses[:, active]``, preserving the exact memory layout the
|
|
487
|
+
ordinary reconstruction uses, including sparse and reversed active
|
|
488
|
+
orders). An empty active order retains the baseline only. The caller
|
|
489
|
+
may freely mutate or release ``responses`` afterwards; replay is
|
|
490
|
+
unaffected.
|
|
491
|
+
"""
|
|
492
|
+
values, active = _validated_response_columns(responses, active_indices)
|
|
493
|
+
baseline = np.frombuffer(values[:,0].tobytes(),dtype=np.float64)
|
|
494
|
+
chosen=values[:,active]
|
|
495
|
+
order='F' if chosen.flags.f_contiguous else 'C'
|
|
496
|
+
selected = np.frombuffer(chosen.tobytes(order=order),dtype=np.float64).reshape(chosen.shape,order=order)
|
|
497
|
+
baseline.setflags(write=False)
|
|
498
|
+
selected.setflags(write=False)
|
|
499
|
+
return PreparedResponse(baseline, selected, _CREATE,combination)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def reconstruct_prepared_response(prepared: Any, packed_coefficients: Any, *, combination=None) -> np.ndarray:
|
|
503
|
+
"""Reconstruct one field from a :func:`prepare_response` result.
|
|
504
|
+
|
|
505
|
+
Executes precisely the ordinary accumulation expression
|
|
506
|
+
(``baseline + selected @ packed_coefficients``) through the same shared
|
|
507
|
+
helper, returning a read-only float64 array. Only objects created by
|
|
508
|
+
``prepare_response`` are admitted; per-request weights are validated for
|
|
509
|
+
shape and finiteness exactly like the ordinary reconstruction.
|
|
510
|
+
"""
|
|
511
|
+
if type(prepared) is not PreparedResponse:
|
|
512
|
+
raise ReconstructionError('prepared must come from prepare_response')
|
|
513
|
+
algorithm = _validated_combination(prepared._combination)
|
|
514
|
+
if combination is not None and _validated_combination(combination) != algorithm:
|
|
515
|
+
raise ReconstructionError('prepared response combination mismatch')
|
|
516
|
+
weights = _validated_weights(packed_coefficients, prepared._selected.shape[1])
|
|
517
|
+
return _combine_response(prepared._baseline, prepared._selected, weights,algorithm)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Typed runtime errors for the anyfesolver-results capsule runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CapsuleError(Exception):
|
|
6
|
+
"""Base class of every anyfesolver-results runtime error."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CapsuleValidationError(CapsuleError):
|
|
10
|
+
"""A recovery capsule failed a lightweight structural validation."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReconstructionError(CapsuleError):
|
|
14
|
+
"""A response reconstruction could not be performed as requested."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DerivationError(CapsuleError):
|
|
18
|
+
"""A derived field specification could not be resolved or computed."""
|