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 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
@@ -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
+ ]
@@ -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()