Coverage for labnirs2snirf / layout.py: 100%
46 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-28 06:02 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-28 06:02 +0000
1"""
2Functions related to importing probe positions.
3"""
5import logging
6from pathlib import Path
8from .error import Labnirs2SnirfError
9from .model import Nirs
12class LayoutError(Labnirs2SnirfError):
13 """Custom error class for layout-related issues."""
16type Layout_3D = dict[str, tuple[float, float, float]]
18log = logging.getLogger(__name__)
21def read_layout(file: Path) -> Layout_3D:
22 """
23 Read optode coordinates from file.
25 Parameters
26 ----------
27 file : Path
28 Path pointing to location file.
30 Returns
31 -------
32 dict[str, tuple[float, float, float]]
33 Dict mapping probe labels to 3D coordinates.
35 Notes
36 -----
37 Location files are expected to follow the .sfp format, which is a tab-separated text file
38 with columns: label, x, y, and z, where 'x', 'y', and 'z' are the 3D coordinates of the optode.
39 Labels are case-sensitive. If duplicate labels are found, a warning is logged and the last
40 occurrence is used.
41 """
43 import polars as pl # pylint: disable=C0415
45 log.debug("Reading layout file: %s", file)
47 try:
48 locations = (
49 pl.scan_csv(
50 file,
51 has_header=False,
52 separator="\t",
53 schema=pl.Schema(
54 zip(["label", "x", "y", "z"], [pl.String] + [pl.Float64] * 3),
55 ),
56 )
57 # remove whitespace around values
58 .with_columns(pl.col(pl.String).str.strip_chars())
59 .collect()
60 )
62 # Check for duplicate labels
63 if locations.height != locations.select("label").unique().height:
64 duplicates = (
65 locations.group_by("label")
66 .agg(pl.len().alias("count"))
67 .filter(pl.col("count") > 1)
68 .get_column("label")
69 .to_list()
70 )
71 log.warning(
72 "Duplicate labels found in layout file: %s. Last occurrence will be used.",
73 ", ".join(duplicates),
74 )
76 layout_dict = {
77 row["label"]: (row["x"], row["y"], row["z"])
78 for row in locations.rows(named=True)
79 }
81 log.debug(
82 "Successfully read %d optode positions from layout file",
83 len(layout_dict),
84 )
85 return layout_dict
87 except (
88 pl.exceptions.ComputeError,
89 pl.exceptions.ColumnNotFoundError,
90 pl.exceptions.SchemaError,
91 ) as e:
92 log.exception("Failed to read layout file %s: %s", file, e)
93 raise LayoutError(
94 f"Failed to read layout file {file}: {e}. "
95 "Ensure the file is tab-separated with four columns: label, x, y, z,"
96 " and that x, y, z are numeric values.",
97 ) from e
98 except FileNotFoundError as e:
99 log.exception("Layout file not found: %s", file)
100 raise LayoutError(f"Layout file not found: {file}") from e
102 except Exception as ex:
103 log.exception("Failed to read layout file %s: %s", file, ex)
104 raise
107def update_layout(data: Nirs, locations: Layout_3D) -> None:
108 """
109 Update source and position 3D coordinates in nirs data.
111 Parameters
112 ----------
113 data : model.Nirs
114 Nirs data produced by `labirs.read_nirs()`. Probes have labels (Si, Di)
115 corresponding to their original numbering in the exported labNIRS data.
116 locations : dict[str, tuple[float, float, float]]
117 Mapping of probe labels to 3D coordinates. These can be read in from file
118 by `read_layout()`. Coordinates are always stored as 3D tuples - when only
119 2D is available, Z is set to 0. Labels are case-sensitive.
121 Raises
122 ------
123 LayoutError
124 If no probe labels present in ``data``.
125 """
126 if data.probe.sourceLabels is None and data.probe.detectorLabels is None:
127 raise LayoutError(
128 "Update layout failed because there are no probe labels in NIRS data.",
129 )
131 log.debug("Updating probe positions with %d provided locations", len(locations))
133 sources_updated = 0
134 detectors_updated = 0
136 # Update source positions
137 if data.probe.sourceLabels:
138 for i, label in enumerate(data.probe.sourceLabels):
139 if label in locations:
140 data.probe.sourcePos3D[i, :] = locations[label]
141 sources_updated += 1
142 else:
143 log.warning(
144 "Source %s missing position data, keeping default (0, 0, 0)",
145 label,
146 )
148 # Update detector positions
149 if data.probe.detectorLabels:
150 for i, label in enumerate(data.probe.detectorLabels):
151 if label in locations:
152 data.probe.detectorPos3D[i, :] = locations[label]
153 detectors_updated += 1
154 else:
155 log.warning(
156 "Detector %s missing position data, keeping default (0, 0, 0)",
157 label,
158 )
160 log.info(
161 "Updated positions for %d sources and %d detectors",
162 sources_updated,
163 detectors_updated,
164 )