aind-metadata-reader 0.2.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.
- aind_metadata_reader/__init__.py +63 -0
- aind_metadata_reader/_enum.py +10 -0
- aind_metadata_reader/_wire.py +84 -0
- aind_metadata_reader/applicability.py +132 -0
- aind_metadata_reader/components.py +125 -0
- aind_metadata_reader/diagnostics.py +38 -0
- aind_metadata_reader/document.py +399 -0
- aind_metadata_reader/formats.py +171 -0
- aind_metadata_reader/inspection.py +109 -0
- aind_metadata_reader/interpretation.py +478 -0
- aind_metadata_reader/py.typed +1 -0
- aind_metadata_reader/readers.py +541 -0
- aind_metadata_reader/smartspim.py +214 -0
- aind_metadata_reader/validation.py +89 -0
- aind_metadata_reader-0.2.0.dist-info/METADATA +396 -0
- aind_metadata_reader-0.2.0.dist-info/RECORD +18 -0
- aind_metadata_reader-0.2.0.dist-info/WHEEL +4 -0
- aind_metadata_reader-0.2.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Conservative SmartSPIM metadata interpretations, selected explicitly by callers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from aind_metadata_reader.applicability import Applicability, RuleEvaluation
|
|
11
|
+
from aind_metadata_reader.components import ArtifactReference, references
|
|
12
|
+
from aind_metadata_reader.document import Document
|
|
13
|
+
from aind_metadata_reader.inspection import ReadStatus, ReadValue, unavailable
|
|
14
|
+
from aind_metadata_reader.interpretation import Change
|
|
15
|
+
from aind_metadata_reader.readers import Process
|
|
16
|
+
|
|
17
|
+
_RULE = "smartspim.LS-17"
|
|
18
|
+
_REVISION = "1"
|
|
19
|
+
# Exact component versions observed in retained production captures. No known fix
|
|
20
|
+
# boundary exists: neither pipeline nor registration versions substitute for these.
|
|
21
|
+
_OBSERVED_FUSION_VERSIONS = frozenset(("0.0.2", "0.0.4"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _vector(value: Any, size: int, *, positive: bool = False) -> list[float]:
|
|
25
|
+
if not isinstance(value, list) or len(value) != size:
|
|
26
|
+
raise ValueError("Transform vector has the wrong dimensionality")
|
|
27
|
+
if any(type(v) not in (int, float) or not math.isfinite(v) or (positive and v <= 0) for v in value):
|
|
28
|
+
raise ValueError("Transform vector must contain finite numbers with valid signs")
|
|
29
|
+
return [float(v) for v in value]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _transforms(value: Any, size: int) -> tuple[list[float], list[float], bool]:
|
|
33
|
+
if not isinstance(value, list) or not 1 <= len(value) <= 2:
|
|
34
|
+
raise ValueError("Expected a scale followed by an optional translation")
|
|
35
|
+
scale = value[0]
|
|
36
|
+
if not isinstance(scale, dict) or set(scale) != {"type", "scale"} or scale["type"] != "scale":
|
|
37
|
+
raise ValueError("Unsupported transform composition")
|
|
38
|
+
spacing = _vector(scale["scale"], size, positive=True)
|
|
39
|
+
if len(value) == 1:
|
|
40
|
+
return spacing, [0.0] * size, False
|
|
41
|
+
translation = value[1]
|
|
42
|
+
if (
|
|
43
|
+
not isinstance(translation, dict)
|
|
44
|
+
or set(translation) != {"type", "translation"}
|
|
45
|
+
or translation["type"] != "translation"
|
|
46
|
+
):
|
|
47
|
+
raise ValueError("Unsupported transform composition")
|
|
48
|
+
return spacing, _vector(translation["translation"], size), True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _close(left: list[float], right: list[float]) -> bool:
|
|
52
|
+
return all(math.isclose(a, b, rel_tol=1e-10, abs_tol=1e-12) for a, b in zip(left, right, strict=True))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _geometry(attributes: Document) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
|
|
56
|
+
"""Recognize only the observed NGFF 0.4, dyadic block-mean geometry."""
|
|
57
|
+
multiscales = attributes.raw.get("multiscales")
|
|
58
|
+
if not isinstance(multiscales, list) or len(multiscales) != 1 or not isinstance(multiscales[0], dict):
|
|
59
|
+
raise ValueError("Expected one multiscale image")
|
|
60
|
+
image = multiscales[0]
|
|
61
|
+
if image.get("version") != "0.4":
|
|
62
|
+
raise ValueError("Only the observed NGFF 0.4 representation is supported")
|
|
63
|
+
axes = image.get("axes")
|
|
64
|
+
if not isinstance(axes, list) or any(not isinstance(axis, dict) for axis in axes):
|
|
65
|
+
raise ValueError("Expected explicit axis objects")
|
|
66
|
+
names = [axis.get("name") for axis in axes]
|
|
67
|
+
if len(names) != len(set(names)) or set(names) != {"t", "c", "z", "y", "x"}:
|
|
68
|
+
raise ValueError("Expected distinct t, c, z, y, x axes")
|
|
69
|
+
spatial = [i for i, axis in enumerate(axes) if axis.get("type") == "space"]
|
|
70
|
+
if {names[i] for i in spatial} != {"x", "y", "z"} or any(axes[i].get("unit") != "micrometer" for i in spatial):
|
|
71
|
+
raise ValueError("Expected three spatial axes in micrometers")
|
|
72
|
+
global_transforms = image.get("coordinateTransformations")
|
|
73
|
+
if global_transforms is not None:
|
|
74
|
+
global_scale, _, global_translation = _transforms(global_transforms, len(axes))
|
|
75
|
+
if global_translation or global_scale != [1.0] * len(axes):
|
|
76
|
+
raise ValueError("Nonidentity image-level transforms need a different rule")
|
|
77
|
+
datasets = image.get("datasets")
|
|
78
|
+
if not isinstance(datasets, list) or not datasets or any(not isinstance(d, dict) for d in datasets):
|
|
79
|
+
raise ValueError("Expected nonempty pyramid datasets")
|
|
80
|
+
if [d.get("path") for d in datasets] != [str(i) for i in range(len(datasets))]:
|
|
81
|
+
raise ValueError("Expected the observed contiguous level paths starting at zero")
|
|
82
|
+
vectors = [_transforms(d.get("coordinateTransformations"), len(axes)) for d in datasets]
|
|
83
|
+
base_scale, origin, _ = vectors[0]
|
|
84
|
+
replacements = []
|
|
85
|
+
for level, (scale, translation, has_translation) in enumerate(vectors):
|
|
86
|
+
ratio = 2**level
|
|
87
|
+
expected_scale = [s * ratio if i in spatial else s for i, s in enumerate(base_scale)]
|
|
88
|
+
if not _close(scale, expected_scale):
|
|
89
|
+
raise ValueError("Pyramid scales do not match the observed dyadic spatial reduction")
|
|
90
|
+
offset = (ratio - 1) / 2
|
|
91
|
+
physical = [o + offset * base_scale[i] if i in spatial else o for i, o in enumerate(origin)]
|
|
92
|
+
voxel_counts = [o + offset if i in spatial else o for i, o in enumerate(origin)]
|
|
93
|
+
if not _close(translation, physical):
|
|
94
|
+
missing = not has_translation and _close(origin, [0.0] * len(axes))
|
|
95
|
+
if not missing and not (has_translation and _close(translation, voxel_counts)):
|
|
96
|
+
raise ValueError("Stored translations match neither corrected geometry nor an observed LS-17 signature")
|
|
97
|
+
replacements.append([{"type": "scale", "scale": scale}, {"type": "translation", "translation": physical}])
|
|
98
|
+
return datasets, replacements
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def evaluate_ls17(
|
|
102
|
+
attributes: Document,
|
|
103
|
+
producer: Process,
|
|
104
|
+
*,
|
|
105
|
+
author: str,
|
|
106
|
+
association_evidence: tuple[str, ...] = (),
|
|
107
|
+
averaging_evidence: tuple[str, ...] = (),
|
|
108
|
+
) -> RuleEvaluation:
|
|
109
|
+
"""Propose physical translations only for evidenced, known-buggy fused outputs.
|
|
110
|
+
|
|
111
|
+
Callers must attest that the selected process produced this artifact and that
|
|
112
|
+
its pyramid uses block averaging. A matching channel basename is an additional
|
|
113
|
+
check, not proof of association. Evaluation performs no IO or application.
|
|
114
|
+
"""
|
|
115
|
+
evidence = (
|
|
116
|
+
f"artifact {attributes.snapshot.content_digest}{attributes.pointer}",
|
|
117
|
+
f"producer {producer.document.snapshot.content_digest}{producer.document.pointer}",
|
|
118
|
+
*association_evidence,
|
|
119
|
+
*averaging_evidence,
|
|
120
|
+
)
|
|
121
|
+
inputs = (attributes.reference(), producer.document.reference())
|
|
122
|
+
readers = () if producer.reader is None else (producer.reader.record_for(producer.document),)
|
|
123
|
+
implementation = "aind_metadata_reader.smartspim.evaluate_ls17/1"
|
|
124
|
+
|
|
125
|
+
def decision(outcome: Applicability, reason: str, changes: tuple[Change, ...] = ()) -> RuleEvaluation:
|
|
126
|
+
return RuleEvaluation(_RULE, _REVISION, outcome, reason, evidence, changes, inputs, readers, implementation)
|
|
127
|
+
|
|
128
|
+
if producer.process_type != "Image tile fusing":
|
|
129
|
+
return decision(Applicability.UNKNOWN, "The selected process does not identify the fusion producer")
|
|
130
|
+
if producer.code_version not in _OBSERVED_FUSION_VERSIONS:
|
|
131
|
+
return decision(Applicability.UNKNOWN, "Fusion component version is missing or outside the observed buggy set")
|
|
132
|
+
if (
|
|
133
|
+
not association_evidence
|
|
134
|
+
or not averaging_evidence
|
|
135
|
+
or any(not isinstance(e, str) or not e.strip() for e in (*association_evidence, *averaging_evidence))
|
|
136
|
+
):
|
|
137
|
+
return decision(Applicability.UNKNOWN, "Artifact association and block-averaging evidence are required")
|
|
138
|
+
locator = attributes.snapshot.source.locator
|
|
139
|
+
output = producer.output_location
|
|
140
|
+
if not locator or not output:
|
|
141
|
+
return decision(Applicability.UNKNOWN, "Artifact locator and producer output path are required")
|
|
142
|
+
try:
|
|
143
|
+
artifact_path = PurePosixPath(urlsplit(locator).path)
|
|
144
|
+
channel = PurePosixPath(urlsplit(output).path).name
|
|
145
|
+
except ValueError:
|
|
146
|
+
return decision(Applicability.UNKNOWN, "Artifact or producer output locator is malformed")
|
|
147
|
+
if artifact_path.name != ".zattrs" or artifact_path.parent.name != channel or not channel.endswith(".zarr"):
|
|
148
|
+
return decision(Applicability.UNKNOWN, "Artifact channel and producer output path disagree")
|
|
149
|
+
try:
|
|
150
|
+
datasets, replacements = _geometry(attributes)
|
|
151
|
+
except (ValueError, TypeError, OverflowError) as error:
|
|
152
|
+
return decision(Applicability.UNKNOWN, str(error))
|
|
153
|
+
changes = []
|
|
154
|
+
# Level zero establishes the origin. It must never acquire a centering offset.
|
|
155
|
+
for level, (dataset, replacement) in enumerate(zip(datasets, replacements, strict=True)):
|
|
156
|
+
if level == 0:
|
|
157
|
+
continue
|
|
158
|
+
_, recorded, _ = _transforms(dataset["coordinateTransformations"], len(replacement[0]["scale"]))
|
|
159
|
+
if _close(recorded, replacement[1]["translation"]):
|
|
160
|
+
continue
|
|
161
|
+
changes.append(
|
|
162
|
+
Change.propose(
|
|
163
|
+
attributes,
|
|
164
|
+
f"/multiscales/0/datasets/{level}/coordinateTransformations",
|
|
165
|
+
replacement,
|
|
166
|
+
operation_id=f"{_RULE}/{_REVISION}/level-{level}",
|
|
167
|
+
rule_id=_RULE,
|
|
168
|
+
rule_revision=_REVISION,
|
|
169
|
+
implementation=implementation,
|
|
170
|
+
kind="correction",
|
|
171
|
+
author=author,
|
|
172
|
+
reason="Place block-averaged voxel centers in the declared physical units",
|
|
173
|
+
evidence=evidence,
|
|
174
|
+
scope=f"Physical geometry of fused pyramid level {level}; not a historical registration frame",
|
|
175
|
+
references=inputs,
|
|
176
|
+
readers=readers,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
if not changes:
|
|
180
|
+
return decision(
|
|
181
|
+
Applicability.NOT_APPLICABLE, "Stored translations already match the block-averaged physical geometry"
|
|
182
|
+
)
|
|
183
|
+
return decision(
|
|
184
|
+
Applicability.APPLICABLE,
|
|
185
|
+
"Known fusion version and observed LS-17 signature with explicit evidence",
|
|
186
|
+
tuple(changes),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def registration_input(process: Process) -> ReadValue[tuple[ArtifactReference, ...]]:
|
|
191
|
+
"""Inspect evidenced file inputs to the SmartSPIM registration import step.
|
|
192
|
+
|
|
193
|
+
Version 0.0.37 records this input in output_parameters.input_location.
|
|
194
|
+
This observed convention is not extrapolated to unverified producer versions
|
|
195
|
+
or interpreted as a generic Code input_data asset-to-file conversion.
|
|
196
|
+
"""
|
|
197
|
+
if process.process_type != "Image importing":
|
|
198
|
+
return unavailable(
|
|
199
|
+
process.document.field(""), ReadStatus.UNSUPPORTED, "Process is not a registration import step"
|
|
200
|
+
)
|
|
201
|
+
inputs = process.inputs
|
|
202
|
+
if inputs.status == ReadStatus.AVAILABLE and inputs.value and all(r.kind == "file" for r in inputs.value):
|
|
203
|
+
return inputs
|
|
204
|
+
repository = (process.code_url or "").rstrip("/").removesuffix(".git")
|
|
205
|
+
if (
|
|
206
|
+
process.code_version == "0.0.37"
|
|
207
|
+
and repository == "https://github.com/AllenNeuralDynamics/aind-smartspim-ccf-registration"
|
|
208
|
+
):
|
|
209
|
+
return references(process.document.field("/output_parameters/input_location"), "paths")
|
|
210
|
+
return unavailable(
|
|
211
|
+
process.document.field(""),
|
|
212
|
+
ReadStatus.UNSUPPORTED,
|
|
213
|
+
"Producer evidence does not establish a file-level registration input",
|
|
214
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Optional validation reports never gate inspection or alter historical metadata."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typing import Literal, NoReturn
|
|
7
|
+
|
|
8
|
+
from aind_metadata_reader.diagnostics import Diagnostic, FieldState
|
|
9
|
+
from aind_metadata_reader.document import Document, Field, SnapshotReference, encode_segment
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ValidationReport:
|
|
14
|
+
"""A scoped JSON Schema result, not an upstream-model or scientific validity claim."""
|
|
15
|
+
|
|
16
|
+
document: SnapshotReference
|
|
17
|
+
status: Literal["not_checked", "valid", "invalid", "unavailable"]
|
|
18
|
+
schema: SnapshotReference | None = None
|
|
19
|
+
validator: str | None = None
|
|
20
|
+
diagnostics: tuple[Diagnostic, ...] = ()
|
|
21
|
+
dialect: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _no_remote(uri: str) -> NoReturn:
|
|
25
|
+
raise LookupError(f"External schema reference is not supplied: {uri}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate(
|
|
29
|
+
document: Document | Field, schema: Document | None = None, *, dialect: str | None = None
|
|
30
|
+
) -> ValidationReport:
|
|
31
|
+
"""Check a supplied historical JSON Schema without fetching, filling defaults, or coercing.
|
|
32
|
+
|
|
33
|
+
No schema means not checked. Missing validator support, an unusable schema,
|
|
34
|
+
or unresolved references mean unavailable, not invalid metadata. Custom
|
|
35
|
+
Pydantic model validators and scientific correctness are outside this report.
|
|
36
|
+
"""
|
|
37
|
+
reference = SnapshotReference(
|
|
38
|
+
document.snapshot.snapshot_id,
|
|
39
|
+
document.snapshot.content_digest,
|
|
40
|
+
document.snapshot.original_digest,
|
|
41
|
+
document.snapshot.source,
|
|
42
|
+
document.pointer,
|
|
43
|
+
)
|
|
44
|
+
if schema is None:
|
|
45
|
+
return ValidationReport(reference, "not_checked")
|
|
46
|
+
try:
|
|
47
|
+
if isinstance(document, Field) and document.state == FieldState.ABSENT:
|
|
48
|
+
raise ValueError("An absent field is not a serialized instance to validate")
|
|
49
|
+
package = import_module("jsonschema")
|
|
50
|
+
referencing = import_module("referencing")
|
|
51
|
+
declared = schema.raw.get("$schema")
|
|
52
|
+
if dialect is not None and declared is not None and dialect != declared:
|
|
53
|
+
raise ValueError("Supplied dialect conflicts with the schema's declaration")
|
|
54
|
+
selected = declared or dialect
|
|
55
|
+
checker = package.validators.validator_for({"$schema": selected}, default=None) if selected else None
|
|
56
|
+
if checker is None:
|
|
57
|
+
# Upstream exported schemas can omit $schema. Their caller must select
|
|
58
|
+
# a dialect explicitly rather than inheriting the installed latest.
|
|
59
|
+
raise ValueError("Schema must declare a supported $schema dialect")
|
|
60
|
+
checker.check_schema(schema.raw)
|
|
61
|
+
validator = checker(schema.raw, registry=referencing.Registry(retrieve=_no_remote))
|
|
62
|
+
errors = tuple(validator.iter_errors(document.raw if isinstance(document, Document) else document.value))
|
|
63
|
+
issues = tuple(
|
|
64
|
+
Diagnostic(
|
|
65
|
+
"schema_validation",
|
|
66
|
+
error.message,
|
|
67
|
+
document.pointer + "".join("/" + encode_segment(str(p)) for p in error.absolute_path),
|
|
68
|
+
document.snapshot.snapshot_id,
|
|
69
|
+
)
|
|
70
|
+
for error in errors
|
|
71
|
+
)
|
|
72
|
+
return ValidationReport(
|
|
73
|
+
reference,
|
|
74
|
+
"invalid" if issues else "valid",
|
|
75
|
+
schema.reference(),
|
|
76
|
+
f"jsonschema/{version('jsonschema')}; assertions only; formats not checked",
|
|
77
|
+
issues,
|
|
78
|
+
selected,
|
|
79
|
+
)
|
|
80
|
+
except Exception as error: # noqa: BLE001 - optional validator failures are reported, never reading gates
|
|
81
|
+
return ValidationReport(
|
|
82
|
+
reference,
|
|
83
|
+
"unavailable",
|
|
84
|
+
schema.reference(),
|
|
85
|
+
diagnostics=(
|
|
86
|
+
Diagnostic("validation_unavailable", str(error), schema.pointer, schema.snapshot.snapshot_id),
|
|
87
|
+
),
|
|
88
|
+
dialect=dialect,
|
|
89
|
+
)
|