tofscope 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tofscope/__init__.py +1 -0
- tofscope/api/__init__.py +0 -0
- tofscope/api/app.py +549 -0
- tofscope/api/auth.py +82 -0
- tofscope/api/schemas.py +91 -0
- tofscope/application/__init__.py +0 -0
- tofscope/application/analyses.py +237 -0
- tofscope/application/cache.py +166 -0
- tofscope/application/commands.py +153 -0
- tofscope/application/explore.py +161 -0
- tofscope/application/identification.py +153 -0
- tofscope/application/identification_models.py +75 -0
- tofscope/application/jobs.py +347 -0
- tofscope/application/lifecycle.py +80 -0
- tofscope/application/models.py +74 -0
- tofscope/application/preparations.py +46 -0
- tofscope/application/resources.py +63 -0
- tofscope/application/saves.py +76 -0
- tofscope/application/signals.py +74 -0
- tofscope/application/spatial.py +78 -0
- tofscope/cli.py +157 -0
- tofscope/config.py +139 -0
- tofscope/presentation/__init__.py +0 -0
- tofscope/presentation/coordinates.py +31 -0
- tofscope/presentation/data.py +54 -0
- tofscope/presentation/explore.py +267 -0
- tofscope/presentation/exports.py +102 -0
- tofscope/presentation/isotopes.py +31 -0
- tofscope/presentation/volumes.py +210 -0
- tofscope/resources/__init__.py +0 -0
- tofscope/resources/reference/README.md +36 -0
- tofscope/resources/reference/isotopes.json +36901 -0
- tofscope/science/__init__.py +0 -0
- tofscope/science/abundance.py +42 -0
- tofscope/science/depth_calibration.py +73 -0
- tofscope/science/integration.py +149 -0
- tofscope/science/isotopes.py +90 -0
- tofscope/science/raw_peaks.py +50 -0
- tofscope/science/signals.py +114 -0
- tofscope/science/volume_layers.py +112 -0
- tofscope/static/assets/VtkScene-q-o3MPFv.js +5774 -0
- tofscope/static/assets/datasophos-wordmark.png +0 -0
- tofscope/static/assets/favicon.svg +5 -0
- tofscope/static/assets/index-CeV0NVRS.css +1 -0
- tofscope/static/assets/index-djLJCKw_.js +24 -0
- tofscope/static/index.html +14 -0
- tofscope/static/licenses/matplotlib.txt +99 -0
- tofscope/static/manifest.json +13 -0
- tofscope/storage/__init__.py +0 -0
- tofscope/storage/acquisitions.py +281 -0
- tofscope/storage/analysis_document.py +210 -0
- tofscope/storage/bounded_events.py +167 -0
- tofscope/storage/compact_copy.py +143 -0
- tofscope/storage/dataset_io.py +166 -0
- tofscope/storage/explorer.py +201 -0
- tofscope/storage/file_browser.py +41 -0
- tofscope/storage/identification_document.py +47 -0
- tofscope/storage/integration_store.py +140 -0
- tofscope/storage/native_saved_copy.py +252 -0
- tofscope/storage/raw_preparation.py +195 -0
- tofscope/storage/saved_files.py +331 -0
- tofscope/storage/vendor_export.py +114 -0
- tofscope-0.1.0.dist-info/METADATA +14 -0
- tofscope-0.1.0.dist-info/RECORD +68 -0
- tofscope-0.1.0.dist-info/WHEEL +5 -0
- tofscope-0.1.0.dist-info/entry_points.txt +2 -0
- tofscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- tofscope-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Signal identity and view invariants, independent of readers and renderers."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
from tofscope.application.commands import AllDepth, SingleFrame, DepthWindow
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def valid_name(value):
|
|
7
|
+
value = value.strip()
|
|
8
|
+
if not 1 <= len(value) <= 128:
|
|
9
|
+
raise ValueError('Signal name must contain 1–128 characters.')
|
|
10
|
+
return value
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_signal(snapshot, signal_id):
|
|
14
|
+
for signal in snapshot.signals:
|
|
15
|
+
if signal.id == signal_id:
|
|
16
|
+
return signal
|
|
17
|
+
raise ValueError('Signal unavailable.')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def image_signal(snapshot):
|
|
21
|
+
return get_signal(snapshot, snapshot.image_signal_id)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def visible_signals(snapshot):
|
|
25
|
+
ids = set(snapshot.profile_signal_ids) | {snapshot.image_signal_id}
|
|
26
|
+
return tuple(sorted((s for s in snapshot.signals if s.id in ids and s.recipe is not None),
|
|
27
|
+
key=lambda s: (s.id != snapshot.image_signal_id, s.style_index)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def usable_frames(snapshot, signal):
|
|
31
|
+
if signal.recipe is None:
|
|
32
|
+
return None
|
|
33
|
+
low = max(0, signal.recipe.frames[0])
|
|
34
|
+
high = min(snapshot.shape[0] - 1, signal.recipe.frames[1])
|
|
35
|
+
return (low, high) if low <= high else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def image_frames(snapshot):
|
|
39
|
+
bounds = usable_frames(snapshot, image_signal(snapshot))
|
|
40
|
+
if bounds is None:
|
|
41
|
+
raise ValueError('Image signal has no coverage in the acquisition.')
|
|
42
|
+
depth = snapshot.depth
|
|
43
|
+
if depth.mode == 'all':
|
|
44
|
+
return bounds
|
|
45
|
+
requested = (depth.index, depth.index) if depth.mode == 'frame' else (depth.first, depth.last)
|
|
46
|
+
low, high = max(bounds[0], requested[0]), min(bounds[1], requested[1])
|
|
47
|
+
return (low, high) if low <= high else (bounds[0], bounds[0])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_view(snapshot):
|
|
51
|
+
if usable_frames(snapshot, image_signal(snapshot)) is None:
|
|
52
|
+
candidates = [s for s in snapshot.signals if usable_frames(snapshot, s)]
|
|
53
|
+
if not candidates:
|
|
54
|
+
raise ValueError('At least one ready signal must cover the acquisition.')
|
|
55
|
+
snapshot = replace(snapshot, image_signal_id=candidates[0].id)
|
|
56
|
+
low, high = image_frames(snapshot)
|
|
57
|
+
depth = snapshot.depth
|
|
58
|
+
if depth.mode == 'frame':
|
|
59
|
+
depth = SingleFrame(mode='frame', index=low)
|
|
60
|
+
elif depth.mode == 'window':
|
|
61
|
+
depth = DepthWindow(mode='window', first=low, last=high)
|
|
62
|
+
ids = tuple(dict.fromkeys((*snapshot.profile_signal_ids, snapshot.image_signal_id)))
|
|
63
|
+
return replace(snapshot, depth=depth, profile_signal_ids=ids)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def signal_label(snapshot, signal):
|
|
67
|
+
recipe = signal.recipe
|
|
68
|
+
name = signal.name
|
|
69
|
+
if sum(r.name == name for r in snapshot.signals) > 1:
|
|
70
|
+
name += f' [{signal.id[:8]}]'
|
|
71
|
+
if recipe is None:
|
|
72
|
+
return name
|
|
73
|
+
source = 'stored' if recipe.source == 'stored' else 'raw'
|
|
74
|
+
return f'{name} · {source} · frames {recipe.frames[0]}–{recipe.frames[1]}'
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Validated, durable spatial display state; numerical recipes remain signals."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
from pydantic import (
|
|
5
|
+
BaseModel,
|
|
6
|
+
ConfigDict,
|
|
7
|
+
Field,
|
|
8
|
+
StrictBool,
|
|
9
|
+
StrictInt,
|
|
10
|
+
model_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Number = Annotated[float, Field(strict=True, allow_inf_nan=False)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SpatialModel(BaseModel):
|
|
17
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Vector(SpatialModel):
|
|
21
|
+
x: Number
|
|
22
|
+
y: Number
|
|
23
|
+
z: Number
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Camera(SpatialModel):
|
|
27
|
+
eye: Vector = Vector(x=1.25, y=1.25, z=1.25)
|
|
28
|
+
center: Vector = Vector(x=0.0, y=0.0, z=0.0)
|
|
29
|
+
up: Vector = Vector(x=0.0, y=0.0, z=1.0)
|
|
30
|
+
projection: Literal["perspective", "orthographic"] = "perspective"
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def nondegenerate(self):
|
|
34
|
+
if sum(v * v for v in (self.up.x, self.up.y, self.up.z)) < 1e-12:
|
|
35
|
+
raise ValueError("Camera up vector must be nonzero.")
|
|
36
|
+
if sum(v * v for v in (self.eye.x, self.eye.y, self.eye.z)) < 1e-12:
|
|
37
|
+
raise ValueError("Camera eye must be nonzero.")
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SpatialLayer(SpatialModel):
|
|
42
|
+
signal_id: str = Field(min_length=1, max_length=128)
|
|
43
|
+
visible: StrictBool = True
|
|
44
|
+
colorscale: Literal[
|
|
45
|
+
"Viridis", "Magma", "Plasma", "Cividis", "Reds", "Blues", "Greens", "Purples"
|
|
46
|
+
] = "Viridis"
|
|
47
|
+
colorscale_reversed: StrictBool = False
|
|
48
|
+
opacity: Number = Field(default=0.12, ge=0, le=1)
|
|
49
|
+
percentile: Number = Field(default=70.0, ge=0, le=100)
|
|
50
|
+
camera: Camera = Camera()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SpatialView(SpatialModel):
|
|
54
|
+
screen: Literal["explore", "volume"] = "explore"
|
|
55
|
+
layers: tuple[SpatialLayer, ...] = Field(default=(), max_length=129)
|
|
56
|
+
mode: Literal["overlay", "tiles"] = "overlay"
|
|
57
|
+
quality: Literal["Fast", "Detailed"] = "Fast"
|
|
58
|
+
scale: Literal["independent", "shared"] = "independent"
|
|
59
|
+
linked: StrictBool = True
|
|
60
|
+
camera: Camera = Camera()
|
|
61
|
+
solo: str | None = Field(default=None, max_length=128)
|
|
62
|
+
page: StrictInt = Field(default=0, ge=0, le=128)
|
|
63
|
+
frame_stretch: Number = Field(default=1.2, ge=0.01, le=1000)
|
|
64
|
+
depth_exaggeration: Number = Field(default=1.0, ge=0.01, le=1000)
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def references(self):
|
|
68
|
+
ids = [x.signal_id for x in self.layers]
|
|
69
|
+
if len(ids) != len(set(ids)) or self.solo is not None and self.solo not in ids:
|
|
70
|
+
raise ValueError("Invalid spatial layer references.")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_spatial(spatial, signals):
|
|
75
|
+
ready = {s.id for s in signals if s.recipe is not None}
|
|
76
|
+
if not {x.signal_id for x in spatial.layers} <= ready:
|
|
77
|
+
raise ValueError("Spatial layers must reference ready named signals.")
|
|
78
|
+
return spatial
|
tofscope/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""One Python service/port, loopback by default; Node is build-time only."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
import hashlib
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import secrets
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import urllib.request
|
|
16
|
+
import webbrowser
|
|
17
|
+
import uvicorn
|
|
18
|
+
from tofscope import __version__
|
|
19
|
+
from tofscope.api.app import create_app
|
|
20
|
+
from tofscope.config import load_settings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def asset_status():
|
|
24
|
+
root = files("tofscope").joinpath("static")
|
|
25
|
+
manifest = root.joinpath("manifest.json")
|
|
26
|
+
if not manifest.is_file():
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"Frontend assets are missing. Run scripts/build_frontend.py before packaging."
|
|
29
|
+
)
|
|
30
|
+
value = json.loads(manifest.read_text())
|
|
31
|
+
for name, digest in value["assets"].items():
|
|
32
|
+
if hashlib.sha256(root.joinpath(name).read_bytes()).hexdigest() != digest:
|
|
33
|
+
raise ValueError("Frontend assets failed integrity verification.")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def open_after_ready(url, secret, stop):
|
|
38
|
+
deadline = time.monotonic() + 30
|
|
39
|
+
while not stop.is_set() and time.monotonic() < deadline:
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(url + "/readyz", timeout=0.5) as response:
|
|
42
|
+
if response.status == 200:
|
|
43
|
+
webbrowser.open(url + "/#bootstrap=" + secret)
|
|
44
|
+
return
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
stop.wait(0.1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_service(settings, browser=False):
|
|
51
|
+
asset_status()
|
|
52
|
+
secret = secrets.token_urlsafe(32)
|
|
53
|
+
url = settings.public_origin or f"http://{settings.host}:{settings.port}"
|
|
54
|
+
# Bind first so occupied ports fail before browser startup or credentials change.
|
|
55
|
+
sock = socket.socket(
|
|
56
|
+
socket.AF_INET6 if ":" in settings.host else socket.AF_INET, socket.SOCK_STREAM
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
sock.bind((settings.host, settings.port))
|
|
60
|
+
sock.listen(128)
|
|
61
|
+
except OSError:
|
|
62
|
+
sock.close()
|
|
63
|
+
raise ValueError("Port is unavailable; select another --port.") from None
|
|
64
|
+
secret_file = settings.runtime_dir / "launch.json"
|
|
65
|
+
stop = threading.Event()
|
|
66
|
+
if settings.mode == "local":
|
|
67
|
+
descriptor = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
68
|
+
os.fchmod(descriptor, 0o600)
|
|
69
|
+
with os.fdopen(descriptor, "w") as out:
|
|
70
|
+
json.dump({"url": url, "secret": secret}, out)
|
|
71
|
+
if browser and settings.mode == "local":
|
|
72
|
+
threading.Thread(
|
|
73
|
+
target=open_after_ready, args=(url, secret, stop), daemon=True
|
|
74
|
+
).start()
|
|
75
|
+
try:
|
|
76
|
+
print(f"ToFScope Explore {__version__}: {url} (original acquisitions read only)", flush=True)
|
|
77
|
+
uvicorn.Server(
|
|
78
|
+
uvicorn.Config(
|
|
79
|
+
create_app(settings, bootstrap_secret=secret),
|
|
80
|
+
host=settings.host,
|
|
81
|
+
port=settings.port,
|
|
82
|
+
workers=1,
|
|
83
|
+
limit_concurrency=64,
|
|
84
|
+
proxy_headers=False,
|
|
85
|
+
access_log=False,
|
|
86
|
+
)
|
|
87
|
+
).run(sockets=[sock])
|
|
88
|
+
finally:
|
|
89
|
+
stop.set()
|
|
90
|
+
sock.close()
|
|
91
|
+
if settings.mode == "local":
|
|
92
|
+
secret_file.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv=None):
|
|
96
|
+
p = argparse.ArgumentParser(description="ToFScope Explore; original acquisitions are read only")
|
|
97
|
+
p.add_argument(
|
|
98
|
+
"command", nargs="?", choices=["serve", "doctor", "open"], default="serve"
|
|
99
|
+
)
|
|
100
|
+
p.add_argument("--config", type=Path)
|
|
101
|
+
p.add_argument("--data-root", type=Path)
|
|
102
|
+
p.add_argument("--runtime-dir", type=Path)
|
|
103
|
+
p.add_argument("--lock-dir", type=Path)
|
|
104
|
+
p.add_argument("--output-root", type=Path)
|
|
105
|
+
p.add_argument("--host")
|
|
106
|
+
p.add_argument("--port", type=int)
|
|
107
|
+
p.add_argument("--mode", choices=["local", "proxy"])
|
|
108
|
+
p.add_argument("--no-browser", action="store_true")
|
|
109
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
110
|
+
a = p.parse_args(argv)
|
|
111
|
+
try:
|
|
112
|
+
settings = load_settings(
|
|
113
|
+
a.config,
|
|
114
|
+
cli={
|
|
115
|
+
k: getattr(a, k)
|
|
116
|
+
for k in (
|
|
117
|
+
"data_root",
|
|
118
|
+
"runtime_dir",
|
|
119
|
+
"lock_dir",
|
|
120
|
+
"output_root",
|
|
121
|
+
"host",
|
|
122
|
+
"port",
|
|
123
|
+
"mode",
|
|
124
|
+
)
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
if a.command == "doctor":
|
|
128
|
+
assets = asset_status()
|
|
129
|
+
public = asdict(settings)
|
|
130
|
+
public.update(
|
|
131
|
+
version=__version__,
|
|
132
|
+
asset_version=assets["version"],
|
|
133
|
+
scope="Explore-only",
|
|
134
|
+
writes_available=False,
|
|
135
|
+
save_copies_available=True,
|
|
136
|
+
notes="Managed array budget excludes Python/HDF5/browser allocator overhead; one API process.",
|
|
137
|
+
)
|
|
138
|
+
print(json.dumps(public, default=str, indent=2))
|
|
139
|
+
return 0
|
|
140
|
+
if a.command == "open":
|
|
141
|
+
credential = json.loads((settings.runtime_dir / "launch.json").read_text())
|
|
142
|
+
webbrowser.open(credential["url"] + "/#bootstrap=" + credential["secret"])
|
|
143
|
+
return 0
|
|
144
|
+
# An explicit service command is browser-free; the default launch opens a browser.
|
|
145
|
+
explicit_serve = bool(
|
|
146
|
+
(argv if argv is not None else sys.argv[1:])
|
|
147
|
+
and (argv if argv is not None else sys.argv[1:])[0] == "serve"
|
|
148
|
+
)
|
|
149
|
+
run_service(settings, browser=not a.no_browser and not explicit_serve)
|
|
150
|
+
return 0
|
|
151
|
+
except (ValueError, OSError) as exc:
|
|
152
|
+
print(f"ToFScope: {exc}", file=sys.stderr)
|
|
153
|
+
return 2
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|
tofscope/config.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Server-owned root, runtime and resource policy. CLI > environment > TOML."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field, fields
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
import tomllib
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Settings:
|
|
13
|
+
data_root: Path = field(default_factory=lambda: Path.cwd())
|
|
14
|
+
runtime_dir: Path = field(
|
|
15
|
+
default_factory=lambda: Path(tempfile.gettempdir()) / f"tofscope-{os.getuid()}"
|
|
16
|
+
)
|
|
17
|
+
lock_dir: Path | None = None
|
|
18
|
+
output_root: Path | None = None
|
|
19
|
+
mode: str = "local"
|
|
20
|
+
host: str = "127.0.0.1"
|
|
21
|
+
port: int = 8765
|
|
22
|
+
allow_writes: bool = False
|
|
23
|
+
allow_unverified_native: bool = False
|
|
24
|
+
initial_file: Path | None = None
|
|
25
|
+
trusted_proxies: tuple[str, ...] = ()
|
|
26
|
+
identity_header: str = "x-tofscope-user"
|
|
27
|
+
public_origin: str | None = None
|
|
28
|
+
global_bytes: int = 4 * 1024**3
|
|
29
|
+
user_bytes: int = 2 * 1024**3
|
|
30
|
+
analysis_bytes: int = 1024**3
|
|
31
|
+
response_bytes: int = 32 * 1024**2
|
|
32
|
+
analyses_per_user: int = 8
|
|
33
|
+
analyses_global: int = 32
|
|
34
|
+
jobs_per_analysis: int = 4
|
|
35
|
+
jobs_per_user: int = 8
|
|
36
|
+
jobs_global: int = 32
|
|
37
|
+
signals_per_analysis: int = 129
|
|
38
|
+
signals_per_user: int = 512
|
|
39
|
+
signals_global: int = 2048
|
|
40
|
+
workers: int = 2
|
|
41
|
+
lease_seconds: int = 45
|
|
42
|
+
reconnect_seconds: int = 120
|
|
43
|
+
idle_seconds: int = 900
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
root = Path(self.data_root).expanduser().resolve()
|
|
47
|
+
if not root.is_dir() or not os.access(root, os.R_OK | os.X_OK):
|
|
48
|
+
raise ValueError("Data root must be an accessible directory.")
|
|
49
|
+
object.__setattr__(self, "data_root", root)
|
|
50
|
+
runtime = Path(self.runtime_dir).expanduser().resolve()
|
|
51
|
+
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
lock = (
|
|
53
|
+
Path(self.lock_dir).expanduser().resolve()
|
|
54
|
+
if self.lock_dir
|
|
55
|
+
else runtime / "locks"
|
|
56
|
+
)
|
|
57
|
+
lock.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
58
|
+
if not os.access(runtime, os.W_OK) or not os.access(lock, os.W_OK):
|
|
59
|
+
raise ValueError("Runtime and lock directories must be writable.")
|
|
60
|
+
object.__setattr__(self, "runtime_dir", runtime)
|
|
61
|
+
output = Path(self.output_root).expanduser().resolve() if self.output_root else root
|
|
62
|
+
object.__setattr__(self, "output_root", output)
|
|
63
|
+
object.__setattr__(self, "lock_dir", lock)
|
|
64
|
+
if self.mode not in ("local", "proxy"):
|
|
65
|
+
raise ValueError("Mode must be local or proxy.")
|
|
66
|
+
if self.mode == "local" and self.host not in ("127.0.0.1", "::1", "localhost"):
|
|
67
|
+
raise ValueError("Local mode requires loopback binding.")
|
|
68
|
+
if self.mode == "proxy" and not self.trusted_proxies:
|
|
69
|
+
raise ValueError("Proxy mode requires trusted proxy addresses.")
|
|
70
|
+
for f in fields(self):
|
|
71
|
+
if f.type is bool and type(getattr(self, f.name)) is not bool:
|
|
72
|
+
raise ValueError(f"{f.name} must be true or false.")
|
|
73
|
+
if f.type is int and (
|
|
74
|
+
type(getattr(self, f.name)) is not int or getattr(self, f.name) <= 0
|
|
75
|
+
):
|
|
76
|
+
raise ValueError(f"{f.name} must be a positive integer.")
|
|
77
|
+
if self.initial_file is not None:
|
|
78
|
+
p = Path(self.initial_file)
|
|
79
|
+
p = p if p.is_absolute() else root / p
|
|
80
|
+
if not p.resolve().is_relative_to(root):
|
|
81
|
+
raise ValueError("Initial acquisition must be inside data root.")
|
|
82
|
+
object.__setattr__(self, "initial_file", p.resolve())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Storage validators use the policy fields directly; no alternate launcher defaults.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_settings(file=None, env: Mapping[str, str] | None = None, cli=None):
|
|
89
|
+
env = os.environ if env is None else env
|
|
90
|
+
values = {}
|
|
91
|
+
if file:
|
|
92
|
+
with Path(file).open("rb") as stream:
|
|
93
|
+
values = tomllib.load(stream).get("tofscope", {})
|
|
94
|
+
for f in fields(Settings):
|
|
95
|
+
key = "TOFSCOPE_" + f.name.upper()
|
|
96
|
+
if key in env:
|
|
97
|
+
v = env[key]
|
|
98
|
+
if f.type is bool:
|
|
99
|
+
if v.lower() not in ("true", "false"):
|
|
100
|
+
raise ValueError(f"{key} must be true or false.")
|
|
101
|
+
v = v.lower() == "true"
|
|
102
|
+
elif f.type is int:
|
|
103
|
+
v = int(v)
|
|
104
|
+
elif f.name == "trusted_proxies":
|
|
105
|
+
v = tuple(x.strip() for x in v.split(",") if x.strip())
|
|
106
|
+
values[f.name] = v
|
|
107
|
+
if "TOF_FILE" in env:
|
|
108
|
+
values["initial_file"] = env["TOF_FILE"]
|
|
109
|
+
values.update({k: v for k, v in (cli or {}).items() if v is not None})
|
|
110
|
+
return Settings(**values)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_data_path(settings, relative_path, *, require_file=False):
|
|
114
|
+
if not isinstance(relative_path, str) or "\x00" in relative_path:
|
|
115
|
+
raise ValueError("A root-relative path is required.")
|
|
116
|
+
p = Path(relative_path)
|
|
117
|
+
if p.is_absolute() or ".." in p.parts:
|
|
118
|
+
raise ValueError("Path must stay within data root.")
|
|
119
|
+
try:
|
|
120
|
+
target = (settings.data_root / p).resolve()
|
|
121
|
+
except RuntimeError:
|
|
122
|
+
raise ValueError("Invalid symbolic link.") from None
|
|
123
|
+
if not target.is_relative_to(settings.data_root) or not target.exists():
|
|
124
|
+
raise ValueError("Data path is unavailable.")
|
|
125
|
+
if require_file and not target.is_file():
|
|
126
|
+
raise ValueError("Select an acquisition file.")
|
|
127
|
+
return target
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_private_output_path(settings, path):
|
|
131
|
+
"""Keep owner output trees private even when acquisition/output roots coincide."""
|
|
132
|
+
if not path.is_relative_to(settings.output_root):
|
|
133
|
+
return False
|
|
134
|
+
if not settings.data_root.is_relative_to(settings.output_root):
|
|
135
|
+
return True
|
|
136
|
+
parts = path.relative_to(settings.output_root).parts
|
|
137
|
+
# Owner directories use the SHA-256 identity namespace. The shared root and
|
|
138
|
+
# ordinary acquisition folders remain browsable; owner folders never do.
|
|
139
|
+
return bool(parts and len(parts[0]) == 64 and all(c in '0123456789abcdef' for c in parts[0]))
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Reusable, sample-preserving depth geometry for Explore and later spatial views.
|
|
2
|
+
|
|
3
|
+
Coordinates describe original acquisition samples, never a resampled intensity grid.
|
|
4
|
+
Unknown physical depths are omitted from physical views, but retained by CSV exports.
|
|
5
|
+
Cell boundaries are display footprints, not measurements of plane thickness.
|
|
6
|
+
"""
|
|
7
|
+
import numpy as np
|
|
8
|
+
from tofscope.science.depth_calibration import DepthCalibration, convert_depth
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calibration(snapshot):
|
|
12
|
+
return DepthCalibration(snapshot.depth_anchors, snapshot.shape[0]) if len(snapshot.depth_anchors) > 1 else None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def depth_samples(snapshot):
|
|
16
|
+
frames = np.arange(snapshot.shape[0])
|
|
17
|
+
active = calibration(snapshot) if snapshot.depth_coordinates == 'depth' else None
|
|
18
|
+
if active is None:
|
|
19
|
+
return frames, frames.astype(float), 'frame', 0
|
|
20
|
+
depths = active.frame_to_depth(frames)
|
|
21
|
+
known = np.isfinite(depths)
|
|
22
|
+
return frames[known], convert_depth(depths[known], 'nm', snapshot.depth_unit), ('µm' if snapshot.depth_unit == 'um' else snapshot.depth_unit), int((~known).sum())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def block_depths(snapshot, frame_blocks):
|
|
26
|
+
"""Mean each block's calibrated samples, including anchor slope changes.
|
|
27
|
+
|
|
28
|
+
A block containing unknown samples is invalid: callers must clip before pooling.
|
|
29
|
+
"""
|
|
30
|
+
active = calibration(snapshot)
|
|
31
|
+
return [float(np.mean(active.frame_to_depth(block))) for block in frame_blocks]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, asdict
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Axis:
|
|
7
|
+
name: str
|
|
8
|
+
unit: str
|
|
9
|
+
values: list[float]
|
|
10
|
+
boundaries: list[float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Curve:
|
|
15
|
+
signal_id: str
|
|
16
|
+
style_index: int
|
|
17
|
+
label: str
|
|
18
|
+
values: list[float | None]
|
|
19
|
+
coverage: list[bool]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Panel:
|
|
24
|
+
kind: Literal["image", "line"]
|
|
25
|
+
content_version: str
|
|
26
|
+
axes: list[Axis]
|
|
27
|
+
values: list[list[float | None]] = field(default_factory=list)
|
|
28
|
+
coverage: list[list[bool]] = field(default_factory=list)
|
|
29
|
+
series: list[Curve] = field(default_factory=list)
|
|
30
|
+
units: str = "intensity"
|
|
31
|
+
aggregation: str = "original"
|
|
32
|
+
intensity_range: tuple[float, float] = (0, 1)
|
|
33
|
+
palette: Literal["ion", "gray", "viridis", "magma", "cividis"] = "viridis"
|
|
34
|
+
log: bool = False
|
|
35
|
+
relative: bool = False
|
|
36
|
+
signal_id: str | None = None
|
|
37
|
+
signal_name: str | None = None
|
|
38
|
+
signal_version: int | None = None
|
|
39
|
+
frame_range: tuple[int, int] | None = None
|
|
40
|
+
acquisition_frames: list[int] = field(default_factory=list)
|
|
41
|
+
omitted_frames: int = 0
|
|
42
|
+
coordinate_kind: Literal['frame', 'depth'] = 'frame'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ExploreData:
|
|
48
|
+
analysis_id: str
|
|
49
|
+
revision: int
|
|
50
|
+
dataset_generation: str
|
|
51
|
+
panels: dict[str, Panel]
|
|
52
|
+
|
|
53
|
+
def to_dict(self):
|
|
54
|
+
return asdict(self)
|