Skip to content

Commit 6106171

Browse files
committed
Unify impedance CSV output across streaming and non-streaming paths
Both synapsectl query and synapsectl query --stream now write identical impedance CSVs via a shared synapse.cli.impedance_csv module: Electrode ID,Magnitude (Ohms),Phase (degrees),Status <electrode_id>,<magnitude>,<phase>,<status> The CSV body is unchanged from the legacy non-streaming format (column header on the first line), so existing parsers keep working. The peripheral the measurement ran on is encoded in the *filename* instead: impedance_measurements_<peripheral>_<timestamp>.csv Changes: - Fix streaming CSV discrepancies: it previously wrote a different header ('Electrode ID,Magnitude,Phase'), omitted units, and had no Status column. It now matches the non-streaming format exactly. - Streaming now also writes failed measurements with Status=0 (success=1); previously failed measurements were dropped from the CSV entirely. - Peripheral name is resolved from the device's peripheral list (preferring the query's peripheral_id, else the broadband recording source) and embedded in the filename.
1 parent 6585182 commit 6106171

3 files changed

Lines changed: 105 additions & 29 deletions

File tree

synapse/cli/impedance_csv.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Shared helpers for writing impedance-measurement CSV files.
2+
3+
Both the non-streaming (`synapsectl query`) and streaming (`--stream`) paths
4+
emit the same CSV so downstream tooling can parse either identically:
5+
6+
Electrode ID,Magnitude (Ohms),Phase (degrees),Status
7+
<electrode_id>,<magnitude>,<phase>,<status>
8+
...
9+
10+
`Status` is 1 for a successful measurement and 0 for a failed one. The
11+
peripheral the measurement ran on is encoded in the filename (see
12+
`make_filename`) rather than in the file body, so the CSV stays compatible
13+
with parsers that expect the column header on the first line.
14+
"""
15+
16+
import csv
17+
import re
18+
import time
19+
20+
from synapse.api.device_pb2 import Peripheral
21+
22+
CSV_COLUMNS = ["Electrode ID", "Magnitude (Ohms)", "Phase (degrees)", "Status"]
23+
24+
STATUS_OK = 1
25+
STATUS_FAILED = 0
26+
27+
# Force LF so the streaming (csv.writer) and non-streaming paths produce
28+
# byte-identical files regardless of platform.
29+
_LINE_TERMINATOR = "\n"
30+
31+
32+
def resolve_peripheral_name(device, impedance_query) -> str:
33+
"""Best-effort name of the peripheral the measurement ran on, for the filename.
34+
35+
Prefers the ``peripheral_id`` named in the query (if the proto carries one);
36+
otherwise falls back to the device's broadband (recording) source, then the
37+
first peripheral. Returns "Unknown" if it can't be resolved.
38+
"""
39+
info = device.info() if device is not None else None
40+
if not info or not info.peripherals:
41+
return "Unknown"
42+
43+
# Command-range ids (e.g. 2 = "first broadband source") won't match a
44+
# concrete peripheral_id and fall through to the broadband lookup below.
45+
peripheral_id = getattr(impedance_query, "peripheral_id", 0)
46+
if peripheral_id:
47+
for p in info.peripherals:
48+
if p.peripheral_id == peripheral_id:
49+
return p.name
50+
51+
for p in info.peripherals:
52+
if p.type == Peripheral.kBroadbandSource:
53+
return p.name
54+
55+
return info.peripherals[0].name
56+
57+
58+
def make_filename(peripheral_name) -> str:
59+
"""Timestamped CSV filename with the peripheral name embedded, e.g.
60+
``impedance_measurements_NYX1-512_20260624-153012.csv``. The name is
61+
sanitized so it's always a safe filename component."""
62+
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", peripheral_name or "Unknown").strip("_")
63+
safe = safe or "Unknown"
64+
timestamp = time.strftime("%Y%m%d-%H%M%S")
65+
return f"impedance_measurements_{safe}_{timestamp}.csv"
66+
67+
68+
def write_header(filename):
69+
"""Create (truncate) the CSV and write the column header."""
70+
with open(filename, "w", newline="") as f:
71+
csv.writer(f, lineterminator=_LINE_TERMINATOR).writerow(CSV_COLUMNS)
72+
73+
74+
def append_measurements(filename, measurements, status=STATUS_OK):
75+
"""Append measurement rows to an existing CSV created by `write_header`."""
76+
with open(filename, "a", newline="") as f:
77+
writer = csv.writer(f, lineterminator=_LINE_TERMINATOR)
78+
for m in measurements:
79+
writer.writerow([m.electrode_id, m.magnitude, m.phase, status])

synapse/cli/query.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
#!/usr/bin/env python3
22
import asyncio
3-
import csv
43
from threading import Thread
5-
import time
64
import sys
75
import synapse as syn
86
from synapse.api.query_pb2 import QueryRequest, StreamQueryRequest
7+
from synapse.cli import impedance_csv
98
from google.protobuf.json_format import Parse
109

1110
from rich.progress import (
@@ -153,11 +152,11 @@ def handle_impedance_stream(self, request):
153152
all_measurements = []
154153
failed_measurements = []
155154

156-
# Create a CSV file to read from at the beginning
157-
filename = f"impedance_measurements_{time.strftime('%Y%m%d-%H%M%S')}.csv"
158-
with open(filename, "w", newline="") as f:
159-
writer = csv.writer(f)
160-
writer.writerow(["Electrode ID", "Magnitude", "Phase"])
155+
# Create a CSV file to read from at the beginning (peripheral name is
156+
# encoded in the filename).
157+
peripheral_name = impedance_csv.resolve_peripheral_name(self.device, query)
158+
filename = impedance_csv.make_filename(peripheral_name)
159+
impedance_csv.write_header(filename)
161160
self.console.print(f"[green] Started saving measurements to {filename}")
162161

163162
progress = Progress(
@@ -217,6 +216,9 @@ def update_progress():
217216
progress.console.log(
218217
f"electrode id (mag, phase): {sample.electrode_id}\t {sample.magnitude},{sample.phase}"
219218
)
219+
self.save_measurement_batch(
220+
filename, failed_batch, status=impedance_csv.STATUS_FAILED
221+
)
220222
measurements_received += len(failed_batch)
221223
progress.update(
222224
task, completed=min(measurements_received, electrode_count)
@@ -269,14 +271,11 @@ def display_impedance_results(self, measurements):
269271
)
270272
self.console.print(table)
271273

272-
def save_measurement_batch(self, filename, measurements):
274+
def save_measurement_batch(
275+
self, filename, measurements, status=impedance_csv.STATUS_OK
276+
):
273277
# Save a batch of measurements as they come in
274-
with open(filename, "a", newline="") as f:
275-
writer = csv.writer(f)
276-
for measurement in measurements:
277-
writer.writerow(
278-
[measurement.electrode_id, measurement.magnitude, measurement.phase]
279-
)
278+
impedance_csv.append_measurements(filename, measurements, status=status)
280279

281280

282281
def load_config_from_file(path_to_config):

synapse/cli/rpc.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from pathlib import Path
2-
import time
32
from datetime import datetime
43
from typing import Optional
54

@@ -13,6 +12,7 @@
1312
from rich.console import Console
1413

1514
from synapse.cli.query import StreamingQueryClient
15+
from synapse.cli import impedance_csv
1616
from synapse.utils.log import log_entry_to_str
1717
from synapse.cli.device_info_display import DeviceInfoDisplay
1818
from synapse.utils.proto import load_device_config
@@ -140,26 +140,24 @@ def load_query_request(path_to_config):
140140
console.print("Running query:")
141141
console.print(query_proto)
142142

143-
result: QueryResponse = syn.Device(args.uri, args.verbose).query(
144-
query_proto
145-
)
143+
device = syn.Device(args.uri, args.verbose)
144+
result: QueryResponse = device.query(query_proto)
146145
if result:
147146
console.print(text_format.MessageToString(result))
148147

149148
if result.HasField("impedance_response"):
150149
measurements = result.impedance_response
151-
# Write impedance measurements to a CSV file
152-
timestamp = time.strftime("%Y%m%d-%H%M%S")
153-
filename = f"impedance_measurements_{timestamp}.csv"
150+
peripheral_name = impedance_csv.resolve_peripheral_name(
151+
device, query_proto.impedance_query
152+
)
153+
# Write impedance measurements to a CSV file (peripheral name
154+
# is encoded in the filename).
155+
filename = impedance_csv.make_filename(peripheral_name)
154156
try:
155-
with open(filename, "w") as f:
156-
f.write(
157-
"Electrode ID,Magnitude (Ohms),Phase (degrees),Status\n"
158-
)
159-
for measurement in measurements.measurements:
160-
f.write(
161-
f"{measurement.electrode_id},{measurement.magnitude},{measurement.phase},1\n"
162-
)
157+
impedance_csv.write_header(filename)
158+
impedance_csv.append_measurements(
159+
filename, measurements.measurements
160+
)
163161
console.print(
164162
f"[green]Impedance measurements saved to {filename}[/green]"
165163
)

0 commit comments

Comments
 (0)