Skip to content

Commit c80a5ee

Browse files
committed
extraction_line: add pre-actuation preview and structured confirmations
New ActuationPreview dataclass with fields: - requested_action, allowed, reasons_blocking, owner - interlocks, affected_children, state_changes - network_region_changes, requires_confirmation, warning_level Manager API: - preview_open_valve(name) -> ActuationPreview - preview_close_valve(name) -> ActuationPreview - Reuses existing _check_ownership, _check_soft_interlocks, _check_positive_interlocks logic Canvas click flow: - Click valve -> ask manager for preview - Show structured dialog with current state, owner, interlocks, affected children, region volume changes - Blocked actions show error dialog instead of silently failing - Existing ManualSwitch/RoughValve confirmation preserved SwitchManager: - Add get_children() helper for preview child enumeration
1 parent ac8fb78 commit c80a5ee

File tree

4 files changed

+310
-53
lines changed

4 files changed

+310
-53
lines changed

pychron/canvas/canvas2D/extraction_line_canvas2D.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,116 @@ def set_state(state):
332332

333333
event.handled = True
334334

335+
item = self.active_item
336+
if item is None:
337+
return
338+
339+
if self.manager and isinstance(item, (BaseValve, Switch)):
340+
self.manager.set_selected_explanation_item(item)
341+
342+
if self.edit_mode:
343+
if event.shift_down:
344+
self._toggle_item_selection(item)
345+
return
346+
347+
self.event_state = "drag"
348+
event.window.set_pointer(self.drag_pointer)
349+
return
350+
351+
if isinstance(item, Laser):
352+
self._toggle_laser_state(item)
353+
return
354+
355+
state = item.state
356+
nstate = not state
357+
if isinstance(item, Switch):
358+
set_state(nstate)
359+
360+
else:
361+
if not isinstance(item, BaseValve):
362+
return
363+
364+
if item.soft_lock:
365+
return
366+
367+
# Use preview API for structured confirmation
368+
if self.manager and self.confirm_open:
369+
action = "open" if nstate else "close"
370+
preview_func = (
371+
self.manager.preview_open_valve if nstate else self.manager.preview_close_valve
372+
)
373+
preview = preview_func(item.name)
374+
375+
if not preview.allowed:
376+
self._show_preview_dialog(preview)
377+
return
378+
379+
if preview.requires_confirmation or self.confirm_open:
380+
if not self._show_preview_dialog(preview):
381+
return
382+
383+
ok, change = set_state(nstate)
384+
385+
if ok:
386+
item.state = nstate
387+
388+
if change and ok:
389+
self._select_hook(item)
390+
391+
if change:
392+
self.invalidate_and_redraw()
393+
394+
event.handled = True
395+
396+
def _show_preview_dialog(self, preview):
397+
"""Show structured preview dialog. Returns True if user confirms."""
398+
from pyface.api import confirm, YES
399+
400+
lines = []
401+
action_label = "Open" if preview.requested_action == "open" else "Close"
402+
lines.append("{} valve {}?".format(action_label, preview.valve_name))
403+
lines.append("")
404+
405+
if preview.current_state is not None:
406+
lines.append("Current state: {}".format("Open" if preview.current_state else "Closed"))
407+
408+
if preview.owner:
409+
lines.append("Owner: {}".format(preview.owner))
410+
411+
if preview.is_soft_locked:
412+
lines.append("WARNING: Software locked")
413+
414+
if preview.interlocks:
415+
lines.append("Interlock present: {}".format(", ".join(preview.interlocks)))
416+
417+
if preview.affected_children:
418+
lines.append("Also affects: {}".format(", ".join(preview.affected_children)))
419+
420+
if preview.network_region_changes:
421+
for change in preview.network_region_changes:
422+
lines.append(change)
423+
424+
if preview.reasons_blocking:
425+
lines.append("")
426+
lines.append("Blocked: {}".format("; ".join(preview.reasons_blocking)))
427+
428+
msg = "\n".join(lines)
429+
430+
if preview.warning_level == "error":
431+
title = "Actuation Blocked"
432+
from pyface.message_dialog import error
433+
434+
error(None, msg, title=title)
435+
return False
436+
437+
if preview.warning_level == "warning":
438+
title = "Actuation Warning"
439+
else:
440+
title = "Confirm Valve Action"
441+
442+
result = confirm(None, msg, title=title)
443+
return result == YES
444+
335445
item = self.active_item
336446
if item is None:
337447
return
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# ===============================================================================
2+
# Copyright 2026 Jake Ross
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
# ===============================================================================
16+
from dataclasses import dataclass, field
17+
from typing import List, Optional
18+
19+
20+
@dataclass
21+
class ActuationPreview:
22+
"""Preview of what would happen if a valve actuation is executed."""
23+
24+
requested_action: str # "open" or "close"
25+
valve_name: str
26+
allowed: bool = True
27+
reasons_blocking: List[str] = field(default_factory=list)
28+
owner: Optional[str] = None
29+
interlocks: List[str] = field(default_factory=list)
30+
affected_children: List[str] = field(default_factory=list)
31+
state_changes: List[str] = field(default_factory=list)
32+
network_region_changes: List[str] = field(default_factory=list)
33+
requires_confirmation: bool = False
34+
warning_level: str = "none" # "none", "info", "warning", "error"
35+
is_soft_locked: bool = False
36+
is_enabled: bool = True
37+
current_state: Optional[bool] = None
38+
39+
@property
40+
def summary(self) -> str:
41+
if not self.allowed:
42+
return "Blocked: {}".format("; ".join(self.reasons_blocking))
43+
parts = []
44+
if self.interlocks:
45+
parts.append("Interlocks: {}".format(", ".join(self.interlocks)))
46+
if self.affected_children:
47+
parts.append("Affects: {}".format(", ".join(self.affected_children)))
48+
if self.network_region_changes:
49+
parts.append("Region changes: {}".format(", ".join(self.network_region_changes)))
50+
return " | ".join(parts) if parts else "No side effects"

pychron/extraction_line/extraction_line_manager.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from pychron.core.wait.wait_group import WaitGroup
4646
from pychron.envisage.consoleable import Consoleable
4747
from pychron.extraction_line import LOG_LEVEL_NAMES, LOG_LEVELS
48+
from pychron.extraction_line.actuation_preview import ActuationPreview
4849
from pychron.extraction_line.canvas.state import (
4950
CanvasSystemState,
5051
NetworkSnapshot,
@@ -654,6 +655,135 @@ def open_valve(self, name, **kw):
654655
def close_valve(self, name, **kw):
655656
return self._open_close_valve(name, "close", **kw)
656657

658+
def preview_open_valve(self, name, description=None, address=None, sender_address=None):
659+
"""Dry-run preview of opening a valve. Returns ActuationPreview."""
660+
return self._preview_actuation(name, "open", description, address, sender_address)
661+
662+
def preview_close_valve(self, name, description=None, address=None, sender_address=None):
663+
"""Dry-run preview of closing a valve. Returns ActuationPreview."""
664+
return self._preview_actuation(name, "close", description, address, sender_address)
665+
666+
def _preview_actuation(self, name, action, description=None, address=None, sender_address=None):
667+
"""Build an ActuationPreview without executing the actuation."""
668+
vm = self.switch_manager
669+
preview = ActuationPreview(
670+
requested_action=action,
671+
valve_name=name,
672+
current_state=None,
673+
)
674+
675+
if vm is None:
676+
preview.allowed = False
677+
preview.reasons_blocking.append("Switch manager not available")
678+
preview.warning_level = "error"
679+
return preview
680+
681+
resolved = self._resolve_switch_name(name, description=description, address=address)
682+
if not resolved:
683+
preview.allowed = False
684+
preview.reasons_blocking.append("Valve '{}' not found".format(name))
685+
preview.warning_level = "error"
686+
return preview
687+
688+
preview.valve_name = resolved
689+
valve = vm.get_switch_by_name(resolved)
690+
if valve is None:
691+
preview.allowed = False
692+
preview.reasons_blocking.append("Valve '{}' not found".format(resolved))
693+
preview.warning_level = "error"
694+
return preview
695+
696+
preview.current_state = valve.state
697+
preview.is_soft_locked = valve.software_lock
698+
preview.is_enabled = getattr(valve, "enabled", True)
699+
preview.owner = valve.owner or ""
700+
701+
# Check if already in target state
702+
target_state = action == "open"
703+
if valve.state == target_state:
704+
preview.allowed = False
705+
preview.reasons_blocking.append(
706+
"Valve is already {}".format("open" if target_state else "closed")
707+
)
708+
preview.warning_level = "info"
709+
return preview
710+
711+
# Check soft lock
712+
if valve.software_lock:
713+
preview.allowed = False
714+
preview.reasons_blocking.append("Software locked")
715+
preview.warning_level = "warning"
716+
return preview
717+
718+
# Check enabled
719+
if not getattr(valve, "enabled", True):
720+
preview.allowed = False
721+
preview.reasons_blocking.append("Valve not enabled")
722+
preview.warning_level = "warning"
723+
return preview
724+
725+
# Check ownership
726+
if not self._check_ownership(resolved, sender_address):
727+
preview.allowed = False
728+
preview.reasons_blocking.append("Owned by {}".format(valve.owner))
729+
preview.warning_level = "warning"
730+
return preview
731+
732+
# Check soft interlocks
733+
if action == "open":
734+
interlocked = vm._check_soft_interlocks(resolved)
735+
if interlocked:
736+
preview.allowed = False
737+
preview.reasons_blocking.append("Interlock: {} is open".format(interlocked.name))
738+
preview.warning_level = "error"
739+
return preview
740+
741+
positive = vm._check_positive_interlocks(resolved)
742+
if positive:
743+
preview.allowed = False
744+
preview.interlocks = positive
745+
preview.reasons_blocking.append(
746+
"Positive interlocks not enabled: {}".format(", ".join(positive))
747+
)
748+
preview.warning_level = "warning"
749+
return preview
750+
else:
751+
interlocked = vm._check_soft_interlocks(resolved)
752+
if interlocked:
753+
preview.allowed = False
754+
preview.reasons_blocking.append("Interlock: {} is open".format(interlocked.name))
755+
preview.warning_level = "error"
756+
return preview
757+
758+
# Collect affected children
759+
children = vm.get_children(resolved)
760+
if children:
761+
preview.affected_children = [c.name for c in children]
762+
763+
# Network region changes
764+
if self.use_network and self.network:
765+
try:
766+
snapshot = self.network.compute_state()
767+
if snapshot and resolved in snapshot.valves:
768+
nv = snapshot.valves[resolved]
769+
if nv.side_volumes:
770+
preview.network_region_changes = [
771+
"Volume changes from {:0.1f} to {:0.1f} cc".format(
772+
min(nv.side_volumes), max(nv.side_volumes) + nv.valve_volume
773+
)
774+
]
775+
if nv.region_id:
776+
preview.network_region_changes.append("Region: {}".format(nv.region_id))
777+
except BaseException:
778+
pass
779+
780+
# Determine if confirmation is needed
781+
if preview.affected_children or preview.network_region_changes:
782+
preview.requires_confirmation = True
783+
preview.warning_level = "info"
784+
785+
return preview
786+
657787
def sample(self, name, **kw):
658788
def sample():
659789
valve = self.switch_manager.get_switch_by_name(name)

0 commit comments

Comments
 (0)