Skip to content

[GANIL-159] create nenovision litescope odemis backend driver - #3534

Draft
nandishjpatel wants to merge 1 commit into
delmic:masterfrom
nandishjpatel:GANIL-159-create-nenovision-litescope-odemis-backend-driver
Draft

[GANIL-159] create nenovision litescope odemis backend driver#3534
nandishjpatel wants to merge 1 commit into
delmic:masterfrom
nandishjpatel:GANIL-159-create-nenovision-litescope-odemis-backend-driver

Conversation

@nandishjpatel

@nandishjpatel nandishjpatel commented Jul 29, 2026

Copy link
Copy Markdown
Contributor
Nenovision_Litescope

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an Odemis backend driver for the NenoVision LiteScope (NI-DAQmx analog-output controlled XY closed-loop piezo scanners) and a corresponding test suite validating waveform generation and core actuator behavior.

Changes:

  • Introduces LiteScope actuator driver with NI-DAQmx canary check, position/velocity VAs, and hardware-timed smoothstep ramping with cancellation support.
  • Adds unit tests for smooth_step math and integration tests for move/stop/cancel/speed validation against NI hardware (or simulator).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/odemis/driver/nenovision.py New LiteScope actuator driver using NI-DAQmx finite AO waveforms with cancellable moves.
src/odemis/driver/test/nenovision_test.py New math + integration tests for LiteScope waveform generation and actuator semantics.
Comments suppressed due to low confidence (3)

src/odemis/driver/nenovision.py:476

  • LiteScope.terminate() shuts down the executor but never calls Component.terminate(), so the component may remain registered (Pyro/VAs/dataflows) and leak resources. Call super().terminate() after local cleanup.
    def terminate(self) -> None:
        """
        Cleans up the component, halts physical motion, and safely shuts down
        the background executor thread pool.
        """
        if self._executor:

src/odemis/driver/test/nenovision_test.py:298

  • test_stop_interrupts_move() currently passes even if stop() does nothing, because it only asserts the final position is <= the target (which is always true). Make the test assert that the move future was cancelled (or skip if the move finishes before stop() can be applied).
        # Use a slow speed so the move takes several seconds
        self.scan_stage.speed.value = {"x": LiteScope.MIN_SPEED_M_S * 100, "y": LiteScope.MIN_SPEED_M_S * 100}
        target = {"x": 80e-6, "y": 80e-6}
        f = self.scan_stage.moveAbs(target)
        time.sleep(0.05)
        self.scan_stage.stop()

src/odemis/driver/nenovision.py:448

  • When a move is cancelled, _do_move_abs() returns normally. This triggers CancellableFuture to log "Task was cancelled but returned result instead of raising an exception." (see model/_futures.py:334). Raise CancelledError after updating internal state when cancellation was requested to avoid spurious warnings.
        self._voltage.update(final_v)
        self.position._set_value(
            {ax: self._v_to_m(ax, self._voltage[ax]) for ax in self.axes},
            force_write=True
        )

Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/test/nenovision_test.py
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new NI-DAQmx LiteScope actuator backend for closed-loop X/Y scanner control. It validates hardware and axis configuration, maps coordinates to voltages, generates smoothstep waveforms, streams finite analog output, and tracks partial position. Moves run asynchronously with cancellation, stopping, and optional settling. Reference, termination, and subprocess-based NI-DAQmx failure detection are included. Tests cover mathematics, movement, cancellation, validation, notifications, performance, and hardware-dependent behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant LiteScope
  participant NIDAQmxTask
  Client->>LiteScope: request absolute or relative move
  LiteScope->>LiteScope: calculate target and smoothstep waveform
  LiteScope->>NIDAQmxTask: configure and stream finite analog output
  NIDAQmxTask-->>LiteScope: report generated samples
  LiteScope-->>Client: complete future and updated position
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the creation of the Nenovision LiteScope Odemis backend driver.
Description check ✅ Passed The image shows LiteScope voltage and derivative waveforms related to the new driver.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
src/odemis/driver/nenovision.py (3)

90-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Chain the original DaqError.

raise ... from ex keeps the underlying NI error visible in the traceback (Ruff B904).

♻️ Proposed fix
-        except nidaqmx.DaqError:
-            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.")
+        except nidaqmx.DaqError as ex:
+            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. "
+                             "Please check the connection.") from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 90 - 94, Update the exception
handler around the device lookup in the constructor to bind the caught
nidaqmx.DaqError and raise the existing ValueError using explicit exception
chaining with the original error. Preserve the current message and device lookup
behavior.

Source: Linters/SAST tools


450-479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Actuator lifecycle polish: referenced VA and super().terminate().

Exposing reference() without a referenced VA is inconsistent with the Odemis Actuator contract — either drop the method or add the VA (set to True for both axes). terminate() should also call super().terminate() so base-class cleanup runs.

♻️ Proposed fix
     def terminate(self) -> None:
         if self._executor:
             self.stop()
             self._executor.shutdown(wait=True)
             self._executor = None
+        super().terminate()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 450 - 479, Update the actuator
class around reference and termination: add a referenced VA initialized to true
for both axes so the existing reference() method satisfies the Actuator
contract, and update terminate() to invoke super().terminate() after shutting
down the executor while preserving the current cleanup behavior.

162-167: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the canary subprocess with a timeout.

The whole point is to survive a broken NI-DAQmx install, but a hanging C library would block init indefinitely since subprocess.run has no timeout. (The S603/command-injection hints are false positives here: the argv is fixed and uses sys.executable.)

♻️ Proposed fix
-        proc = subprocess.run(canary_cmd)
+        try:
+            proc = subprocess.run(canary_cmd, timeout=30)
+        except subprocess.TimeoutExpired:
+            raise model.HwError("NI-DAQmx failed to load: the canary check timed out.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 162 - 167, Update the canary
subprocess invocation in the NI-DAQmx initialization flow to pass a finite
timeout to subprocess.run, ensuring a hung C library cannot block initialization
indefinitely. Preserve the existing return-code handling for completed processes
and handle the timeout consistently with the existing hardware-error behavior.
src/odemis/driver/test/nenovision_test.py (3)

135-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Chain the original exception and annotate the fixtures.

raise unittest.SkipTest(...) from ex (Ruff B904). Also add return type hints to the test methods/fixtures in this class — setUpClass in TestLiteScopeMath is annotated while these are not.

As per coding guidelines: "Always use type hints for function parameters and return types in Python code".

♻️ Proposed fix
     `@classmethod`
-    def setUpClass(cls):
+    def setUpClass(cls) -> None:
         if not nenovision:
             raise unittest.SkipTest("nenovision driver is not available. Check if python3-nidaqmx is installed.")
         try:
             cls.scan_stage = LiteScope(**CONFIG_LITESCOPE)
         except (model.HwError, ValueError) as ex:
-            raise unittest.SkipTest(f"Cannot connect to NI DAQ device: {ex}")
+            raise unittest.SkipTest(f"Cannot connect to NI DAQ device: {ex}") from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 135 - 142, Update
TestLiteScopeMath.setUpClass to chain the unittest.SkipTest exception from the
caught exception using “from ex”. Add return type annotations to setUpClass and
the other test methods/fixtures in this class, using appropriate types while
preserving existing test behavior.

Sources: Coding guidelines, Linters/SAST tools


33-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Gate hardware tests on TEST_NOHW as well.

The repo convention runs tests with env TEST_NOHW=1, but this module decides purely on driver importability, so TestLiteScope will attempt to open Dev1 in a no-hardware run and rely on the SkipTest fallback. Reading TEST_NOHW makes the intent explicit and avoids the slow canary subprocess. Also, as ex in the import guard is unused.

As per coding guidelines: "Run tests with the template command: env TEST_NOHW=1 python3 src/odemis/.../name_of_the_test_file.py TestCaseClassName.test_method_name".

♻️ Proposed change
+import os
+
 try:
     from odemis.driver import nenovision
     from odemis.driver.nenovision import LiteScope
-except ImportError as ex:
+except ImportError:
     nenovision = None
+
+TEST_NOHW = os.environ.get("TEST_NOHW", "0") != "0"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 33 - 40, Update the
module-level hardware-test gating around the nenovision import to also check the
TEST_NOHW environment variable, preventing TestLiteScope from running when no
hardware tests are requested; remove the unused exception binding in the
ImportError handler and preserve the existing importability fallback.

Source: Coding guidelines


345-372: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Config-validation tests are needlessly hardware-gated.

test_init_raises_on_empty_axes and test_init_raises_on_missing_axis_key fail before any DAQ access, so they belong in TestLiteScopeMath where they still run without a device. Keeping them here means they are skipped exactly in the no-hardware CI runs where they are cheapest to exercise.

Note that test_init_raises_on_missing_axis_key only reaches the key check after the device lookup succeeds, so it does depend on hardware as written; asserting a bad-key config against a valid device is fine, but the empty-axes case can move.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 345 - 372, Move
test_init_raises_on_empty_axes from the hardware-dependent test class into
TestLiteScopeMath so it runs without DAQ access. Keep
test_init_raises_on_missing_axis_key in its current class because LiteScope
performs device lookup before validating the axis key; retain the valid-device
setup needed for that test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 60-63: Ensure MAX_SPEED_ENGAGED_M_S is enforced whenever the probe
is engaged, using a probe-state-dependent VA speed range alongside the existing
MAX_SPEED_RETRACTED_M_S enforcement. If engaged-state enforcement is not
intended, remove MAX_SPEED_ENGAGED_M_S and update the class documentation to
match; do not leave the constant unused.
- Around line 96-101: Update the component initialization around the metadata
assignment so self._metadata is populated only after HwComponent.__init__()
completes, preserving MD_HW_NAME, MD_SW_VERSION, and MD_HW_VERSION.
Alternatively, use HwComponent’s supported swVersion and hwVersion attributes,
but ensure the metadata values are not overwritten during super().__init__().
- Around line 265-272: Update _write_ao_finite to clear future._running_task
while holding future._moving_lock before the nidaqmx.Task context exits,
ensuring _cancel_current_move cannot call stop() on a closed task. Preserve
cancellation signaling and existing task cleanup behavior.
- Around line 397-408: Update the timeout branch in the task-wait loop of the
move execution method to avoid returning n_samples when task.is_task_done() is
still false. Explicitly fail the operation or return
task.out_stream.total_samp_per_chan_generated so _do_move_abs cannot report the
target position and resolve the future successfully after a hardware stall;
preserve the existing cancellation behavior.
- Around line 116-132: Validate each axis’s rng_m and rng_v in the axis
initialization loop before storing them or computing the center voltage. Reject
equal endpoints to prevent zero-denominator conversions, and ensure rng_v lies
within the device AO limits before accepting the configuration; raise a clear
ValueError identifying the axis and invalid range.
- Around line 311-318: Update the relative-move flow in `moveRel` and
`_do_move_rel` so target coordinates are calculated inside the queued worker
from the live `_voltage`, converted via `_v_to_m`, and combined with the
inversion-adjusted shift. Remove the submit-time `self.position.value` target
calculation and delegate the resolved target to `_do_move_abs`, preserving the
existing future and executor behavior.

---

Nitpick comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 90-94: Update the exception handler around the device lookup in
the constructor to bind the caught nidaqmx.DaqError and raise the existing
ValueError using explicit exception chaining with the original error. Preserve
the current message and device lookup behavior.
- Around line 450-479: Update the actuator class around reference and
termination: add a referenced VA initialized to true for both axes so the
existing reference() method satisfies the Actuator contract, and update
terminate() to invoke super().terminate() after shutting down the executor while
preserving the current cleanup behavior.
- Around line 162-167: Update the canary subprocess invocation in the NI-DAQmx
initialization flow to pass a finite timeout to subprocess.run, ensuring a hung
C library cannot block initialization indefinitely. Preserve the existing
return-code handling for completed processes and handle the timeout consistently
with the existing hardware-error behavior.

In `@src/odemis/driver/test/nenovision_test.py`:
- Around line 135-142: Update TestLiteScopeMath.setUpClass to chain the
unittest.SkipTest exception from the caught exception using “from ex”. Add
return type annotations to setUpClass and the other test methods/fixtures in
this class, using appropriate types while preserving existing test behavior.
- Around line 33-40: Update the module-level hardware-test gating around the
nenovision import to also check the TEST_NOHW environment variable, preventing
TestLiteScope from running when no hardware tests are requested; remove the
unused exception binding in the ImportError handler and preserve the existing
importability fallback.
- Around line 345-372: Move test_init_raises_on_empty_axes from the
hardware-dependent test class into TestLiteScopeMath so it runs without DAQ
access. Keep test_init_raises_on_missing_axis_key in its current class because
LiteScope performs device lookup before validating the axis key; retain the
valid-device setup needed for that test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fe38920b-9a6f-4fec-98ec-96bc74ecadc4

📥 Commits

Reviewing files that changed from the base of the PR and between f7eafae and 589f4fa.

📒 Files selected for processing (2)
  • src/odemis/driver/nenovision.py
  • src/odemis/driver/test/nenovision_test.py

Comment on lines +60 to +63
# Physical speed limits in m/s (Assuming +/-10V maps to +/-80um)
MAX_SPEED_ENGAGED_M_S = 20e-6 # 2.5 V/s
MAX_SPEED_RETRACTED_M_S = 320e-6 # 40.0 V/s
MIN_SPEED_M_S = 1e-7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

MAX_SPEED_ENGAGED_M_S is never enforced.

The class docstring claims strict speed-limit enforcement, but only MAX_SPEED_RETRACTED_M_S bounds the speed VA, so an engaged probe can be driven at 16x the intended limit. Confirm whether the engaged limit is meant to be applied (e.g. via a probe-state-dependent VA range) or the constant should be dropped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 60 - 63, Ensure
MAX_SPEED_ENGAGED_M_S is enforced whenever the probe is engaged, using a
probe-state-dependent VA speed range alongside the existing
MAX_SPEED_RETRACTED_M_S enforcement. If engaged-state enforcement is not
intended, remove MAX_SPEED_ENGAGED_M_S and update the class documentation to
match; do not leave the constant unused.

Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/nenovision.py
Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/nenovision.py Outdated
Comment on lines +397 to +408
while not task.is_task_done():
left = end_time - time.monotonic()
if left <= 0:
break

sleept = max(0.001, min(left / 2.0, 0.1))
if future._must_stop.wait(sleept):
logging.debug(f"[{self.name}] Move cancelled mid-execution.")
task.stop()
return task.out_stream.total_samp_per_chan_generated

return n_samples

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Timeout is reported as a successful full move.

When the wait deadline expires, the loop breaks with the task still not done, yet the function returns n_samples. _do_move_abs then sets the position VA to the target and the future resolves successfully, hiding a hardware stall. Fail explicitly (or return the actual generated count) instead.

🐛 Proposed fix
             while not task.is_task_done():
                 left = end_time - time.monotonic()
                 if left <= 0:
-                    break
+                    task.stop()
+                    raise TimeoutError(f"AO generation did not complete within {timeout:.3f}s "
+                                       f"({task.out_stream.total_samp_per_chan_generated}/{n_samples} samples)")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while not task.is_task_done():
left = end_time - time.monotonic()
if left <= 0:
break
sleept = max(0.001, min(left / 2.0, 0.1))
if future._must_stop.wait(sleept):
logging.debug(f"[{self.name}] Move cancelled mid-execution.")
task.stop()
return task.out_stream.total_samp_per_chan_generated
return n_samples
while not task.is_task_done():
left = end_time - time.monotonic()
if left <= 0:
task.stop()
raise TimeoutError(
f"AO generation did not complete within {timeout:.3f}s "
f"({task.out_stream.total_samp_per_chan_generated}/{n_samples} samples)"
)
sleept = max(0.001, min(left / 2.0, 0.1))
if future._must_stop.wait(sleept):
logging.debug(f"[{self.name}] Move cancelled mid-execution.")
task.stop()
return task.out_stream.total_samp_per_chan_generated
return n_samples
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 397 - 408, Update the timeout
branch in the task-wait loop of the move execution method to avoid returning
n_samples when task.is_task_done() is still false. Explicitly fail the operation
or return task.out_stream.total_samp_per_chan_generated so _do_move_abs cannot
report the target position and resolve the future successfully after a hardware
stall; preserve the existing cancellation behavior.

Copilot AI review requested due to automatic review settings July 31, 2026 08:29
@nandishjpatel
nandishjpatel force-pushed the GANIL-159-create-nenovision-litescope-odemis-backend-driver branch from 589f4fa to 31912d8 Compare July 31, 2026 08:29

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
src/odemis/driver/test/nenovision_test.py (4)

345-362: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the hardware-independent init tests to TestLiteScopeMath. test_init_raises_on_missing_axis_key and test_init_raises_on_empty_axes raise before any DAQ access. In TestLiteScope they are skipped whenever no device is present, so the validation logic gets no coverage in the standard TEST_NOHW=1 run.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 345 - 362, Move
test_init_raises_on_missing_axis_key and test_init_raises_on_empty_axes from
TestLiteScope to TestLiteScopeMath, preserving their assertions and inputs so
they run without hardware and are not skipped when TEST_NOHW=1.

Source: Coding guidelines


33-37: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bind LiteScope to None in the import guard. If the import fails, LiteScope stays undefined. Any later reference at module level or in a test that runs before the skip raises NameError instead of a clear skip. Also log the captured exception so the cause is visible.

♻️ Proposed change
 try:
     from odemis.driver import nenovision
     from odemis.driver.nenovision import LiteScope
 except ImportError as ex:
+    logging.info(f"nenovision driver not available: {ex}")
     nenovision = None
+    LiteScope = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 33 - 37, Update the
import guard around nenovision and LiteScope so the ImportError path assigns
LiteScope to None alongside nenovision, and log the captured exception using the
test module’s existing logging mechanism. Preserve the successful import
behavior and existing skip handling.

287-308: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert that stop() really interrupted the move. The current assertions pass even when the stage reaches the target, because the target equals the upper bound. Compare against the target with assertLess, or assert on the future state, so the test can fail.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 287 - 308, Update
test_stop_interrupts_move so it verifies that stop() interrupted the motion
rather than allowing the target position: use strict position comparisons
against the target, or assert that the move future is cancelled, while
preserving the existing timeout and cancellation handling.

132-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Honor TEST_NOHW in the hardware-dependent test class. The repository test command is env TEST_NOHW=1 python3 src/odemis/.../nenovision_test.py .... With TEST_NOHW=1 this class still tries to open the NI device and relies on the connection failure to skip. Skip explicitly instead.

♻️ Proposed change
+import os
+
+TEST_NOHW = os.environ.get("TEST_NOHW", "0")  # Default: run with real hardware
+
...
     `@classmethod`
     def setUpClass(cls):
         if not nenovision:
             raise unittest.SkipTest("nenovision driver is not available. Check if python3-nidaqmx is installed.")
+        if TEST_NOHW == "1":
+            raise unittest.SkipTest("No hardware available, skipping the LiteScope integration tests.")
         try:
             cls.scan_stage = LiteScope(**CONFIG_LITESCOPE)
         except (model.HwError, ValueError) as ex:
             raise unittest.SkipTest(f"Cannot connect to NI DAQ device: {ex}") from ex

As per coding guidelines: "Run tests with the template command: env TEST_NOHW=1 python3 src/odemis/.../name_of_the_test_file.py TestCaseClassName.test_method_name".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 132 - 142, Update
TestLiteScope.setUpClass to check the TEST_NOHW environment flag before
constructing LiteScope, raising unittest.SkipTest immediately when hardware
tests are disabled; retain the existing nenovision availability check and
connection-error skip behavior for hardware-enabled runs.

Sources: Coding guidelines, Linters/SAST tools

src/odemis/driver/nenovision.py (2)

90-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Chain the original DaqError. Ruff flags B904. The original NI error text helps to diagnose connection failures.

♻️ Proposed change
-        except nidaqmx.DaqError:
-            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.")
+        except nidaqmx.DaqError as ex:
+            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. "
+                             f"Please check the connection.") from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 90 - 94, Update the exception
handling around device lookup in the driver initialization flow to re-raise the
ValueError from the caught nidaqmx.DaqError using explicit exception chaining,
preserving the existing user-facing message while retaining the original NI
error as the cause.

Source: Linters/SAST tools


195-196: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the ambiguous parameter l. Ruff flags E741. Use n_samples for clarity, and update the internal call at line 354 and the tests in src/odemis/driver/test/nenovision_test.py.

♻️ Proposed rename
-    def smooth_step(l: int, start: float, end: float, vstart: float = 0.0, vend: float = 1.0) -> numpy.ndarray:
+    def smooth_step(n_samples: int, start: float, end: float, vstart: float = 0.0,
+                    vend: float = 1.0) -> numpy.ndarray:
...
-        :param l: Number of values (samples) to generate.
+        :param n_samples: Number of values (samples) to generate.
...
-        x = numpy.linspace(start, end, l, dtype=numpy.float64)
+        x = numpy.linspace(start, end, n_samples, dtype=numpy.float64)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 195 - 196, Rename the
smooth_step parameter l to n_samples in the smooth_step method, update its
internal references, and adjust the call site around the noted driver logic plus
all affected tests in nenovision_test.py to use the new keyword or positional
contract consistently.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 162-167: Update the canary subprocess invocation in the NI-DAQmx
initialization flow to pass an appropriate timeout to subprocess.run. Catch the
resulting timeout exception and raise model.HwError with a clear message
indicating that the NI-DAQmx C-library hung during loading, while preserving the
existing handling for negative and nonzero return codes.
- Around line 450-460: Add a self.referenced value attribute in the nenovision
driver for the axes handled by reference(), using the same representation and
initialization conventions as other drivers. Update reference() as needed to
maintain that attribute while preserving its instantly resolved future behavior,
so reference-based callers can validate and discover the referenceable axes.

---

Nitpick comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 90-94: Update the exception handling around device lookup in the
driver initialization flow to re-raise the ValueError from the caught
nidaqmx.DaqError using explicit exception chaining, preserving the existing
user-facing message while retaining the original NI error as the cause.
- Around line 195-196: Rename the smooth_step parameter l to n_samples in the
smooth_step method, update its internal references, and adjust the call site
around the noted driver logic plus all affected tests in nenovision_test.py to
use the new keyword or positional contract consistently.

In `@src/odemis/driver/test/nenovision_test.py`:
- Around line 345-362: Move test_init_raises_on_missing_axis_key and
test_init_raises_on_empty_axes from TestLiteScope to TestLiteScopeMath,
preserving their assertions and inputs so they run without hardware and are not
skipped when TEST_NOHW=1.
- Around line 33-37: Update the import guard around nenovision and LiteScope so
the ImportError path assigns LiteScope to None alongside nenovision, and log the
captured exception using the test module’s existing logging mechanism. Preserve
the successful import behavior and existing skip handling.
- Around line 287-308: Update test_stop_interrupts_move so it verifies that
stop() interrupted the motion rather than allowing the target position: use
strict position comparisons against the target, or assert that the move future
is cancelled, while preserving the existing timeout and cancellation handling.
- Around line 132-142: Update TestLiteScope.setUpClass to check the TEST_NOHW
environment flag before constructing LiteScope, raising unittest.SkipTest
immediately when hardware tests are disabled; retain the existing nenovision
availability check and connection-error skip behavior for hardware-enabled runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5fb42fc7-8827-4886-a903-4d8038aa1630

📥 Commits

Reviewing files that changed from the base of the PR and between 589f4fa and 31912d8.

📒 Files selected for processing (2)
  • src/odemis/driver/nenovision.py
  • src/odemis/driver/test/nenovision_test.py

Comment thread src/odemis/driver/nenovision.py
Comment thread src/odemis/driver/nenovision.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Suppressed comments (6)

src/odemis/driver/nenovision.py:193

  • _v_to_m() has the same inversion issue as _m_to_v(): it uses the advertised (possibly inverted) axis range, but it is used to compute internal coordinates from voltages. For inverted axes with non-symmetric ranges this returns the wrong physical coordinate and makes the position VA inconsistent.
        rng_m = self.axes[axis].range
        rng_v = self._range_v[axis]
        return rng_m[0] + (val_v - rng_v[0]) / (rng_v[1] - rng_v[0]) * (rng_m[1] - rng_m[0])

src/odemis/driver/nenovision.py:263

  • _set_voltage() updates the position VA from internal voltage state, but does not convert to the external coordinate system. This breaks inverted axes: after any move the reported position will be inconsistent with commanded external coordinates.
        self._voltage.update(volts)
        self.position._set_value(
            {ax: self._v_to_m(ax, self._voltage[ax]) for ax in self.axes},
            force_write=True
        )

src/odemis/driver/nenovision.py:448

  • _do_move_abs() publishes position updates in internal coordinates (derived from voltages) without applying _applyInversion(). This makes the public position VA inconsistent with the external coordinate system for inverted axes.
        self._voltage.update(final_v)
        self.position._set_value(
            {ax: self._v_to_m(ax, self._voltage[ax]) for ax in self.axes},
            force_write=True
        )

src/odemis/driver/test/nenovision_test.py:140

  • These integration tests always attempt to connect to a physical NI DAQ device. Other driver tests commonly honor TEST_NOHW to avoid touching real hardware in CI (e.g., src/odemis/driver/test/tucsen_test.py:36-45). Consider skipping this hardware suite when TEST_NOHW=1 so no-hardware runs don’t try to enumerate/init NI devices.
    @classmethod
    def setUpClass(cls):
        if not nenovision:
            raise unittest.SkipTest("nenovision driver is not available. Check if python3-nidaqmx is installed.")
        try:
            cls.scan_stage = LiteScope(**CONFIG_LITESCOPE)

src/odemis/driver/nenovision.py:318

  • moveRel() computes an internal target position by adding an internally-inverted shift to self.position.value, but self.position is an external-facing VA. For inverted axes this will move in the wrong direction once position reporting is fixed to be external. Compute the internal current position via _applyInversion(self.position.value) first.
        shift = self._applyInversion(shift)

        target = {ax: self.position.value[ax] + shift.get(ax, 0.0) for ax in self.axes if ax in shift}
        f = self._create_future()
        return self._executor.submitf(f, self._do_move_abs, f, target)

src/odemis/driver/nenovision.py:350

  • _generate_waveform() can allocate extremely large waveforms at low speeds (n_samples scales with duration * sample_rate). With MIN_SPEED_M_S=1e-7 and a full-range move, this can easily reach tens of millions of samples per axis (hundreds of MB), risking OOM or very long precompute times. Consider capping n_samples / downsampling the sample_rate for long moves, or raising a clear error when the request would exceed a safe limit.
        # Smoothstep peak derivative is 1.5x the average. Multiply duration to ensure safety.
        duration = max(durations) * 1.5
        max_dv = max(abs(target_v[ax] - start_v[ax]) for ax in axes)

        sample_rate = max(self._ao_min_rate, min(TARGET_SAMPLE_RATE, self._ao_max_rate))
        n_samples = max(2, math.ceil(duration * sample_rate))

Comment thread src/odemis/driver/nenovision.py
Comment on lines +138 to +141
# Initialize tracking VAs
init_positions = {ax: self._v_to_m(ax, self._voltage[ax]) for ax in axes}
self.position = model.VigilantAttribute(init_positions, unit="m", readonly=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you could also do, is to forbid axis inversion, via the "inverted" parameter, and instead just make sure that it's possible to pass v_min > v_max (in which case this will behave the same as axis inversion). Or you could even make it "fancy", and interpret here the "inverted" parameter and swap v_min/v_max in such case.

Copilot AI review requested due to automatic review settings July 31, 2026 12:16
@nandishjpatel
nandishjpatel force-pushed the GANIL-159-create-nenovision-litescope-odemis-backend-driver branch from 31912d8 to ff3db11 Compare July 31, 2026 12:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
src/odemis/driver/test/nenovision_test.py (4)

390-390: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the unused fig binding. Ruff flags RUF059.

♻️ Proposed change
-        fig, (ax_voltage, ax_dvdt) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
+        _, (ax_voltage, ax_dvdt) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` at line 390, Update the
plt.subplots assignment in the test to discard the unused figure return value
while preserving the ax_voltage and ax_dvdt axes bindings.

Source: Linters/SAST tools


80-125: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add return type hints to the test methods. setUpClass at Line 76 declares -> None, but the test methods in this class and in TestLiteScope do not. The math assertions themselves are correct.

As per coding guidelines: "Always use type hints for function parameters and return types in Python code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 80 - 125, Add an
explicit -> None return annotation to each test method shown, including the
smooth-step tests and the corresponding methods in TestLiteScope. Preserve the
existing test parameters and assertion logic, matching the setUpClass annotation
style.

Source: Coding guidelines


135-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Gate the integration tests on TEST_NOHW. setUpClass skips only when device construction fails. If a simulated NI device exists on the machine, these tests still drive the DAQ under TEST_NOHW=1. Add an explicit check so the documented no-hardware run is honoured. Also chain the caught exception to silence Ruff B904.

♻️ Proposed change
+import os
+
+TEST_NOHW = os.environ.get("TEST_NOHW", "0")  # Default: run with hardware
...
     `@classmethod`
     def setUpClass(cls):
         if not nenovision:
             raise unittest.SkipTest("nenovision driver is not available. Check if python3-nidaqmx is installed.")
+        if TEST_NOHW == "1":
+            raise unittest.SkipTest("No hardware available.")
         try:
             cls.scan_stage = LiteScope(**CONFIG_LITESCOPE)
         except (model.HwError, ValueError) as ex:
-            raise unittest.SkipTest(f"Cannot connect to NI DAQ device: {ex}")
+            raise unittest.SkipTest(f"Cannot connect to NI DAQ device: {ex}") from ex

As per path instructions: "Run tests with the template command: env TEST_NOHW=1 python3 src/odemis/.../name_of_the_test_file.py TestCaseClassName.test_method_name".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 135 - 142, Update
setUpClass in the nenovision integration tests to immediately raise
unittest.SkipTest when TEST_NOHW indicates a no-hardware run, before
constructing LiteScope; preserve the existing availability and connection
checks. When converting model.HwError or ValueError into SkipTest, explicitly
chain the caught exception to satisfy Ruff B904.

Sources: Path instructions, Linters/SAST tools


33-37: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Log the caught ImportError. The handler binds ex but discards it, so the reason for the skip is invisible. Log the message, or include it in the SkipTest reason.

♻️ Proposed change
 try:
     from odemis.driver import nenovision
     from odemis.driver.nenovision import LiteScope
 except ImportError as ex:
+    logging.info(f"Skipping nenovision tests: {ex}")
     nenovision = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 33 - 37, Update the
ImportError handler around the nenovision and LiteScope imports to use the
captured ex value: log the exception or include its message in the SkipTest
reason, while preserving the existing nenovision = None behavior.
src/odemis/driver/nenovision.py (2)

197-198: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the parameter l. Ruff flags E741 for the ambiguous name. num_samples reads better and the tests call the method positionally, so the rename is safe.

♻️ Proposed change
-    def smooth_step(l: int, start: float, end: float, vstart: float = 0.0, vend: float = 1.0) -> numpy.ndarray:
+    def smooth_step(num_samples: int, start: float, end: float,
+                    vstart: float = 0.0, vend: float = 1.0) -> numpy.ndarray:

Update the body and docstring accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 197 - 198, Rename the
smooth_step parameter l to num_samples in the static method smooth_step,
updating all references in its body and docstring while preserving
positional-call behavior.

Source: Linters/SAST tools


92-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Chain the original DaqError. Ruff flags B904 here. The underlying NI error text is lost, which makes device-lookup failures harder to diagnose.

♻️ Proposed change
-        except nidaqmx.DaqError:
-            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.")
+        except nidaqmx.DaqError as ex:
+            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.") from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 92 - 96, Update the exception
handling around device lookup in the initialization flow to preserve the
original nidaqmx.DaqError by chaining it when raising the ValueError. Keep the
existing user-facing message and device lookup behavior unchanged.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 152-153: The initialization path currently calls
_set_voltage(self._voltage), bypassing the speed-limited ramp. Read the current
analog-output value and use _generate_waveform to ramp safely to the center
voltage before applying it, preserving the class’s S-curve speed-limit behavior.
- Around line 473-481: Update LiteScope.terminate to call super().terminate()
after stopping and shutting down the executor, preserving the existing cleanup
order and ensuring Component.terminate unregisters the component’s dataflows,
VAs, events, and daemon registration.
- Around line 344-360: Adjust waveform sizing in the move-generation flow around
smooth_step and n_samples so long-duration, slow moves cannot allocate
excessively large buffers. Cap n_samples to a safe maximum and derive the
effective sample_rate from the capped sample count and duration, while
preserving the minimum sample count and existing duration/max_dv outputs.

In `@src/odemis/driver/test/nenovision_test.py`:
- Around line 276-279: Update both interruption-test assertion sites in
src/odemis/driver/test/nenovision_test.py (lines 276-279 and 305-308): use
strict less-than checks so reaching the target fails the test, and assert that
the stage position has moved beyond its start position; alternatively skip the
second test when the device is simulated, as documented.

---

Nitpick comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 197-198: Rename the smooth_step parameter l to num_samples in the
static method smooth_step, updating all references in its body and docstring
while preserving positional-call behavior.
- Around line 92-96: Update the exception handling around device lookup in the
initialization flow to preserve the original nidaqmx.DaqError by chaining it
when raising the ValueError. Keep the existing user-facing message and device
lookup behavior unchanged.

In `@src/odemis/driver/test/nenovision_test.py`:
- Line 390: Update the plt.subplots assignment in the test to discard the unused
figure return value while preserving the ax_voltage and ax_dvdt axes bindings.
- Around line 80-125: Add an explicit -> None return annotation to each test
method shown, including the smooth-step tests and the corresponding methods in
TestLiteScope. Preserve the existing test parameters and assertion logic,
matching the setUpClass annotation style.
- Around line 135-142: Update setUpClass in the nenovision integration tests to
immediately raise unittest.SkipTest when TEST_NOHW indicates a no-hardware run,
before constructing LiteScope; preserve the existing availability and connection
checks. When converting model.HwError or ValueError into SkipTest, explicitly
chain the caught exception to satisfy Ruff B904.
- Around line 33-37: Update the ImportError handler around the nenovision and
LiteScope imports to use the captured ex value: log the exception or include its
message in the SkipTest reason, while preserving the existing nenovision = None
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6e60d01-c0f6-4a86-841e-9c5265c630bc

📥 Commits

Reviewing files that changed from the base of the PR and between 31912d8 and ff3db11.

📒 Files selected for processing (2)
  • src/odemis/driver/nenovision.py
  • src/odemis/driver/test/nenovision_test.py

Comment on lines +152 to +153
# Apply initial physical voltages to hardware
self._set_voltage(self._voltage)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

The initial voltage write bypasses the speed limit. _set_voltage uses write_one_sample, so the DAC jumps immediately from its present output to the center of rng_v. If the DAC was previously at a range end, this is a full-scale step (up to 20 V) applied in one sample. That contradicts the class docstring, which states that the driver enforces speed limits with S-curve ramps to prevent overshoot and mechanical stress.

Consider reading back the current AO value and ramping to the center with _generate_waveform, or document that the probe must be retracted and the stage voltage already near center before initialization.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 152 - 153, The initialization
path currently calls _set_voltage(self._voltage), bypassing the speed-limited
ramp. Read the current analog-output value and use _generate_waveform to ramp
safely to the center voltage before applying it, preserving the class’s S-curve
speed-limit behavior.

Comment thread src/odemis/driver/nenovision.py
Comment thread src/odemis/driver/nenovision.py
Comment on lines +276 to +279
# Position must be consistent — somewhere between start and target
pos = self.scan_stage.position.value
self.assertLessEqual(pos["x"], 80e-6)
self.assertLessEqual(pos["y"], 80e-6)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Both interruption tests use non-strict inequalities against the target. assertLessEqual(pos[ax], 80e-6) is satisfied when the stage reaches the target, so neither test detects a failed cancel or stop.

  • src/odemis/driver/test/nenovision_test.py#L276-L279: replace the two assertLessEqual checks with assertLess(pos[ax], 80e-6), and assert that the position moved past the start.
  • src/odemis/driver/test/nenovision_test.py#L305-L308: apply the same strict check, or skip the test when the device is simulated, as the docstring notes.
📍 Affects 1 file
  • src/odemis/driver/test/nenovision_test.py#L276-L279 (this comment)
  • src/odemis/driver/test/nenovision_test.py#L305-L308
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 276 - 279, Update
both interruption-test assertion sites in
src/odemis/driver/test/nenovision_test.py (lines 276-279 and 305-308): use
strict less-than checks so reaching the target fails the test, and assert that
the stage position has moved beyond its start position; alternatively skip the
second test when the device is simulated, as documented.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (6)

src/odemis/driver/nenovision.py:141

  • init_positions is computed in internal coordinates but position should be exposed in external coordinates (respecting Actuator axis inversion). Without applying _applyInversion here, inverted axes will report the wrong sign from startup.
        init_positions = {ax: self._v_to_m(ax, self._voltage[ax]) for ax in axes}

src/odemis/driver/nenovision.py:403

  • If the finite AO task fails to complete before timeout, the loop breaks and the method currently returns n_samples, which makes the caller treat the move as fully completed (and snap software state to the target). On timeout, stop the task and raise an error (or at least return the actual generated sample count).
            while not task.is_task_done():
                left = end_time - time.monotonic()
                if left <= 0:
                    break

src/odemis/driver/nenovision.py:265

  • The position VA update after _set_voltage() uses internal coordinates; for inverted axes this will report the wrong sign unless _applyInversion() is applied when publishing position.
        self.position._set_value(
            {ax: self._v_to_m(ax, self._voltage[ax]) for ax in self.axes},
            force_write=True
        )

src/odemis/driver/nenovision.py:450

  • The position VA is updated with internal coordinates; inverted axes will be reported with the wrong sign unless _applyInversion() is applied. Also, when a CancellableFuture is cancelled, letting the worker return normally triggers CancellableFuture.set_result() to log a warning; raising CancelledError avoids that noise while keeping the future cancelled.
        self._voltage.update(final_v)
        self.position._set_value(
            {ax: self._v_to_m(ax, self._voltage[ax]) for ax in self.axes},
            force_write=True
        )

src/odemis/driver/nenovision.py:320

  • moveRel() applies _applyInversion() to shift before adding it to self.position.value. If position is exposed in external coordinates (as in other actuators), this will double-apply inversion and compute the wrong target for inverted axes. Compute the target in external coordinates first, then apply _applyInversion() once when sending to the hardware thread.
        self._checkMoveRel(shift)
        shift = self._applyInversion(shift)

        target = {ax: self.position.value[ax] + shift.get(ax, 0.0) for ax in self.axes if ax in shift}
        f = self._create_future()
        return self._executor.submitf(f, self._do_move_abs, f, target)

src/odemis/driver/nenovision.py:358

  • Waveform generation starts at x=1.0/n_samples, so the first hardware-timed sample is not start_v. For small n_samples (notably the enforced minimum of 2) this creates a large initial voltage step, undermining the intended speed/acceleration limiting.
        # 1.0 / n_samples prevents 1-sample flat spots since DAC is already holding start_v
        waves = [
            self.smooth_step(n_samples, 1.0 / n_samples, 1.0, start_v[ax], target_v[ax])
            for ax in axes
        ]

Comment on lines +33 to +37
try:
from odemis.driver import nenovision
from odemis.driver.nenovision import LiteScope
except ImportError as ex:
nenovision = None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module tests nenovision. There is no reason it's not there. So no need to avoid it.
Or is that for the nidaq import?
If so, please clarify it as a comment.

Comment on lines +59 to +65
CONFIG_LITESCOPE = {
"name": "LiteScope",
"role": "scan-stage",
"device": "Dev1",
"settle_time": 0.0,
"axes": CONFIG_AXES,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add somewhere here or, at the top of the nenovision module how to create a simulated device. With nidaqmxconfig you can export/import a "nce" file that makes it easy to recreated the simulator (without the special NI Hardware config utility).
For the semnidaq, I had:
nidaqmxconfig --import ni-pci6361-sim.nce --replace . You can place the new .nce file next to ni-pci6361-sim.nce in the Google Drive.

Comment on lines +278 to +279
self.assertLessEqual(pos["x"], 80e-6)
self.assertLessEqual(pos["y"], 80e-6)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree a little bit with codderabbit here. It'd be good to also check it has moved away from the original position (0).

@nandishjpatel nandishjpatel Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, there was a bug and I needed to extend the functionality to solve this. Nice catch!

Comment thread src/odemis/driver/test/nenovision_test.py Outdated
Comment thread src/odemis/driver/test/nenovision_test.py Outdated
Comment on lines +138 to +141
# Initialize tracking VAs
init_positions = {ax: self._v_to_m(ax, self._voltage[ax]) for ax in axes}
self.position = model.VigilantAttribute(init_positions, unit="m", readonly=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you could also do, is to forbid axis inversion, via the "inverted" parameter, and instead just make sure that it's possible to pass v_min > v_max (in which case this will behave the same as axis inversion). Or you could even make it "fancy", and interpret here the "inverted" parameter and swap v_min/v_max in such case.

Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/nenovision.py Outdated
Comment thread src/odemis/driver/nenovision.py
Copilot AI review requested due to automatic review settings August 5, 2026 07:51
@nandishjpatel
nandishjpatel force-pushed the GANIL-159-create-nenovision-litescope-odemis-backend-driver branch from ff3db11 to e4aa1d6 Compare August 5, 2026 07:51
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
src/odemis/driver/nenovision.py (2)

106-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Chain the original DaqError.

Ruff reports B904 here. Attach the cause so the DAQ error text survives in the traceback.

♻️ Proposed change
-        except nidaqmx.DaqError:
-            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.")
+        except nidaqmx.DaqError as ex:
+            raise ValueError(f"Failed to find NI DAQ device '{self._device}'. Please check the connection.") from ex
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 106 - 107, Update the
nidaqmx.DaqError handler to chain the caught exception when raising ValueError,
preserving the original DAQ error details in the traceback while keeping the
existing message and behavior.

Source: Linters/SAST tools


217-217: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the l parameter.

Ruff reports E741 for the ambiguous name l. n_samples matches the naming already used by the callers in _generate_waveform. Update the docstring and the test call sites if you rename it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` at line 217, Rename the ambiguous l
parameter in smooth_step to n_samples, update its docstring and all references
within the function, and adjust test call sites and any _generate_waveform usage
to use the new keyword or positional-compatible name.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 286-295: The _cancel_current_move method must avoid waiting when
the move worker has not started or cancellation has already succeeded for a
pending future. Track whether _running_task is active before signaling stop,
wait on _position_updated only for an actually running task, and adjust the
cancellation/shutdown flow so terminate() does not wait for queued executor work
after motion is stopped.
- Around line 418-426: Update the wait loop around task.is_task_done() to exit
when the monotonic deadline, including the required margin, is reached instead
of continuing with 0.001-second sleeps. On deadline expiry, raise TimeoutError
while preserving the existing cancellation handling and normal completion
behavior.
- Around line 370-374: Update the capped-sample branch in the waveform
generation flow around `sample_rate` and `n_samples` so the recomputed rate is
clamped to `self._ao_min_rate`, then recompute `n_samples` from the clamped rate
and duration. Log when this adjustment causes the actual rate to exceed the
requested speed-limit-derived rate, while preserving the `MAX_SAMPLES` cap and
ensuring the final timing configuration uses a device-valid sample rate.

In `@src/odemis/driver/test/nenovision_test.py`:
- Around line 341-358: Move test_init_raises_on_missing_axis_key and
test_init_raises_on_empty_axes out of TestLiteScope into a class whose setup
only verifies that nenovision is importable and does not create a Dev1 device.
Preserve both ValueError assertions while ensuring they run on systems without
hardware.
- Line 380: Update both full-range position sequences in
src/odemis/driver/test/nenovision_test.py at lines 380-380 and 399-399 to use an
endpoint-inclusive range from -80 µm through +80 µm, such as 161-point linspace,
so both tests measure the final endpoint.
- Around line 77-81: Add return type annotations to every function definition in
the test file, including test methods and lifecycle callbacks, following the
project’s Python typing conventions. Add concise plain-text docstrings to
definitions that lack them, prioritizing both setUpClass methods, tearDownClass,
and on_pos; preserve the existing test behavior and signatures otherwise.

---

Nitpick comments:
In `@src/odemis/driver/nenovision.py`:
- Around line 106-107: Update the nidaqmx.DaqError handler to chain the caught
exception when raising ValueError, preserving the original DAQ error details in
the traceback while keeping the existing message and behavior.
- Line 217: Rename the ambiguous l parameter in smooth_step to n_samples, update
its docstring and all references within the function, and adjust test call sites
and any _generate_waveform usage to use the new keyword or positional-compatible
name.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 412c8550-10f2-415d-af96-4a3dd24ec780

📥 Commits

Reviewing files that changed from the base of the PR and between 1ad5342 and e4aa1d6.

📒 Files selected for processing (2)
  • src/odemis/driver/nenovision.py
  • src/odemis/driver/test/nenovision_test.py

Comment on lines +286 to +295
def _cancel_current_move(self, future: CancellableFuture) -> bool:
"""Called by CancellableFuture cancel() to interrupt an in-progress move."""
logging.debug(f"[{self.name}] Cancelling current move...")
with future._moving_lock:
future._must_stop.set()
if future._running_task is not None:
future._running_task.stop()
# Wait briefly for the background thread to update position
future._position_updated.wait(5.0)
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect odemis CancellableFuture.cancel() and CancellableThreadPoolExecutor.cancel()
# to determine when task_canceller runs for pending versus running futures.
set -euo pipefail

fd -t f '_futures.py' src/odemis/model --exec rg -n -C 20 'def cancel\(self\)|task_canceller|class Cancellable'

Repository: delmic/odemis

Length of output: 13012


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== nenovision relevant section =="
sed -n '250,310p' src/odemis/driver/nenovision.py
sed -n '440,505p' src/odemis/driver/nenovision.py

echo "== future submit/stop/cancel usages in nenovision =="
rg -n -C 3 'CancellableFuture|task_canceller|_cancel_current_move|_do_move_abs|\.stop\(|\.cancel\(' src/odemis/driver/nenovision.py

Repository: delmic/odemis

Length of output: 9210


Avoid unbounded waits when cancelling futures.

CancellableThreadPoolExecutor.cancel() calls Future.cancel() for pending futures, so _cancel_current_move() can return True before the worker runs or before _do_move_abs() sets _position_updated. If cancellation succeeds, terminate() can also shut down the executor after calling stop(), potentially leaving a 2-minute waiting shutdown for queued moves. Set up the cancellation wait so it only waits when the worker is actually running, and avoid waiting on executor shutdown after motion is stopped.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 286 - 295, The
_cancel_current_move method must avoid waiting when the move worker has not
started or cancellation has already succeeded for a pending future. Track
whether _running_task is active before signaling stop, wait on _position_updated
only for an actually running task, and adjust the cancellation/shutdown flow so
terminate() does not wait for queued executor work after motion is stopped.

Comment on lines +370 to +374
sample_rate = max(self._ao_min_rate, min(self.TARGET_SAMPLE_RATE, self._ao_max_rate))
n_samples = max(2, math.ceil(duration * sample_rate))
if n_samples > self.MAX_SAMPLES:
n_samples = self.MAX_SAMPLES
sample_rate = n_samples / duration

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The capped sample rate can fall below the device AO minimum.

sample_rate is recomputed as n_samples / duration without clamping to self._ao_min_rate. A slow long move produces a very low rate: at MIN_SPEED_M_S a full 160 µm move gives duration of about 2400 s, so sample_rate becomes about 27 Hz. If that is below self._ao_min_rate, cfg_samp_clk_timing raises DaqError and the move fails. If you clamp the rate silently, the move finishes faster than the requested speed limit allows.

Clamp the rate to the device minimum, recompute n_samples, and log the deviation so the speed-limit contract stays visible.

🛡️ Proposed fix
         if n_samples > self.MAX_SAMPLES:
             n_samples = self.MAX_SAMPLES
-            sample_rate = n_samples / duration
+            sample_rate = n_samples / duration
+            if sample_rate < self._ao_min_rate:
+                # The device cannot clock this slowly: keep the minimum rate and accept more samples.
+                sample_rate = self._ao_min_rate
+                n_samples = max(2, math.ceil(duration * sample_rate))
+                logging.warning(f"[{self.name}] Move needs {n_samples} samples at the minimum AO rate "
+                                f"{sample_rate} Hz to keep the requested {duration:.1f}s duration.")

Based on learnings that generated analog-output waveforms are capped at 2**16 samples with the sample rate derived from the capped count and the required move duration, the cap itself is correct; only the rate clamp is missing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 370 - 374, Update the
capped-sample branch in the waveform generation flow around `sample_rate` and
`n_samples` so the recomputed rate is clamped to `self._ao_min_rate`, then
recompute `n_samples` from the clamped rate and duration. Log when this
adjustment causes the actual rate to exceed the requested speed-limit-derived
rate, while preserving the `MAX_SAMPLES` cap and ensuring the final timing
configuration uses a device-valid sample rate.

Source: Learnings

Comment on lines +418 to +426
end_time = time.monotonic() + duration
task.start()

while not task.is_task_done():
remaining = end_time - time.monotonic()
sleept = remaining / 2.0 if remaining > 0 else 0.001
if future._must_stop.wait(sleept):
logging.debug(f"[{self.name}] Move cancelled mid-execution.")
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The wait loop can never exit on a hardware stall.

end_time is only used to size the sleep interval. After the deadline passes, remaining <= 0, so sleept becomes 0.001 and the loop repeats forever while task.is_task_done() stays False. A stalled AO task then blocks the single executor worker permanently, and stop() / terminate() can only recover through cancellation. The previous revision left the loop on timeout; that exit path is now gone.

Add a hard deadline with margin and raise TimeoutError, as requested in the earlier discussion.

🛡️ Proposed fix
-                end_time = time.monotonic() + duration
+                end_time = time.monotonic() + duration + 1.0
                 task.start()
 
                 while not task.is_task_done():
                     remaining = end_time - time.monotonic()
-                    sleept = remaining / 2.0 if remaining > 0 else 0.001
+                    if remaining <= 0:
+                        raise TimeoutError(
+                            f"AO generation did not complete within {duration + 1.0:.3f}s "
+                            f"({task.out_stream.total_samp_per_chan_generated}/{n_samples} samples)")
+                    sleept = min(max(remaining / 2.0, 0.001), 0.1)
                     if future._must_stop.wait(sleept):
                         logging.debug(f"[{self.name}] Move cancelled mid-execution.")
                         break
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/nenovision.py` around lines 418 - 426, Update the wait loop
around task.is_task_done() to exit when the monotonic deadline, including the
required margin, is reached instead of continuing with 0.001-second sleeps. On
deadline expiry, raise TimeoutError while preserving the existing cancellation
handling and normal completion behavior.

Comment on lines +77 to +81
def setUpClass(cls) -> None:
if not nenovision:
raise unittest.SkipTest("nenovision driver is not available. Check if python3-nidaqmx is installed.")

def test_smooth_step_reaches_vend(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/odemis/driver/test/nenovision_test.py"

echo "== file exists and line count =="
if [ -f "$FILE" ]; then
  wc -l "$FILE"
else
  echo "missing $FILE"
  exit 0
fi

echo
echo "== relevant line ranges =="
sed -n '1,170p' "$FILE" | cat -n
echo
sed -n '120,340p' "$FILE" | cat -n

echo
echo "== function definitions in file =="
python3 - <<'PY'
import ast
from pathlib import Path
p = Path("src/odemis/driver/test/nenovision_test.py")
tree = ast.parse(p.read_text())
for node in ast.walk(tree):
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
        line = tree.body.index(node) + 1 if node in tree.body else node.lineno
        args = ast.dump(node.args, include_attributes=False)
        ret = ast.dump(node.returns, include_attributes=False) if node.returns else None
        doc = ast.get_docstring(node)
        print(f"{node.name}@{line}: args={args}")
        print(f"  return={ret}, docstring={doc is not None}, call_count={sum(1 for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == node.name)}")
        print(f"  {ast.get_docstring(node)[:120] if doc else ''}")
PY

echo
echo "== quality config references =="
git ls-files | rg '(^|/)(pyproject\.toml|noxfile\.py|tox\.ini|setup\.cfg|setup\.py|poetry\.lock|requirements.*\.txt|mypy\.ini|\.ruff\.toml|ruff\.toml)' || true
for f in pyproject.toml noxfile.py tox.ini setup.cfg ruff.toml .ruff.toml; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

Repository: delmic/odemis

Length of output: 27488


Add function type hints and the missing docstrings.

All functions in this test file need value annotations per Python guidelines. Add plain-text docstrings to the docstring-less definitions, especially the two setUpClass methods, tearDownClass, and on_pos.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 77 - 81, Add return
type annotations to every function definition in the test file, including test
methods and lifecycle callbacks, following the project’s Python typing
conventions. Add concise plain-text docstrings to definitions that lack them,
prioritizing both setUpClass methods, tearDownClass, and on_pos; preserve the
existing test behavior and signatures otherwise.

Sources: Coding guidelines, Learnings

Comment on lines +341 to +358
def test_init_raises_on_missing_axis_key(self):
"""Missing required axis key must raise ValueError before touching hardware."""
bad_axes = {
"x": {
"channel": 0,
"speed": 50e-6,
"rng_m": [-80e-6, 80e-6],
# "rng_v" intentionally omitted
"unit": "m",
}
}
with self.assertRaises(ValueError):
LiteScope(name="bad", role="test", axes=bad_axes, device="Dev1")

def test_init_raises_on_empty_axes(self):
"""Empty axes dict must raise ValueError."""
with self.assertRaises(ValueError):
LiteScope(name="bad", role="test", axes={}, device="Dev1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run pre-hardware validation tests without Dev1.

TestLiteScope.setUpClass creates a device before these tests run. Systems without Dev1 skip the class, so these tests do not verify validation before hardware access.

Move these tests to a class that only checks that nenovision is importable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` around lines 341 - 358, Move
test_init_raises_on_missing_axis_key and test_init_raises_on_empty_axes out of
TestLiteScope into a class whose setup only verifies that nenovision is
importable and does not create a Dev1 device. Preserve both ValueError
assertions while ensuring they run on systems without hardware.

f.result(timeout=60)

step = 1e-6
positions = numpy.arange(-80e-6, 80e-6, step)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Include +80 µm in both full-range sequences.

numpy.arange(-80e-6, 80e-6, step) ends at +79 µm. The first test then asserts +80 µm and fails. The second test does not measure the final endpoint.

  • src/odemis/driver/test/nenovision_test.py#L380-L380: use an endpoint-inclusive sequence, such as numpy.linspace(-80e-6, 80e-6, 161).
  • src/odemis/driver/test/nenovision_test.py#L399-L399: use the same endpoint-inclusive sequence.
📍 Affects 1 file
  • src/odemis/driver/test/nenovision_test.py#L380-L380 (this comment)
  • src/odemis/driver/test/nenovision_test.py#L399-L399
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/odemis/driver/test/nenovision_test.py` at line 380, Update both
full-range position sequences in src/odemis/driver/test/nenovision_test.py at
lines 380-380 and 399-399 to use an endpoint-inclusive range from -80 µm through
+80 µm, such as 161-point linspace, so both tests measure the final endpoint.

overheads.append(overhead)

mean_overhead_ms = (sum(overheads) / len(overheads)) * 1000
logging.info(f"Mean move overhead: {mean_overhead_ms:.3f} ms (target < 15 ms)")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pieleric the mean overhead is around ~11-13 ms for this testcase, is it acceptable?

nenovision_test:418 Move 116/161: elapsed=0.0163s, theoretical=0.0047s, overhead=11.580ms

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11ms per move? For what it does, it sounds a little excessive, especially for a move that takes 5ms. It'd good to check with an app specialist what they think would be the smallest dwell time per pixel/position the user would realistically use. If it's above 100ms, it's really not an issue, otherwise it might be a little annoying.

In parallel, you could instrument the code a little bit to check where the overhead time comes from. Most likely, it's either the Future scheduling, the NI task creation, or finding out that the NI task is finished.

If it's the task creation, it might be easy to fix it by reusing the task and writer.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Suppressed comments (3)

src/odemis/driver/nenovision.py:42

  • Avoid importing CancelledError from the private concurrent.futures._base module; use the public concurrent.futures API instead to reduce breakage across Python versions.
from concurrent.futures._base import CancelledError

src/odemis/driver/test/nenovision_test.py:389

  • This test asserts a tight timing/performance threshold (mean overhead < 15ms), which is highly dependent on host load, NI-DAQ driver state, and hardware model, making it prone to flakiness. Consider marking it as a manual benchmark (skipped by default) like the debug-plot test below.
    def test_move_start_stop_overhead_full_range(self):

src/odemis/driver/nenovision.py:375

  • When the move duration is long enough to hit MAX_SAMPLES, sample_rate is recomputed as MAX_SAMPLES/duration without re-checking the device minimum AO rate. This can produce a sample_rate below self._ao_min_rate and then fail at cfg_samp_clk_timing with a DAQ error; it’s better to validate and raise a clear ValueError (or otherwise handle it) at waveform-generation time.
        if n_samples > self.MAX_SAMPLES:
            n_samples = self.MAX_SAMPLES
            sample_rate = n_samples / duration

Comment on lines +379 to +387
step = 1e-6
positions = numpy.arange(-80e-6, 80e-6, step)

for target_x in positions:
f = self.scan_stage.moveAbs({"x": float(target_x)})
f.result(timeout=10)
time.sleep(0.001)

self.assertAlmostEqual(self.scan_stage.position.value["x"], 80e-6, places=7)
v_per_m = abs(rng_v[1] - rng_v[0]) / (rng_m[1] - rng_m[0])
speed_v_s = LiteScope.MAX_SPEED_RETRACTED_M_S * v_per_m
dv = abs(self.scan_stage._m_to_v("x", target_x) - self.scan_stage._m_to_v("x", x_start))
theoretical_s = (dv / speed_v_s) * 1.5 # smoothstep peak factor

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a very convoluted way to get the duration of the move while the driver provides a speed information (m/s). But I guess it's because you want the exact duration it'll take, right? If so, wouldn't be simpler to call _generate_waveform() and deduce the duration based on the number of samples and sample rate?

to prevent overshoot and mechanical stress on the piezo stages.
"""

TARGET_SAMPLE_RATE = 10000.0 # 10 kHz target sample rate for smooth analog output

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants at the top of the class are a bit atypical for our codebase I think. Most drivers have them at the top of the module.

rng_v = self._range_v[axis]
return rng_v[0] + (val_m - rng_m[0]) / (rng_m[1] - rng_m[0]) * (rng_v[1] - rng_v[0])

def _v_to_m(self, axis: str, val_v: float) -> float:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_voltage_to_meters gives it more context I would say


return numpy.vstack(waves), sample_rate, n_samples, duration, max_dv

def _write_ao_finite(self, axes: List[str], waveform: numpy.ndarray, sample_rate: float,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ao stands for analog output? Maybe clarify it in the docstring (or even function name).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants