Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/copy_probe_features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
curl -o src/probeinterface/resources/neuropixels_probe_features.json \
https://raw.githubusercontent.com/billkarsh/ProbeTable/refs/heads/main/Tables/probe_features.json
- name: Derive IMRO type mappings from catalogue
run: python resources/postprocess_neuropixels_probe_features.py

- name: Commit changes if any
id: commit
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ uv.lock
# libraries
**/neuropixels_library_generated
**/cambridgeneurotech_library
.codex
131 changes: 131 additions & 0 deletions resources/postprocess_neuropixels_probe_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Post-process neuropixels_probe_features.json after syncing from ProbeTable.

Derives two mappings from the catalogue and writes them back into the JSON:

- z_imro_format_type_to_imro_format: IMRO type code -> IMRO format name
(e.g. "0" -> "imro_np1000", "1110" -> "imro_np1110")

- z_imro_format_type_to_part_number: IMRO type code -> canonical probe part number
(e.g. "0" -> "NP1000", "1110" -> "NP1110")

This script is called by the GitHub Action workflow that syncs probe_features.json
from billkarsh/ProbeTable, and can also be run standalone.
"""

import json
import re
from pathlib import Path

PROBE_FEATURES_PATH = (
Path(__file__).absolute().parent
/ "../src/probeinterface/resources/neuropixels_probe_features.json"
)


def _parse_type_values_from_val_def(val_def: str) -> list[str]:
"""Extract IMRO type code(s) from a val_def string.

Two patterns in ProbeTable:
type:{0,1020,1030,...} -> set of values
type:1110 -> single value
"""
match = re.match(r"type:\{([^}]+)\}", val_def)
if match:
return [v.strip() for v in match.group(1).split(",")]

match = re.match(r"type:(\d+)", val_def)
if match:
return [match.group(1)]

raise ValueError(f"Cannot parse type from val_def: {val_def!r}")


def build_derived_mappings(probe_features: dict) -> tuple[dict, dict]:
"""Build type-to-format and type-to-part-number mappings from the catalogue."""

imro_formats = probe_features["z_imro_formats"]
probes = probe_features["neuropixels_probes"]

# 1. Build type -> format mapping from val_def entries
type_to_format = {}
for key, val_def in imro_formats.items():
if not key.endswith("_val_def"):
continue
# e.g. "imro_np1000_val_def" -> "imro_np1000"
format_name = key.removesuffix("_val_def")
for type_code in _parse_type_values_from_val_def(val_def):
if type_code in type_to_format:
raise ValueError(
f"IMRO type {type_code!r} maps to both "
f"{type_to_format[type_code]!r} and {format_name!r}"
)
type_to_format[type_code] = format_name

# 2. Build type -> canonical part number mapping
# For each type, find probes that use the matching format, then pick
# the first NP-prefixed part number alphabetically.
#
# We also need to verify the candidate actually belongs to this type,
# not just the same format. For example, NP1021 uses imro_np1000 format
# but its IMRO type is not "0". We filter by checking the format's
# val_def includes the type code we're resolving.

# Invert: format -> set of type codes it covers
format_to_types = {}
for type_code, format_name in type_to_format.items():
format_to_types.setdefault(format_name, set()).add(type_code)

type_to_part_number = {}
for type_code, format_name in sorted(type_to_format.items()):
candidates = [
pn
for pn, spec in probes.items()
if spec.get("imro_table_format_type") == format_name
]

# Prefer a probe whose part number contains the type code (e.g. NP1020 for type "1020").
# This matters because many probes share the same IMRO format but have different
# physical geometries (e.g. NP1000 has 960 contacts, NP1020 has 2496).
exact_matches = sorted(
pn for pn in candidates if pn.startswith("NP") and type_code in pn
)
if exact_matches:
type_to_part_number[type_code] = exact_matches[0]
continue

# Fall back to first NP-prefixed name alphabetically
np_candidates = sorted(pn for pn in candidates if pn.startswith("NP"))
other_candidates = sorted(pn for pn in candidates if not pn.startswith("NP"))
ordered = np_candidates + other_candidates

if ordered:
type_to_part_number[type_code] = ordered[0]

return type_to_format, type_to_part_number


def postprocess(filepath: Path = PROBE_FEATURES_PATH) -> None:
filepath = filepath.resolve()
with open(filepath) as f:
probe_features = json.load(f)

type_to_format, type_to_part_number = build_derived_mappings(probe_features)

probe_features["z_imro_format_type_to_imro_format"] = dict(sorted(type_to_format.items(), key=lambda kv: int(kv[0])))
probe_features["z_imro_format_type_to_part_number"] = dict(sorted(type_to_part_number.items(), key=lambda kv: int(kv[0])))

with open(filepath, "w") as f:
json.dump(probe_features, f, indent=4)
f.write("\n")

print(f"Wrote derived mappings to {filepath}")
print(f" z_imro_format_type_to_imro_format: {len(type_to_format)} entries")
print(f" z_imro_format_type_to_part_number: {len(type_to_part_number)} entries")
for type_code in sorted(type_to_format, key=int):
pn = type_to_part_number.get(type_code, "???")
print(f" type {type_code:>5s} -> format={type_to_format[type_code]}, part_number={pn}")


if __name__ == "__main__":
postprocess()
Loading
Loading