bone-imaging-derivatives 0.1.0__tar.gz
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.
- bone_imaging_derivatives-0.1.0/LICENSE +21 -0
- bone_imaging_derivatives-0.1.0/PKG-INFO +14 -0
- bone_imaging_derivatives-0.1.0/README.md +3 -0
- bone_imaging_derivatives-0.1.0/pyproject.toml +21 -0
- bone_imaging_derivatives-0.1.0/setup.cfg +4 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/__init__.py +19 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/compatibility.py +73 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/discovery.py +41 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/families.py +25 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/layout.py +24 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/manifest.py +135 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/planning.py +77 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/progress.py +46 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/records.py +62 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/roles.py +27 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/PKG-INFO +14 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/SOURCES.txt +23 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/dependency_links.txt +1 -0
- bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/top_level.txt +1 -0
- bone_imaging_derivatives-0.1.0/tests/test_compatibility.py +45 -0
- bone_imaging_derivatives-0.1.0/tests/test_discovery.py +41 -0
- bone_imaging_derivatives-0.1.0/tests/test_layout_and_validation.py +29 -0
- bone_imaging_derivatives-0.1.0/tests/test_manifest.py +62 -0
- bone_imaging_derivatives-0.1.0/tests/test_planning.py +41 -0
- bone_imaging_derivatives-0.1.0/tests/test_progress.py +13 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bone Imaging Tools
|
|
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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bone-imaging-derivatives
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-light contracts for bone imaging derivative datasets
|
|
5
|
+
Author: Bone Imaging Tools
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# bone-imaging-derivatives
|
|
13
|
+
|
|
14
|
+
Small, standard-library-only models and helpers for interoperable bone-imaging derivative manifests.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bone-imaging-derivatives"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Dependency-light contracts for bone imaging derivative datasets"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "Bone Imaging Tools"}]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
where = ["src"]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
pythonpath = ["src"]
|
|
21
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public contract API for bone-imaging derivative datasets."""
|
|
2
|
+
|
|
3
|
+
from .compatibility import (
|
|
4
|
+
discover_legacy_registered_microarchitecture_records, discover_legacy_timelapsed_records,
|
|
5
|
+
write_compatibility_manifest,
|
|
6
|
+
)
|
|
7
|
+
from .discovery import discover_manifests, find_records
|
|
8
|
+
from .manifest import DerivativeManifest, read_manifest, write_manifest
|
|
9
|
+
from .planning import WorkflowPlan, WorkflowRequirement, resolve_workflow_plan
|
|
10
|
+
from .progress import DerivativeProgressEvent, format_progress_event, parse_progress_event
|
|
11
|
+
from .records import DerivativeRecord
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DerivativeManifest", "DerivativeProgressEvent", "DerivativeRecord", "WorkflowPlan",
|
|
15
|
+
"WorkflowRequirement", "discover_legacy_registered_microarchitecture_records",
|
|
16
|
+
"discover_legacy_timelapsed_records", "discover_manifests", "find_records",
|
|
17
|
+
"format_progress_event", "parse_progress_event", "read_manifest", "resolve_workflow_plan",
|
|
18
|
+
"write_compatibility_manifest", "write_manifest",
|
|
19
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Best-effort readers for pre-manifest derivative layouts."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .layout import manifest_path
|
|
6
|
+
from .manifest import DerivativeManifest, write_manifest
|
|
7
|
+
from .records import DerivativeRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _context(path: Path) -> tuple[str, str, str | None]:
|
|
11
|
+
subject = next((part[4:] for part in path.parts if part.startswith("sub-")), "legacy")
|
|
12
|
+
site = next((part[5:] for part in path.parts if part.startswith("site-")), "unknown")
|
|
13
|
+
session = next((part[4:] for part in path.parts if part.startswith("ses-")), None)
|
|
14
|
+
return subject, site, session
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _space(path: Path) -> str:
|
|
18
|
+
name = str(path).lower()
|
|
19
|
+
return "reference" if "reference" in name or "registered" in name else "native"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def discover_legacy_timelapsed_records(dataset_root: Path) -> list[DerivativeRecord]:
|
|
23
|
+
"""Discover recognizable artifacts below the historical TimelapsedHRpQCT tree."""
|
|
24
|
+
root = Path(dataset_root)
|
|
25
|
+
legacy = root / "derivatives" / "TimelapsedHRpQCT"
|
|
26
|
+
if not legacy.exists():
|
|
27
|
+
return []
|
|
28
|
+
artifact_patterns = (
|
|
29
|
+
("formation", "Timelapsed", "formation_mask"),
|
|
30
|
+
("resorption", "Timelapsed", "resorption_mask"),
|
|
31
|
+
("stable", "Timelapsed", "stable_mask"),
|
|
32
|
+
("remodelling", "Timelapsed", "remodelling_pairwise_table"),
|
|
33
|
+
("transform", "Registration", "transform_to_reference"),
|
|
34
|
+
("common", "CommonRegion", "scan_region_native_common"),
|
|
35
|
+
("region", "CommonRegion", "scan_region_native_common"),
|
|
36
|
+
)
|
|
37
|
+
records: list[DerivativeRecord] = []
|
|
38
|
+
for path in sorted(item for item in legacy.rglob("*") if item.is_file()):
|
|
39
|
+
name = path.name.lower()
|
|
40
|
+
artifact = next(((family, role) for key, family, role in artifact_patterns if key in name), None)
|
|
41
|
+
if artifact is None:
|
|
42
|
+
continue
|
|
43
|
+
subject, site, session = _context(path)
|
|
44
|
+
family, role = artifact
|
|
45
|
+
records.append(DerivativeRecord(family, role, subject, site, session, None,
|
|
46
|
+
_space(path), path, "legacy"))
|
|
47
|
+
return records
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def discover_legacy_registered_microarchitecture_records(dataset_root: Path) -> list[DerivativeRecord]:
|
|
51
|
+
"""Discover registered microarchitecture CSV tables not yet recorded in a manifest."""
|
|
52
|
+
root = Path(dataset_root)
|
|
53
|
+
base = root / "derivatives" / "Microarchitecture"
|
|
54
|
+
if not base.exists():
|
|
55
|
+
return []
|
|
56
|
+
records: list[DerivativeRecord] = []
|
|
57
|
+
for path in sorted(base.rglob("*.csv")):
|
|
58
|
+
if "registered" not in str(path).lower():
|
|
59
|
+
continue
|
|
60
|
+
subject, site, session = _context(path)
|
|
61
|
+
records.append(DerivativeRecord("Microarchitecture", "measurements_table", subject, site,
|
|
62
|
+
session, None, "table", path, "legacy", content_type="table"))
|
|
63
|
+
return records
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_compatibility_manifest(dataset_root: Path) -> Path:
|
|
67
|
+
"""Write a manifest that explicitly exposes all currently recognized legacy files."""
|
|
68
|
+
root = Path(dataset_root)
|
|
69
|
+
records = tuple(discover_legacy_timelapsed_records(root) + discover_legacy_registered_microarchitecture_records(root))
|
|
70
|
+
output = manifest_path(root, "Compatibility")
|
|
71
|
+
manifest = DerivativeManifest.create("Compatibility", root, {"name": "bone-imaging-derivatives", "version": "0.1.0"}, records)
|
|
72
|
+
write_manifest(manifest, output)
|
|
73
|
+
return output
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Manifest discovery and record filtering."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from .manifest import DerivativeManifest, read_manifest
|
|
7
|
+
from .records import DerivativeRecord
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_manifests(dataset_root: Path, *, strict: bool = False) -> list[DerivativeManifest]:
|
|
11
|
+
"""Return readable manifests below ``derivatives`` in deterministic path order.
|
|
12
|
+
|
|
13
|
+
By default, malformed or incomplete manifests are skipped so an interrupted
|
|
14
|
+
write in one family does not hide completed derivatives in another. Set
|
|
15
|
+
``strict`` to surface the first read error to validation callers.
|
|
16
|
+
"""
|
|
17
|
+
root = Path(dataset_root)
|
|
18
|
+
derivatives = root / "derivatives"
|
|
19
|
+
if not derivatives.exists():
|
|
20
|
+
return []
|
|
21
|
+
manifests: list[DerivativeManifest] = []
|
|
22
|
+
for path in sorted(derivatives.rglob("manifest.json")):
|
|
23
|
+
try:
|
|
24
|
+
manifests.append(read_manifest(path))
|
|
25
|
+
except (OSError, TypeError, ValueError):
|
|
26
|
+
if strict:
|
|
27
|
+
raise
|
|
28
|
+
return manifests
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_records(manifests: Sequence[DerivativeManifest], *, derivative: str | None = None,
|
|
32
|
+
role: str | None = None, subject_id: str | None = None, site: str | None = None,
|
|
33
|
+
session_id: str | None = None, stack_index: int | None = None,
|
|
34
|
+
space: str | None = None) -> list[DerivativeRecord]:
|
|
35
|
+
"""Filter records using only specified attributes."""
|
|
36
|
+
expected = {key: value for key, value in {
|
|
37
|
+
"derivative": derivative, "role": role, "subject_id": subject_id, "site": site,
|
|
38
|
+
"session_id": session_id, "stack_index": stack_index, "space": space,
|
|
39
|
+
}.items() if value is not None}
|
|
40
|
+
return [record for manifest in manifests for record in manifest.records
|
|
41
|
+
if all(getattr(record, key) == value for key, value in expected.items())]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Standard derivative-family vocabulary."""
|
|
2
|
+
|
|
3
|
+
REGISTRATION = "Registration"
|
|
4
|
+
COMMON_REGION = "CommonRegion"
|
|
5
|
+
SEGMENTATION = "Segmentation"
|
|
6
|
+
MICROARCHITECTURE = "Microarchitecture"
|
|
7
|
+
PLATE_ROD_MORPHOMETRY = "PlateRodMorphometry"
|
|
8
|
+
TIMELAPSED = "Timelapsed"
|
|
9
|
+
FEA = "FEA"
|
|
10
|
+
MECHANOREGULATION = "Mechanoregulation"
|
|
11
|
+
VOID_SPACE = "VoidSpace"
|
|
12
|
+
COMPATIBILITY = "Compatibility"
|
|
13
|
+
|
|
14
|
+
DERIVATIVE_FAMILIES = frozenset({
|
|
15
|
+
REGISTRATION, COMMON_REGION, SEGMENTATION, MICROARCHITECTURE,
|
|
16
|
+
PLATE_ROD_MORPHOMETRY, TIMELAPSED, FEA, MECHANOREGULATION, VOID_SPACE,
|
|
17
|
+
COMPATIBILITY,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_derivative_family(value: str) -> str:
|
|
22
|
+
"""Return a recognized derivative family or raise a useful error."""
|
|
23
|
+
if value not in DERIVATIVE_FAMILIES:
|
|
24
|
+
raise ValueError(f"Unknown derivative family: {value!r}")
|
|
25
|
+
return value
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Helpers for the standard derivatives directory layout."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .families import validate_derivative_family
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def derivative_family_root(dataset_root: Path, derivative_family: str) -> Path:
|
|
9
|
+
validate_derivative_family(derivative_family)
|
|
10
|
+
return Path(dataset_root) / "derivatives" / derivative_family
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def manifest_path(dataset_root: Path, derivative_family: str) -> Path:
|
|
14
|
+
return derivative_family_root(dataset_root, derivative_family) / "manifest.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def record_output_path(dataset_root: Path, derivative_family: str, subject_id: str, site: str,
|
|
18
|
+
*parts: str) -> Path:
|
|
19
|
+
"""Build a standard subject/site derivative artifact path."""
|
|
20
|
+
if not subject_id or subject_id.startswith("sub-"):
|
|
21
|
+
raise ValueError("subject_id must omit the 'sub-' prefix")
|
|
22
|
+
if not site:
|
|
23
|
+
raise ValueError("site must be non-empty")
|
|
24
|
+
return derivative_family_root(dataset_root, derivative_family) / f"sub-{subject_id}" / f"site-{site}" / Path(*parts)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""JSON serialization for derivative manifests."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .families import validate_derivative_family
|
|
10
|
+
from .records import DerivativeRecord
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DerivativeManifest:
|
|
17
|
+
schema_version: int
|
|
18
|
+
derivative_family: str
|
|
19
|
+
dataset_root: Path
|
|
20
|
+
records: tuple[DerivativeRecord, ...]
|
|
21
|
+
software: Mapping[str, str]
|
|
22
|
+
created_at: str
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
if self.schema_version != SCHEMA_VERSION:
|
|
26
|
+
raise ValueError(f"Unsupported schema_version: {self.schema_version}")
|
|
27
|
+
validate_derivative_family(self.derivative_family)
|
|
28
|
+
if not self.software.get("name") or not self.software.get("version"):
|
|
29
|
+
raise ValueError("software must include name and version")
|
|
30
|
+
object.__setattr__(self, "dataset_root", Path(self.dataset_root))
|
|
31
|
+
object.__setattr__(self, "records", tuple(self.records))
|
|
32
|
+
object.__setattr__(self, "software", dict(self.software))
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def create(cls, derivative_family: str, dataset_root: Path, software: Mapping[str, str],
|
|
36
|
+
records: tuple[DerivativeRecord, ...] = (), created_at: str | None = None) -> "DerivativeManifest":
|
|
37
|
+
return cls(SCHEMA_VERSION, derivative_family, Path(dataset_root), tuple(records), dict(software),
|
|
38
|
+
created_at or datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _portable_path(path: Path, root: Path) -> str:
|
|
42
|
+
try:
|
|
43
|
+
return str(path.relative_to(root))
|
|
44
|
+
except ValueError:
|
|
45
|
+
return str(path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _record_payload(record: DerivativeRecord, root: Path) -> dict[str, Any]:
|
|
49
|
+
data: dict[str, Any] = {
|
|
50
|
+
"derivative": record.derivative, "role": record.role, "subject_id": record.subject_id,
|
|
51
|
+
"site": record.site, "session_id": record.session_id, "stack_index": record.stack_index,
|
|
52
|
+
"space": record.space, "path": _portable_path(record.path, root), "source": record.source,
|
|
53
|
+
"inputs": list(record.inputs), "metadata": dict(record.metadata), "record_id": record.record_id,
|
|
54
|
+
}
|
|
55
|
+
for key in ("content_type", "coordinate_reference", "settings_hash", "software"):
|
|
56
|
+
value = getattr(record, key)
|
|
57
|
+
if value is not None:
|
|
58
|
+
data[key] = value
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def write_manifest(manifest: DerivativeManifest, path: Path) -> None:
|
|
63
|
+
"""Write an indented, portable schema-v1 JSON manifest."""
|
|
64
|
+
path = Path(path)
|
|
65
|
+
root = manifest.dataset_root.resolve()
|
|
66
|
+
manifest_location = path.resolve()
|
|
67
|
+
dataset_root = "." if _manifest_is_within_dataset(manifest_location, root) else str(root)
|
|
68
|
+
payload = {
|
|
69
|
+
"schema_version": manifest.schema_version, "derivative_family": manifest.derivative_family,
|
|
70
|
+
"software": dict(manifest.software), "dataset_root": dataset_root, "created_at": manifest.created_at,
|
|
71
|
+
"records": [_record_payload(record, root) for record in manifest.records],
|
|
72
|
+
}
|
|
73
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _manifest_is_within_dataset(path: Path, dataset_root: Path) -> bool:
|
|
78
|
+
try:
|
|
79
|
+
path.relative_to(dataset_root)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dataset_root_from_manifest(path: Path, raw_root: Path) -> Path:
|
|
86
|
+
"""Resolve a portable ``'.'`` root from the standard derivatives layout."""
|
|
87
|
+
if raw_root != Path("."):
|
|
88
|
+
return (path.parent / raw_root).resolve()
|
|
89
|
+
for parent in path.parents:
|
|
90
|
+
if parent.name == "derivatives":
|
|
91
|
+
return parent.parent.resolve()
|
|
92
|
+
return path.parent.resolve()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _required(data: Mapping[str, Any], key: str) -> Any:
|
|
96
|
+
if key not in data:
|
|
97
|
+
raise ValueError(f"Manifest record is missing required field: {key}")
|
|
98
|
+
return data[key]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_record(data: Mapping[str, Any], root: Path) -> DerivativeRecord:
|
|
102
|
+
raw_path = Path(_required(data, "path"))
|
|
103
|
+
path = raw_path if raw_path.is_absolute() else root / raw_path
|
|
104
|
+
return DerivativeRecord(
|
|
105
|
+
derivative=_required(data, "derivative"), role=_required(data, "role"),
|
|
106
|
+
subject_id=_required(data, "subject_id"), site=_required(data, "site"),
|
|
107
|
+
session_id=_required(data, "session_id"), stack_index=_required(data, "stack_index"),
|
|
108
|
+
space=_required(data, "space"), path=path, source=_required(data, "source"),
|
|
109
|
+
inputs=tuple(_required(data, "inputs")), metadata=_required(data, "metadata"),
|
|
110
|
+
record_id=data.get("record_id"), content_type=data.get("content_type"),
|
|
111
|
+
coordinate_reference=data.get("coordinate_reference"), settings_hash=data.get("settings_hash"),
|
|
112
|
+
software=data.get("software"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_manifest(path: Path) -> DerivativeManifest:
|
|
117
|
+
"""Read a schema-v1 manifest and resolve relative records from its dataset root."""
|
|
118
|
+
path = Path(path)
|
|
119
|
+
try:
|
|
120
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
121
|
+
except json.JSONDecodeError as exc:
|
|
122
|
+
raise ValueError(f"Invalid manifest JSON: {path}") from exc
|
|
123
|
+
if not isinstance(payload, dict):
|
|
124
|
+
raise ValueError("Manifest must be a JSON object")
|
|
125
|
+
version = _required(payload, "schema_version")
|
|
126
|
+
if version != SCHEMA_VERSION:
|
|
127
|
+
raise ValueError(f"Unsupported schema_version: {version}")
|
|
128
|
+
raw_root = Path(_required(payload, "dataset_root"))
|
|
129
|
+
root = raw_root if raw_root.is_absolute() else _dataset_root_from_manifest(path, raw_root)
|
|
130
|
+
records_data = _required(payload, "records")
|
|
131
|
+
if not isinstance(records_data, list):
|
|
132
|
+
raise ValueError("records must be a list")
|
|
133
|
+
return DerivativeManifest(version, _required(payload, "derivative_family"), root,
|
|
134
|
+
tuple(_read_record(record, root) for record in records_data),
|
|
135
|
+
_required(payload, "software"), _required(payload, "created_at"))
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Small prerequisite planner shared by package batch workflows."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from .discovery import find_records
|
|
7
|
+
from .manifest import DerivativeManifest
|
|
8
|
+
from .records import DerivativeRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class WorkflowRequirement:
|
|
13
|
+
derivative: str
|
|
14
|
+
roles: tuple[str, ...]
|
|
15
|
+
required: bool = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class WorkflowPlan:
|
|
20
|
+
workflow: str
|
|
21
|
+
available: tuple[DerivativeRecord, ...]
|
|
22
|
+
missing: tuple[WorkflowRequirement, ...]
|
|
23
|
+
steps: tuple[str, ...]
|
|
24
|
+
blocked: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_REQUIREMENTS = {
|
|
28
|
+
"CommonRegion": (WorkflowRequirement("Registration", ("transform_to_reference",)),),
|
|
29
|
+
"Microarchitecture": (WorkflowRequirement("CommonRegion", ("scan_region_native_common",), False),),
|
|
30
|
+
"PlateRodMorphometry": (WorkflowRequirement("CommonRegion", ("scan_region_native_common",), False),),
|
|
31
|
+
"Timelapsed": (
|
|
32
|
+
WorkflowRequirement("Registration", ("transform_to_reference",)),
|
|
33
|
+
WorkflowRequirement("CommonRegion", ("scan_region_native_common",), False),
|
|
34
|
+
),
|
|
35
|
+
"FEA": (WorkflowRequirement("CommonRegion", ("scan_region_native_common",), False),),
|
|
36
|
+
"Mechanoregulation": (
|
|
37
|
+
WorkflowRequirement("Timelapsed", ("remodelling_pairwise_table",)),
|
|
38
|
+
WorkflowRequirement("FEA", ("solver_output",)),
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_workflow_plan(workflow: str, *, manifests: Sequence[DerivativeManifest], subject_id: str,
|
|
44
|
+
site: str, sessions: Sequence[str], generate_missing: bool) -> WorkflowPlan:
|
|
45
|
+
"""Resolve prerequisites for every selected session.
|
|
46
|
+
|
|
47
|
+
The first selected session is the registration reference, so it does not
|
|
48
|
+
require a ``transform_to_reference`` record. All other requirements are
|
|
49
|
+
checked at each requested session.
|
|
50
|
+
"""
|
|
51
|
+
if workflow not in _REQUIREMENTS:
|
|
52
|
+
raise ValueError(f"Unknown workflow: {workflow!r}")
|
|
53
|
+
available = tuple(find_records(manifests, subject_id=subject_id, site=site))
|
|
54
|
+
missing: list[WorkflowRequirement] = []
|
|
55
|
+
steps: list[str] = []
|
|
56
|
+
for requirement in _REQUIREMENTS[workflow]:
|
|
57
|
+
required_sessions = tuple(str(session) for session in sessions)
|
|
58
|
+
if requirement.derivative == "Registration" and "transform_to_reference" in requirement.roles:
|
|
59
|
+
required_sessions = required_sessions[1:]
|
|
60
|
+
missing_sessions = tuple(
|
|
61
|
+
session for session in required_sessions
|
|
62
|
+
if not all(any(record.derivative == requirement.derivative and record.role == role
|
|
63
|
+
and record.session_id == session for record in available)
|
|
64
|
+
for role in requirement.roles)
|
|
65
|
+
)
|
|
66
|
+
has_all_roles = not missing_sessions
|
|
67
|
+
session_note = f" for sessions: {', '.join(missing_sessions)}" if missing_sessions else ""
|
|
68
|
+
if has_all_roles:
|
|
69
|
+
steps.append(f"Reuse {requirement.derivative} {'/'.join(requirement.roles)}")
|
|
70
|
+
else:
|
|
71
|
+
missing.append(requirement)
|
|
72
|
+
if generate_missing:
|
|
73
|
+
steps.append(f"Generate {requirement.derivative} {'/'.join(requirement.roles)}{session_note}")
|
|
74
|
+
elif requirement.required:
|
|
75
|
+
steps.append(f"Missing {requirement.derivative} {'/'.join(requirement.roles)}{session_note}")
|
|
76
|
+
blocked = any(requirement.required for requirement in missing) and not generate_missing
|
|
77
|
+
return WorkflowPlan(workflow, available, tuple(missing), tuple(steps), blocked)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Line-oriented JSON progress protocol for background processes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
_PREFIX = "BONE_DERIVATIVES_PROGRESS "
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DerivativeProgressEvent:
|
|
12
|
+
family: str
|
|
13
|
+
subject_id: str | None
|
|
14
|
+
site: str | None
|
|
15
|
+
session_id: str | None
|
|
16
|
+
step: str
|
|
17
|
+
status: str
|
|
18
|
+
message: str
|
|
19
|
+
path: Path | None = None
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if not self.family or not self.step or not self.status:
|
|
23
|
+
raise ValueError("family, step, and status must be non-empty")
|
|
24
|
+
if self.path is not None:
|
|
25
|
+
object.__setattr__(self, "path", Path(self.path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_progress_event(event: DerivativeProgressEvent) -> str:
|
|
29
|
+
"""Format one parseable progress line without printing it."""
|
|
30
|
+
data = {"family": event.family, "subject_id": event.subject_id, "site": event.site,
|
|
31
|
+
"session_id": event.session_id, "step": event.step, "status": event.status,
|
|
32
|
+
"message": event.message, "path": str(event.path) if event.path is not None else None}
|
|
33
|
+
return _PREFIX + json.dumps(data, sort_keys=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_progress_event(line: str) -> DerivativeProgressEvent | None:
|
|
37
|
+
"""Parse a progress line, returning ``None`` for normal process output."""
|
|
38
|
+
if not line.startswith(_PREFIX):
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(line[len(_PREFIX):])
|
|
42
|
+
return DerivativeProgressEvent(data["family"], data.get("subject_id"), data.get("site"),
|
|
43
|
+
data.get("session_id"), data["step"], data["status"],
|
|
44
|
+
data.get("message", ""), Path(data["path"]) if data.get("path") else None)
|
|
45
|
+
except (TypeError, KeyError, ValueError, json.JSONDecodeError):
|
|
46
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Immutable records describing one derivative artifact."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
from .families import validate_derivative_family
|
|
9
|
+
from .roles import validate_role
|
|
10
|
+
|
|
11
|
+
_SPACES = frozenset({"native", "reference", "moving", "fixed", "model", "table"})
|
|
12
|
+
_SOURCES = frozenset({"generated", "provided", "derived", "legacy"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DerivativeRecord:
|
|
17
|
+
derivative: str
|
|
18
|
+
role: str
|
|
19
|
+
subject_id: str
|
|
20
|
+
site: str
|
|
21
|
+
session_id: str | None
|
|
22
|
+
stack_index: int | None
|
|
23
|
+
space: str
|
|
24
|
+
path: Path
|
|
25
|
+
source: str
|
|
26
|
+
inputs: tuple[str, ...] = ()
|
|
27
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
28
|
+
record_id: str | None = None
|
|
29
|
+
content_type: str | None = None
|
|
30
|
+
coordinate_reference: Mapping[str, Any] | None = None
|
|
31
|
+
settings_hash: str | None = None
|
|
32
|
+
software: Mapping[str, str] | None = None
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
validate_derivative_family(self.derivative)
|
|
36
|
+
validate_role(self.role)
|
|
37
|
+
if not self.subject_id or self.subject_id.startswith("sub-"):
|
|
38
|
+
raise ValueError("subject_id must be non-empty and omit the 'sub-' prefix")
|
|
39
|
+
if not self.site or "/" in self.site:
|
|
40
|
+
raise ValueError("site must be a non-empty normalized identifier")
|
|
41
|
+
if self.session_id is not None and not self.session_id:
|
|
42
|
+
raise ValueError("session_id must be non-empty when provided")
|
|
43
|
+
if self.stack_index is not None and not isinstance(self.stack_index, int):
|
|
44
|
+
raise ValueError("stack_index must be an integer or None")
|
|
45
|
+
if self.space not in _SPACES:
|
|
46
|
+
raise ValueError(f"Unknown coordinate space: {self.space!r}")
|
|
47
|
+
if self.source not in _SOURCES:
|
|
48
|
+
raise ValueError(f"Unknown record source: {self.source!r}")
|
|
49
|
+
object.__setattr__(self, "path", Path(self.path))
|
|
50
|
+
object.__setattr__(self, "inputs", tuple(self.inputs))
|
|
51
|
+
object.__setattr__(self, "metadata", dict(self.metadata))
|
|
52
|
+
if self.coordinate_reference is not None:
|
|
53
|
+
object.__setattr__(self, "coordinate_reference", dict(self.coordinate_reference))
|
|
54
|
+
if self.software is not None:
|
|
55
|
+
object.__setattr__(self, "software", dict(self.software))
|
|
56
|
+
if self.record_id is None:
|
|
57
|
+
object.__setattr__(self, "record_id", self._default_record_id())
|
|
58
|
+
|
|
59
|
+
def _default_record_id(self) -> str:
|
|
60
|
+
parts = (self.derivative, self.role, self.subject_id, self.site,
|
|
61
|
+
self.session_id or "", str(self.stack_index), self.space, str(self.path))
|
|
62
|
+
return sha256("\x1f".join(parts).encode()).hexdigest()[:20]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Stable semantic roles used by derivative records."""
|
|
2
|
+
|
|
3
|
+
DERIVATIVE_ROLES = frozenset({
|
|
4
|
+
"transform_pairwise", "transform_to_reference", "transform_from_reference",
|
|
5
|
+
"registration_mask", "registration_qc", "scan_region_native",
|
|
6
|
+
"scan_region_reference", "scan_region_common_reference",
|
|
7
|
+
"scan_region_native_common", "bone_segmentation", "periosteal_mask",
|
|
8
|
+
"endosteal_mask", "trabecular_mask", "cortical_mask", "scan_region_mask",
|
|
9
|
+
"void_mask", "measurements_table", "trabecular_thickness_map",
|
|
10
|
+
"trabecular_spacing_map", "trabecular_number_map", "cortical_thickness_map",
|
|
11
|
+
"cortical_porosity_map", "plate_rod_label_map", "plate_rod_measurements_table",
|
|
12
|
+
"plate_local_thickness_map", "rod_local_thickness_map", "skeleton_map",
|
|
13
|
+
"remodelling_pairwise_table", "remodelling_trajectory_table", "formation_mask",
|
|
14
|
+
"resorption_mask", "stable_mask", "transformed_image", "filled_image",
|
|
15
|
+
"qc_report", "mesh", "material_map", "boundary_conditions", "solver_input",
|
|
16
|
+
"solver_output", "strain_map", "stress_map", "load_transfer_table",
|
|
17
|
+
"mechanoregulation_table", "mechanical_signal_map", "formation_mechanics_map",
|
|
18
|
+
"resorption_mechanics_map", "adaptation_classification_map",
|
|
19
|
+
"void_measurements_table", "void_distance_map", "void_connectivity_map",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_role(value: str) -> str:
|
|
24
|
+
"""Return a recognized record role or raise a useful error."""
|
|
25
|
+
if value not in DERIVATIVE_ROLES:
|
|
26
|
+
raise ValueError(f"Unknown derivative role: {value!r}")
|
|
27
|
+
return value
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bone-imaging-derivatives
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency-light contracts for bone imaging derivative datasets
|
|
5
|
+
Author: Bone Imaging Tools
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# bone-imaging-derivatives
|
|
13
|
+
|
|
14
|
+
Small, standard-library-only models and helpers for interoperable bone-imaging derivative manifests.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/bone_imaging_derivatives/__init__.py
|
|
5
|
+
src/bone_imaging_derivatives/compatibility.py
|
|
6
|
+
src/bone_imaging_derivatives/discovery.py
|
|
7
|
+
src/bone_imaging_derivatives/families.py
|
|
8
|
+
src/bone_imaging_derivatives/layout.py
|
|
9
|
+
src/bone_imaging_derivatives/manifest.py
|
|
10
|
+
src/bone_imaging_derivatives/planning.py
|
|
11
|
+
src/bone_imaging_derivatives/progress.py
|
|
12
|
+
src/bone_imaging_derivatives/records.py
|
|
13
|
+
src/bone_imaging_derivatives/roles.py
|
|
14
|
+
src/bone_imaging_derivatives.egg-info/PKG-INFO
|
|
15
|
+
src/bone_imaging_derivatives.egg-info/SOURCES.txt
|
|
16
|
+
src/bone_imaging_derivatives.egg-info/dependency_links.txt
|
|
17
|
+
src/bone_imaging_derivatives.egg-info/top_level.txt
|
|
18
|
+
tests/test_compatibility.py
|
|
19
|
+
tests/test_discovery.py
|
|
20
|
+
tests/test_layout_and_validation.py
|
|
21
|
+
tests/test_manifest.py
|
|
22
|
+
tests/test_planning.py
|
|
23
|
+
tests/test_progress.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bone_imaging_derivatives
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from bone_imaging_derivatives import (
|
|
4
|
+
DerivativeManifest,
|
|
5
|
+
discover_legacy_registered_microarchitecture_records,
|
|
6
|
+
discover_legacy_timelapsed_records,
|
|
7
|
+
read_manifest,
|
|
8
|
+
resolve_workflow_plan,
|
|
9
|
+
write_compatibility_manifest,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_legacy_discovery_and_compatibility_manifest_cover_existing_outputs(tmp_path: Path) -> None:
|
|
14
|
+
"""Legacy folders must remain available while packages migrate to manifests."""
|
|
15
|
+
root = tmp_path / "dataset"
|
|
16
|
+
(root / "derivatives" / "TimelapsedHRpQCT" / "sub-S01" / "site-tibia" / "ses-1").mkdir(parents=True)
|
|
17
|
+
(root / "derivatives" / "TimelapsedHRpQCT" / "sub-S01" / "site-tibia" / "ses-1" / "formation_mask.nii.gz").touch()
|
|
18
|
+
micro_path = root / "derivatives" / "Microarchitecture" / "sub-S01" / "site-tibia" / "registered" / "measurements.csv"
|
|
19
|
+
micro_path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
micro_path.touch()
|
|
21
|
+
|
|
22
|
+
timelapsed = discover_legacy_timelapsed_records(root)
|
|
23
|
+
microarchitecture = discover_legacy_registered_microarchitecture_records(root)
|
|
24
|
+
manifest_path = write_compatibility_manifest(root)
|
|
25
|
+
|
|
26
|
+
assert timelapsed[0].role == "formation_mask"
|
|
27
|
+
assert timelapsed[0].source == "legacy"
|
|
28
|
+
assert microarchitecture[0].role == "measurements_table"
|
|
29
|
+
assert len(read_manifest(manifest_path).records) == 2
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_legacy_transform_is_registration_and_satisfies_common_region_planning(tmp_path: Path) -> None:
|
|
33
|
+
"""Misclassifying a legacy transform would falsely block common-region migration."""
|
|
34
|
+
root = tmp_path / "dataset"
|
|
35
|
+
transform = root / "derivatives" / "TimelapsedHRpQCT" / "sub-S01" / "site-tibia" / "ses-2" / "transform_to_reference.tfm"
|
|
36
|
+
transform.parent.mkdir(parents=True)
|
|
37
|
+
transform.touch()
|
|
38
|
+
|
|
39
|
+
records = discover_legacy_timelapsed_records(root)
|
|
40
|
+
manifest = DerivativeManifest.create("Compatibility", root, {"name": "test", "version": "1"}, tuple(records))
|
|
41
|
+
plan = resolve_workflow_plan("CommonRegion", manifests=(manifest,), subject_id="S01", site="tibia",
|
|
42
|
+
sessions=("1", "2"), generate_missing=False)
|
|
43
|
+
|
|
44
|
+
assert [(record.derivative, record.role) for record in records] == [("Registration", "transform_to_reference")]
|
|
45
|
+
assert plan.blocked is False
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from bone_imaging_derivatives import (
|
|
4
|
+
DerivativeManifest, DerivativeRecord, discover_manifests, find_records, write_manifest,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _manifest(root: Path, family: str, record: DerivativeRecord) -> DerivativeManifest:
|
|
9
|
+
return DerivativeManifest.create(
|
|
10
|
+
derivative_family=family, dataset_root=root, software={"name": "test", "version": "1"},
|
|
11
|
+
records=(record,), created_at="2026-08-29T00:00:00Z",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_discovery_and_filtering_return_only_matching_records(tmp_path: Path) -> None:
|
|
16
|
+
"""Ignoring filter fields would feed a workflow records from another session."""
|
|
17
|
+
root = tmp_path / "dataset"
|
|
18
|
+
registration = DerivativeRecord("Registration", "transform_to_reference", "S01", "tibia", "1", 1, "native", root / "a.tfm", "generated")
|
|
19
|
+
common = DerivativeRecord("CommonRegion", "scan_region_native_common", "S01", "tibia", "2", 1, "native", root / "b.nii.gz", "generated")
|
|
20
|
+
write_manifest(_manifest(root, "Registration", registration), root / "derivatives" / "Registration" / "manifest.json")
|
|
21
|
+
write_manifest(_manifest(root, "CommonRegion", common), root / "derivatives" / "CommonRegion" / "manifest.json")
|
|
22
|
+
|
|
23
|
+
manifests = discover_manifests(root)
|
|
24
|
+
found = find_records(manifests, derivative="CommonRegion", subject_id="S01", session_id="2", space="native")
|
|
25
|
+
|
|
26
|
+
assert [record.path for record in found] == [root / "b.nii.gz"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_discovery_skips_a_malformed_manifest_when_valid_manifests_exist(tmp_path: Path) -> None:
|
|
30
|
+
"""One interrupted write must not hide records from other completed families."""
|
|
31
|
+
root = tmp_path / "dataset"
|
|
32
|
+
record = DerivativeRecord("Registration", "transform_to_reference", "S01", "tibia", "1", 1,
|
|
33
|
+
"native", root / "transform.tfm", "generated")
|
|
34
|
+
write_manifest(_manifest(root, "Registration", record), root / "derivatives" / "Registration" / "manifest.json")
|
|
35
|
+
malformed = root / "derivatives" / "CommonRegion" / "manifest.json"
|
|
36
|
+
malformed.parent.mkdir(parents=True)
|
|
37
|
+
malformed.write_text("{not valid JSON")
|
|
38
|
+
|
|
39
|
+
manifests = discover_manifests(root)
|
|
40
|
+
|
|
41
|
+
assert [manifest.derivative_family for manifest in manifests] == ["Registration"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from bone_imaging_derivatives import DerivativeRecord
|
|
6
|
+
from bone_imaging_derivatives.families import validate_derivative_family
|
|
7
|
+
from bone_imaging_derivatives.layout import derivative_family_root, record_output_path
|
|
8
|
+
from bone_imaging_derivatives.roles import validate_role
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_layout_helpers_build_the_standard_subject_site_layout(tmp_path: Path) -> None:
|
|
12
|
+
"""A changed layout would make independently produced derivatives undiscoverable."""
|
|
13
|
+
root = tmp_path / "dataset"
|
|
14
|
+
assert derivative_family_root(root, "CommonRegion") == root / "derivatives" / "CommonRegion"
|
|
15
|
+
assert record_output_path(root, "CommonRegion", "S01", "tibia", "native_space", "ses-1", "masks", "mask.nii.gz") == (
|
|
16
|
+
root / "derivatives" / "CommonRegion" / "sub-S01" / "site-tibia" / "native_space" / "ses-1" / "masks" / "mask.nii.gz"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_family_role_and_record_validation_reject_unknown_contract_values(tmp_path: Path) -> None:
|
|
21
|
+
"""Unknown family or role strings would create non-interoperable records."""
|
|
22
|
+
assert validate_derivative_family("CommonRegion") == "CommonRegion"
|
|
23
|
+
assert validate_role("scan_region_native_common") == "scan_region_native_common"
|
|
24
|
+
with pytest.raises(ValueError, match="Unknown derivative family"):
|
|
25
|
+
validate_derivative_family("Unknown")
|
|
26
|
+
with pytest.raises(ValueError, match="Unknown derivative role"):
|
|
27
|
+
validate_role("not-a-role")
|
|
28
|
+
with pytest.raises(ValueError, match="Unknown derivative role"):
|
|
29
|
+
DerivativeRecord("CommonRegion", "not-a-role", "S01", "tibia", "1", None, "native", tmp_path / "x", "generated")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import shutil
|
|
3
|
+
|
|
4
|
+
from bone_imaging_derivatives import DerivativeManifest, DerivativeRecord, read_manifest, write_manifest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_manifest_round_trip_stores_dataset_relative_paths(tmp_path: Path) -> None:
|
|
8
|
+
"""Changing the dataset root would break portable manifest records."""
|
|
9
|
+
dataset_root = tmp_path / "dataset"
|
|
10
|
+
output = dataset_root / "derivatives" / "CommonRegion" / "manifest.json"
|
|
11
|
+
record_path = dataset_root / "derivatives" / "CommonRegion" / "sub-S01" / "mask.nii.gz"
|
|
12
|
+
manifest = DerivativeManifest.create(
|
|
13
|
+
derivative_family="CommonRegion",
|
|
14
|
+
dataset_root=dataset_root,
|
|
15
|
+
software={"name": "timelapsed-hrpqct", "version": "2.0"},
|
|
16
|
+
records=(
|
|
17
|
+
DerivativeRecord(
|
|
18
|
+
derivative="CommonRegion", role="scan_region_native_common", subject_id="S01",
|
|
19
|
+
site="tibia", session_id="1", stack_index=1, space="native", path=record_path,
|
|
20
|
+
source="generated", inputs=("input-1",), metadata={"method": "overlap"},
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
created_at="2026-08-29T00:00:00Z",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
write_manifest(manifest, output)
|
|
27
|
+
loaded = read_manifest(output)
|
|
28
|
+
|
|
29
|
+
assert loaded == manifest
|
|
30
|
+
assert '"path": "derivatives/CommonRegion/sub-S01/mask.nii.gz"' in output.read_text()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_manifest_rejects_unsupported_schema_version(tmp_path: Path) -> None:
|
|
34
|
+
"""Accepting a future schema could silently misinterpret a record."""
|
|
35
|
+
path = tmp_path / "manifest.json"
|
|
36
|
+
path.write_text('{"schema_version": 2}')
|
|
37
|
+
|
|
38
|
+
import pytest
|
|
39
|
+
|
|
40
|
+
with pytest.raises(ValueError, match="schema_version"):
|
|
41
|
+
read_manifest(path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_manifest_relative_records_follow_a_copied_dataset_root(tmp_path: Path) -> None:
|
|
45
|
+
"""An absolute serialized root would make copied datasets point at stale artifacts."""
|
|
46
|
+
original = tmp_path / "original"
|
|
47
|
+
original_record = original / "derivatives" / "Registration" / "sub-S01" / "transform.tfm"
|
|
48
|
+
manifest_path = original / "derivatives" / "Registration" / "manifest.json"
|
|
49
|
+
manifest = DerivativeManifest.create(
|
|
50
|
+
"Registration", original, {"name": "timelapsed-hrpqct", "version": "2.0"},
|
|
51
|
+
(DerivativeRecord("Registration", "transform_to_reference", "S01", "tibia", "1", None,
|
|
52
|
+
"native", original_record, "generated"),),
|
|
53
|
+
"2026-08-29T00:00:00Z",
|
|
54
|
+
)
|
|
55
|
+
write_manifest(manifest, manifest_path)
|
|
56
|
+
copied = tmp_path / "copied"
|
|
57
|
+
shutil.copytree(original, copied)
|
|
58
|
+
|
|
59
|
+
loaded = read_manifest(copied / "derivatives" / "Registration" / "manifest.json")
|
|
60
|
+
|
|
61
|
+
assert loaded.dataset_root == copied.resolve()
|
|
62
|
+
assert loaded.records[0].path == copied / "derivatives" / "Registration" / "sub-S01" / "transform.tfm"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from bone_imaging_derivatives import DerivativeManifest, DerivativeRecord, WorkflowRequirement, resolve_workflow_plan
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_planner_blocks_common_region_without_registration_when_generation_disabled(tmp_path: Path) -> None:
|
|
7
|
+
"""Missing required transforms must block a common-region run when generation is disabled."""
|
|
8
|
+
manifest = DerivativeManifest.create("Registration", tmp_path, {"name": "test", "version": "1"}, (), "2026-08-29T00:00:00Z")
|
|
9
|
+
|
|
10
|
+
plan = resolve_workflow_plan("CommonRegion", manifests=(manifest,), subject_id="S01", site="tibia", sessions=("1", "2"), generate_missing=False)
|
|
11
|
+
|
|
12
|
+
assert plan.blocked is True
|
|
13
|
+
assert plan.missing[0].derivative == "Registration"
|
|
14
|
+
assert plan.missing[0].roles == ("transform_to_reference",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_planner_allows_missing_prerequisite_generation(tmp_path: Path) -> None:
|
|
18
|
+
"""The generation option should turn a satisfiable prerequisite into an executable step."""
|
|
19
|
+
plan = resolve_workflow_plan("Timelapsed", manifests=(), subject_id="S01", site="tibia", sessions=("1", "2"), generate_missing=True)
|
|
20
|
+
|
|
21
|
+
assert plan.blocked is False
|
|
22
|
+
assert any("Generate Registration" in step for step in plan.steps)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_planner_requires_transform_for_every_non_reference_session(tmp_path: Path) -> None:
|
|
26
|
+
"""Treating one session's transform as all sessions' input would start incomplete work."""
|
|
27
|
+
first_session_transform = DerivativeRecord(
|
|
28
|
+
"Registration", "transform_to_reference", "S01", "tibia", "1", None,
|
|
29
|
+
"native", tmp_path / "ses-1.tfm", "generated",
|
|
30
|
+
)
|
|
31
|
+
manifest = DerivativeManifest.create(
|
|
32
|
+
"Registration", tmp_path, {"name": "test", "version": "1"},
|
|
33
|
+
(first_session_transform,), "2026-08-29T00:00:00Z",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
plan = resolve_workflow_plan("CommonRegion", manifests=(manifest,), subject_id="S01", site="tibia",
|
|
37
|
+
sessions=("1", "2"), generate_missing=False)
|
|
38
|
+
|
|
39
|
+
assert plan.blocked is True
|
|
40
|
+
assert plan.missing == (WorkflowRequirement("Registration", ("transform_to_reference",)),)
|
|
41
|
+
assert any("sessions: 2" in step for step in plan.steps)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from bone_imaging_derivatives import DerivativeProgressEvent, format_progress_event, parse_progress_event
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_progress_events_round_trip_and_ignore_unrelated_output(tmp_path: Path) -> None:
|
|
7
|
+
"""A progress parser must recover job state without treating arbitrary logs as events."""
|
|
8
|
+
event = DerivativeProgressEvent("CommonRegion", "S01", "tibia", "1", "write", "completed", "Saved mask", tmp_path / "mask.nii.gz")
|
|
9
|
+
|
|
10
|
+
line = format_progress_event(event)
|
|
11
|
+
|
|
12
|
+
assert parse_progress_event(line) == event
|
|
13
|
+
assert parse_progress_event("ordinary application log") is None
|