Skip to content

Commit c2b32ac

Browse files
committed
Add ibl annotation property
1 parent bb36de5 commit c2b32ac

1 file changed

Lines changed: 229 additions & 1 deletion

File tree

src/aind_session/extensions/ecephys.py

Lines changed: 229 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
import concurrent.futures
44
import contextlib
5+
import datetime
56
import functools
67
import itertools
78
import json
89
import logging
9-
from collections.abc import Iterator
10+
from collections.abc import Iterator, Mapping
1011
from typing import Any, ClassVar, Literal
1112

1213
import codeocean.computation
@@ -16,6 +17,7 @@
1617

1718
import aind_session.extension
1819
import aind_session.utils.codeocean_utils
20+
import aind_session.utils.docdb_utils
1921

2022
logger = logging.getLogger(__name__)
2123

@@ -100,6 +102,232 @@ class EcephysExtension(aind_session.extension.ExtensionBaseClass):
100102

101103
DEFAULT_SORTING_PIPELINE_ID: ClassVar[str] = "1f8f159a-7670-47a9-baf1-078905fc9c2e"
102104
DEFAULT_TRIGGER_CAPSULE_ID: ClassVar[str] = "eb5a26e4-a391-4d79-9da5-1ab65b71253f"
105+
IBL_ALIGNMENT_EVALUATION_PREFIX: ClassVar[str] = "Probe Alignment for"
106+
107+
@property
108+
def latest_ibl_annotations(self) -> dict[str, dict[str, Any]]:
109+
"""Latest IBL probe-alignment annotations in DocDB, keyed by probe name.
110+
111+
The annotations are stored as QC evaluations on the latest derived ecephys
112+
DocDB asset, not as separate DocDB records per probe.
113+
114+
For a given channel record with xyz coordinates, convert to ccf with:
115+
ccf_ap: y * 1000
116+
ccf_ml: x * -1000
117+
ccf_dv: z * -1000
118+
119+
Examples
120+
--------
121+
>>> session = aind_session.Session('ecephys_795555_2025-08-26_11-29-20')
122+
>>> annotations = session.ecephys.latest_ibl_annotations
123+
>>> sorted(annotations)
124+
['ProbeA_0', 'ProbeB_0', 'ProbeC_0', 'ProbeD_0', 'ProbeE_0']
125+
>>> annotations['ProbeA_0']['ccf_channel_results']['channel_0']
126+
{'x': -4.172946453094482, 'y': 3.727851629257202, 'z': -5.629019260406494, 'axial': 0.0, 'lateral': 16.0, 'brain_region_id': 698, 'brain_region': 'OLF', 'channel_number': 0, 'ccf_ap': 3727.851629257202, 'ccf_ml': 4172.946453094482, 'ccf_dv': 5629.019260406494}
127+
"""
128+
return EcephysExtension.get_latest_ibl_annotations(self._base.id)
129+
130+
@staticmethod
131+
def get_latest_ibl_annotations(
132+
session_id: str,
133+
) -> dict[str, dict[str, Any]]:
134+
"""Return the latest IBL probe-alignment annotations for a session.
135+
136+
Results are keyed by probe name, such as ``ProbeA_0``. Each value includes
137+
asset metadata, evaluation metadata, and the parsed curation payload written
138+
by the IBL ephys alignment GUI.
139+
"""
140+
docdb_api_client = aind_session.utils.docdb_utils.get_docdb_api_client()
141+
142+
records = docdb_api_client.retrieve_docdb_records(
143+
filter_query= {
144+
"data_description.data_level": "derived",
145+
"data_description.name": {"$regex": session_id},
146+
"data_description.modality.abbreviation": "ecephys",
147+
"quality_control.evaluations.name": {
148+
"$regex": f"{EcephysExtension.IBL_ALIGNMENT_EVALUATION_PREFIX} .*{session_id}"
149+
},
150+
},
151+
projection={
152+
"_id": 1,
153+
"name": 1,
154+
"created": 1,
155+
"last_modified": 1,
156+
"data_description.name": 1,
157+
"quality_control.evaluations.name": 1,
158+
"quality_control.evaluations.created": 1,
159+
"quality_control.evaluations.latest_status": 1,
160+
"quality_control.evaluations.metrics.name": 1,
161+
"quality_control.evaluations.metrics.status_history": 1,
162+
"quality_control.evaluations.metrics.value.curation_history": 1,
163+
"quality_control.evaluations.metrics.value.curations": 1,
164+
},
165+
sort={"created": 1},
166+
)
167+
if not records:
168+
raise KeyError(
169+
f"No IBL probe-alignment evaluations found in DocDB for {session_id!r}"
170+
)
171+
172+
latest_record = records[-1]
173+
latest_by_probe: dict[str, dict[str, Any]] = {}
174+
for evaluation in EcephysExtension._iter_ibl_alignment_evaluations(
175+
latest_record, session_id
176+
):
177+
probe = EcephysExtension._get_probe_name_from_alignment_evaluation_name(
178+
evaluation.get("name", ""), session_id
179+
)
180+
if probe is None:
181+
continue
182+
previous = latest_by_probe.get(probe)
183+
if previous is not None and EcephysExtension._parse_docdb_timestamp(
184+
previous["created"]
185+
) >= EcephysExtension._parse_docdb_timestamp(evaluation.get("created")):
186+
continue
187+
latest_by_probe[probe] = (
188+
EcephysExtension._format_ibl_alignment_annotation(
189+
record=latest_record,
190+
evaluation=evaluation,
191+
)
192+
)
193+
194+
if not latest_by_probe:
195+
raise KeyError(
196+
f"No IBL probe-alignment evaluations found in latest DocDB asset for {session_id!r}"
197+
)
198+
199+
return dict(sorted(latest_by_probe.items()))
200+
201+
@staticmethod
202+
def _iter_ibl_alignment_evaluations(
203+
record: dict[str, Any], session_id: str
204+
) -> Iterator[dict[str, Any]]:
205+
evaluations = (record.get("quality_control") or {}).get("evaluations") or []
206+
for evaluation in evaluations:
207+
name = evaluation.get("name", "")
208+
if (
209+
isinstance(name, str)
210+
and name.startswith(EcephysExtension.IBL_ALIGNMENT_EVALUATION_PREFIX)
211+
and session_id in name
212+
):
213+
yield evaluation
214+
215+
@staticmethod
216+
def _get_probe_name_from_alignment_evaluation_name(
217+
evaluation_name: str, session_id: str
218+
) -> str | None:
219+
marker = f"{session_id}_"
220+
if marker not in evaluation_name:
221+
return None
222+
return evaluation_name.rsplit(marker, maxsplit=1)[-1]
223+
224+
@staticmethod
225+
def _format_ibl_alignment_annotation(
226+
record: dict[str, Any], evaluation: dict[str, Any]
227+
) -> dict[str, Any]:
228+
metric = next(iter(evaluation.get("metrics") or []), {})
229+
value = metric.get("value") or {}
230+
curation_history = value.get("curation_history") or []
231+
latest_curation = curation_history[-1] if curation_history else {}
232+
status_history = metric.get("status_history") or []
233+
latest_status = status_history[-1] if status_history else {}
234+
curation = EcephysExtension._parse_latest_curation(value)
235+
channel_results = curation.get("channel_results")
236+
237+
return {
238+
"asset_id": record.get("_id"),
239+
"asset_name": record.get("name"),
240+
"asset_data_description_name": (record.get("data_description") or {}).get(
241+
"name"
242+
),
243+
"asset_created": record.get("created"),
244+
"asset_last_modified": record.get("last_modified"),
245+
"name": evaluation.get("name"),
246+
"created": evaluation.get("created"),
247+
"latest_status": evaluation.get("latest_status"),
248+
"curator": latest_curation.get("curator") or latest_status.get("evaluator"),
249+
"curation_timestamp": latest_curation.get("timestamp"),
250+
"channel_results": channel_results,
251+
"previous_alignments": curation.get("previous_alignments"),
252+
"ccf_channel_results": (
253+
EcephysExtension._parse_ibl_annotation_channel_records(channel_results)
254+
if isinstance(channel_results, Mapping)
255+
else curation.get("ccf_channel_results")
256+
),
257+
}
258+
259+
@staticmethod
260+
def _parse_ibl_annotation_channel_records(
261+
channel_results: Mapping[str, Any],
262+
) -> dict[str, dict[str, Any]]:
263+
return {
264+
str(channel_name): EcephysExtension.parse_ibl_annotation_channel_record(
265+
str(channel_name), channel_record
266+
)
267+
for channel_name, channel_record in channel_results.items()
268+
if isinstance(channel_record, Mapping)
269+
}
270+
271+
@staticmethod
272+
def parse_ibl_annotation_channel_record(
273+
channel_name: str, channel_record: Mapping[str, Any]
274+
) -> dict[str, Any]:
275+
"""Return an IBL annotation channel record with channel number and CCF coords.
276+
277+
``x``, ``y``, and ``z`` values are stored in mm by the IBL ephys alignment
278+
GUI. The returned CCF coordinates are in microns:
279+
``ccf_ap = y * 1000``, ``ccf_ml = x * -1000``, and
280+
``ccf_dv = z * -1000``.
281+
"""
282+
parsed = dict(channel_record)
283+
with contextlib.suppress(ValueError):
284+
parsed["channel_number"] = int(channel_name.rsplit("_", maxsplit=1)[-1])
285+
286+
coordinates = {
287+
key: value
288+
for key, value in (
289+
("x", parsed.get("x")),
290+
("y", parsed.get("y")),
291+
("z", parsed.get("z")),
292+
)
293+
if isinstance(value, (int, float))
294+
}
295+
# sometimes values are stored as mm, sometimes microns - need to detect:
296+
scale = 1000.0 if all(abs(v) < 10 for v in coordinates.values()) else 1.0
297+
if len(coordinates) == 3:
298+
parsed["ccf_ap"] = coordinates["y"] * scale
299+
parsed["ccf_ml"] = coordinates["x"] * -scale
300+
parsed["ccf_dv"] = coordinates["z"] * -scale
301+
return parsed
302+
303+
@staticmethod
304+
def _parse_latest_curation(value: dict[str, Any]) -> dict[str, Any]:
305+
curations = value.get("curations") or []
306+
if not curations:
307+
return {}
308+
try:
309+
curation = json.loads(curations[-1])
310+
except json.JSONDecodeError:
311+
return {}
312+
if not isinstance(curation, dict):
313+
return {}
314+
return curation
315+
316+
@staticmethod
317+
def _parse_docdb_timestamp(value: object) -> datetime.datetime:
318+
if value is None:
319+
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
320+
if isinstance(value, datetime.datetime):
321+
return value if value.tzinfo else value.replace(tzinfo=datetime.timezone.utc)
322+
323+
text = str(value).strip().replace(" ", "T")
324+
if text.endswith("Z"):
325+
text = f"{text[:-1]}+00:00"
326+
try:
327+
timestamp = datetime.datetime.fromisoformat(text)
328+
except ValueError:
329+
return datetime.datetime.min.replace(tzinfo=datetime.timezone.utc)
330+
return timestamp if timestamp.tzinfo else timestamp.replace(tzinfo=datetime.timezone.utc)
103331

104332
@property
105333
def clipped_dir(self) -> upath.UPath:

0 commit comments

Comments
 (0)