-
Notifications
You must be signed in to change notification settings - Fork 41
[CSDM-1188][feat] Plugin to monitor and control the camera cooling #3367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pieleric
merged 1 commit into
delmic:master
from
pieleric:feat-plugin-to-monitor-and-control-the-camera-cooling
Mar 27, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,289 @@ | ||
| # -*- coding: utf-8 -*- | ||
| """ | ||
| Created on 13 Feb 2026 | ||
|
|
||
| @author: Éric Piel | ||
|
|
||
| Copyright © 2026 Éric Piel, Delmic | ||
|
|
||
| This file is part of Odemis. | ||
|
|
||
| Odemis is free software: you can redistribute it and/or modify it under the terms of the GNU | ||
| General Public License version 2 as published by the Free Software Foundation. | ||
|
|
||
| Odemis is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even | ||
| the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General | ||
| Public License for more details. | ||
|
|
||
| You should have received a copy of the GNU General Public License along with Odemis. If not, | ||
| see http://www.gnu.org/licenses/. | ||
| """ | ||
|
|
||
| # This plugin provides an extra menu entry "Acquisition/Camera temperature..." which opens a | ||
| # small window to monitor and control the camera cooling. | ||
|
|
||
| import logging | ||
| from typing import Optional | ||
|
|
||
| import wx | ||
|
|
||
| from odemis import model, util | ||
| from odemis.gui.plugin import Plugin | ||
| from odemis.gui.util import call_in_wx_main | ||
| import odemis.gui as gui | ||
|
|
||
| COOLING_OFF_TEMP = 25 # Temperature in °C that indicates cooling is off | ||
| DEFAULT_COOLING_TEMP = -75 # Default target temperature in °C when turning on cooling | ||
| MD_TARGET_COOLING_TEMP = "target cooling temperature" # Metadata key for storing previous target | ||
|
|
||
|
|
||
| class CameraTemperaturePlugin(Plugin): | ||
| name = "Camera Temperature Control" | ||
| __version__ = "1.0" | ||
| __author__ = "Éric Piel" | ||
| __license__ = "GPLv2" | ||
|
|
||
| def __init__(self, microscope: model.Microscope, main_app) -> None: | ||
| super().__init__(microscope, main_app) | ||
|
|
||
| # Check if there is a camera with temperature control | ||
| main_data = self.main_app.main_data | ||
| if not main_data.ccds: | ||
| logging.info("%s plugin cannot load as there is no camera", self.name) | ||
| return | ||
|
|
||
| self._camera = main_data.ccds[0] | ||
|
|
||
| # Check if the camera has the required VAs | ||
| if not model.hasVA(self._camera, "temperature"): | ||
| logging.info("%s plugin cannot load as the camera has no temperature VA", self.name) | ||
| return | ||
|
|
||
| if not model.hasVA(self._camera, "targetTemperature"): | ||
| logging.info("%s plugin cannot load as the camera has no targetTemperature VA", self.name) | ||
| return | ||
|
|
||
| # Store the target temperature to disable the cooling. Typically, this is 25°C (ie, ambient temperature), | ||
| # but make sure it fits in the VA range. | ||
| self._temp_cooling_off = self._camera.targetTemperature.clip(COOLING_OFF_TEMP) | ||
|
|
||
| # Store a default target cooling temperature in metadata if not already present | ||
| metadata = self._camera.getMetadata() | ||
| if MD_TARGET_COOLING_TEMP not in metadata: | ||
| target_temp = self._camera.targetTemperature.value | ||
| if target_temp < self._temp_cooling_off: | ||
| # If cooling is currently on, store the current target as the default | ||
| self._camera.updateMetadata({MD_TARGET_COOLING_TEMP: target_temp}) | ||
|
|
||
| # Add menu entry | ||
| self.addMenu("Acquisition/Camera temperature...", self._on_menu_item) | ||
|
|
||
| # Reference to the dialog window (to prevent multiple instances) | ||
| self._dlg: Optional[TemperatureControlDialog] = None | ||
|
|
||
| def _on_menu_item(self) -> None: | ||
| """ | ||
| Callback for the menu item. | ||
| Opens the temperature control dialog if not already open. | ||
| """ | ||
| if self._dlg and self._dlg.IsShown(): | ||
| # Dialog already open, just bring it to front | ||
| self._dlg.Raise() | ||
| return | ||
|
|
||
|
pieleric marked this conversation as resolved.
|
||
| # Create and show the dialog | ||
| self._dlg = TemperatureControlDialog(self.main_app.main_frame, self._camera, self._temp_cooling_off) | ||
| self._dlg.Show() | ||
|
|
||
|
|
||
| class TemperatureControlDialog(wx.Dialog): | ||
| """ | ||
| Non-modal dialog for monitoring and controlling camera temperature. | ||
| """ | ||
|
|
||
| def __init__(self, parent: wx.Window, camera: model.HwComponent, temp_cooling_off: float) -> None: | ||
| """ | ||
| Initialize the temperature control dialog. | ||
|
|
||
| :param parent: Parent window | ||
| :param camera: Camera component with temperature and targetTemperature VAs | ||
| """ | ||
| super().__init__(parent, title="Camera temperature", | ||
| style=wx.DEFAULT_DIALOG_STYLE & ~(wx.RESIZE_BORDER | wx.MAXIMIZE_BOX | wx.MINIMIZE_BOX)) | ||
|
|
||
| self._camera = camera | ||
| self._temp_cooling_off = temp_cooling_off | ||
| self._updating_ui = False # Flag to prevent feedback loops | ||
|
|
||
| # Create UI | ||
| self._create_ui() | ||
|
|
||
| # Subscribe to temperature VA for live updates | ||
| self._camera.temperature.subscribe(self._on_temperature_update, init=True) | ||
| self._camera.targetTemperature.subscribe(self._on_target_temperature_update, init=True) | ||
|
|
||
| # Bind close event for cleanup | ||
| self.Bind(wx.EVT_CLOSE, self._on_close) | ||
|
|
||
| # Set initial size and center on parent | ||
| self.CenterOnParent() | ||
|
|
||
| def _create_ui(self) -> None: | ||
| """ | ||
| Create the user interface elements. | ||
| """ | ||
| # Main sizer | ||
| main_sizer = wx.BoxSizer(wx.VERTICAL) | ||
|
|
||
| # Current temperature display | ||
|
pieleric marked this conversation as resolved.
|
||
| temp_panel = wx.Panel(self) | ||
| temp_panel.SetBackgroundColour(gui.BG_COLOUR_MAIN) | ||
| temp_sizer = wx.BoxSizer(wx.HORIZONTAL) | ||
|
|
||
| temp_label = wx.StaticText(temp_panel, label="Current temperature:") | ||
| temp_label.SetForegroundColour(gui.FG_COLOUR_MAIN) | ||
| temp_sizer.Add(temp_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.LEFT | wx.RIGHT, 5) | ||
|
|
||
| self._temp_display = wx.StaticText(temp_panel, label="-- °C") | ||
| self._temp_display.SetForegroundColour(gui.FG_COLOUR_MAIN) | ||
| font = self._temp_display.GetFont() | ||
| font.PointSize += 2 | ||
| font.SetWeight(wx.FONTWEIGHT_BOLD) | ||
| self._temp_display.SetFont(font) | ||
| temp_sizer.Add(self._temp_display, 1, wx.ALIGN_CENTER_VERTICAL) | ||
|
|
||
| temp_panel.SetSizer(temp_sizer) | ||
| main_sizer.Add(temp_panel, 0, wx.EXPAND | wx.ALL, 10) | ||
|
|
||
| # Cooling control panel | ||
| control_panel = wx.Panel(self) | ||
| control_panel.SetBackgroundColour(gui.BG_COLOUR_MAIN) | ||
| control_sizer = wx.BoxSizer(wx.VERTICAL) | ||
|
|
||
| # Cooling checkbox | ||
| self._cooling_checkbox = wx.CheckBox(control_panel, label="Enable cooling") | ||
| self._cooling_checkbox.SetForegroundColour(gui.FG_COLOUR_MAIN) | ||
| self._cooling_checkbox.Bind(wx.EVT_CHECKBOX, self._on_cooling_toggle) | ||
| control_sizer.Add(self._cooling_checkbox, 0, wx.ALL, 5) | ||
|
|
||
| # Target temperature control | ||
| target_sizer = wx.BoxSizer(wx.HORIZONTAL) | ||
| target_label = wx.StaticText(control_panel, label="Target temperature (°C):") | ||
| target_label.SetForegroundColour(gui.FG_COLOUR_MAIN) | ||
| target_sizer.Add(target_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) | ||
|
|
||
| # Get range from VA if available | ||
| try: | ||
| temp_range = self._camera.targetTemperature.range | ||
| min_temp, max_temp = temp_range | ||
| except (AttributeError, TypeError): | ||
| # Default range if not available | ||
| min_temp, max_temp = -100, 25 | ||
|
|
||
| current_temp = self._camera.targetTemperature.value | ||
| self._target_spin = wx.SpinCtrlDouble(control_panel, value=str(current_temp), | ||
| min=min_temp, max=max_temp, initial=current_temp, inc=1) | ||
| self._target_spin.SetDigits(1) | ||
| self._target_spin.Bind(wx.EVT_SPINCTRLDOUBLE, self._on_target_change) | ||
| target_sizer.Add(self._target_spin, 1, wx.EXPAND) | ||
|
|
||
| control_sizer.Add(target_sizer, 0, wx.EXPAND | wx.ALL, 5) | ||
|
|
||
| control_panel.SetSizer(control_sizer) | ||
| main_sizer.Add(control_panel, 0, wx.EXPAND | wx.ALL, 10) | ||
|
|
||
| # Set main sizer | ||
| self.SetSizer(main_sizer) | ||
| self.Layout() | ||
| self.Fit() | ||
|
|
||
| @call_in_wx_main | ||
| def _on_temperature_update(self, temperature: float) -> None: | ||
| """ | ||
| Callback for temperature VA updates. | ||
| Updates the current temperature display. | ||
|
|
||
| :param temperature: Current temperature in °C | ||
| """ | ||
| self._temp_display.SetLabel(f"{temperature:.1f} °C") | ||
|
|
||
|
pieleric marked this conversation as resolved.
|
||
| @call_in_wx_main | ||
| def _on_target_temperature_update(self, target_temp: float) -> None: | ||
| """ | ||
| Callback for target temperature VA updates. | ||
| Updates the UI to reflect the current state. | ||
|
|
||
| :param target_temp: Target temperature in °C | ||
| """ | ||
| if self._updating_ui: | ||
| return | ||
|
|
||
| self._updating_ui = True | ||
| try: | ||
| # Determine if cooling is on or off | ||
| is_cooling = target_temp < self._temp_cooling_off | ||
|
|
||
| self._cooling_checkbox.SetValue(is_cooling) | ||
| self._target_spin.SetValue(target_temp) | ||
| self._target_spin.Enable(is_cooling) | ||
| finally: | ||
| self._updating_ui = False | ||
|
|
||
| def _on_cooling_toggle(self, event: wx.Event) -> None: | ||
| """ | ||
| Callback for cooling checkbox toggle. | ||
| Turns cooling on/off by adjusting target temperature. | ||
| """ | ||
| if self._updating_ui: | ||
| return | ||
|
|
||
| is_cooling = self._cooling_checkbox.GetValue() | ||
|
|
||
| if is_cooling: | ||
| # Turn cooling on: restore previous target or use default | ||
| metadata = self._camera.getMetadata() | ||
| try: | ||
| previous_target = metadata[MD_TARGET_COOLING_TEMP] | ||
| except KeyError: | ||
| previous_target = self._camera.targetTemperature.clip(DEFAULT_COOLING_TEMP) | ||
|
|
||
| self._camera.targetTemperature.value = previous_target | ||
| self._target_spin.SetValue(previous_target) | ||
| self._target_spin.Enable(True) | ||
| logging.info("Camera cooling enabled, target temperature: %.1f °C", previous_target) | ||
|
pieleric marked this conversation as resolved.
pieleric marked this conversation as resolved.
|
||
| else: | ||
| # Turn cooling off: store current target and set to room temperature | ||
| current_target = self._camera.targetTemperature.value | ||
| if current_target < self._temp_cooling_off: | ||
| # Only store if it was a valid cooling temperature | ||
| self._camera.updateMetadata({MD_TARGET_COOLING_TEMP: current_target}) | ||
| self._camera.targetTemperature.value = self._temp_cooling_off | ||
| self._target_spin.Enable(False) | ||
| logging.info("Camera cooling disabled") | ||
|
|
||
| def _on_target_change(self, event: wx.Event) -> None: | ||
| """ | ||
| Callback for target temperature spin control changes. | ||
| Updates the camera's target temperature. | ||
| """ | ||
| if self._updating_ui: | ||
| return | ||
|
|
||
| new_target = self._target_spin.GetValue() | ||
|
|
||
| # Only update if cooling is enabled | ||
| if self._cooling_checkbox.GetValue(): | ||
| self._camera.targetTemperature.value = new_target | ||
| logging.info("Camera target temperature changed to: %.1f °C", new_target) | ||
|
|
||
| def _on_close(self, event: wx.Event) -> None: | ||
| """ | ||
| Callback for dialog close event. | ||
| Unsubscribes from VAs and destroys the dialog. | ||
| """ | ||
| # Unsubscribe from VAs | ||
| self._camera.temperature.unsubscribe(self._on_temperature_update) | ||
| self._camera.targetTemperature.unsubscribe(self._on_target_temperature_update) | ||
|
|
||
| # Destroy the dialog | ||
| self.Destroy() | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.