Skip to content

Commit f0e08fb

Browse files
authored
Merge pull request #15 from Brain-Modulation-Lab/feat/segments-amplitude
enh: divide amplitute bet segments
2 parents cc362da + 0a0e1e6 commit f0e08fb

3 files changed

Lines changed: 269 additions & 74 deletions

File tree

src/clinical_dbs_annotator/ui/amplitude_split_widget.py

Lines changed: 125 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def __init__(self, amp_edit: QLineEdit, parent=None):
5454
# Public API
5555
# ------------------------------------------------------------------
5656

57-
def update_cathodes(self, cathode_labels: list[str]) -> None:
57+
def update_cathodes(self, cathode_labels: list[str], is_single_grouped_directional: bool = False) -> None:
5858
"""Rebuild the rows to match the current set of active cathodes.
5959
6060
Called by the parent view whenever the electrode canvas changes.
@@ -68,8 +68,10 @@ def update_cathodes(self, cathode_labels: list[str]) -> None:
6868
# Filter out "case" — CASE as cathode does not get a percentage row
6969
cathode_labels = [lbl for lbl in cathode_labels if lbl.lower() != "case"]
7070

71-
if len(cathode_labels) <= 1:
72-
# Single or no cathode → hide everything
71+
# Use the passed parameter to determine if this is a single grouped directional contact
72+
73+
# Hide widget if no cathodes, or single non-grouped cathode
74+
if len(cathode_labels) == 0 or (len(cathode_labels) == 1 and not is_single_grouped_directional):
7375
self._clear_rows()
7476
self._cathode_labels = []
7577
self._percentages.clear()
@@ -86,7 +88,11 @@ def update_cathodes(self, cathode_labels: list[str]) -> None:
8688

8789
# Compute new default percentages
8890
self._cathode_labels = list(cathode_labels)
89-
self._redistribute_percentages()
91+
if len(cathode_labels) == 1 and is_single_grouped_directional:
92+
# For single grouped directional contact, set 100% to enable segment splitting
93+
self._percentages[cathode_labels[0]] = 100.0
94+
else:
95+
self._redistribute_percentages()
9096

9197
# Rebuild UI rows
9298
self._rebuild_rows()
@@ -104,9 +110,12 @@ def get_amplitude_text(self) -> str:
104110
e.g. ``"1.5_1.0"``.
105111
"""
106112
total_text = self._amp_edit.text().strip()
113+
114+
# For single or no cathode, return total amplitude
107115
if len(self._cathode_labels) <= 1:
108116
return total_text
109117

118+
# Multiple cathodes - use regular logic
110119
try:
111120
total_amp = float(total_text)
112121
except (ValueError, TypeError):
@@ -151,29 +160,102 @@ def _rebuild_rows(self) -> None:
151160
"""Tear down and recreate all percentage rows."""
152161
self._clear_rows()
153162

154-
for i, lbl in enumerate(self._cathode_labels):
163+
# Check if we have a single grouped contact (E1, E2, etc.)
164+
if (len(self._cathode_labels) == 1 and
165+
len(self._cathode_labels[0]) >= 2 and
166+
self._cathode_labels[0][0] == 'E' and
167+
self._cathode_labels[0][1:].isdigit()):
168+
# Create segment rows for grouped contact
169+
self._create_segment_rows_only(self._cathode_labels[0])
170+
else:
171+
# Create regular rows for individual contacts/segments
172+
for i, lbl in enumerate(self._cathode_labels):
173+
self._create_regular_row(lbl, i)
174+
175+
self._refresh_ma_values()
176+
177+
def _create_regular_row(self, lbl: str, index: int) -> None:
178+
"""Create a regular percentage row for a single contact."""
179+
row_w = QWidget()
180+
row_layout = QHBoxLayout(row_w)
181+
row_layout.setContentsMargins(20, 0, 0, 0)
182+
row_layout.setSpacing(4)
183+
184+
# Contact label
185+
name_lbl = QLabel(f"<b>{lbl}</b>")
186+
name_lbl.setMinimumWidth(40)
187+
row_layout.addWidget(name_lbl)
188+
189+
# Percentage edit
190+
pct_edit = QLineEdit()
191+
pct_edit.setMaximumWidth(55)
192+
pct_edit.setAlignment(Qt.AlignRight)
193+
pct_edit.setValidator(QDoubleValidator(0.0, 100.0, 1))
194+
pct_edit.setText(f"{self._percentages.get(lbl, 0.0):g}")
195+
row_layout.addWidget(pct_edit)
196+
197+
pct_label = QLabel("%")
198+
row_layout.addWidget(pct_label)
199+
200+
arrow_lbl = QLabel("->")
201+
row_layout.addWidget(arrow_lbl)
202+
203+
# Computed mA label (read-only)
204+
ma_label = QLabel("")
205+
ma_label.setMinimumWidth(60)
206+
ma_label.setStyleSheet("color: #64748b;")
207+
row_layout.addWidget(ma_label)
208+
209+
row_layout.addStretch(1)
210+
211+
is_last = (index == len(self._cathode_labels) - 1)
212+
213+
# Connect editing
214+
if is_last:
215+
pct_edit.setReadOnly(True)
216+
pct_edit.setStyleSheet("background: transparent; border: none; color: #64748b;")
217+
else:
218+
pct_edit.editingFinished.connect(
219+
lambda lbl_=lbl: self._on_pct_edited(lbl_)
220+
)
221+
pct_edit.textChanged.connect(
222+
lambda _text, lbl_=lbl: self._on_pct_text_changed(lbl_)
223+
)
224+
225+
self._rows[lbl] = (row_w, pct_edit, ma_label)
226+
self._layout.addWidget(row_w)
227+
228+
def _create_segment_rows_only(self, parent_lbl: str) -> None:
229+
"""Create only segment rows for a grouped directional contact (no main row)."""
230+
seg_labels = ["a", "b", "c"]
231+
parent_percentage = self._percentages.get(parent_lbl, 0.0)
232+
segment_percentage = round(parent_percentage / 3.0, 1) # Equal split by default, rounded to 1 decimal
233+
234+
for seg_idx, seg_label in enumerate(seg_labels):
235+
seg_lbl = f"{parent_lbl}{seg_label}"
236+
155237
row_w = QWidget()
156238
row_layout = QHBoxLayout(row_w)
157-
row_layout.setContentsMargins(20, 0, 0, 0)
239+
row_layout.setContentsMargins(20, 0, 0, 0) # Regular indentation (not extra)
158240
row_layout.setSpacing(4)
159241

160-
# Contact label
161-
name_lbl = QLabel(f"<b>{lbl}</b>")
242+
# Segment label
243+
name_lbl = QLabel(f"<b>{seg_lbl}</b>")
162244
name_lbl.setMinimumWidth(40)
163245
row_layout.addWidget(name_lbl)
164246

165-
# Percentage edit
247+
# Percentage edit (editable for segments)
166248
pct_edit = QLineEdit()
167249
pct_edit.setMaximumWidth(55)
168250
pct_edit.setAlignment(Qt.AlignRight)
169251
pct_edit.setValidator(QDoubleValidator(0.0, 100.0, 1))
170-
pct_edit.setText(f"{self._percentages.get(lbl, 0.0):g}")
252+
pct_edit.setText(f"{segment_percentage:g}")
171253
row_layout.addWidget(pct_edit)
172254

173255
pct_label = QLabel("%")
174256
row_layout.addWidget(pct_label)
175257

176-
arrow_lbl = QLabel("")
258+
arrow_lbl = QLabel("->")
177259
row_layout.addWidget(arrow_lbl)
178260

179261
# Computed mA label (read-only)
@@ -184,24 +266,25 @@ def _rebuild_rows(self) -> None:
184266

185267
row_layout.addStretch(1)
186268

187-
is_last = (i == len(self._cathode_labels) - 1)
269+
# Store segment percentages
270+
self._percentages[seg_lbl] = segment_percentage
188271

189-
# Connect editing
272+
# Connect editing for segment rows
273+
is_last = (seg_idx == len(seg_labels) - 1)
190274
if is_last:
191275
pct_edit.setReadOnly(True)
192276
pct_edit.setStyleSheet("background: transparent; border: none; color: #64748b;")
193277
else:
194278
pct_edit.editingFinished.connect(
195-
lambda lbl_=lbl: self._on_pct_edited(lbl_)
279+
lambda lbl_=seg_lbl: self._on_pct_edited(lbl_)
196280
)
197281
pct_edit.textChanged.connect(
198-
lambda _text, lbl_=lbl: self._on_pct_text_changed(lbl_)
282+
lambda _text, lbl_=seg_lbl: self._on_pct_text_changed(lbl_)
199283
)
200284

201-
self._rows[lbl] = (row_w, pct_edit, ma_label)
285+
self._rows[seg_lbl] = (row_w, pct_edit, ma_label)
202286
self._layout.addWidget(row_w)
203287

204-
self._refresh_ma_values()
205288

206289
def _on_pct_edited(self, edited_label: str) -> None:
207290
"""Called when user finishes editing a percentage field."""
@@ -266,7 +349,8 @@ def _refresh_ma_values(self) -> None:
266349
except (ValueError, TypeError):
267350
total_amp = 0.0
268351

269-
for lbl in self._cathode_labels:
352+
# Update all rows (both main contacts and segments)
353+
for lbl in list(self._rows.keys()):
270354
row_data = self._rows.get(lbl)
271355
if not row_data:
272356
continue
@@ -351,18 +435,30 @@ def get_cathode_labels(canvas) -> list[str]:
351435

352436
if model.is_directional:
353437
for contact_idx in range(model.num_contacts):
354-
seg_states = [
355-
canvas.contact_states.get((contact_idx, seg), ContactState.OFF)
356-
for seg in range(3)
357-
]
358-
if all(s == ContactState.CATHODIC for s in seg_states):
359-
labels.append(f"E{contact_idx}")
360-
continue
361-
seg_labels = ["a", "b", "c"]
362-
for seg, seg_state in enumerate(seg_states):
363-
if seg_state == ContactState.CATHODIC:
364-
labels.append(f"E{contact_idx}{seg_labels[seg]}")
438+
# Check if this contact level is directional (has segments)
439+
is_contact_directional = model.is_level_directional(contact_idx)
440+
441+
if is_contact_directional:
442+
# This contact has segments - check individual segments
443+
seg_states = [
444+
canvas.contact_states.get((contact_idx, seg), ContactState.OFF)
445+
for seg in range(3)
446+
]
447+
if all(s == ContactState.CATHODIC for s in seg_states):
448+
# All segments are cathodic - show as ring contact (no segment suffix)
449+
labels.append(f"E{contact_idx}")
450+
continue
451+
seg_labels = ["a", "b", "c"]
452+
for seg, seg_state in enumerate(seg_states):
453+
if seg_state == ContactState.CATHODIC:
454+
labels.append(f"E{contact_idx}{seg_labels[seg]}")
455+
else:
456+
# This contact is a ring contact - no segments
457+
state = canvas.contact_states.get((contact_idx, 0), ContactState.OFF)
458+
if state == ContactState.CATHODIC:
459+
labels.append(f"E{contact_idx}")
365460
else:
461+
# Non-directional model - all contacts are ring contacts
366462
for contact_idx in range(model.num_contacts):
367463
state = canvas.contact_states.get((contact_idx, 0), ContactState.OFF)
368464
if state == ContactState.CATHODIC:

src/clinical_dbs_annotator/views/step1_view.py

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,39 @@ def refresh_theme_icons(self) -> None:
121121
if btn:
122122
btn.setIcon(self._create_settings_icon())
123123

124+
def _is_single_grouped_directional(self, cathode_labels: list[str], canvas) -> bool:
125+
"""Check if a single cathode label represents a grouped directional contact."""
126+
if len(cathode_labels) != 1 or not canvas or not canvas.model:
127+
return False
128+
129+
lbl = cathode_labels[0]
130+
# Check if this is a grouped contact (no segment suffix) that could be directional
131+
if len(lbl) >= 2 and lbl[0] == 'E' and lbl[1:].isdigit():
132+
try:
133+
contact_idx = int(lbl[1:])
134+
return (canvas.model.is_directional and
135+
canvas.model.is_level_directional(contact_idx))
136+
except (ValueError, IndexError):
137+
pass
138+
return False
139+
124140
def _on_left_canvas_validation(self, is_valid: bool, error_msg: str) -> None:
125141
"""Callback when left electrode canvas validation state changes."""
126142
self._left_selection_valid = is_valid
127143
self.update_configuration_display()
128144
if hasattr(self, "left_amp_split"):
129-
self.left_amp_split.update_cathodes(get_cathode_labels(self.left_canvas))
145+
left_labels = get_cathode_labels(self.left_canvas)
146+
left_is_grouped = self._is_single_grouped_directional(left_labels, self.left_canvas)
147+
self.left_amp_split.update_cathodes(left_labels, left_is_grouped)
130148

131149
def _on_right_canvas_validation(self, is_valid: bool, error_msg: str) -> None:
132150
"""Callback when right electrode canvas validation state changes."""
133151
self._right_selection_valid = is_valid
134152
self.update_configuration_display()
135153
if hasattr(self, "right_amp_split"):
136-
self.right_amp_split.update_cathodes(get_cathode_labels(self.right_canvas))
154+
right_labels = get_cathode_labels(self.right_canvas)
155+
right_is_grouped = self._is_single_grouped_directional(right_labels, self.right_canvas)
156+
self.right_amp_split.update_cathodes(right_labels, right_is_grouped)
137157

138158
def _setup_ui(self) -> None:
139159
"""Set up the UI layout."""
@@ -567,13 +587,15 @@ def _format_configuration_html(self, canvas: ElectrodeCanvas) -> str:
567587
state = canvas.contact_states[contact_id]
568588
state_str = "Anodic (+)" if state == ContactState.ANODIC else "Cathodic (-)"
569589

570-
if model.is_directional:
590+
if model.is_directional and model.is_level_directional(contact_idx):
591+
# This contact has segments - show segment label
571592
segment_labels = ['a', 'b', 'c']
572593
contact_name = f"C{contact_idx}{segment_labels[seg_idx]}"
573594
else:
595+
# This contact is a ring contact - no segment label
574596
contact_name = f"C{contact_idx}"
575597

576-
lines.append(f" {contact_name}: {state_str}")
598+
lines.append(f" &bull; {contact_name}: {state_str}")
577599
else:
578600
lines.append("<i>No active contacts</i>")
579601

@@ -611,21 +633,34 @@ def _get_anode_cathode_texts(self, canvas: ElectrodeCanvas) -> tuple[str, str]:
611633

612634
if model.is_directional:
613635
for contact_idx in range(model.num_contacts):
614-
seg_states = [canvas.contact_states.get((contact_idx, seg), ContactState.OFF) for seg in range(3)]
615-
if all(s == ContactState.ANODIC for s in seg_states):
616-
anode_items.append(f"E{contact_idx}")
617-
continue
618-
if all(s == ContactState.CATHODIC for s in seg_states):
619-
cathode_items.append(f"E{contact_idx}")
620-
continue
636+
# Check if this contact level is directional (has segments)
637+
is_contact_directional = model.is_level_directional(contact_idx)
638+
639+
if is_contact_directional:
640+
# This contact has segments - check individual segments
641+
seg_states = [canvas.contact_states.get((contact_idx, seg), ContactState.OFF) for seg in range(3)]
642+
if all(s == ContactState.ANODIC for s in seg_states):
643+
anode_items.append(f"E{contact_idx}")
644+
continue
645+
if all(s == ContactState.CATHODIC for s in seg_states):
646+
cathode_items.append(f"E{contact_idx}")
647+
continue
621648

622-
seg_labels = ["a", "b", "c"]
623-
for seg, seg_state in enumerate(seg_states):
624-
if seg_state == ContactState.ANODIC:
625-
anode_items.append(f"E{contact_idx}{seg_labels[seg]}")
626-
elif seg_state == ContactState.CATHODIC:
627-
cathode_items.append(f"E{contact_idx}{seg_labels[seg]}")
649+
seg_labels = ["a", "b", "c"]
650+
for seg, seg_state in enumerate(seg_states):
651+
if seg_state == ContactState.ANODIC:
652+
anode_items.append(f"E{contact_idx}{seg_labels[seg]}")
653+
elif seg_state == ContactState.CATHODIC:
654+
cathode_items.append(f"E{contact_idx}{seg_labels[seg]}")
655+
else:
656+
# This contact is a ring contact - no segments
657+
state = canvas.contact_states.get((contact_idx, 0), ContactState.OFF)
658+
if state == ContactState.ANODIC:
659+
anode_items.append(f"E{contact_idx}")
660+
elif state == ContactState.CATHODIC:
661+
cathode_items.append(f"E{contact_idx}")
628662
else:
663+
# Non-directional model - all contacts are ring contacts
629664
for contact_idx in range(model.num_contacts):
630665
state = canvas.contact_states.get((contact_idx, 0), ContactState.OFF)
631666
if state == ContactState.ANODIC:
@@ -709,9 +744,13 @@ def apply_tokens(text: str, state: ContactState) -> None:
709744
canvas.update()
710745
# Refresh amplitude split widget for this canvas
711746
if canvas is self.left_canvas and hasattr(self, "left_amp_split"):
712-
self.left_amp_split.update_cathodes(get_cathode_labels(self.left_canvas))
747+
left_labels = get_cathode_labels(self.left_canvas)
748+
left_is_grouped = self._is_single_grouped_directional(left_labels, self.left_canvas)
749+
self.left_amp_split.update_cathodes(left_labels, left_is_grouped)
713750
elif canvas is self.right_canvas and hasattr(self, "right_amp_split"):
714-
self.right_amp_split.update_cathodes(get_cathode_labels(self.right_canvas))
751+
right_labels = get_cathode_labels(self.right_canvas)
752+
right_is_grouped = self._is_single_grouped_directional(right_labels, self.right_canvas)
753+
self.right_amp_split.update_cathodes(right_labels, right_is_grouped)
715754

716755
def reset_all(self):
717756
"""Reset all contacts and case"""

0 commit comments

Comments
 (0)