dirigo 0.3.8__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 (45) hide show
  1. dirigo-0.3.8/LICENSE +21 -0
  2. dirigo-0.3.8/PKG-INFO +71 -0
  3. dirigo-0.3.8/README.md +46 -0
  4. dirigo-0.3.8/dirigo/__init__.py +6 -0
  5. dirigo-0.3.8/dirigo/components/__init__.py +0 -0
  6. dirigo-0.3.8/dirigo/components/hardware.py +206 -0
  7. dirigo-0.3.8/dirigo/components/io.py +226 -0
  8. dirigo-0.3.8/dirigo/components/optics.py +82 -0
  9. dirigo-0.3.8/dirigo/components/profiling.py +128 -0
  10. dirigo-0.3.8/dirigo/components/units.py +648 -0
  11. dirigo-0.3.8/dirigo/hw_interfaces/__init__.py +4 -0
  12. dirigo-0.3.8/dirigo/hw_interfaces/camera.py +221 -0
  13. dirigo-0.3.8/dirigo/hw_interfaces/detector.py +112 -0
  14. dirigo-0.3.8/dirigo/hw_interfaces/digitizer.py +870 -0
  15. dirigo-0.3.8/dirigo/hw_interfaces/encoder.py +122 -0
  16. dirigo-0.3.8/dirigo/hw_interfaces/hw_interface.py +24 -0
  17. dirigo-0.3.8/dirigo/hw_interfaces/illuminator.py +35 -0
  18. dirigo-0.3.8/dirigo/hw_interfaces/scanner.py +453 -0
  19. dirigo-0.3.8/dirigo/hw_interfaces/stage.py +316 -0
  20. dirigo-0.3.8/dirigo/main.py +164 -0
  21. dirigo-0.3.8/dirigo/plugins/__init__.py +0 -0
  22. dirigo-0.3.8/dirigo/plugins/acquisitions.py +997 -0
  23. dirigo-0.3.8/dirigo/plugins/calibrations.py +413 -0
  24. dirigo-0.3.8/dirigo/plugins/detectors.py +66 -0
  25. dirigo-0.3.8/dirigo/plugins/displays.py +413 -0
  26. dirigo-0.3.8/dirigo/plugins/encoders.py +313 -0
  27. dirigo-0.3.8/dirigo/plugins/illuminators.py +43 -0
  28. dirigo-0.3.8/dirigo/plugins/loaders.py +94 -0
  29. dirigo-0.3.8/dirigo/plugins/processors.py +722 -0
  30. dirigo-0.3.8/dirigo/plugins/scanners.py +836 -0
  31. dirigo-0.3.8/dirigo/plugins/writers.py +292 -0
  32. dirigo-0.3.8/dirigo/sw_interfaces/__init__.py +6 -0
  33. dirigo-0.3.8/dirigo/sw_interfaces/acquisition.py +168 -0
  34. dirigo-0.3.8/dirigo/sw_interfaces/display.py +235 -0
  35. dirigo-0.3.8/dirigo/sw_interfaces/processor.py +77 -0
  36. dirigo-0.3.8/dirigo/sw_interfaces/worker.py +222 -0
  37. dirigo-0.3.8/dirigo/sw_interfaces/writer.py +84 -0
  38. dirigo-0.3.8/dirigo.egg-info/PKG-INFO +71 -0
  39. dirigo-0.3.8/dirigo.egg-info/SOURCES.txt +43 -0
  40. dirigo-0.3.8/dirigo.egg-info/dependency_links.txt +1 -0
  41. dirigo-0.3.8/dirigo.egg-info/entry_points.txt +52 -0
  42. dirigo-0.3.8/dirigo.egg-info/requires.txt +6 -0
  43. dirigo-0.3.8/dirigo.egg-info/top_level.txt +1 -0
  44. dirigo-0.3.8/pyproject.toml +89 -0
  45. dirigo-0.3.8/setup.cfg +4 -0
dirigo-0.3.8/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Massachusetts Institute of Technology
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.
dirigo-0.3.8/PKG-INFO ADDED
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirigo
3
+ Version: 0.3.8
4
+ Summary: Dirigo is an extensible backend for scientific image acquisition
5
+ Author-email: "T. D. Weber" <tweber@mit.edu>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/dirigo-developers/dirigo
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Topic :: System :: Hardware
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numba
19
+ Requires-Dist: scipy
20
+ Requires-Dist: toml
21
+ Requires-Dist: platformdirs
22
+ Requires-Dist: tifffile
23
+ Requires-Dist: nidaqmx
24
+ Dynamic: license-file
25
+
26
+ # Dirigo
27
+ ![PyPI](https://img.shields.io/pypi/v/dirigo)
28
+ [![Documentation Status](https://readthedocs.org/projects/dirigo/badge/?version=latest)](https://dirigo.readthedocs.io/en/latest/?badge=latest)
29
+
30
+ **Dirigo** is an extensible, high-performance backend for scientific image acquisition, designed with high-speed laser scanning microscopy in mind but adaptable to a wide range of medium- to high-complexity imaging systems.
31
+
32
+ Dirigo separates hardware control, acquisition logic, and user interface, making it easy to:
33
+
34
+ - Add new hardware via plugin drivers that implement generic device interfaces (digitizers, scanners, stages, cameras, etc.)
35
+
36
+ - Reconfigure data acquisition and processing pipelines using a Worker/publisher–subscriber model
37
+
38
+ - Integrate adaptive acquisition strategies through feedback loops between processing and control
39
+
40
+ - Build custom GUIs or integrate with existing tools via a clean API (a reference GUI is available as a [separate package](https://github.com/dirigo-developers/dirigo-gui))
41
+
42
+ Performance-critical operations are accelerated with [Numba](https://numba.pydata.org/) JIT compilation, releasing the GIL during execution and enabling parallel/vectorized processing.
43
+
44
+ Dirigo follows a modular, package-oriented architecture: almost all components—hardware drivers, processing modules, GUIs—are separate Python packages that can be developed, installed, and updated independently.
45
+
46
+ Dirigo is in *very* early development. While the API and architecture are functional, documentation and ready-to-use releases are in progress.
47
+
48
+
49
+ ## Digitizer ↔ LSM mode compatibility
50
+
51
+ **Legend:** ✓ supported now · △ possible/experimental · ✗ not recommended/unsupported · — unknown
52
+
53
+ | Digitizer (vendor/model family) | Galvo–Galvo **analog** | Galvo–Galvo **photon counting** | Resonant–Galvo **analog** | Polygon–Galvo **analog** |
54
+ |---|:---:|:---:|:---:|:---:|
55
+ | **NI X-Series** (e.g., PCIe-63xx) | ✓* | ✓ (up to 4 chan.) | ✗† | ✗† |
56
+ | **NI S-Series** (e.g., PCI-6110/6115) | ✓ | ✓ (up to 2 chan.) | △§ | △§ |
57
+ | **AlazarTech** (e.g., ATS9440) | ✓ | ✗ | ✓ | ✓ |
58
+ | **Teledyne SP Devices** (e.g., ADQ32) | ✓ | ✗ | ✓ | ✓ |
59
+ | **Other / custom** (contact [TDW](https://github.com/tweber225)) | — | — | — | — |
60
+
61
+ *Notes*
62
+ \* Multichannel acquisition subject to aggregate AI sample rate (e.g 2 channels: 500 kS/s, 4 channels 250 kS/s).
63
+ † AI sample rate typically insufficient for resonant/polygon rates.
64
+ § Borderline: Max sample rate may limit pixels per line, dependent on scanner frequency. Not yet validated.
65
+
66
+
67
+ ## Funding
68
+
69
+ Development of Dirigo has been supported in part by the National Cancer Institute of the National Institutes of Health under award number R01CA249151.
70
+
71
+ The content of this repository is solely the responsibility of the authors and does not necessarily represent the official views of the National Institutes of Health.
dirigo-0.3.8/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # Dirigo
2
+ ![PyPI](https://img.shields.io/pypi/v/dirigo)
3
+ [![Documentation Status](https://readthedocs.org/projects/dirigo/badge/?version=latest)](https://dirigo.readthedocs.io/en/latest/?badge=latest)
4
+
5
+ **Dirigo** is an extensible, high-performance backend for scientific image acquisition, designed with high-speed laser scanning microscopy in mind but adaptable to a wide range of medium- to high-complexity imaging systems.
6
+
7
+ Dirigo separates hardware control, acquisition logic, and user interface, making it easy to:
8
+
9
+ - Add new hardware via plugin drivers that implement generic device interfaces (digitizers, scanners, stages, cameras, etc.)
10
+
11
+ - Reconfigure data acquisition and processing pipelines using a Worker/publisher–subscriber model
12
+
13
+ - Integrate adaptive acquisition strategies through feedback loops between processing and control
14
+
15
+ - Build custom GUIs or integrate with existing tools via a clean API (a reference GUI is available as a [separate package](https://github.com/dirigo-developers/dirigo-gui))
16
+
17
+ Performance-critical operations are accelerated with [Numba](https://numba.pydata.org/) JIT compilation, releasing the GIL during execution and enabling parallel/vectorized processing.
18
+
19
+ Dirigo follows a modular, package-oriented architecture: almost all components—hardware drivers, processing modules, GUIs—are separate Python packages that can be developed, installed, and updated independently.
20
+
21
+ Dirigo is in *very* early development. While the API and architecture are functional, documentation and ready-to-use releases are in progress.
22
+
23
+
24
+ ## Digitizer ↔ LSM mode compatibility
25
+
26
+ **Legend:** ✓ supported now · △ possible/experimental · ✗ not recommended/unsupported · — unknown
27
+
28
+ | Digitizer (vendor/model family) | Galvo–Galvo **analog** | Galvo–Galvo **photon counting** | Resonant–Galvo **analog** | Polygon–Galvo **analog** |
29
+ |---|:---:|:---:|:---:|:---:|
30
+ | **NI X-Series** (e.g., PCIe-63xx) | ✓* | ✓ (up to 4 chan.) | ✗† | ✗† |
31
+ | **NI S-Series** (e.g., PCI-6110/6115) | ✓ | ✓ (up to 2 chan.) | △§ | △§ |
32
+ | **AlazarTech** (e.g., ATS9440) | ✓ | ✗ | ✓ | ✓ |
33
+ | **Teledyne SP Devices** (e.g., ADQ32) | ✓ | ✗ | ✓ | ✓ |
34
+ | **Other / custom** (contact [TDW](https://github.com/tweber225)) | — | — | — | — |
35
+
36
+ *Notes*
37
+ \* Multichannel acquisition subject to aggregate AI sample rate (e.g 2 channels: 500 kS/s, 4 channels 250 kS/s).
38
+ † AI sample rate typically insufficient for resonant/polygon rates.
39
+ § Borderline: Max sample rate may limit pixels per line, dependent on scanner frequency. Not yet validated.
40
+
41
+
42
+ ## Funding
43
+
44
+ Development of Dirigo has been supported in part by the National Cancer Institute of the National Institutes of Health under award number R01CA249151.
45
+
46
+ The content of this repository is solely the responsibility of the authors and does not necessarily represent the official views of the National Institutes of Health.
@@ -0,0 +1,6 @@
1
+ print('importing dirigo')
2
+
3
+ from .components import units, io
4
+
5
+
6
+ __all__ = ["units", "io"]
File without changes
@@ -0,0 +1,206 @@
1
+ from functools import lru_cache, cached_property
2
+ import importlib.metadata as im
3
+ from typing import TYPE_CHECKING
4
+
5
+ from dirigo.components.optics import LaserScanningOptics, CameraOptics
6
+ from dirigo.hw_interfaces.detector import DetectorSet, Detector
7
+
8
+ if TYPE_CHECKING:
9
+ from dirigo.components.io import SystemConfig
10
+ from dirigo.hw_interfaces.digitizer import Digitizer
11
+ from dirigo.hw_interfaces.stage import MultiAxisStage
12
+ from dirigo.hw_interfaces.encoder import MultiAxisLinearEncoder
13
+ from dirigo.hw_interfaces.scanner import FastRasterScanner, SlowRasterScanner, ObjectiveZScanner
14
+ from dirigo.hw_interfaces.camera import FrameGrabber, LineCamera
15
+ from dirigo.hw_interfaces.illuminator import Illuminator
16
+
17
+
18
+
19
+ @lru_cache
20
+ def _eps(group: str) -> dict[str, im.EntryPoint]:
21
+ return {ep.name.lower(): ep for ep in im.entry_points().select(group=group)}
22
+
23
+
24
+ class HardwareError(RuntimeError): pass
25
+
26
+
27
+ class NotConfiguredError(HardwareError):
28
+ def __init__(self, section: str):
29
+ super().__init__(f"[{section}] missing in system_config.toml")
30
+ self.section = section
31
+
32
+
33
+ class PluginNotFoundError(HardwareError):
34
+ def __init__(self, group: str, type_name: str, available: list[str]):
35
+ avail = ", ".join(available) or "none"
36
+ super().__init__(f"No plugin '{type_name}' in entry-point group '{group}'. Available: {avail}")
37
+ self.group, self.type_name, self.available = group, type_name, available
38
+
39
+
40
+ class PluginInitError(HardwareError):
41
+ def __init__(self, cls, kwargs: dict):
42
+ super().__init__(f"Failed to initialize {cls.__name__} with kwargs {kwargs}")
43
+ self.cls, self.kwargs = cls, kwargs
44
+
45
+
46
+ class Hardware:
47
+ """
48
+ Loads hardware specified in system_config.toml and holds references to the
49
+ components.
50
+ """
51
+ def __init__(self, system_config: "SystemConfig") -> None:
52
+ self._cfg = system_config
53
+
54
+ # --- helper ---
55
+ def _load(self, group: str, type_name: str, **kw):
56
+ """
57
+ Lazy-load the concrete driver class registered under *type_name*
58
+ in entry-point *group*, instantiate it with **kw, and return it.
59
+ """
60
+ try:
61
+ cls = _eps(group)[type_name.lower()].load()
62
+ except KeyError as e:
63
+ raise PluginNotFoundError(group, type_name, available=list(_eps(group)).copy()) from e
64
+ try:
65
+ return cls(**kw)
66
+ except TypeError as e:
67
+ raise PluginInitError(cls, kwargs=kw) from e
68
+
69
+ # --- hardward devices (lazy loading) ---
70
+ @cached_property
71
+ def digitizer(self) -> "Digitizer":
72
+ cfg = self._cfg.digitizer
73
+ if cfg is None:
74
+ raise NotConfiguredError("digitizer")
75
+ return self._load("dirigo_digitizers", cfg["type"], **cfg)
76
+
77
+ @cached_property
78
+ def fast_raster_scanner(self) -> "FastRasterScanner":
79
+ cfg = self._cfg.fast_raster_scanner
80
+ if cfg is None:
81
+ raise NotConfiguredError("fast raster scanner")
82
+ return self._load("dirigo_scanners", cfg["type"], **cfg)
83
+
84
+ @cached_property
85
+ def slow_raster_scanner(self) -> "SlowRasterScanner":
86
+ cfg = self._cfg.slow_raster_scanner
87
+ if cfg is None:
88
+ raise NotConfiguredError("slow raster scanner")
89
+ return self._load("dirigo_scanners", cfg["type"],
90
+ fast_scanner=self.fast_raster_scanner, **cfg)
91
+
92
+ @cached_property
93
+ def objective_z_scanner(self) -> "ObjectiveZScanner":
94
+ cfg = self._cfg.objective_z_scanner
95
+ if cfg is None:
96
+ raise NotConfiguredError("objective z scanner")
97
+ return self._load("dirigo_scanners", cfg["type"], **cfg)
98
+
99
+ @cached_property
100
+ def stages(self) -> "MultiAxisStage":
101
+ cfg = self._cfg.stages
102
+ if cfg is None:
103
+ raise NotConfiguredError("stages")
104
+ return self._load("dirigo_stages", cfg["type"], **cfg)
105
+
106
+ @cached_property
107
+ def preferred_z_motor(self):
108
+ """Returns the objective z scanner or multi-axis stage z axis."""
109
+ try:
110
+ return self.objective_z_scanner
111
+ except NotConfiguredError:
112
+ pass
113
+
114
+ try:
115
+ return self.stages.z
116
+ except (NotConfiguredError, NotImplementedError):
117
+ pass
118
+
119
+ raise NotConfiguredError("No Z motor")
120
+
121
+ @cached_property
122
+ def encoders(self) -> "MultiAxisLinearEncoder":
123
+ cfg = self._cfg.encoders
124
+ if cfg is None:
125
+ raise NotConfiguredError("encoders")
126
+ return self._load("dirigo_encoders", cfg["type"], **cfg)
127
+
128
+ @cached_property
129
+ def detectors(self) -> DetectorSet[Detector]:
130
+ cfg = self._cfg.detectors
131
+ if cfg is None:
132
+ raise NotConfiguredError("detectors")
133
+ dset = DetectorSet()
134
+ for key, det_cfg in cfg.items():
135
+ det = self._load("dirigo_detectors",
136
+ det_cfg.pop("type"),
137
+ **det_cfg,
138
+ fast_scanner=self.fast_raster_scanner)
139
+ dset.append(det)
140
+ return dset
141
+
142
+ @cached_property
143
+ def frame_grabber(self) -> "FrameGrabber":
144
+ cfg = self._cfg.frame_grabber
145
+ if cfg is None:
146
+ raise NotConfiguredError("frame grabber")
147
+ return self._load("dirigo_frame_grabbers", cfg["type"], **cfg)
148
+
149
+ @cached_property
150
+ def line_camera(self) -> "LineCamera": # this could just be camera
151
+ cfg = self._cfg.line_camera
152
+ if cfg is None:
153
+ raise NotConfiguredError("line camera")
154
+ return self._load("dirigo_line_cameras", cfg["type"],
155
+ frame_grabber=self.frame_grabber, **cfg)
156
+
157
+ @cached_property
158
+ def illuminator(self) -> "Illuminator":
159
+ cfg = self._cfg.illuminator
160
+ if cfg is None:
161
+ raise NotConfiguredError("illuminator")
162
+ return self._load("dirigo_illuminators", cfg["type"], **cfg)
163
+
164
+ @cached_property
165
+ def laser_scanning_optics(self) -> LaserScanningOptics:
166
+ cfg = self._cfg.laser_scanning_optics
167
+ if cfg is None:
168
+ raise NotConfiguredError("laser scanning optics")
169
+ return LaserScanningOptics(**cfg)
170
+
171
+ @cached_property
172
+ def camera_optics(self) -> CameraOptics:
173
+ cfg = self._cfg.camera_optics
174
+ if cfg is None:
175
+ raise NotConfiguredError("camera optics")
176
+ return CameraOptics(**cfg)
177
+
178
+ # --- conveniences ---
179
+ # TODO deprecate these methods to restore Hardware as a container/lazy loader
180
+ @property
181
+ def nchannels_enabled(self) -> int:
182
+ """
183
+ Returns the number channels currently enabled on the primary data
184
+ acquisition device.
185
+ """
186
+ if self._cfg.digitizer:
187
+ return sum([channel.enabled for channel in self.digitizer.channels])
188
+ elif self._cfg.line_camera:
189
+ return 1 # monochrome only for now, but RGB cameras should be 3-channel
190
+ else:
191
+ raise RuntimeError("No channels available: lacking digitizer or camera")
192
+
193
+ @property
194
+ def nchannels_present(self) -> int:
195
+ """
196
+ Returns the number channels present on the primary data acquisition
197
+ device.
198
+ """
199
+ if self._cfg.digitizer:
200
+ return len(self.digitizer.channels)
201
+ elif self._cfg.line_camera:
202
+ return 1 # monochrome only for now, but RGB cameras should be 3-channel
203
+ else:
204
+ raise RuntimeError("No channels available: lacking digitizer or camera")
205
+
206
+ # TODO __repr__ method
@@ -0,0 +1,226 @@
1
+ from pathlib import Path
2
+ import tomllib
3
+ import csv
4
+ from typing import Any
5
+ from functools import cached_property
6
+ from types import MappingProxyType
7
+ from copy import deepcopy
8
+
9
+ import numpy as np
10
+ from numpy.polynomial.polynomial import Polynomial
11
+ import platformdirs as pd
12
+
13
+ from dirigo.components import units
14
+
15
+
16
+
17
+ def config_path() -> Path:
18
+ return pd.user_config_path("Dirigo")
19
+
20
+
21
+ def load_toml(file_name: Path | str) -> dict[str, Any]:
22
+ file_name = Path(file_name)
23
+ if not file_name.exists():
24
+ raise FileNotFoundError(f"Can not find TOML file: {file_name}")
25
+ if file_name.suffix != ".toml":
26
+ raise ValueError(f"Requested to load a non-TOML file: {file_name}")
27
+ with open(file_name, mode="rb") as toml_file:
28
+ toml_contents = tomllib.load(toml_file)
29
+ return toml_contents
30
+
31
+
32
+ try:
33
+ d = load_toml(config_path() / "logging.toml") # 1st choice: user-specified path
34
+ _data_path = Path(d['data_path'])
35
+ except:
36
+ _data_path = pd.user_documents_path() / "Dirigo" # Backup path
37
+
38
+ def data_path() -> Path:
39
+ return _data_path
40
+
41
+ def load_scanner_calibration(
42
+ path: Path = config_path() / "scanner/calibration.csv"
43
+ ) -> tuple:
44
+
45
+ ampls, freqs, phases = np.loadtxt(
46
+ path,
47
+ delimiter=',',
48
+ unpack=True,
49
+ skiprows=1
50
+ )
51
+ return ampls, freqs, phases
52
+
53
+
54
+ def load_line_distortion_calibration(
55
+ amplitude: units.Angle,
56
+ path: Path = config_path() / "optics/line_distortion_calibration.csv"
57
+ ):
58
+ data = np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1, ndmin=2)
59
+ amplitudes = data[:,0]
60
+ coefs = data[:,1:]
61
+
62
+ for i,a in enumerate(amplitudes):
63
+ if abs(a - amplitude)/amplitude < 0.001:
64
+ return Polynomial(coefs[i])
65
+
66
+ raise RuntimeError("Could not find distortion calibration")
67
+
68
+
69
+ def update_distortion_table(
70
+ scanner_ampl: units.Angle,
71
+ c0=None,
72
+ c1=None,
73
+ c2=None,
74
+ path: Path = config_path() / "optics/line_distortion_calibration.csv"
75
+ ):
76
+ """
77
+ Update polynomial coefficients for a given scanner amplitude.
78
+
79
+ Parameters
80
+ ----------
81
+ scanner_ampl : units.Angle
82
+ Scanner amplitude (matched in radians).
83
+ c0, c1, c2 : float | None
84
+ Polynomial coefficients to update. None leaves value unchanged.
85
+ path : Path
86
+ CSV file path.
87
+ """
88
+ rows = []
89
+ updated = False
90
+
91
+ with path.open("r", newline="") as f:
92
+ reader = csv.DictReader(f)
93
+ fieldnames = reader.fieldnames
94
+ if fieldnames is None:
95
+ raise ValueError("CSV has no header")
96
+
97
+ for row in reader:
98
+ row_rad = float(row["# scanner amplitude (rad)"])
99
+
100
+ if abs(row_rad - scanner_ampl) <= 1e-3:
101
+ if c0 is not None:
102
+ row["c0"] = f"{float(c0):.16e}"
103
+ if c1 is not None:
104
+ row["c1"] = f"{float(c1):.16e}"
105
+ if c2 is not None:
106
+ row["c2"] = f"{float(c2):.16e}"
107
+ updated = True
108
+
109
+ rows.append(row)
110
+
111
+ if not updated:
112
+ raise ValueError(
113
+ f"No entry found for scanner amplitude {scanner_ampl}"
114
+ )
115
+
116
+ with path.open("w", newline="") as f:
117
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
118
+ writer.writeheader()
119
+ writer.writerows(rows)
120
+
121
+
122
+ def load_stage_scanner_angle(
123
+ path: Path = config_path() / "optics/stage_scanner_angle.csv"
124
+ ) -> units.Angle:
125
+ try:
126
+ data = np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1)
127
+ return units.Angle(float(data))
128
+ except FileNotFoundError:
129
+ # If not calibrated, then return 0 angle (no error axis error)
130
+ return units.Angle("0 deg")
131
+
132
+
133
+ def load_signal_offset(
134
+ path: Path = config_path() / "digitizer/signal_offset.csv"
135
+ ):
136
+ try:
137
+ return np.loadtxt(path, delimiter=',', dtype=np.float64, skiprows=1, ndmin=1)
138
+ except FileNotFoundError:
139
+ return np.array([0])
140
+
141
+
142
+ def load_line_gradient_calibration(
143
+ line_width: units.Position,
144
+ pixel_size: units.Position,
145
+ path: Path = config_path() / "optics/line_gradient.csv"
146
+ ):
147
+ """Returns a function to correct intensity vignetting."""
148
+
149
+ entries = np.loadtxt(
150
+ fname = path,
151
+ delimiter = ',',
152
+ dtype = np.float64,
153
+ skiprows = 1,
154
+ )
155
+
156
+ x = entries[:,0]
157
+ if abs(line_width - (x[-1] - x[0])) / line_width > 0.001:
158
+ raise RuntimeError("Line gradient calibrated on different line size.")
159
+ print(abs(pixel_size - (x[1] - x[0])))
160
+ if abs(pixel_size - (x[1] - x[0])) / pixel_size > 0.01:
161
+ raise RuntimeError("Line gradient calibrated on different pixel size.")
162
+ # one could interpolate here probably TODO
163
+
164
+ n_x, n_c = entries.shape
165
+ n_c -= 1 # -1 to account for x axis label
166
+ correction = np.zeros((n_x,n_c), dtype=np.float32)
167
+ for c in range(n_c):
168
+ y = entries[:,c+1]
169
+ correction[:,c] = 1 / y
170
+
171
+ return np.average(correction,axis=1)
172
+
173
+
174
+ _CONFIG_KEYS = (
175
+ "laser_scanning_optics", "camera_optics", "digitizer", "detectors",
176
+ "objective_z_scanner", "stages", "line_camera", "illuminator",
177
+ "frame_grabber", "encoders", "fast_raster_scanner", "slow_raster_scanner",
178
+ )
179
+
180
+ class SystemConfig:
181
+ """
182
+ Slotted, read-mostly config: each known section becomes an attribute whose
183
+ value is either a dict[...] or None if the section is absent in TOML.
184
+ No cached_property needed; lookups are direct attribute access.
185
+ """
186
+ __slots__ = (*_CONFIG_KEYS, "_raw")
187
+
188
+ # explicit (optional) type hints for IDEs / type checkers
189
+ laser_scanning_optics: dict[str, Any] | None
190
+ camera_optics: dict[str, Any] | None
191
+ digitizer: dict[str, Any] | None
192
+ detectors: dict[str, Any] | None
193
+ objective_z_scanner: dict[str, Any] | None
194
+ stages: dict[str, Any] | None
195
+ line_camera: dict[str, Any] | None
196
+ illuminator: dict[str, Any] | None
197
+ frame_grabber: dict[str, Any] | None
198
+ encoders: dict[str, Any] | None
199
+ fast_raster_scanner: dict[str, Any] | None
200
+ slow_raster_scanner: dict[str, Any] | None
201
+
202
+ def __init__(self, data: dict[str, dict[str, Any]]):
203
+ self._raw = data
204
+ for key in _CONFIG_KEYS:
205
+ setattr(self, key, data.get(key)) # missing -> None
206
+
207
+ @classmethod
208
+ def from_toml(cls, toml_path: "Path") -> "SystemConfig":
209
+ return cls(load_toml(toml_path))
210
+
211
+ def has(self, key: str) -> bool:
212
+ return getattr(self, key) is not None
213
+
214
+ def to_dict(self, *, copy: bool = False, readonly: bool = False) -> dict[str, dict[str, Any] | None]:
215
+ # expose the normalized view (sections -> dict | None)
216
+ d = {k: getattr(self, k) for k in _CONFIG_KEYS}
217
+ if copy:
218
+ return deepcopy(d)
219
+ if readonly:
220
+ return MappingProxyType(d)
221
+ return d
222
+
223
+ def raw(self) -> dict[str, dict[str, Any]]:
224
+ # original TOML mapping (no None entries; missing keys absent)
225
+ return self._raw
226
+
@@ -0,0 +1,82 @@
1
+ from typing import Optional
2
+
3
+ from dirigo.components import units, io
4
+
5
+
6
+ """
7
+ Notes:
8
+ Concerning distortion and magnification error, we assume that the scanner always
9
+ produces the correct angle. This may or may not be true, but besides the point.
10
+ Any corrections are applied at Optics class level.
11
+ """
12
+
13
+ class LaserScanningOptics:
14
+ def __init__(self,
15
+ objective_focal_length: str,
16
+ relay_magnification: float) -> None:
17
+ """
18
+ fast_axis_correction (float): extends scan by factor to correct mag error
19
+ """
20
+ self._objective_focal_length = units.Position(objective_focal_length)
21
+ self._relay_magnification = float(relay_magnification)
22
+
23
+ # load line width calibration, TODO
24
+
25
+ # Load stage-scanner angle
26
+ self.stage_scanner_angle = io.load_stage_scanner_angle()
27
+
28
+
29
+ @property
30
+ def objective_focal_length(self) -> units.Position:
31
+ """Returns the objective focal length."""
32
+ return self._objective_focal_length
33
+
34
+ @property
35
+ def relay_magnification(self) -> float:
36
+ """Returns the scan relay system (typically: scan lens + tube lens)
37
+ lateral magnification.
38
+ """
39
+ return self._relay_magnification
40
+
41
+ def scan_angle_to_object_position(self,
42
+ angle: units.Angle,
43
+ axis: Optional[str] = None
44
+ ) -> units.Position:
45
+ """
46
+ Return the focus position for a certain scanner angle (optical).
47
+
48
+ Specify axis ('fast', 'slow') to invoke correction factor.
49
+ """
50
+ objective_angle = angle / self.relay_magnification
51
+ position = float(objective_angle) * self.objective_focal_length
52
+ return units.Position(position)
53
+
54
+ def object_position_to_scan_angle(self,
55
+ position: units.Position) -> units.Angle:
56
+ """
57
+ Return the scanner angle (optical) required for a certain focus position.
58
+
59
+ Specify axis ('fast', 'slow') to invoke correction factor.
60
+ """
61
+ objective_angle = position / self.objective_focal_length
62
+ angle = objective_angle * self.relay_magnification
63
+
64
+ return units.Angle(angle)
65
+
66
+
67
+ class CameraOptics:
68
+ """
69
+ Optics to use with a parallel array of detectors, usually an image sensor.
70
+ """
71
+ def __init__(self, magnification: float | int, **kwargs):
72
+ if not (isinstance(magnification, float) or isinstance(magnification, int) ):
73
+ raise ValueError("Magnification must be a float or an integer")
74
+ self._magnification = float(magnification)
75
+
76
+ self.stage_camera_angle = units.Angle(0) # TODO load this from calibration
77
+
78
+ @property
79
+ def magnification(self) -> float:
80
+ return self._magnification
81
+
82
+