|
2 | 2 |
|
3 | 3 | import concurrent.futures |
4 | 4 | import contextlib |
| 5 | +import datetime |
5 | 6 | import functools |
6 | 7 | import itertools |
7 | 8 | import json |
8 | 9 | import logging |
9 | | -from collections.abc import Iterator |
| 10 | +from collections.abc import Iterator, Mapping |
10 | 11 | from typing import Any, ClassVar, Literal |
11 | 12 |
|
12 | 13 | import codeocean.computation |
|
16 | 17 |
|
17 | 18 | import aind_session.extension |
18 | 19 | import aind_session.utils.codeocean_utils |
| 20 | +import aind_session.utils.docdb_utils |
19 | 21 |
|
20 | 22 | logger = logging.getLogger(__name__) |
21 | 23 |
|
@@ -100,6 +102,232 @@ class EcephysExtension(aind_session.extension.ExtensionBaseClass): |
100 | 102 |
|
101 | 103 | DEFAULT_SORTING_PIPELINE_ID: ClassVar[str] = "1f8f159a-7670-47a9-baf1-078905fc9c2e" |
102 | 104 | 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) |
103 | 331 |
|
104 | 332 | @property |
105 | 333 | def clipped_dir(self) -> upath.UPath: |
|
0 commit comments