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.
Files changed (25) hide show
  1. bone_imaging_derivatives-0.1.0/LICENSE +21 -0
  2. bone_imaging_derivatives-0.1.0/PKG-INFO +14 -0
  3. bone_imaging_derivatives-0.1.0/README.md +3 -0
  4. bone_imaging_derivatives-0.1.0/pyproject.toml +21 -0
  5. bone_imaging_derivatives-0.1.0/setup.cfg +4 -0
  6. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/__init__.py +19 -0
  7. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/compatibility.py +73 -0
  8. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/discovery.py +41 -0
  9. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/families.py +25 -0
  10. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/layout.py +24 -0
  11. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/manifest.py +135 -0
  12. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/planning.py +77 -0
  13. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/progress.py +46 -0
  14. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/records.py +62 -0
  15. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives/roles.py +27 -0
  16. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/PKG-INFO +14 -0
  17. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/SOURCES.txt +23 -0
  18. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/dependency_links.txt +1 -0
  19. bone_imaging_derivatives-0.1.0/src/bone_imaging_derivatives.egg-info/top_level.txt +1 -0
  20. bone_imaging_derivatives-0.1.0/tests/test_compatibility.py +45 -0
  21. bone_imaging_derivatives-0.1.0/tests/test_discovery.py +41 -0
  22. bone_imaging_derivatives-0.1.0/tests/test_layout_and_validation.py +29 -0
  23. bone_imaging_derivatives-0.1.0/tests/test_manifest.py +62 -0
  24. bone_imaging_derivatives-0.1.0/tests/test_planning.py +41 -0
  25. 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,3 @@
1
+ # bone-imaging-derivatives
2
+
3
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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