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.
@@ -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
+ )