yld.run 0.1.0__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.
- yld/LICENSE +21 -0
- yld/__init__.py +84 -0
- yld/adapt.py +209 -0
- yld/archive/__init__.py +36 -0
- yld/archive/container.py +208 -0
- yld/archive/reader.py +541 -0
- yld/archive/visualisation.py +278 -0
- yld/bounds.py +140 -0
- yld/edit/__init__.py +84 -0
- yld/edit/handles.py +407 -0
- yld/edit/reconcile.py +132 -0
- yld/edit/transforms.py +1518 -0
- yld/execution.py +174 -0
- yld/outputs.py +49 -0
- yld/problem/__init__.py +45 -0
- yld/problem/boundary_conditions.py +76 -0
- yld/problem/coordinates.py +149 -0
- yld/problem/evaluation.py +21 -0
- yld/problem/fields.py +71 -0
- yld/problem/loads.py +112 -0
- yld/problem/model.py +1955 -0
- yld/problem/naming.py +599 -0
- yld/problem/spatial.py +452 -0
- yld/problem/topology.py +184 -0
- yld/solve_request.py +1076 -0
- yld/solvers.py +100 -0
- yld/targets.py +153 -0
- yld/visualisation/__init__.py +3 -0
- yld/visualisation/bernstein.py +91 -0
- yld/visualisation/glyphs.py +370 -0
- yld/visualisation/tooltip.py +112 -0
- yld/visualisation/viewer.py +355 -0
- yld_run-0.1.0.dist-info/METADATA +165 -0
- yld_run-0.1.0.dist-info/RECORD +36 -0
- yld_run-0.1.0.dist-info/WHEEL +4 -0
- yld_run-0.1.0.dist-info/licenses/LICENSE +21 -0
yld/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sam Stanier
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
yld/__init__.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The public `yld` package: authoring, solve requests, execution client and archive results.
|
|
2
|
+
|
|
3
|
+
This package is **pure Python by construction**. It states problems, materials, spatial fields,
|
|
4
|
+
targets and adaptation recipes; encodes the canonical solve request; submits it to an explicitly
|
|
5
|
+
configured executor; and reads the canonical `.yld` archive the executor returns, including the
|
|
6
|
+
material-parameter fields the analysis actually assembled. It imports no native module, has no
|
|
7
|
+
private dependency, and needs no compiler, CMake or nightly build variable to install or run.
|
|
8
|
+
|
|
9
|
+
Numerical execution — meshing, assembly, the conic solve, adaptation, independent verification and
|
|
10
|
+
archive writing — lives in the proprietary `yld_core` package, which is configured explicitly:
|
|
11
|
+
|
|
12
|
+
import yld, yld_core
|
|
13
|
+
yld.use_executor(yld_core.NativeExecutor())
|
|
14
|
+
|
|
15
|
+
problem = yld.Problem("prandtl")
|
|
16
|
+
... author ...
|
|
17
|
+
|
|
18
|
+
result = yld.solve(problem, yld.FELA_LB_Solver(points=1200, order=3),
|
|
19
|
+
target=yld.Target.strength(), adapt=[yld.adapt.p(order=3)])
|
|
20
|
+
result.multiplier, result.verified
|
|
21
|
+
|
|
22
|
+
Without a configured executor, `yld.solve()` refuses with `yld.NoExecutorError`; there is no default
|
|
23
|
+
executor, no discovery and no environment-variable backend switching.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from . import adapt, archive, bounds, edit, outputs, solve_request
|
|
29
|
+
from .archive import ArchivedResult, open_yld
|
|
30
|
+
from .execution import Executor, NoExecutorError, solve, use_executor
|
|
31
|
+
from .outputs import Output
|
|
32
|
+
from .problem import (
|
|
33
|
+
BondedInterface,
|
|
34
|
+
ConstantField,
|
|
35
|
+
Field,
|
|
36
|
+
Interface,
|
|
37
|
+
LinearField,
|
|
38
|
+
Map,
|
|
39
|
+
MeshFeature,
|
|
40
|
+
MohrCoulomb,
|
|
41
|
+
Problem,
|
|
42
|
+
ProblemLoad,
|
|
43
|
+
Random,
|
|
44
|
+
RigidBody,
|
|
45
|
+
SmoothInterface,
|
|
46
|
+
SplineField,
|
|
47
|
+
Tresca,
|
|
48
|
+
)
|
|
49
|
+
from .solvers import FELA_LB_Solver, FELA_UB_Solver
|
|
50
|
+
from .targets import Target
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ArchivedResult",
|
|
54
|
+
"BondedInterface",
|
|
55
|
+
"ConstantField",
|
|
56
|
+
"Executor",
|
|
57
|
+
"FELA_LB_Solver",
|
|
58
|
+
"FELA_UB_Solver",
|
|
59
|
+
"Field",
|
|
60
|
+
"Interface",
|
|
61
|
+
"LinearField",
|
|
62
|
+
"Map",
|
|
63
|
+
"MeshFeature",
|
|
64
|
+
"MohrCoulomb",
|
|
65
|
+
"NoExecutorError",
|
|
66
|
+
"Output",
|
|
67
|
+
"Problem",
|
|
68
|
+
"ProblemLoad",
|
|
69
|
+
"Random",
|
|
70
|
+
"RigidBody",
|
|
71
|
+
"SmoothInterface",
|
|
72
|
+
"SplineField",
|
|
73
|
+
"Target",
|
|
74
|
+
"Tresca",
|
|
75
|
+
"adapt",
|
|
76
|
+
"archive",
|
|
77
|
+
"bounds",
|
|
78
|
+
"edit",
|
|
79
|
+
"open_yld",
|
|
80
|
+
"outputs",
|
|
81
|
+
"solve",
|
|
82
|
+
"solve_request",
|
|
83
|
+
"use_executor",
|
|
84
|
+
]
|
yld/adapt.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Typed adaptation recipe operations for the yld solver machinery.
|
|
2
|
+
|
|
3
|
+
The recipe is an ordinary Python list; Python is the sequence language. Build
|
|
4
|
+
recipes with list literals, loops and any other Python control flow::
|
|
5
|
+
|
|
6
|
+
from yld.adapt import p, h, r
|
|
7
|
+
|
|
8
|
+
recipe = [p(order=2)]
|
|
9
|
+
for elements in (200, 400, 800):
|
|
10
|
+
recipe += [h(elements=elements), r()]
|
|
11
|
+
recipe += [p(order=3), r(tol=1e-4)]
|
|
12
|
+
|
|
13
|
+
Each helper returns a typed, declarative operation: a value, not a compiled object. It states the
|
|
14
|
+
operation and the numbers the author wrote, and nothing else — the numerical executor translates the
|
|
15
|
+
declarations into its own operations when it runs, so a client can author a recipe without any
|
|
16
|
+
compiled extension and a recipe is part of a solve request like every other declaration.
|
|
17
|
+
|
|
18
|
+
p(*, order) set the polynomial order for subsequent solves
|
|
19
|
+
h(*, elements=None) one h adaptation from the current solution
|
|
20
|
+
r(*, iterations=None, tol=None) r adaptation toward a plateau
|
|
21
|
+
|
|
22
|
+
There is no persistent target-element or r-control state. `elements` belongs
|
|
23
|
+
to the `h()` that consumes it, and `iterations`/`tol` belong to the `r()` that
|
|
24
|
+
consumes them, so an operation never inherits an earlier one's numbers. In
|
|
25
|
+
particular ``h()`` targets the current mesh element count, resolved when the
|
|
26
|
+
step executes:
|
|
27
|
+
|
|
28
|
+
[h(elements=400), r(), h()]
|
|
29
|
+
|
|
30
|
+
means h toward ~400, r to plateau, then h again toward the post-r element
|
|
31
|
+
count, not toward 400 again.
|
|
32
|
+
|
|
33
|
+
`describe()` is the canonical serialised form of a recipe. It is what a solve request carries and
|
|
34
|
+
what a numerical executor translates back into its own operations.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from enum import IntEnum
|
|
41
|
+
from typing import Any, Iterable
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AdaptOp",
|
|
45
|
+
"AdaptOpKind",
|
|
46
|
+
"describe",
|
|
47
|
+
"from_description",
|
|
48
|
+
"h",
|
|
49
|
+
"p",
|
|
50
|
+
"r",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AdaptOpKind(IntEnum):
|
|
55
|
+
"""The three adaptation operations. The values are the recipe's serialised op identity."""
|
|
56
|
+
|
|
57
|
+
SetOrder = 0
|
|
58
|
+
HAdapt = 1
|
|
59
|
+
RAdaptToPlateau = 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: The canonical serialised operation name, by kind.
|
|
63
|
+
_OP_BY_VALUE = {0: "p", 1: "h", 2: "r"}
|
|
64
|
+
_KIND_BY_NAME = {name: AdaptOpKind(value) for value, name in _OP_BY_VALUE.items()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AdaptOp:
|
|
69
|
+
"""One declarative adaptation operation.
|
|
70
|
+
|
|
71
|
+
A value, not a command: it is compared, printed, described and transported as ordinary data. The
|
|
72
|
+
executor decides what the operation does to its own mesh.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
kind: AdaptOpKind
|
|
76
|
+
order: int = 1
|
|
77
|
+
elements: int | None = None
|
|
78
|
+
iterations: int | None = None
|
|
79
|
+
tol: float | None = None
|
|
80
|
+
|
|
81
|
+
def __post_init__(self) -> None:
|
|
82
|
+
object.__setattr__(self, "kind", AdaptOpKind(self.kind))
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
"""The operation's canonical serialised name: `p`, `h` or `r`."""
|
|
87
|
+
|
|
88
|
+
return _OP_BY_VALUE[int(self.kind)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _op(kind: AdaptOpKind, *, order: int = 1, elements=None, iterations=None, tol=None) -> AdaptOp:
|
|
92
|
+
return AdaptOp(
|
|
93
|
+
kind=kind, order=int(order), elements=elements, iterations=iterations, tol=tol
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def p(*, order: int):
|
|
98
|
+
"""Set the polynomial order used by subsequent solves.
|
|
99
|
+
|
|
100
|
+
``order`` is keyword-only, so ``p(3)`` is a ``TypeError``. Changing the
|
|
101
|
+
order invalidates the current answer, but does not itself force a solve.
|
|
102
|
+
"""
|
|
103
|
+
if int(order) < 1:
|
|
104
|
+
raise ValueError("p(order=...) needs order >= 1")
|
|
105
|
+
return _op(AdaptOpKind.SetOrder, order=int(order))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def h(*, elements: int | None = None):
|
|
109
|
+
"""Perform one h adaptation from the current solution.
|
|
110
|
+
|
|
111
|
+
``elements`` is keyword-only. When it is omitted the step targets the
|
|
112
|
+
current mesh element count, resolved at execution time; when it is given it
|
|
113
|
+
targets approximately that many elements for this step only.
|
|
114
|
+
"""
|
|
115
|
+
if elements is not None and int(elements) < 1:
|
|
116
|
+
raise ValueError("h(elements=...) needs elements >= 1")
|
|
117
|
+
return _op(AdaptOpKind.HAdapt, elements=None if elements is None else int(elements))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def r(*, iterations: int | None = None, tol: float | None = None):
|
|
121
|
+
"""Run r adaptation toward a plateau.
|
|
122
|
+
|
|
123
|
+
``iterations`` caps the number of outer r iterations and ``tol`` is the
|
|
124
|
+
relative objective improvement below which the plateau criterion is
|
|
125
|
+
satisfied; both are keyword-only and optional. An ``iterations`` budget is
|
|
126
|
+
a maximum: the step may stop earlier when it has already converged.
|
|
127
|
+
"""
|
|
128
|
+
if iterations is not None and int(iterations) < 1:
|
|
129
|
+
raise ValueError("r(iterations=...) needs iterations >= 1")
|
|
130
|
+
if tol is not None and not float(tol) > 0.0:
|
|
131
|
+
raise ValueError("r(tol=...) needs tol > 0")
|
|
132
|
+
return _op(
|
|
133
|
+
AdaptOpKind.RAdaptToPlateau,
|
|
134
|
+
iterations=None if iterations is None else int(iterations),
|
|
135
|
+
tol=None if tol is None else float(tol),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def describe(recipe: Iterable[Any]) -> list[dict[str, Any]]:
|
|
140
|
+
"""Canonical serialisable form of a recipe.
|
|
141
|
+
|
|
142
|
+
One entry per operation, in order, carrying only the numbers that operation states. An omitted
|
|
143
|
+
optional number is absent rather than null, so ``h()`` and ``h(elements=None)`` describe
|
|
144
|
+
identically — they are the same operation.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
described: list[dict[str, Any]] = []
|
|
148
|
+
for operation in recipe:
|
|
149
|
+
if not isinstance(operation, AdaptOp):
|
|
150
|
+
raise TypeError(
|
|
151
|
+
f"{operation!r} is not an adaptation operation; build a recipe from "
|
|
152
|
+
"yld.adapt.p(), h() and r()"
|
|
153
|
+
)
|
|
154
|
+
entry: dict[str, Any] = {"op": operation.name}
|
|
155
|
+
if operation.kind == AdaptOpKind.SetOrder:
|
|
156
|
+
entry["order"] = int(operation.order)
|
|
157
|
+
elif operation.kind == AdaptOpKind.HAdapt and operation.elements is not None:
|
|
158
|
+
entry["elements"] = int(operation.elements)
|
|
159
|
+
elif operation.kind == AdaptOpKind.RAdaptToPlateau:
|
|
160
|
+
if operation.iterations is not None:
|
|
161
|
+
entry["iterations"] = int(operation.iterations)
|
|
162
|
+
if operation.tol is not None:
|
|
163
|
+
entry["tol"] = float(operation.tol)
|
|
164
|
+
described.append(entry)
|
|
165
|
+
return described
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def from_description(described: Iterable[Any]) -> list[AdaptOp]:
|
|
169
|
+
"""The recipe a `describe()` list states, rebuilt as ordinary declarations.
|
|
170
|
+
|
|
171
|
+
This is the inverse of :func:`describe` and the only place a serialised recipe becomes an
|
|
172
|
+
authorable one. An unknown operation name or an unstated required number is refused by name.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
recipe: list[AdaptOp] = []
|
|
176
|
+
for index, raw in enumerate(described):
|
|
177
|
+
if not isinstance(raw, dict):
|
|
178
|
+
raise ValueError(f"adapt[{index}]: expected an object, got {type(raw).__name__}")
|
|
179
|
+
name = raw.get("op")
|
|
180
|
+
if name not in _KIND_BY_NAME:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"adapt[{index}].op: {name!r} is not an adaptation operation; the known operations "
|
|
183
|
+
f"are {', '.join(sorted(_KIND_BY_NAME))}"
|
|
184
|
+
)
|
|
185
|
+
kind = _KIND_BY_NAME[name]
|
|
186
|
+
known = {"op", "order", "elements", "iterations", "tol"}
|
|
187
|
+
unknown = sorted(set(raw) - known)
|
|
188
|
+
if unknown:
|
|
189
|
+
raise ValueError(f"adapt[{index}]: {name} states no member {unknown}")
|
|
190
|
+
try:
|
|
191
|
+
if kind == AdaptOpKind.SetOrder:
|
|
192
|
+
recipe.append(p(order=int(raw["order"])))
|
|
193
|
+
elif kind == AdaptOpKind.HAdapt:
|
|
194
|
+
elements = raw.get("elements")
|
|
195
|
+
recipe.append(h(elements=None if elements is None else int(elements)))
|
|
196
|
+
else:
|
|
197
|
+
iterations = raw.get("iterations")
|
|
198
|
+
tol = raw.get("tol")
|
|
199
|
+
recipe.append(
|
|
200
|
+
r(
|
|
201
|
+
iterations=None if iterations is None else int(iterations),
|
|
202
|
+
tol=None if tol is None else float(tol),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
except KeyError as error:
|
|
206
|
+
raise ValueError(f"adapt[{index}]: {name} needs {error.args[0]!r}") from None
|
|
207
|
+
except (TypeError, ValueError) as error:
|
|
208
|
+
raise ValueError(f"adapt[{index}]: {error}") from None
|
|
209
|
+
return recipe
|
yld/archive/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The `.yld` archive: one container, one manifest, native numerical members.
|
|
2
|
+
|
|
3
|
+
`architecture/YLD_ARCHIVE.md` is the specification. What the **public** package holds is everything
|
|
4
|
+
needed to read an archive without executing anything:
|
|
5
|
+
|
|
6
|
+
* `container` -- the zip, the append-only member table and canonical JSON (one writer);
|
|
7
|
+
* `reader` -- `open_yld`: validate, then report, in pure Python and without solving anything;
|
|
8
|
+
* `visualisation` -- one PyVista scene builder for archived Bernstein fields.
|
|
9
|
+
|
|
10
|
+
There is one archive format, `format_version` 1: the shared container, the manifest that describes
|
|
11
|
+
it, and canonical result records. The producers are private, because producing one requires the
|
|
12
|
+
numerical side: `yld_core.archive_writer` writes a Bernstein FELA analysis, and
|
|
13
|
+
`yld_core.FeaResult.save` writes an incremental history into the same layout.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .container import (
|
|
17
|
+
FORMAT_VERSION,
|
|
18
|
+
MANIFEST,
|
|
19
|
+
PROBLEM_SOURCE,
|
|
20
|
+
SOLVE_REQUEST,
|
|
21
|
+
ArchiveError,
|
|
22
|
+
ArchiveWriter,
|
|
23
|
+
)
|
|
24
|
+
from .reader import ArchivedMember, ArchivedResult, open_yld
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ArchiveError",
|
|
28
|
+
"ArchiveWriter",
|
|
29
|
+
"ArchivedMember",
|
|
30
|
+
"ArchivedResult",
|
|
31
|
+
"FORMAT_VERSION",
|
|
32
|
+
"MANIFEST",
|
|
33
|
+
"PROBLEM_SOURCE",
|
|
34
|
+
"SOLVE_REQUEST",
|
|
35
|
+
"open_yld",
|
|
36
|
+
]
|
yld/archive/container.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The `.yld` container: one zip, an append-only member table and canonical metadata.
|
|
2
|
+
|
|
3
|
+
`architecture/YLD_ARCHIVE.md` fixes the container. What lives here is the part every producer
|
|
4
|
+
shares, so there is exactly one implementation of it:
|
|
5
|
+
|
|
6
|
+
* member names are fixed by the specification, and each member is written **once** (an archive is
|
|
7
|
+
evidence, so it is never rewritten in place);
|
|
8
|
+
* every member's bytes are hashed as they are written, in one pass, so the manifest's
|
|
9
|
+
`(name, sha256, bytes)` table describes exactly the bytes the container holds;
|
|
10
|
+
* the container records no wall-clock time -- two writings of one result have to be the same bytes
|
|
11
|
+
(Q30) -- so every member carries the same fixed timestamp;
|
|
12
|
+
* publication is atomic (write `*.partial`, then replace), and an existing archive is refused
|
|
13
|
+
rather than overwritten.
|
|
14
|
+
|
|
15
|
+
Format version 1 is the single supported layout: `manifest.json`, the structured solve request, the
|
|
16
|
+
optional authored script, and one member per accepted result and native degree. A reader supports
|
|
17
|
+
exactly that version; a writer states it once.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import io
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import re
|
|
29
|
+
import zipfile
|
|
30
|
+
|
|
31
|
+
#: The `.yld` archive format this build writes, and the only one it reads. One integer, as
|
|
32
|
+
#: `architecture/YLD_ARCHIVE.md` §10 states: version 1 is the initial contract, and a reader refuses
|
|
33
|
+
#: any other number, naming what it found. An incompatible change to the layout is a new number and
|
|
34
|
+
#: a deliberate break, never a second layout behind the same one.
|
|
35
|
+
FORMAT_VERSION = 1
|
|
36
|
+
|
|
37
|
+
MANIFEST = "manifest.json"
|
|
38
|
+
SOLVE_REQUEST = "solve-request.json"
|
|
39
|
+
PROBLEM_SOURCE = "problem.py"
|
|
40
|
+
RESULTS = "results"
|
|
41
|
+
#: `results/step-NNNN.vtu`: one member per committed FEA increment. The member name is the
|
|
42
|
+
#: producer's; what the archive *means* is stated by its result records, not by a name.
|
|
43
|
+
INCREMENT_MEMBER = RESULTS + "/step-{:04d}.vtu"
|
|
44
|
+
#: `results/result-NNNN-orderK.vtu`: one member per accepted FELA result **and native degree**.
|
|
45
|
+
RESULT_MEMBER = RESULTS + "/result-{:04d}-order{}.vtu"
|
|
46
|
+
#: `results/result-NNNN-parameter-<name>-aA-orderK.vtu`: one member per accepted result's
|
|
47
|
+
#: material-parameter field, per parameter, **material attribute** and degree. Its cells are the
|
|
48
|
+
#: exact polynomial pieces of the strength field the assembly consumed, not a resampled plot of it.
|
|
49
|
+
#: The attribute is part of the name because two materials may declare the same parameter name.
|
|
50
|
+
PARAMETER_MEMBER = RESULTS + "/result-{:04d}-parameter-{}-a{}-order{}.vtu"
|
|
51
|
+
|
|
52
|
+
#: The DOS epoch. A fixed timestamp is what makes two writings of one result the same bytes.
|
|
53
|
+
TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
|
54
|
+
#: A block size for streaming a member into the container without holding it in memory.
|
|
55
|
+
BLOCK = 1 << 20
|
|
56
|
+
#: How much of a member's XML header is scanned for its declared arrays. The header of an
|
|
57
|
+
#: appended-raw `.vtu` is a few kilobytes; the limit only bounds the scan.
|
|
58
|
+
HEADER_LIMIT = 1 << 20
|
|
59
|
+
#: A named `Float64` array, read from a member's own XML header: the quantities it carries are what
|
|
60
|
+
#: the member holds, never what the writer meant to hold.
|
|
61
|
+
ARRAY_NAME = re.compile(rb'<DataArray type="Float64" Name="([^"]+)"')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def finite(value) -> float | None:
|
|
65
|
+
"""A JSON-safe reading of a measured number: `null` where the number does not exist.
|
|
66
|
+
|
|
67
|
+
`json.dumps(..., allow_nan=False)` is deliberate -- a bare `NaN` is not valid JSON and an archive
|
|
68
|
+
that cannot be parsed is not evidence -- so a non-finite measurement is recorded as `null`, never
|
|
69
|
+
as a fabricated zero.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
number = float(value)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
return number if math.isfinite(number) else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def member_arrays(header: bytes) -> list[str]:
|
|
80
|
+
"""The named `Float64` arrays a member declares, in emission order.
|
|
81
|
+
|
|
82
|
+
The mesh's own arrays are not quantities: `Points` carries no name and
|
|
83
|
+
`connectivity`/`offsets`/`types` are integer types, so the member's structure never appears in
|
|
84
|
+
the inventory as data.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
return sorted({found.decode("utf-8") for found in ARRAY_NAME.findall(header)})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def add_member(archive: zipfile.ZipFile, name: str, blocks, header: bytearray | None = None) -> dict:
|
|
91
|
+
"""Write one member once, and describe exactly the bytes that were stored.
|
|
92
|
+
|
|
93
|
+
The digest and the length are computed over the same bytes the container holds -- one pass, one
|
|
94
|
+
encoding -- so the member table cannot describe something other than the member. `header`, when
|
|
95
|
+
given, gathers the member's XML prefix for the caller's quantity inventory.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
info = zipfile.ZipInfo(name, date_time=TIMESTAMP)
|
|
99
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
100
|
+
info.external_attr = 0o644 << 16
|
|
101
|
+
digest = hashlib.sha256()
|
|
102
|
+
length = 0
|
|
103
|
+
with archive.open(info, "w") as member:
|
|
104
|
+
for block in blocks:
|
|
105
|
+
if header is not None and len(header) < HEADER_LIMIT:
|
|
106
|
+
header += block[: HEADER_LIMIT - len(header)]
|
|
107
|
+
digest.update(block)
|
|
108
|
+
member.write(block)
|
|
109
|
+
length += len(block)
|
|
110
|
+
return {"name": name, "sha256": digest.hexdigest(), "bytes": length}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def file_blocks(path: Path, header: bytearray | None = None):
|
|
114
|
+
"""A file's bytes in blocks, gathering the XML header its arrays are read from."""
|
|
115
|
+
|
|
116
|
+
with open(path, "rb") as handle:
|
|
117
|
+
while True:
|
|
118
|
+
block = handle.read(BLOCK)
|
|
119
|
+
if not block:
|
|
120
|
+
return
|
|
121
|
+
if header is not None and len(header) < HEADER_LIMIT:
|
|
122
|
+
header += block[: HEADER_LIMIT - len(header)]
|
|
123
|
+
yield block
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def canonical_json(document: dict) -> str:
|
|
127
|
+
"""The container's one JSON encoding: sorted keys, two-space indent, no NaN, a final newline."""
|
|
128
|
+
|
|
129
|
+
return json.dumps(document, sort_keys=True, indent=2, ensure_ascii=True, allow_nan=False) + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ArchiveError(ValueError):
|
|
133
|
+
"""An archive cannot be written or read as the specification states it."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ArchiveWriter:
|
|
137
|
+
"""One `.yld` archive, published atomically -- to a file, or to memory.
|
|
138
|
+
|
|
139
|
+
`target` must not exist: an archive is written once. Members are added in the order the
|
|
140
|
+
specification puts them (the problem record, then results), and `publish` appends the manifest
|
|
141
|
+
last, so the manifest can describe every other member without describing itself.
|
|
142
|
+
|
|
143
|
+
With `target=None` the archive is built in memory and `publish` leaves its bytes in `data`.
|
|
144
|
+
That is how an executor returns "archive bytes": a service hands the same bytes to a response
|
|
145
|
+
body, and no temporary file stands between a result and its reader.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, target: Path | None):
|
|
149
|
+
self._table: list[dict] = []
|
|
150
|
+
self._published = False
|
|
151
|
+
if target is None:
|
|
152
|
+
self.target = None
|
|
153
|
+
self._partial = None
|
|
154
|
+
self._buffer = io.BytesIO()
|
|
155
|
+
self._archive = zipfile.ZipFile(self._buffer, "w")
|
|
156
|
+
self.data: bytes | None = None
|
|
157
|
+
return
|
|
158
|
+
self.data = None
|
|
159
|
+
self.target = Path(target)
|
|
160
|
+
if self.target.exists():
|
|
161
|
+
raise FileExistsError(
|
|
162
|
+
f"{self.target} already exists; a `.yld` archive is written once, because its "
|
|
163
|
+
f"members are append-only and an existing archive is evidence rather than a draft"
|
|
164
|
+
)
|
|
165
|
+
self._partial = self.target.with_name(self.target.name + ".partial")
|
|
166
|
+
self._buffer = None
|
|
167
|
+
self._archive = zipfile.ZipFile(self._partial, "w")
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> "ArchiveWriter":
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
173
|
+
self.close(published=exc_type is None and self._published)
|
|
174
|
+
|
|
175
|
+
def add(self, name: str, blocks, header: bytearray | None = None) -> dict:
|
|
176
|
+
entry = add_member(self._archive, name, blocks, header)
|
|
177
|
+
self._table.append(entry)
|
|
178
|
+
return entry
|
|
179
|
+
|
|
180
|
+
def add_bytes(self, name: str, data: bytes, header: bytearray | None = None) -> dict:
|
|
181
|
+
return self.add(name, [data], header)
|
|
182
|
+
|
|
183
|
+
def publish(self, manifest: dict) -> None:
|
|
184
|
+
"""Write `manifest.json` last, with the member table it describes."""
|
|
185
|
+
|
|
186
|
+
document = dict(manifest)
|
|
187
|
+
# Sorted by member name; the manifest cannot list itself, because a table cannot hold its
|
|
188
|
+
# own digest.
|
|
189
|
+
document["members"] = sorted(self._table, key=lambda entry: entry["name"])
|
|
190
|
+
self.add_bytes(MANIFEST, canonical_json(document).encode("utf-8"))
|
|
191
|
+
self._published = True
|
|
192
|
+
self.close(published=True)
|
|
193
|
+
|
|
194
|
+
def close(self, *, published: bool) -> None:
|
|
195
|
+
if self._archive is None:
|
|
196
|
+
return
|
|
197
|
+
self._archive.close()
|
|
198
|
+
self._archive = None
|
|
199
|
+
if self._buffer is not None:
|
|
200
|
+
if published:
|
|
201
|
+
self.data = self._buffer.getvalue()
|
|
202
|
+
self._buffer.close()
|
|
203
|
+
self._buffer = None
|
|
204
|
+
return
|
|
205
|
+
if published:
|
|
206
|
+
os.replace(self._partial, self.target)
|
|
207
|
+
elif self._partial.exists():
|
|
208
|
+
self._partial.unlink()
|