Skip to content

[feat] add peak-finding algorithm#3395

Open
yxkdejong wants to merge 11 commits into
delmic:masterfrom
yxkdejong:add-automated-grating-alignment
Open

[feat] add peak-finding algorithm#3395
yxkdejong wants to merge 11 commits into
delmic:masterfrom
yxkdejong:add-automated-grating-alignment

Conversation

@yxkdejong
Copy link
Copy Markdown
Contributor

No description provided.

@yxkdejong yxkdejong requested review from pieleric and tepals March 4, 2026 13:23
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 4, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a spectral peak alignment workflow for grating–detector offset calibration. A new module implements gaussian modeling, peak finding, peak presence checks, coarse goffset scanning, goffset-to-pixel scale estimation, and an asynchronous iterative alignment task (sparc_auto_grating_offset). A separate orchestrator (auto_align_grating_detector_offsets and goffset_alignment helpers) runs multi-grating/multi-detector sequences with selector support, cancellation, progress reporting, and time estimates. New unit and integration tests and GUI wiring for an Auto-Calibration button are included.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as sparc_auto_grating_offset
    participant Task as _do_sparc_auto_grating_offset
    participant Spectrograph
    participant Detector

    Client->>API: invoke alignment(tolerance_px,max_it)
    API->>API: create ProgressiveFuture
    API->>Task: start background task
    API-->>Client: return ProgressiveFuture

    loop while error > tolerance
        Task->>Detector: acquire spectrum
        Task->>Task: find_peak_position()
        Task->>Task: compute error from center
        Task->>Spectrograph: adjust goffset (bounded proportional step)
        Task->>Task: update progress / check cancellation
        alt Cancelled
            Task->>Client: raise CancelledError
        end
    end

    alt converged
        Task->>API: set result (final pos)
    else max iterations
        Task->>API: set last pos, log warning
    end
    API->>API: mark FINISHED
Loading
sequenceDiagram
    participant Client
    participant API as auto_align_grating_detector_offsets
    participant Orchestrator as _do_auto_align_grating_detector_offsets
    participant sparc as sparc_auto_grating_offset
    participant Selector
    participant Spectrograph

    Client->>API: call(spectrograph, detectors, selector?)
    API->>API: validate inputs, create ProgressiveFuture
    API->>Orchestrator: start background task
    API-->>Client: return ProgressiveFuture

    rect rgba(100,150,200,0.5)
        Note over Orchestrator: Stage 1 — align first grating across all detectors
        loop for each detector
            alt selector provided
                Orchestrator->>Selector: switch to detector's selector state
            end
            Orchestrator->>sparc: align current grating (subfuture)
            sparc-->>Orchestrator: return result
        end
    end

    rect rgba(200,150,100,0.5)
        Note over Orchestrator: Stage 2 — align remaining gratings with first detector
        loop for each remaining grating
            Orchestrator->>sparc: align grating
            sparc-->>Orchestrator: return result
        end
    end

    Orchestrator->>Selector: restore original selector state
    Orchestrator->>Spectrograph: restore original position
    Orchestrator->>API: set aggregated results
    API->>API: mark FINISHED
Loading

Possibly related PRs

  • delmic/odemis#3251: Overlaps with GUI auto-alignment button and related SPARC2 align tab handlers; likely related to UI integration for auto-calibration.
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title mentions 'peak-finding algorithm' but the PR implements comprehensive grating-detector offset auto-alignment including peak detection, coarse scanning, scale estimation, and iterative centering. Update the title to reflect the main objective, such as: '[feat] add automated grating-detector offset alignment'
Description check ⚠️ Warning No pull request description was provided by the author, making it impossible to assess relatedness to the changeset. Add a detailed description explaining the purpose, implementation, and scope of the automated grating-detector offset alignment feature.
Docstring Coverage ⚠️ Warning Docstring coverage is 53.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 and usage tips.

Comment thread src/odemis/acq/align/goffset_alignment.py
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

♻️ Duplicate comments (4)
src/odemis/acq/align/goffset_alignment.py (1)

43-46: ⚠️ Potential issue | 🟠 Major

Remove or properly type streams; right now it is a dead, unresolved API parameter.

Stream is not imported anywhere, and streams is never used in either the public entry point or the worker. That leaves the signature with a lint-blocking unresolved annotation and an argument callers may assume matters.

Also applies to: 75-80

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset_alignment.py` around lines 43 - 46, The
parameter streams on auto_align_grating_detector_offsets is an unused,
unresolved type (Stream not imported) and should be removed or properly typed
and threaded through; update the signature of
auto_align_grating_detector_offsets (and the corresponding worker/public entry
point functions mentioned around lines 75-80) to either (a) drop the streams
parameter everywhere it’s declared and called, or (b) import or declare the
Stream type and pass streams into the worker flow so it’s actually used; ensure
you update all call sites and any type annotations for model.ProgressiveFuture,
the worker function, and selector/detectors handling to keep the API and types
consistent.
src/odemis/acq/align/test/goffset_test.py (1)

106-117: ⚠️ Potential issue | 🟠 Major

test_cancel is masking failures instead of testing cancellation.

The body never calls f.cancel(), and the bare except: pass hides timeouts and real alignment errors alike. This can go green without proving any cancellation behavior. Assert cancel() / cancelled() and catch only the expected exception.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 106 - 117, The
test_cancel test is masking failures and never actually tests cancellation:
modify test_cancel to call cancel() on the SparcAutoGratingOffset future
(f.cancel()), assert that the cancel call returned True (or that f.cancelled()
becomes True), and then call f.result() expecting the appropriate cancellation
exception (catch only concurrent.futures.CancelledError or the specific
exception your future raises) instead of a bare except; finally keep the
existing f.done() assertion to ensure the future finished. Use the symbols
test_cancel, SparcAutoGratingOffset, f.cancel(), f.cancelled(), f.result(), and
f.done() to locate and update the logic.
src/odemis/acq/align/test/goffset_alignment_test.py (1)

118-122: ⚠️ Potential issue | 🟠 Major

Fail the test when sp-ccd returns a flat image.

Printing a warning here lets the test pass on the exact failure mode this post-alignment check is trying to detect. Turn this into an assertion and send any extra diagnostics through logging.debug() instead of print().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_alignment_test.py` around lines 118 - 122,
The check that currently prints a warning when sp-ccd returns a flat image
should be turned into a failing assertion: replace the print-based conditional
around `data.max() == data.min()` with an assertion such as `assert data.max()
!= data.min(), "sp-ccd returned a flat image"` and move any extra diagnostics
into `logging.debug()` calls (e.g., log the min/max and any context), ensuring
`logging` is imported or configured in the test file and removing the `print()`s
so the test fails on the flat-image condition.
src/odemis/acq/align/goffset.py (1)

151-157: ⚠️ Potential issue | 🔴 Critical

Bound the retry path before this recurses indefinitely.

If the measured scale stays outside the accepted range but no RuntimeError is raised, this calls estimate_goffset_scale() again with the same inputs and no retry budget. That can recurse until RecursionError while repeatedly moving the hardware.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 151 - 157, The current logic
can recurse indefinitely when estimate_goffset_scale(spgr, detector) returns a
scale outside the accepted range but does not raise; replace the implicit
recursion with a bounded retry loop: implement a max_attempts constant (e.g.,
3–5), call estimate_goffset_scale(spgr, detector) inside the loop, break when
the returned scale is within [1e-3, 10.0], and on RuntimeError or exhausting
attempts log a warning and set scale to the safe default (0.5); update
references in this block to use the looped variables scale, p0, p1 and ensure no
recursive call to estimate_goffset_scale remains.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 17-26: The function _checkCancelled currently snapshots state
before acquiring the mutex, allowing a race where cancellation can be missed;
fix it by resolving lock_name and state_name as you do now, fetch the lock via
getattr(future, lock_name) first, then enter with lock: and inside the
with-block read state = getattr(future, state_name) and raise CancelledError()
if state == CANCELLED (preserving lock acquisition around the state read).
Ensure you still compute lock_name/state_name using hasattr as before and
reference CANCELLED and CancelledError exactly as in the current code.
- Around line 116-124: The loop currently skips calling
sparc_auto_grating_offset and recording results when selector is None; change
the logic in auto_align_grating_detector_offsets so
selector.moveAbsSync({selector_axes: detector_to_selector[d]}) is executed only
if selector exists, but sparc_auto_grating_offset(spectrograph, d) is always
invoked (set future._subfuture = sparc_auto_grating_offset(...)), await/result
it, and write results[(g0, d.name)] = success regardless of selector presence;
keep logging and _checkCancelled(future) as-is and reuse detector_to_selector
only when selector is not None.

In `@src/odemis/acq/align/goffset.py`:
- Around line 214-216: The code uses detector.width/2 for center_target but
find_peak_position() returns zero-based pixel coords, so change center_target to
use (detector.resolution.value[0] - 1) / 2 (i.e., midpoint in the same
zero-based coordinate system) where center_target is computed in
estimate_goffset_scale-related logic (currently set in the block using
estimate_goffset_scale and the subsequent block around lines 234-243); update
all other places that compute target midpoint the same way so the peak fit and
detector midpoint share the same coordinate system.
- Around line 65-75: The centroid calculation uses window_data instead of the
clipped positive weights, so replace the numerator/denominator in the else
branch to use weights (i.e., use numpy.sum(window_idx * weights) /
numpy.sum(weights)) to compute weighted_avg; keep the fallback that sets
weighted_avg = float(peak_idx) when weights.sum() == 0 and retain the logging
call referencing peak_idx.
- Around line 96-117: The function estimate_goffset_scale currently annotates
its return as -> float but actually returns (scale, p0, p1); update the
signature to -> Tuple[float, float, float] and import Tuple from typing (or
typing import tuple alias) so type checkers match runtime behavior, and make the
docstring explicitly state the tuple return; alternatively, if you prefer to
return only the scale, change estimate_goffset_scale to return just scale and
update all call sites that unpack three values to use only the single returned
value and adjust the docstring accordingly — pick one approach and ensure
function name estimate_goffset_scale and its callers are updated consistently.

In `@src/odemis/acq/align/test/goffset_alignment_test.py`:
- Around line 69-79: The test suite defines two methods named test_cancel so the
later one overwrites the earlier; rename one of them (e.g., rename the earlier
test_cancel to test_cancel_before_start or test_cancel_after_delay) or merge
their assertions into a single test to cover both cancellation scenarios; update
references to the future returned by auto_align_grating_detector_offsets (f),
its cancel() and cancelled() calls, and the CancelledError expectation on
f.result(...) so both scenarios are executed and collected by the test runner.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 44-50: Uncomment and re-enable the class teardown so the
spectrograph state is always restored: implement/restore the `@classmethod`
tearDownClass that calls cls.spgr.moveAbsSync(cls._original_position) inside a
try/except and logs failures with logging.exception("Failed restoring
spectrograph position"), ensuring the test suite saved cls._original_position
(e.g., in setUpClass) before moving the device so cleanup will reliably return
the backend to its original state.
- Around line 95-100: The test sets delta = 0 so it never introduces any
misalignment; change delta to a non-zero value that is clamped to stay inside
the axis range before calling self.spgr.moveRelSync. Compute a small positive
candidate delta (e.g. 1 step or a fraction) and then clamp it against
goffset_max - current (and a small epsilon) so it won’t exceed the axis, keep
the existing direction calculation using current, goffset_max and direction, and
then call self.spgr.moveRelSync({"goffset": delta * direction}) to actually
create the misalignment the test intends to verify.

---

Duplicate comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 43-46: The parameter streams on
auto_align_grating_detector_offsets is an unused, unresolved type (Stream not
imported) and should be removed or properly typed and threaded through; update
the signature of auto_align_grating_detector_offsets (and the corresponding
worker/public entry point functions mentioned around lines 75-80) to either (a)
drop the streams parameter everywhere it’s declared and called, or (b) import or
declare the Stream type and pass streams into the worker flow so it’s actually
used; ensure you update all call sites and any type annotations for
model.ProgressiveFuture, the worker function, and selector/detectors handling to
keep the API and types consistent.

In `@src/odemis/acq/align/goffset.py`:
- Around line 151-157: The current logic can recurse indefinitely when
estimate_goffset_scale(spgr, detector) returns a scale outside the accepted
range but does not raise; replace the implicit recursion with a bounded retry
loop: implement a max_attempts constant (e.g., 3–5), call
estimate_goffset_scale(spgr, detector) inside the loop, break when the returned
scale is within [1e-3, 10.0], and on RuntimeError or exhausting attempts log a
warning and set scale to the safe default (0.5); update references in this block
to use the looped variables scale, p0, p1 and ensure no recursive call to
estimate_goffset_scale remains.

In `@src/odemis/acq/align/test/goffset_alignment_test.py`:
- Around line 118-122: The check that currently prints a warning when sp-ccd
returns a flat image should be turned into a failing assertion: replace the
print-based conditional around `data.max() == data.min()` with an assertion such
as `assert data.max() != data.min(), "sp-ccd returned a flat image"` and move
any extra diagnostics into `logging.debug()` calls (e.g., log the min/max and
any context), ensuring `logging` is imported or configured in the test file and
removing the `print()`s so the test fails on the flat-image condition.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 106-117: The test_cancel test is masking failures and never
actually tests cancellation: modify test_cancel to call cancel() on the
SparcAutoGratingOffset future (f.cancel()), assert that the cancel call returned
True (or that f.cancelled() becomes True), and then call f.result() expecting
the appropriate cancellation exception (catch only
concurrent.futures.CancelledError or the specific exception your future raises)
instead of a bare except; finally keep the existing f.done() assertion to ensure
the future finished. Use the symbols test_cancel, SparcAutoGratingOffset,
f.cancel(), f.cancelled(), f.result(), and f.done() to locate and update the
logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 09eb8918-bbc1-43a4-9edf-2f7d4f5d883a

📥 Commits

Reviewing files that changed from the base of the PR and between 549fc70 and f75f0f0.

📒 Files selected for processing (4)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/goffset_alignment.py
  • src/odemis/acq/align/test/goffset_alignment_test.py
  • src/odemis/acq/align/test/goffset_test.py

Comment thread src/odemis/acq/align/goffset_alignment.py
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/test/goffset_alignment_test.py
Comment thread src/odemis/acq/align/test/goffset_test.py
Comment thread src/odemis/acq/align/test/goffset_test.py
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (2)
src/odemis/acq/align/test/goffset_test.py (2)

44-50: ⚠️ Potential issue | 🟠 Major

Re-enable suite cleanup for the spectrograph state.

test_auto_grating_offset() moves the spectrograph, but tearDownClass() is still commented out. That leaves backend state dirty for later tests and keeps this suite order-dependent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 44 - 50, Uncomment
and re-enable the class teardown that restores the spectrograph position:
restore the `@classmethod` tearDownClass implementation (the one calling
cls.spgr.moveAbsSync(cls._original_position)) so test_auto_grating_offset no
longer leaves backend state dirty; ensure the method uses the classmethod
decorator, wraps the moveAbsSync call in a try/except and logs failures via
logging.exception("Failed restoring spectrograph position") so the original
position is reliably restored even if errors occur.

95-100: ⚠️ Potential issue | 🟠 Major

Actually introduce a misalignment before calling the aligner.

delta is 0, so Line 100 is a no-op and this test can still pass even if recovery from a goffset regression is broken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 95 - 100, The test
currently sets delta = 0 so no misalignment occurs; change delta to a non-zero
value (e.g., 1 or a small step) and compute it defensively so it stays within
bounds: derive delta from the available headroom (goffset_max - current) or a
fixed minimal misalignment, then recompute direction and call
self.spgr.moveRelSync({"goffset": delta * direction}) so the test actually
introduces a goffset regression before running the aligner; update the delta
assignment near the variables current, goffset_max, direction and the subsequent
self.spgr.moveRelSync call.
🧹 Nitpick comments (2)
src/odemis/acq/align/test/goffset_test.py (2)

35-55: Add the repo-required annotations and missing hook docstrings.

The lifecycle hooks and tests are missing -> None return annotations, and setUpClass() / setUp() also miss function docstrings.

Based on learnings: not applicable. As per coding guidelines, "Always use type hints for function parameters and return types in Python code" and "Include docstrings for all functions and classes, following the reStructuredText style guide (without type information)".

Also applies to: 56-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 35 - 55, Add explicit
return type annotations (-> None) to the lifecycle methods and tests and add
simple reStructuredText-style docstrings for setUpClass, setUp (and the
commented tearDownClass if re-enabled) to satisfy repository guidelines; update
the signatures for setUpClass and setUp to include "-> None" and add a one- or
two-line docstring above each method (e.g., in the methods named setUpClass,
setUp, and tearDownClass) describing their purpose without type info.

12-12: Use the public concurrent.futures.CancelledError import.

Importing from concurrent.futures._base couples the test to a private stdlib module. Please switch this to from concurrent.futures import CancelledError before using it in the cancellation assertion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` at line 12, Replace the private
import of CancelledError with the public one: change the import statement that
currently reads "from concurrent.futures._base import CancelledError" to "from
concurrent.futures import CancelledError" so the test uses the public stdlib
symbol CancelledError for cancellation assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 119-120: Add a trailing newline to the end of the file so flake8
W292 is resolved: ensure the file ending after the if __name__ == "__main__": /
unittest.main() block has a single newline character (i.e., terminate the file
with a newline).
- Line 23: The test currently changes the root logger at import via
logging.getLogger().setLevel(logging.DEBUG); instead, stop mutating the root
logger and either create and configure a module logger (logger =
logging.getLogger(__name__); logger.setLevel(logging.DEBUG)) or move the level
change into the specific test using pytest's caplog.set_level(logging.DEBUG) (or
a temporary context) so only that test's logging is affected; update references
to use the module logger if you choose the first approach.
- Around line 84-88: The test is asserting on a tuple because
estimate_goffset_scale(...) returns multiple values; unpack its return into
scale, p0, p1 (e.g., scale, p0, p1 = estimate_goffset_scale(self.spgr,
self.detector)) before running assertions so scale is a float; then keep the
existing assertions self.assertIsInstance(scale, float),
self.assertNotEqual(scale, 0.0), and self.assertTrue(np.isfinite(scale)).
- Around line 111-117: The test never exercises cancellation: after creating the
future with sparc_auto_grating_offset(self.spgr, self.detector) you should
cancel it and assert that calling result() raises a CancelledError instead of
swallowing all exceptions. Replace the try/except block and the lone
self.assertTrue(f.done()) with calling f.cancel(), then use the test helper
(e.g. self.assertRaises(CancelledError, f.result) or a with
self.assertRaises(CancelledError): f.result()) to verify cancellation; ensure
you import the appropriate CancelledError (asyncio.CancelledError or
concurrent.futures.CancelledError) that matches sparc_auto_grating_offset's
future implementation.

---

Duplicate comments:
In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 44-50: Uncomment and re-enable the class teardown that restores
the spectrograph position: restore the `@classmethod` tearDownClass implementation
(the one calling cls.spgr.moveAbsSync(cls._original_position)) so
test_auto_grating_offset no longer leaves backend state dirty; ensure the method
uses the classmethod decorator, wraps the moveAbsSync call in a try/except and
logs failures via logging.exception("Failed restoring spectrograph position") so
the original position is reliably restored even if errors occur.
- Around line 95-100: The test currently sets delta = 0 so no misalignment
occurs; change delta to a non-zero value (e.g., 1 or a small step) and compute
it defensively so it stays within bounds: derive delta from the available
headroom (goffset_max - current) or a fixed minimal misalignment, then recompute
direction and call self.spgr.moveRelSync({"goffset": delta * direction}) so the
test actually introduces a goffset regression before running the aligner; update
the delta assignment near the variables current, goffset_max, direction and the
subsequent self.spgr.moveRelSync call.

---

Nitpick comments:
In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 35-55: Add explicit return type annotations (-> None) to the
lifecycle methods and tests and add simple reStructuredText-style docstrings for
setUpClass, setUp (and the commented tearDownClass if re-enabled) to satisfy
repository guidelines; update the signatures for setUpClass and setUp to include
"-> None" and add a one- or two-line docstring above each method (e.g., in the
methods named setUpClass, setUp, and tearDownClass) describing their purpose
without type info.
- Line 12: Replace the private import of CancelledError with the public one:
change the import statement that currently reads "from concurrent.futures._base
import CancelledError" to "from concurrent.futures import CancelledError" so the
test uses the public stdlib symbol CancelledError for cancellation assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61440f3e-aaa1-4abe-86dd-08c8df65e054

📥 Commits

Reviewing files that changed from the base of the PR and between f75f0f0 and 2f65bf7.

📒 Files selected for processing (1)
  • src/odemis/acq/align/test/goffset_test.py

Comment thread src/odemis/acq/align/test/goffset_test.py
Comment thread src/odemis/acq/align/test/goffset_test.py
Comment thread src/odemis/acq/align/test/goffset_test.py
Comment thread src/odemis/acq/align/test/goffset_test.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

♻️ Duplicate comments (2)
src/odemis/acq/align/goffset.py (1)

409-419: ⚠️ Potential issue | 🔴 Critical

The first-grating alignment is still skipped when selector is absent.

This loop has the same control-flow bug as the dedicated orchestrator module: the valid single-detector path never calls sparc_auto_grating_offset() because the work is still nested under if selector:.

Possible fix
         for d in detectors_sorted:
             _checkCancelled(future)
             logging.info("Starting alignment | Detector: %s | Grating: %s", d.name, g0)
 
             if selector:
                 selector.moveAbsSync({selector_axes: detector_to_selector[d]})
-                future._subfuture = sparc_auto_grating_offset(spectrograph, d)
-                success = future._subfuture.result()
-                results[(g0, d.name)] = success
-
-                logging.info("Finished alignment | Detector: %s | Grating: %s | Success: %s", d.name, g0)
+            future._subfuture = sparc_auto_grating_offset(spectrograph, d)
+            success = future._subfuture.result()
+            results[(g0, d.name)] = success
+            logging.info("Finished alignment | Detector: %s | Grating: %s | Success: %s", d.name, g0, success)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 409 - 419, The loop over
detectors_sorted incorrectly nests the alignment call under if selector:, so
when selector is None the code never invokes sparc_auto_grating_offset for
single-detector runs; move the call to sparc_auto_grating_offset (and the
subsequent future._subfuture/result handling, results[(g0, d.name)] = success,
and the "Finished alignment" log) out of the if selector: block so that
selector.moveAbsSync({selector_axes: detector_to_selector[d]}) only runs when
selector is present but sparc_auto_grating_offset(spectrograph, d) always runs
for each detector.
src/odemis/acq/align/goffset_alignment.py (1)

145-155: ⚠️ Potential issue | 🔴 Critical

Always run the first-grating alignment, even without a selector.

The no-selector path is explicitly allowed on Lines 66-71, but the actual sparc_auto_grating_offset() call and result write are still nested under if selector: here. In the valid single-detector case, this loop returns no result for the initial grating.

Possible fix
         for d in detectors_sorted:
             _checkCancelled(future)
             logging.info(f"Starting alignment | Detector: {d.name} | Grating: {g0}")
 
             if selector:
                 selector.moveAbsSync({selector_axes: detector_to_selector[d]})
-                future._subfuture = sparc_auto_grating_offset(spectrograph, d)
-                success = future._subfuture.result()
-                results[(g0, d.name)] = success
+            future._subfuture = sparc_auto_grating_offset(spectrograph, d)
+            success = future._subfuture.result()
+            results[(g0, d.name)] = success
 
-                logging.info(f"Finished alignment | Detector: {d.name} | Grating: {g0} | Success: {success}")
+            logging.info(f"Finished alignment | Detector: {d.name} | Grating: {g0} | Success: {success}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset_alignment.py` around lines 145 - 155, The loop
currently only calls sparc_auto_grating_offset and writes to results[(g0,
d.name)] when selector is present, so the first-grating alignment is skipped
when selector is None; update the loop over detectors_sorted to always invoke
sparc_auto_grating_offset (and await its result via future._subfuture.result())
and assign results[(g0, d.name)] = success regardless of selector presence—keep
the selector.moveAbsSync({selector_axes: detector_to_selector[d]}) inside the if
selector: block but move the sparc_auto_grating_offset(spectrograph, d) call,
future._subfuture assignment, result collection, and the "Finished alignment"
log out of that if so they run for both selector and no-selector paths.
🧹 Nitpick comments (1)
src/odemis/acq/align/goffset.py (1)

17-27: Add annotations to the new public helper.

gaussian() is part of the new API surface, but its parameters/return type are untyped and the docstring fields are not valid reST (:param x0 = ...). Please make this helper fully typed/documented like the rest of the module.

Possible fix
-def gaussian(x, amplitude, x0, width):
+def gaussian(
+    x: numpy.ndarray,
+    amplitude: float,
+    x0: float,
+    width: float,
+) -> numpy.ndarray:
     """
     Gaussian function (for curve fitting).
 
     :param x: input coordinates
-    :param amplitude:  peak intensity
-    :param x0 = peak's center
-    :param width = standard deviation
+    :param amplitude: peak intensity
+    :param x0: peak center
+    :param width: standard deviation
     :return: Gaussian function evaluated at x
     """

As per coding guidelines, **/*.py: Always use type hints for function parameters and return types in Python codeandInclude docstrings for all functions and classes, following the reStructuredText style guide (without type information)`.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 17 - 27, The gaussian helper
lacks type hints, a proper reST docstring and a return statement; update def
gaussian to include type hints (e.g. x: numpy.ndarray | float, amplitude: float,
x0: float, width: float) with a return type of numpy.ndarray | float (or use
numpy.typing.NDArray if you prefer arrays), add a reStructuredText-style
docstring using :param x:, :param amplitude:, :param x0:, :param width: and
:return:, and ensure the function returns the computed intensity (i.e., add a
return intensity at the end) so gaussian is fully typed and documented
consistent with the module.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 17-25: The _checkCancelled function is inspecting the wrong
attributes (_function_lock/_function_state) and should use the same cancellation
fields initialized by auto_align_grating_detector_offsets (f._task_lock and
f._task_state); update _checkCancelled to acquire future._task_lock and test
future._task_state == CANCELLED, then raise CancelledError as before, ensuring
you reference the CANCELLED symbol and CancelledError so behavior remains
identical.
- Line 48: The undefined forward-reference to Stream in type annotations should
be resolved by importing Stream under a TYPE_CHECKING guard: add "from typing
import TYPE_CHECKING" and inside "if TYPE_CHECKING:" import "Stream" from
"odemis.acq.stream" so annotations like Optional[List['Stream']] (seen in the
function/method signatures in goffset_alignment.py) are recognized by linters
without importing Stream at runtime; alternatively, you can directly import
Stream at top-level if you prefer.

In `@src/odemis/acq/align/goffset.py`:
- Around line 228-229: The loop calling _checkCancelled() is missing the
required future argument; update the for-loop at "for i in range(max_it):" to
call _checkCancelled(future) (or the local future variable in scope) so the
helper receives the current Future object; ensure the same future variable used
elsewhere in this alignment routine is passed into _checkCancelled to avoid the
TypeError before the alignment loop begins.
- Around line 271-278: The canceller _cancel_sparc_auto_grating_offset currently
flips future._task_state to CANCELLED but returns None so
model.ProgressiveFuture.cancel() doesn't detect success; update
_cancel_sparc_auto_grating_offset (accepting future: model.ProgressiveFuture) to
return a truthy value (e.g., True) after acquiring future._task_lock and setting
future._task_state = CANCELLED, ensuring the outer cancel() call observes the
task was cancelled; preserve the lock usage and existing CANCELLED constant and
do not change other semantics.
- Around line 128-134: The temporary move via spgr.moveRelSync({"goffset":
actual_delta}) must always be undone even if detector.data.get() or
find_peak_position() raise; wrap the measurement and fitting steps in a
try/finally around the call to spgr.moveRelSync so that
spgr.moveRelSync({"goffset": -actual_delta}) runs in the finally block, and
re-raise the exception after rollback so callers still see the error. Ensure the
finally references the same actual_delta and use the existing
functions/spgr.moveRelSync, detector.data.get, and find_peak_position to locate
where to add the try/finally.
- Around line 253-256: The syntax error is caused by having logging.debug(...)
and spgr.moveRelSync(...) on the same line; edit the code in goffset.py (around
the loop in the function handling alignment) to place
spgr.moveRelSync({"goffset": delta_goffset}) on its own line after the
logging.debug call with correct indentation so the two statements are separate
(locate the logging.debug(...) and split it so spgr.moveRelSync is a standalone
statement).

---

Duplicate comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 145-155: The loop currently only calls sparc_auto_grating_offset
and writes to results[(g0, d.name)] when selector is present, so the
first-grating alignment is skipped when selector is None; update the loop over
detectors_sorted to always invoke sparc_auto_grating_offset (and await its
result via future._subfuture.result()) and assign results[(g0, d.name)] =
success regardless of selector presence—keep the
selector.moveAbsSync({selector_axes: detector_to_selector[d]}) inside the if
selector: block but move the sparc_auto_grating_offset(spectrograph, d) call,
future._subfuture assignment, result collection, and the "Finished alignment"
log out of that if so they run for both selector and no-selector paths.

In `@src/odemis/acq/align/goffset.py`:
- Around line 409-419: The loop over detectors_sorted incorrectly nests the
alignment call under if selector:, so when selector is None the code never
invokes sparc_auto_grating_offset for single-detector runs; move the call to
sparc_auto_grating_offset (and the subsequent future._subfuture/result handling,
results[(g0, d.name)] = success, and the "Finished alignment" log) out of the if
selector: block so that selector.moveAbsSync({selector_axes:
detector_to_selector[d]}) only runs when selector is present but
sparc_auto_grating_offset(spectrograph, d) always runs for each detector.

---

Nitpick comments:
In `@src/odemis/acq/align/goffset.py`:
- Around line 17-27: The gaussian helper lacks type hints, a proper reST
docstring and a return statement; update def gaussian to include type hints
(e.g. x: numpy.ndarray | float, amplitude: float, x0: float, width: float) with
a return type of numpy.ndarray | float (or use numpy.typing.NDArray if you
prefer arrays), add a reStructuredText-style docstring using :param x:, :param
amplitude:, :param x0:, :param width: and :return:, and ensure the function
returns the computed intensity (i.e., add a return intensity at the end) so
gaussian is fully typed and documented consistent with the module.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 063ae5ca-68f1-4dc3-805c-dfd4c7f560d0

📥 Commits

Reviewing files that changed from the base of the PR and between 2f65bf7 and 1d430b9.

📒 Files selected for processing (2)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/goffset_alignment.py

Comment thread src/odemis/acq/align/goffset_alignment.py
Comment thread src/odemis/acq/align/goffset_alignment.py
Comment thread src/odemis/acq/align/goffset.py
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from 2c1fdcf to 5eccdad Compare March 9, 2026 14:28
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (10)
src/odemis/acq/align/test/goffset_test.py (5)

43-49: ⚠️ Potential issue | 🟠 Major

Restore the spectrograph state after this suite.

The tearDownClass is commented out, leaving the spectrograph in a modified state after tests complete. This creates order-dependent test behavior. Please uncomment and enable the cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 43 - 49, Uncomment
and re-enable the class-level teardown to restore the spectrograph state:
reinstate the `@classmethod` tearDownClass in the test class and ensure it calls
cls.spgr.moveAbsSync(cls._original_position) inside a try/except that uses
logging.exception("Failed restoring spectrograph position") on failure; make
sure cls._original_position is set in setUpClass and that cls.spgr is available
so the spectrograph is reliably returned to its original position after the test
suite.

83-87: ⚠️ Potential issue | 🔴 Critical

Unpack estimate_goffset_scale() return value before asserting.

Per the implementation in goffset.py, estimate_goffset_scale() returns a tuple (scale, p0, p1). The current code assigns the full tuple to scale, causing assertIsInstance(scale, float) to fail.

Suggested fix
-        scale = estimate_goffset_scale(self.spgr, self.detector)
+        scale, _p0, _p1 = estimate_goffset_scale(self.spgr, self.detector)

         self.assertIsInstance(scale, float)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 83 - 87, The test is
assigning the entire tuple returned by estimate_goffset_scale() to scale; update
the test to unpack the tuple returned by estimate_goffset_scale(self.spgr,
self.detector) into (scale, p0, p1) (or similar names) and then run the existing
assertions on scale (assertIsInstance(scale, float), assertNotEqual(scale, 0.0),
assertTrue(np.isfinite(scale))); reference estimate_goffset_scale to locate the
implementation and adjust the test accordingly.

117-119: ⚠️ Potential issue | 🟡 Minor

Fix whitespace and trailing newline issues.

Pipeline failures indicate:

  • Line 117: blank line contains whitespace (W293)
  • Line 207: no newline at end of file (W292)

Run autopep8 --in-place --select W291,W292,W293,W391 to fix these. As per coding guidelines, clean up code using autopep8.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 117 - 119, The file
src/odemis/acq/align/test/goffset_test.py has trailing whitespace on a blank
line (W293 at line 117) and is missing a newline at EOF (W292 at line 207); run
autopep8 with the suggested flags (autopep8 --in-place --select
W291,W292,W293,W391) on goffset_test.py or manually remove trailing spaces and
add a final newline so the file ends with a single newline, then commit the
cleaned file.

110-116: ⚠️ Potential issue | 🟠 Major

Test never exercises cancellation.

The test waits for result(), catches all exceptions silently, then only checks done(). This passes on normal completion or any failure. To properly test cancellation, call f.cancel() and assert CancelledError is raised.

Suggested fix
         f = sparc_auto_grating_offset(self.spgr, self.detector)
-        # Wait for the result or a timeout
-        try:
-            f.result(timeout=5)
-        except:
-            pass
-        self.assertTrue(f.done())
+        time.sleep(0.5)  # Let task start
+        cancelled = f.cancel()
+        self.assertTrue(cancelled)
+        with self.assertRaises(CancelledError):
+            f.result(timeout=5)
+        self.assertTrue(f.cancelled())

Also, the bare except: on Line 114 violates E722 and S110.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 110 - 116, Modify the
test around sparc_auto_grating_offset/self.spgr to actually exercise
cancellation: call f.cancel() after starting the future, then assert that
awaiting f.result() raises the appropriate CancelledError (use
asyncio.CancelledError or concurrent.futures.CancelledError consistent with how
sparc_auto_grating_offset returns a future), and replace the bare except: with a
specific exception assertion (e.g., with self.assertRaises(CancelledError):
f.result(timeout=5)) or a try/except that only catches CancelledError; ensure
you assert f.cancelled() or that the CancelledError was raised rather than
silently swallowing all exceptions.

94-99: ⚠️ Potential issue | 🟠 Major

Test never introduces misalignment due to delta = 0.

With delta = 0, the moveRelSync call on Line 99 is a no-op. The test claims to verify recovery from an intentional misalignment but never creates one. Use a non-zero delta.

Suggested fix
-        delta = 0 # intentionally misalign
+        delta = 100  # intentionally misalign by a small amount
         current = self.spgr.position.value["goffset"]
         goffset_max = self.spgr.axes["goffset"].range[1]
+        goffset_min = self.spgr.axes["goffset"].range[0]
-        direction = 1 if (current + delta < goffset_max) else -1
+        # Ensure delta stays within axis bounds
+        if current + delta > goffset_max:
+            direction = -1
+        elif current - delta < goffset_min:
+            direction = 1
+        else:
+            direction = 1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 94 - 99, The test
sets delta = 0 so no misalignment is introduced; change delta to a non-zero
value (e.g., a small safe offset) so the call to
self.spgr.moveRelSync({"goffset": delta * direction}) actually moves the axis;
ensure the chosen delta stays within bounds by computing direction using current
= self.spgr.position.value["goffset"] and goffset_max =
self.spgr.axes["goffset"].range[1] (as already used) so the relative move is
valid and triggers the intended misalignment/recovery behavior.
src/odemis/acq/align/goffset.py (5)

61-71: ⚠️ Potential issue | 🟠 Major

Use clipped weights in the centroid calculation.

The code computes weights = window_data.clip(min=0) but then uses window_data in the weighted average calculation. If negative values exist in window_data, they will distort the centroid estimate.

Suggested fix
     else:
-        weighted_avg = float(numpy.sum(window_idx * window_data) / numpy.sum(window_data))
+        weighted_avg = float(numpy.sum(window_idx * weights) / numpy.sum(weights))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 61 - 71, The centroid uses raw
window_data instead of the non-negative weights previously computed; change the
weighted average calculation in the else branch to use weights (the clipped
values) and weights.sum() instead of window_data and numpy.sum(window_data) so
negative values don't skew the estimate—update the assignment to weighted_avg to
compute float(numpy.sum(window_idx * weights) / numpy.sum(weights)) while
keeping the existing peak_idx fallback and logging intact.

220-220: ⚠️ Potential issue | 🟠 Major

Use zero-based midpoint for center_target.

find_peak_position() returns zero-based pixel coordinates. For an even-width detector, the center should be (width - 1) / 2, not width / 2. With the current formula, a perfectly centered peak reads as 0.5 px off.

Suggested fix
-        center_target = detector.resolution.value[0] / 2 # adjust if 0 is not the center
+        center_target = (detector.resolution.value[0] - 1) / 2  # zero-based midpoint
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` at line 220, The center_target calculation
uses detector.resolution.value[0] / 2 which assumes one-based centering and
yields a 0.5 px bias for even widths; change it to use a zero-based midpoint:
set center_target = (detector.resolution.value[0] - 1) / 2 so it matches
find_peak_position()’s zero-based pixel coordinates and update any accompanying
comment accordingly.

253-256: ⚠️ Potential issue | 🔴 Critical

Critical syntax error: two statements on one line.

The pipeline fails because logging.debug(...) and spgr.moveRelSync(...) are on the same line without a separator. This prevents the module from being imported.

Suggested fix
             logging.debug(
                 "DEBUG | Iter: %d | Peak: %.1f | Error: %.1f | Move: %.4f | Total Change: %.4f",
                 i, peak_px, error_px, delta_goffset, total_goffset_displacement
-            )            spgr.moveRelSync({"goffset": delta_goffset})
+            )
+            spgr.moveRelSync({"goffset": delta_goffset})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 253 - 256, The
logging.debug(...) call and spgr.moveRelSync({"goffset": delta_goffset}) are
accidentally on the same line causing a syntax error; update the block (around
the loop that logs iteration status) to place logging.debug(...) and
spgr.moveRelSync(...) on separate statements/lines so each is its own statement
(preserve the same arguments and indentation), ensuring the logging.debug(...)
call is followed by a newline before calling spgr.moveRelSync.

271-278: ⚠️ Potential issue | 🟠 Major

Return a boolean from the task canceller.

model.CancellableFuture.cancel() only marks the future as cancelled when the canceller returns a truthy value. Currently, _cancel_sparc_auto_grating_offset returns None, so cancellation may appear to fail even though the state was updated.

Suggested fix
-def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture):
-
+def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture) -> bool:
     """
     Canceller of _do_sparc_auto_grating_offset task.
     """
     with future._task_lock:
+        if future._task_state == FINISHED:
+            return False
         future._task_state = CANCELLED
+    return True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 271 - 278, The canceller
_cancel_sparc_auto_grating_offset currently sets future._task_state = CANCELLED
inside the with future._task_lock block but returns None; change it so after
acquiring the lock and setting the state it returns True (a truthy value) so
model.CancellableFuture.cancel() will treat the cancel as successful—i.e.,
inside _cancel_sparc_auto_grating_offset keep the with future._task_lock, set
future._task_state = CANCELLED, then return True.

128-134: ⚠️ Potential issue | 🟠 Major

Wrap measurement in try/finally to ensure goffset is restored.

If detector.data.get() or find_peak_position() raises after the spectrograph move, the rollback on Line 134 never executes, leaving the hardware in a modified state.

Suggested fix
     # move and measure
     spgr.moveRelSync({"goffset": actual_delta})
-    data1 = detector.data.get(asap=False)
-    p1 = find_peak_position(data1)
-
-    # return back to start
-    spgr.moveRelSync({"goffset": -actual_delta})
+    try:
+        data1 = detector.data.get(asap=False)
+        p1 = find_peak_position(data1)
+    finally:
+        # return back to start
+        spgr.moveRelSync({"goffset": -actual_delta})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 128 - 134, After calling
spgr.moveRelSync({"goffset": actual_delta}) you must wrap the measurement block
in a try/finally so that spgr.moveRelSync({"goffset": -actual_delta}) always
runs even if detector.data.get(...) or find_peak_position(...) raises; modify
the block around spgr.moveRelSync, detector.data.get, and find_peak_position to
perform the measurement in the try and put the rollback moveRelSync({"goffset":
-actual_delta}) in the finally, preserving the current actual_delta variable and
re-raising any exception after cleanup.
🧹 Nitpick comments (5)
src/odemis/acq/align/goffset.py (3)

92-92: Add type hint for delta parameter.

The delta parameter lacks a type hint.

Suggested fix
-def estimate_goffset_scale(spgr: model.Actuator, detector: model.Detector, delta=5.0, retries = 1) -> Tuple[float, float, float]:
+def estimate_goffset_scale(spgr: model.Actuator, detector: model.Detector, delta: float = 5.0, retries: int = 1) -> Tuple[float, float, float]:

Also note the extra space before = 1 which should be removed per PEP8.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` at line 92, The function signature for
estimate_goffset_scale is missing a type hint for the delta parameter and has an
extra space before the default for retries; update the signature in
estimate_goffset_scale(spgr: model.Actuator, detector: model.Detector,
delta=5.0, retries = 1) to add an appropriate type (e.g., delta: float) and
remove the space before the equals in retries (retries=1) so it reads something
like delta: float = 5.0 and retries=1.

386-386: Avoid f-strings in logging calls.

Using f-strings in logging statements evaluates the string even when the log level is disabled. Use %-style formatting instead.

Suggested fix
-    logging.info(f"Available gratings: {list(spectrograph.axes['grating'].choices.keys())}")
+    logging.info("Available gratings: %s", list(spectrograph.axes['grating'].choices.keys()))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` at line 386, The logging call uses an
f-string which evaluates eagerly; change the logging.info call that references
spectrograph.axes['grating'].choices.keys() to use %-style formatting so the
message is formatted lazily (e.g. logging.info("Available gratings: %s",
list(spectrograph.axes['grating'].choices.keys()))). Update the logging
invocation where the current f-string appears (the logging.info line in
goffset.py) to this %-style form.

17-28: Add type hints to the gaussian function parameters.

As per coding guidelines, always use type hints for function parameters and return types.

Suggested fix
-def gaussian(x, amplitude, x0, width):
+def gaussian(x: numpy.ndarray, amplitude: float, x0: float, width: float) -> numpy.ndarray:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 17 - 28, The gaussian function
lacks type hints; update the signature of gaussian to annotate parameters (x,
amplitude, x0, width) and its return type — e.g., use numpy.typing.ArrayLike or
numpy.ndarray/float for x and float for amplitude, x0 and width, and annotate
the return as numpy.ndarray (or Union[float, numpy.ndarray] if you accept scalar
input); add any necessary imports from typing or numpy.typing and ensure the
docstring remains accurate.
src/odemis/acq/align/test/goffset_test.py (2)

186-190: Avoid print() in tests; use logging or test assertions.

Using print() for diagnostic output is discouraged. Either use logging.warning() or convert this into a proper test assertion that fails if the data is unexpectedly flat.

Suggested fix
         # check data is not flat
         if data.max() == data.min():
-            print("WARNING: sp-ccd is returning a flat image!")
+            logging.warning("sp-ccd is returning a flat image!")
         else:
-            print(f"sp-ccd signal range: {data.min()} to {data.max()}")
+            logging.debug("sp-ccd signal range: %s to %s", data.min(), data.max())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 186 - 190, Replace
the ad-hoc print-based diagnostics in the test by either using the logging
module or a proper test assertion: remove the print statements that reference
the test variable `data` and instead import Python's logging and call
logging.warning(...) when you want non-failing diagnostic output, or better,
replace the branch with an assertion such as `assert data.max() != data.min(),
"sp-ccd returned a flat image"` so the test fails on unexpected flat data;
update the test function containing `data` accordingly and add `import logging`
at the top if you choose logging.

144-144: Prefer next(iter(...)) over list slicing for single element.

Static analysis suggests using next(iter(self.spgr.axes["grating"].choices.keys())) instead of converting to a list and indexing [0].

Suggested fix
-        first_grating = list(self.spgr.axes["grating"].choices.keys())[0]
+        first_grating = next(iter(self.spgr.axes["grating"].choices.keys()))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` at line 144, Replace the list
conversion and index access when grabbing the first grating with an
iterator-based approach: locate the assignment to first_grating in
goffset_test.py (the line using
list(self.spgr.axes["grating"].choices.keys())[0]) and change it to use
next(iter(...)) over the same keys expression
(self.spgr.axes["grating"].choices.keys()) to avoid building an intermediate
list and make intent explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/odemis/acq/align/goffset.py`:
- Around line 413-418: The loop currently only sets results[(g0, d.name)] inside
the if selector: branch, so when selector is None the results entry is never
populated; modify the loop around detector variable d to handle the else case by
invoking sparc_auto_grating_offset for that detector (as done when selector is
present), obtaining the success value (via the returned future or direct call)
and assigning results[(g0, d.name)] = success; ensure you reference and set
future._subfuture or otherwise capture the return of
sparc_auto_grating_offset(spectrograph, d) and store its result for consistency
with the selector path.

---

Duplicate comments:
In `@src/odemis/acq/align/goffset.py`:
- Around line 61-71: The centroid uses raw window_data instead of the
non-negative weights previously computed; change the weighted average
calculation in the else branch to use weights (the clipped values) and
weights.sum() instead of window_data and numpy.sum(window_data) so negative
values don't skew the estimate—update the assignment to weighted_avg to compute
float(numpy.sum(window_idx * weights) / numpy.sum(weights)) while keeping the
existing peak_idx fallback and logging intact.
- Line 220: The center_target calculation uses detector.resolution.value[0] / 2
which assumes one-based centering and yields a 0.5 px bias for even widths;
change it to use a zero-based midpoint: set center_target =
(detector.resolution.value[0] - 1) / 2 so it matches find_peak_position()’s
zero-based pixel coordinates and update any accompanying comment accordingly.
- Around line 253-256: The logging.debug(...) call and
spgr.moveRelSync({"goffset": delta_goffset}) are accidentally on the same line
causing a syntax error; update the block (around the loop that logs iteration
status) to place logging.debug(...) and spgr.moveRelSync(...) on separate
statements/lines so each is its own statement (preserve the same arguments and
indentation), ensuring the logging.debug(...) call is followed by a newline
before calling spgr.moveRelSync.
- Around line 271-278: The canceller _cancel_sparc_auto_grating_offset currently
sets future._task_state = CANCELLED inside the with future._task_lock block but
returns None; change it so after acquiring the lock and setting the state it
returns True (a truthy value) so model.CancellableFuture.cancel() will treat the
cancel as successful—i.e., inside _cancel_sparc_auto_grating_offset keep the
with future._task_lock, set future._task_state = CANCELLED, then return True.
- Around line 128-134: After calling spgr.moveRelSync({"goffset": actual_delta})
you must wrap the measurement block in a try/finally so that
spgr.moveRelSync({"goffset": -actual_delta}) always runs even if
detector.data.get(...) or find_peak_position(...) raises; modify the block
around spgr.moveRelSync, detector.data.get, and find_peak_position to perform
the measurement in the try and put the rollback moveRelSync({"goffset":
-actual_delta}) in the finally, preserving the current actual_delta variable and
re-raising any exception after cleanup.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 43-49: Uncomment and re-enable the class-level teardown to restore
the spectrograph state: reinstate the `@classmethod` tearDownClass in the test
class and ensure it calls cls.spgr.moveAbsSync(cls._original_position) inside a
try/except that uses logging.exception("Failed restoring spectrograph position")
on failure; make sure cls._original_position is set in setUpClass and that
cls.spgr is available so the spectrograph is reliably returned to its original
position after the test suite.
- Around line 83-87: The test is assigning the entire tuple returned by
estimate_goffset_scale() to scale; update the test to unpack the tuple returned
by estimate_goffset_scale(self.spgr, self.detector) into (scale, p0, p1) (or
similar names) and then run the existing assertions on scale
(assertIsInstance(scale, float), assertNotEqual(scale, 0.0),
assertTrue(np.isfinite(scale))); reference estimate_goffset_scale to locate the
implementation and adjust the test accordingly.
- Around line 117-119: The file src/odemis/acq/align/test/goffset_test.py has
trailing whitespace on a blank line (W293 at line 117) and is missing a newline
at EOF (W292 at line 207); run autopep8 with the suggested flags (autopep8
--in-place --select W291,W292,W293,W391) on goffset_test.py or manually remove
trailing spaces and add a final newline so the file ends with a single newline,
then commit the cleaned file.
- Around line 110-116: Modify the test around
sparc_auto_grating_offset/self.spgr to actually exercise cancellation: call
f.cancel() after starting the future, then assert that awaiting f.result()
raises the appropriate CancelledError (use asyncio.CancelledError or
concurrent.futures.CancelledError consistent with how sparc_auto_grating_offset
returns a future), and replace the bare except: with a specific exception
assertion (e.g., with self.assertRaises(CancelledError): f.result(timeout=5)) or
a try/except that only catches CancelledError; ensure you assert f.cancelled()
or that the CancelledError was raised rather than silently swallowing all
exceptions.
- Around line 94-99: The test sets delta = 0 so no misalignment is introduced;
change delta to a non-zero value (e.g., a small safe offset) so the call to
self.spgr.moveRelSync({"goffset": delta * direction}) actually moves the axis;
ensure the chosen delta stays within bounds by computing direction using current
= self.spgr.position.value["goffset"] and goffset_max =
self.spgr.axes["goffset"].range[1] (as already used) so the relative move is
valid and triggers the intended misalignment/recovery behavior.

---

Nitpick comments:
In `@src/odemis/acq/align/goffset.py`:
- Line 92: The function signature for estimate_goffset_scale is missing a type
hint for the delta parameter and has an extra space before the default for
retries; update the signature in estimate_goffset_scale(spgr: model.Actuator,
detector: model.Detector, delta=5.0, retries = 1) to add an appropriate type
(e.g., delta: float) and remove the space before the equals in retries
(retries=1) so it reads something like delta: float = 5.0 and retries=1.
- Line 386: The logging call uses an f-string which evaluates eagerly; change
the logging.info call that references
spectrograph.axes['grating'].choices.keys() to use %-style formatting so the
message is formatted lazily (e.g. logging.info("Available gratings: %s",
list(spectrograph.axes['grating'].choices.keys()))). Update the logging
invocation where the current f-string appears (the logging.info line in
goffset.py) to this %-style form.
- Around line 17-28: The gaussian function lacks type hints; update the
signature of gaussian to annotate parameters (x, amplitude, x0, width) and its
return type — e.g., use numpy.typing.ArrayLike or numpy.ndarray/float for x and
float for amplitude, x0 and width, and annotate the return as numpy.ndarray (or
Union[float, numpy.ndarray] if you accept scalar input); add any necessary
imports from typing or numpy.typing and ensure the docstring remains accurate.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 186-190: Replace the ad-hoc print-based diagnostics in the test by
either using the logging module or a proper test assertion: remove the print
statements that reference the test variable `data` and instead import Python's
logging and call logging.warning(...) when you want non-failing diagnostic
output, or better, replace the branch with an assertion such as `assert
data.max() != data.min(), "sp-ccd returned a flat image"` so the test fails on
unexpected flat data; update the test function containing `data` accordingly and
add `import logging` at the top if you choose logging.
- Line 144: Replace the list conversion and index access when grabbing the first
grating with an iterator-based approach: locate the assignment to first_grating
in goffset_test.py (the line using
list(self.spgr.axes["grating"].choices.keys())[0]) and change it to use
next(iter(...)) over the same keys expression
(self.spgr.axes["grating"].choices.keys()) to avoid building an intermediate
list and make intent explicit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b56e39ce-e95a-464d-aac1-f7d76f609ec0

📥 Commits

Reviewing files that changed from the base of the PR and between 1d430b9 and 2c1fdcf.

📒 Files selected for processing (2)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/test/goffset_test.py

Comment thread src/odemis/acq/align/goffset.py
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch 6 times, most recently from b8111d3 to de03ff2 Compare March 9, 2026 17:06
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (14)
src/odemis/acq/align/test/goffset_test.py (5)

43-49: ⚠️ Potential issue | 🟠 Major

Restore the spectrograph state in tearDownClass().

Leaving the cleanup path commented out makes later tests order-dependent and can leave the backend in a modified state after this suite finishes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 43 - 49, Uncomment
and re-enable the tearDownClass cleanup so the spectrograph state is restored:
restore the commented-out `@classmethod` tearDownClass in the test class, call
cls.spgr.moveAbsSync(cls._original_position) inside a try/except, and on
exception call logging.exception("Failed restoring spectrograph position") so
that the spectrograph (cls.spgr) is returned to cls._original_position after the
suite runs.

83-87: ⚠️ Potential issue | 🔴 Critical

Unpack estimate_goffset_scale() before asserting on scale.

The helper returns (scale, p0, p1), so scale is currently a tuple and assertIsInstance(scale, float) will fail immediately.

Suggested fix
-        scale = estimate_goffset_scale(self.spgr, self.detector)
+        scale, _p0, _p1 = estimate_goffset_scale(self.spgr, self.detector)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 83 - 87, The test is
calling estimate_goffset_scale(self.spgr, self.detector) but that function
returns a tuple (scale, p0, p1); update the test in goffset_test.py to unpack
the returned tuple (e.g. scale, p0, p1 = estimate_goffset_scale(...)) and then
run the existing assertions against scale (assertIsInstance(scale, float),
assertNotEqual(scale, 0.0), assertTrue(np.isfinite(scale))). Ensure you
reference estimate_goffset_scale and the local variables p0/p1 when making the
change.

94-99: ⚠️ Potential issue | 🟠 Major

Use a non-zero delta so this test actually creates misalignment.

delta = 0 turns the relative move into a no-op, so this can pass even if recovery from an offset regression is broken.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 94 - 99, The test
sets delta = 0 so the relative move is a no-op; change the test in
goffset_test.py to use a non-zero delta (e.g., a small amount appropriate for
the system) instead of 0, keeping the existing computation of current =
self.spgr.position.value["goffset"], goffset_max =
self.spgr.axes["goffset"].range[1], direction = 1 if (current + delta <
goffset_max) else -1 and then calling self.spgr.moveRelSync({"goffset": delta *
direction}) so the test actually introduces a misalignment to verify recovery.

110-116: ⚠️ Potential issue | 🟠 Major

Exercise cancellation instead of swallowing every exception.

This path never calls cancel(), so it can pass on normal completion, timeout, or unrelated failures.

Suggested fix
         f = sparc_auto_grating_offset(self.spgr, self.detector)
-        # Wait for the result or a timeout
-        try:
-            f.result(timeout=5)
-        except:
-            pass
+        self.assertTrue(f.cancel())
+        with self.assertRaises(CancelledError):
+            f.result(timeout=5)
         self.assertTrue(f.done())
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 110 - 116, The test
currently swallows all exceptions when waiting on the future returned by
sparc_auto_grating_offset(self.spgr, self.detector); change the try/except so
you only catch the timeout, call f.cancel() when a timeout occurs, and let other
exceptions propagate. Specifically, replace the broad except with catching
concurrent.futures.TimeoutError (or asyncio.TimeoutError if using asyncio
futures), call f.cancel() inside that except block, and avoid catching Exception
or bare except so real failures still surface; keep the final assertion
self.assertTrue(f.done()) to verify the future was finished or cancelled.

22-22: ⚠️ Potential issue | 🟡 Minor

Stop changing the root logger at import time.

This leaks DEBUG logging into unrelated tests and makes suite behavior order-dependent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` at line 22, The module is calling
logging.getLogger().setLevel(logging.DEBUG) at import time which mutates the
root logger and leaks DEBUG into other tests; remove that import-time root-level
change and instead obtain a module logger via logger =
logging.getLogger(__name__) and use that logger for local debug calls, or move
any test-specific logging configuration into the test setup/teardown (or an if
__name__ == '__main__' guard) so you do not set the root logger level during
import.
src/odemis/acq/align/goffset.py (5)

127-133: ⚠️ Potential issue | 🟠 Major

Always undo the probe move in a finally block.

If acquisition or fitting fails after the temporary goffset move, the rollback never runs and the spectrograph stays at the test offset.

Suggested fix
     # move and measure
     spgr.moveRelSync({"goffset": actual_delta})
-    data1 = detector.data.get(asap=False)
-    p1 = find_peak_position(data1)
-
-    # return back to start
-    spgr.moveRelSync({"goffset": -actual_delta})
+    try:
+        data1 = detector.data.get(asap=False)
+        p1 = find_peak_position(data1)
+    finally:
+        # return back to start
+        spgr.moveRelSync({"goffset": -actual_delta})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 127 - 133, The temporary
spectrograph move using spgr.moveRelSync({"goffset": actual_delta}) must be
reverted inside a finally block so the rollback always runs even if
detector.data.get or find_peak_position raise; wrap the "move and measure"
sequence (spgr.moveRelSync({"goffset": actual_delta}), data1 =
detector.data.get(asap=False), p1 = find_peak_position(data1)) in try and
perform spgr.moveRelSync({"goffset": -actual_delta}) in a finally clause,
preserving any raised exceptions (re-raise if needed) so the spectrograph is
always returned to the start position.

271-278: ⚠️ Potential issue | 🟠 Major

Return a boolean from the task canceller.

ProgressiveFuture.cancel() depends on the task canceller returning truthy. This helper currently returns None, so callers can see cancel() fail even though _task_state was flipped locally.

Suggested fix
-def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture):
+def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture) -> bool:
@@
     """
     Canceller of _do_sparc_auto_grating_offset task.
     """
     with future._task_lock:
-        future._task_state = CANCELLED
+        if future._task_state == FINISHED:
+            return False
+        future._task_state = CANCELLED
+    return True
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 271 - 278, The canceller
_cancel_sparc_auto_grating_offset currently sets future._task_state = CANCELLED
under future._task_lock but returns None, which makes ProgressiveFuture.cancel()
see a falsy result; update _cancel_sparc_auto_grating_offset to return a truthy
value (e.g., True) after successfully acquiring the lock and setting
future._task_state, ensuring ProgressiveFuture.cancel() observes a successful
cancellation; keep the lock usage and state assignment as-is and simply add the
boolean return at the end.

64-72: ⚠️ Potential issue | 🟠 Major

Use the clipped positive weights in the centroid fallback.

The guard checks weights.sum(), but the centroid still uses window_data. Negative baseline values can cancel the denominator or pull the estimate away from the positive lobe.

Suggested fix
-        weighted_avg = float(numpy.sum(window_idx * window_data) / numpy.sum(window_data))
+        weighted_avg = float(numpy.sum(window_idx * weights) / numpy.sum(weights))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 64 - 72, The centroid fallback
is inconsistent: the guard checks weights.sum() but the centroid uses
window_data, allowing negative baseline values to skew the result; change the
centroid computation for weighted_avg to use the clipped positive weights (use
numpy.sum(window_idx * weights) / numpy.sum(weights)) so the numerator and
denominator reference the same positive-only weights and remain consistent with
the existing if weights.sum() check (keep peak_idx fallback unchanged).

413-419: ⚠️ Potential issue | 🔴 Critical

Run the first-grating alignment even without a selector.

The single-detector path is allowed above, but this branch only executes the alignment when selector is truthy. With selector=None, results never gets an entry for the first grating.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 413 - 419, The branch currently
only runs sparc_auto_grating_offset and populates results when selector is
truthy, leaving the first grating out when selector is None; change the logic so
selector.moveAbsSync({selector_axes: detector_to_selector[d]}) is only called if
selector is not None, but sparc_auto_grating_offset(spectrograph, d) is always
invoked and its result stored in future._subfuture/result and results[(g0,
d.name)] regardless of selector being None, preserving the mapping keys g0 and
d.name.

218-220: ⚠️ Potential issue | 🟠 Major

Target the detector midpoint in the same coordinate system as the peak fit.

find_peak_position() returns zero-based pixel coordinates, so an even-width detector is centered at (width - 1) / 2, not width / 2. The current target leaves a perfect alignment 0.5 px off.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset.py` around lines 218 - 220, find_peak_position()
returns zero-based pixel coords so the detector midpoint should be computed as
(width - 1) / 2; update the center_target assignment in goffset.py (the line
after estimate_goffset_scale(...) where center_target is set) to use
(detector.resolution.value[0] - 1) / 2 (or the equivalent accessor for detector
width) instead of detector.resolution.value[0] / 2 so alignments don't end up
0.5 px off.
src/odemis/acq/align/test/goffset_alignment_test.py (1)

39-49: ⚠️ Potential issue | 🟠 Major

Rename or merge the two test_cancel methods.

Python keeps only the later definition, so one cancellation scenario is silently dropped from the suite.

Also applies to: 68-79

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_alignment_test.py` around lines 39 - 49,
There are two identically named test methods (both named test_cancel) so only
the latter runs; rename one (e.g., to test_cancel_immediate or
test_cancel_after_sleep) or merge their assertions into a single test to
preserve both cancellation scenarios; update references to the future returned
by auto_align_grating_detector_offsets, keep the same assertions around
f.cancel(), f.cancelled(), and the with self.assertRaises(CancelledError):
f.result(...) block, and ensure the timeout decorator (timeout(...)) remains
appropriate for the combined/renamed test.
src/odemis/acq/align/goffset_alignment.py (3)

17-25: ⚠️ Potential issue | 🔴 Critical

Use the cancellation fields this future actually initializes.

auto_align_grating_detector_offsets() sets _task_lock / _task_state, but _checkCancelled() reads _function_lock / _function_state. The first cancellation check will raise AttributeError instead of stopping cleanly.

Suggested fix
-def _checkCancelled(future: "model.ProgressiveFuture"):
+def _checkCancelled(future: "model.ProgressiveFuture"):
@@
-    with future._function_lock:
-        if future._function_state == CANCELLED:
+    with future._task_lock:
+        if future._task_state == CANCELLED:
             raise CancelledError()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset_alignment.py` around lines 17 - 25,
_checkCancelled is accessing non-existent attributes
_function_lock/_function_state while auto_align_grating_detector_offsets
initializes _task_lock/_task_state, causing AttributeError; update
_checkCancelled to use the same cancellation fields that the future initializes
(use future._task_lock and future._task_state) and keep the same behavior of
raising CancelledError when state == CANCELLED so cancellation stops cleanly (or
alternatively change the future initialization to set
_function_lock/_function_state consistently with _checkCancelled).

145-155: ⚠️ Potential issue | 🔴 Critical

Run the first-grating alignment even when selector is None.

The public API explicitly allows the single-detector/no-selector case, but this loop only calls sparc_auto_grating_offset() inside if selector:. In that valid configuration the first grating is never aligned or recorded.

Suggested fix
         for d in detectors_sorted:
             _checkCancelled(future)
             logging.info(f"Starting alignment | Detector: {d.name} | Grating: {g0}")
 
             if selector:
                 selector.moveAbsSync({selector_axes: detector_to_selector[d]})
-                future._subfuture = sparc_auto_grating_offset(spectrograph, d)
-                success = future._subfuture.result()
-                results[(g0, d.name)] = success
-
-                logging.info(f"Finished alignment | Detector: {d.name} | Grating: {g0} | Success: {success}")
+            future._subfuture = sparc_auto_grating_offset(spectrograph, d)
+            success = future._subfuture.result()
+            results[(g0, d.name)] = success
+            logging.info(f"Finished alignment | Detector: {d.name} | Grating: {g0} | Success: {success}")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset_alignment.py` around lines 145 - 155, The loop
only calls sparc_auto_grating_offset() when selector is truthy, so in the
single-detector/no-selector case the first grating is never aligned or recorded;
update the loop in goffset_alignment.py to always invoke
sparc_auto_grating_offset(spectrograph, d) and set future._subfuture = that call
and results[(g0, d.name)] = success, but keep the selector movement guarded: if
selector: selector.moveAbsSync({selector_axes: detector_to_selector[d]}) before
calling sparc_auto_grating_offset; preserve the _checkCancelled(future) call and
the start/finish logging (including Success) around the alignment so the
behavior and logging for detectors_sorted, future, selector_axes,
detector_to_selector, sparc_auto_grating_offset, results, g0 and d.name remain
consistent.

45-200: 🛠️ Refactor suggestion | 🟠 Major

Keep one source of truth for the orchestration helpers.

src/odemis/acq/align/goffset.py already contains the same timing constants and the same auto_align_grating_detector_offsets() / _do_auto_align_grating_detector_offsets() / _cancel_auto_align_grating_detector_offsets() trio. The copies are already drifting, so bug fixes here will keep missing the other module.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/goffset_alignment.py` around lines 45 - 200, Duplicate
orchestration helpers and timing constants exist here and in goffset.py, causing
drift; remove the copies in this file and import the canonical implementations
and constants instead. Replace the local definitions of MOVE_TIME_GRATING,
MOVE_TIME_DETECTOR, EST_ALIGN_TIME and the functions
auto_align_grating_detector_offsets, _do_auto_align_grating_detector_offsets,
_cancel_auto_align_grating_detector_offsets with imports from the single source
(goffset.py) so all callers use the same implementation; ensure any local
references (e.g., spectrograph, selector, sparc_auto_grating_offset) remain
compatible or adapt call sites to the imported functions' signatures. Finally
run tests to confirm no behavioral changes.
🧹 Nitpick comments (1)
src/odemis/acq/align/test/goffset_test.py (1)

120-204: Keep the multi-detector alignment suite in one file only.

TestAutoAlignGratingDetectorOffsets is already implemented in src/odemis/acq/align/test/goffset_alignment_test.py. Keeping two near-identical copies makes this hardware-heavy flow slower and guarantees the assertions will drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/odemis/acq/align/test/goffset_test.py` around lines 120 - 204, This file
defines a duplicate TestAutoAlignGratingDetectorOffsets test class that
duplicates the implementation in goffset_alignment_test.py; remove the duplicate
class (TestAutoAlignGratingDetectorOffsets) or delete the entire file to keep
only the single canonical suite in goffset_alignment_test.py, ensuring any
unique assertions or setup from this copy are merged into the canonical test
(e.g., the tests test_single_detector_iteration, test_multi_detector_iteration,
test_cancel and uses of auto_align_grating_detector_offsets, selector, ccd,
sp-ccd) before removal so test coverage is preserved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/odemis/acq/align/goffset.py`:
- Line 17: The functions lack Python type hints: add explicit parameter and
return type annotations to gaussian (annotate x: Sequence[float] or
numpy.ndarray, amplitude: float, x0: float, width: float, -> numpy.ndarray or
Sequence[float]/float depending on implementation) and to estimate_goffset_scale
(annotate delta: float, retries: int, plus any untyped return type), update
imports (typing or numpy types) if needed, and ensure the signatures for
gaussian and estimate_goffset_scale exactly use the project’s preferred numeric
types (float/int/numpy.ndarray) and include return type hints to satisfy the
coding guideline.
- Around line 308-311: The type annotation uses a forward reference
List['Stream'] in auto_align_grating_detector_offsets (and the other occurrences
around line 360) but Stream is not imported, causing an F821 undefined name; fix
by adding the import "from odemis.acq.stream import Stream" at the top of the
module so the Stream name is defined for the annotations (or alternatively
convert the string annotations to typing.TYPE_CHECKING guarded import), ensuring
auto_align_grating_detector_offsets and related signatures resolve correctly.

In `@src/odemis/acq/align/test/goffset_alignment_test.py`:
- Around line 123-125: Remove the trailing whitespace on the final line that
contains the "__name__ == '__main__'" block and ensure the file ends with a
single newline; specifically edit the lines with "if __name__ == '__main__':"
and "unittest.main()" to remove any trailing spaces and add a final newline
character, then run autopep8 (autopep8 --in-place --select W291,W292,W293,W391)
to automatically fix W293/W292 lint warnings.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 117-118: The file src/odemis/acq/align/test/goffset_test.py
contains trailing whitespace / whitespace-only blank lines around the area
indicated (causing W293); remove those blank lines (or strip trailing spaces) so
no whitespace-only lines remain, then reformat the file with autopep8 (autopep8
--in-place --select W291,W292,W293,W391) to ensure the W293 lint error is
cleared; look for the blank lines in the goffset_test.py test module near the
reported location to make the change.

---

Duplicate comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 17-25: _checkCancelled is accessing non-existent attributes
_function_lock/_function_state while auto_align_grating_detector_offsets
initializes _task_lock/_task_state, causing AttributeError; update
_checkCancelled to use the same cancellation fields that the future initializes
(use future._task_lock and future._task_state) and keep the same behavior of
raising CancelledError when state == CANCELLED so cancellation stops cleanly (or
alternatively change the future initialization to set
_function_lock/_function_state consistently with _checkCancelled).
- Around line 145-155: The loop only calls sparc_auto_grating_offset() when
selector is truthy, so in the single-detector/no-selector case the first grating
is never aligned or recorded; update the loop in goffset_alignment.py to always
invoke sparc_auto_grating_offset(spectrograph, d) and set future._subfuture =
that call and results[(g0, d.name)] = success, but keep the selector movement
guarded: if selector: selector.moveAbsSync({selector_axes:
detector_to_selector[d]}) before calling sparc_auto_grating_offset; preserve the
_checkCancelled(future) call and the start/finish logging (including Success)
around the alignment so the behavior and logging for detectors_sorted, future,
selector_axes, detector_to_selector, sparc_auto_grating_offset, results, g0 and
d.name remain consistent.
- Around line 45-200: Duplicate orchestration helpers and timing constants exist
here and in goffset.py, causing drift; remove the copies in this file and import
the canonical implementations and constants instead. Replace the local
definitions of MOVE_TIME_GRATING, MOVE_TIME_DETECTOR, EST_ALIGN_TIME and the
functions auto_align_grating_detector_offsets,
_do_auto_align_grating_detector_offsets,
_cancel_auto_align_grating_detector_offsets with imports from the single source
(goffset.py) so all callers use the same implementation; ensure any local
references (e.g., spectrograph, selector, sparc_auto_grating_offset) remain
compatible or adapt call sites to the imported functions' signatures. Finally
run tests to confirm no behavioral changes.

In `@src/odemis/acq/align/goffset.py`:
- Around line 127-133: The temporary spectrograph move using
spgr.moveRelSync({"goffset": actual_delta}) must be reverted inside a finally
block so the rollback always runs even if detector.data.get or
find_peak_position raise; wrap the "move and measure" sequence
(spgr.moveRelSync({"goffset": actual_delta}), data1 =
detector.data.get(asap=False), p1 = find_peak_position(data1)) in try and
perform spgr.moveRelSync({"goffset": -actual_delta}) in a finally clause,
preserving any raised exceptions (re-raise if needed) so the spectrograph is
always returned to the start position.
- Around line 271-278: The canceller _cancel_sparc_auto_grating_offset currently
sets future._task_state = CANCELLED under future._task_lock but returns None,
which makes ProgressiveFuture.cancel() see a falsy result; update
_cancel_sparc_auto_grating_offset to return a truthy value (e.g., True) after
successfully acquiring the lock and setting future._task_state, ensuring
ProgressiveFuture.cancel() observes a successful cancellation; keep the lock
usage and state assignment as-is and simply add the boolean return at the end.
- Around line 64-72: The centroid fallback is inconsistent: the guard checks
weights.sum() but the centroid uses window_data, allowing negative baseline
values to skew the result; change the centroid computation for weighted_avg to
use the clipped positive weights (use numpy.sum(window_idx * weights) /
numpy.sum(weights)) so the numerator and denominator reference the same
positive-only weights and remain consistent with the existing if weights.sum()
check (keep peak_idx fallback unchanged).
- Around line 413-419: The branch currently only runs sparc_auto_grating_offset
and populates results when selector is truthy, leaving the first grating out
when selector is None; change the logic so selector.moveAbsSync({selector_axes:
detector_to_selector[d]}) is only called if selector is not None, but
sparc_auto_grating_offset(spectrograph, d) is always invoked and its result
stored in future._subfuture/result and results[(g0, d.name)] regardless of
selector being None, preserving the mapping keys g0 and d.name.
- Around line 218-220: find_peak_position() returns zero-based pixel coords so
the detector midpoint should be computed as (width - 1) / 2; update the
center_target assignment in goffset.py (the line after
estimate_goffset_scale(...) where center_target is set) to use
(detector.resolution.value[0] - 1) / 2 (or the equivalent accessor for detector
width) instead of detector.resolution.value[0] / 2 so alignments don't end up
0.5 px off.

In `@src/odemis/acq/align/test/goffset_alignment_test.py`:
- Around line 39-49: There are two identically named test methods (both named
test_cancel) so only the latter runs; rename one (e.g., to test_cancel_immediate
or test_cancel_after_sleep) or merge their assertions into a single test to
preserve both cancellation scenarios; update references to the future returned
by auto_align_grating_detector_offsets, keep the same assertions around
f.cancel(), f.cancelled(), and the with self.assertRaises(CancelledError):
f.result(...) block, and ensure the timeout decorator (timeout(...)) remains
appropriate for the combined/renamed test.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 43-49: Uncomment and re-enable the tearDownClass cleanup so the
spectrograph state is restored: restore the commented-out `@classmethod`
tearDownClass in the test class, call
cls.spgr.moveAbsSync(cls._original_position) inside a try/except, and on
exception call logging.exception("Failed restoring spectrograph position") so
that the spectrograph (cls.spgr) is returned to cls._original_position after the
suite runs.
- Around line 83-87: The test is calling estimate_goffset_scale(self.spgr,
self.detector) but that function returns a tuple (scale, p0, p1); update the
test in goffset_test.py to unpack the returned tuple (e.g. scale, p0, p1 =
estimate_goffset_scale(...)) and then run the existing assertions against scale
(assertIsInstance(scale, float), assertNotEqual(scale, 0.0),
assertTrue(np.isfinite(scale))). Ensure you reference estimate_goffset_scale and
the local variables p0/p1 when making the change.
- Around line 94-99: The test sets delta = 0 so the relative move is a no-op;
change the test in goffset_test.py to use a non-zero delta (e.g., a small amount
appropriate for the system) instead of 0, keeping the existing computation of
current = self.spgr.position.value["goffset"], goffset_max =
self.spgr.axes["goffset"].range[1], direction = 1 if (current + delta <
goffset_max) else -1 and then calling self.spgr.moveRelSync({"goffset": delta *
direction}) so the test actually introduces a misalignment to verify recovery.
- Around line 110-116: The test currently swallows all exceptions when waiting
on the future returned by sparc_auto_grating_offset(self.spgr, self.detector);
change the try/except so you only catch the timeout, call f.cancel() when a
timeout occurs, and let other exceptions propagate. Specifically, replace the
broad except with catching concurrent.futures.TimeoutError (or
asyncio.TimeoutError if using asyncio futures), call f.cancel() inside that
except block, and avoid catching Exception or bare except so real failures still
surface; keep the final assertion self.assertTrue(f.done()) to verify the future
was finished or cancelled.
- Line 22: The module is calling logging.getLogger().setLevel(logging.DEBUG) at
import time which mutates the root logger and leaks DEBUG into other tests;
remove that import-time root-level change and instead obtain a module logger via
logger = logging.getLogger(__name__) and use that logger for local debug calls,
or move any test-specific logging configuration into the test setup/teardown (or
an if __name__ == '__main__' guard) so you do not set the root logger level
during import.

---

Nitpick comments:
In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 120-204: This file defines a duplicate
TestAutoAlignGratingDetectorOffsets test class that duplicates the
implementation in goffset_alignment_test.py; remove the duplicate class
(TestAutoAlignGratingDetectorOffsets) or delete the entire file to keep only the
single canonical suite in goffset_alignment_test.py, ensuring any unique
assertions or setup from this copy are merged into the canonical test (e.g., the
tests test_single_detector_iteration, test_multi_detector_iteration, test_cancel
and uses of auto_align_grating_detector_offsets, selector, ccd, sp-ccd) before
removal so test coverage is preserved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 094c64f4-6487-4331-907b-6ecb9a601c52

📥 Commits

Reviewing files that changed from the base of the PR and between 2c1fdcf and 2825651.

📒 Files selected for processing (4)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/goffset_alignment.py
  • src/odemis/acq/align/test/goffset_alignment_test.py
  • src/odemis/acq/align/test/goffset_test.py

Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/test/goffset_alignment_test.py Outdated
Comment thread src/odemis/acq/align/test/goffset_test.py Outdated
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from de03ff2 to af6f838 Compare March 9, 2026 17:13
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from af6f838 to 37c53b3 Compare March 9, 2026 17:26
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/test/goffset_alignment_test.py
Comment thread src/odemis/acq/align/test/goffset_alignment_test.py
Comment thread src/odemis/acq/align/goffset.py
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py Outdated
Comment thread src/odemis/acq/align/goffset.py
Comment thread src/odemis/acq/align/goffset.py
Comment thread src/odemis/acq/align/goffset.py
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch 2 times, most recently from fc43a4a to d03d78d Compare April 2, 2026 09:24
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from d03d78d to 9670b75 Compare April 2, 2026 11:33
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from 11d5873 to d58b74f Compare May 7, 2026 09:24
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

♻️ Duplicate comments (11)
src/odemis/acq/align/goffset_alignment.py (3)

17-25: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use the fields this future actually initializes.

auto_align_grating_detector_offsets() sets _task_lock and _task_state, but _checkCancelled() reads _function_lock and _function_state. The first cancellation check will raise AttributeError instead of cancelling cleanly.

🤖 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/acq/align/goffset_alignment.py` around lines 17 - 25, The
cancellation checker _checkCancelled reads non-existent attributes
_function_lock/_function_state causing AttributeError; update it to use the
actual fields initialized by the future—use future._task_lock and
future._task_state (and keep the same CANCELLED comparison and CancelledError
raise) so auto_align_grating_detector_offsets() can cancel cleanly.

149-155: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Run the first-grating alignment even when selector is None.

The single-detector/no-selector case is explicitly allowed above, but this loop only calls sparc_auto_grating_offset() inside the if selector: branch. In that valid configuration the first grating never gets aligned or stored in results.

🤖 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/acq/align/goffset_alignment.py` around lines 149 - 155, The loop
currently only calls sparc_auto_grating_offset and stores its result when
selector is truthy, so in the allowed single-detector/no-selector case the first
grating (g0) never gets aligned or added to results; change the logic so
sparc_auto_grating_offset(spectrograph, d) is invoked and awaited (via
future._subfuture and .result()) regardless of selector, but only call
selector.moveAbsSync when selector is not None; ensure results[(g0, d.name)] =
success and the logging.info line run in both branches so the alignment result
for g0 is always recorded.

45-48: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve the Stream forward references.

Ruff is already reporting F821 for both annotations in this file. Add a guarded import so these signatures stop failing lint/type checks.

Also applies to: 93-97

🤖 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/acq/align/goffset_alignment.py` around lines 45 - 48, The
annotations use the forward reference 'Stream' and cause F821; add a guarded
import using typing.TYPE_CHECKING and import Stream inside the TYPE_CHECKING
block so the runtime is unchanged but the name is available for linting/typing.
Specifically, add "from typing import TYPE_CHECKING" and then "if TYPE_CHECKING:
from <appropriate_module> import Stream" (replace <appropriate_module> with the
module that defines Stream) near the top of the file, and ensure the same
guarded import covers the other functions using 'Stream' (e.g.,
auto_align_grating_detector_offsets and the functions around lines 93–97) so
their annotations no longer raise F821.
src/odemis/acq/align/test/goffset_test.py (4)

488-494: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Actually exercise the cancellation path here.

This test waits on result() and swallows every exception, so it can pass on normal completion too. Call cancel() and assert result() raises CancelledError.

🤖 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/acq/align/test/goffset_test.py` around lines 488 - 494, The test
currently waits on the future returned by sparc_auto_grating_offset(self.spgr,
self.detector) and swallows all exceptions, so it doesn't verify cancellation;
modify the test to call cancel() on the future (f.cancel()) after creating it,
then assert that f.cancelled() is True (or f.done()) and that calling f.result()
raises the appropriate CancelledError (use the async future/asyncio
CancelledError or concurrent.futures.CancelledError depending on which Future
type sparc_auto_grating_offset returns) instead of silently catching all
exceptions.

44-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore the spectrograph state in tearDownClass().

This suite moves the spectrograph repeatedly, but cleanup is commented out. That makes later tests order-dependent and leaves the backend mutated after this file finishes.

🤖 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/acq/align/test/goffset_test.py` around lines 44 - 50, Uncomment
and re-enable the tearDownClass class method so the test suite restores
spectrograph state after tests: implement a `@classmethod` tearDownClass(cls) that
calls cls.spgr.moveAbsSync(cls._original_position) inside a try/except, logging
any exception with logging.exception("Failed restoring spectrograph position")
to ensure the backend is returned to its original position and tests remain
order-independent; reference the existing cls.spgr, cls._original_position, and
tearDownClass identifiers when restoring state.

82-90: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Unpack estimate_goffset_scale() before asserting on scale.

The helper returns (scale, p0, p1). As written, scale is the full tuple, so assertIsInstance(scale, float) will fail immediately.

🤖 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/acq/align/test/goffset_test.py` around lines 82 - 90, The test
currently treats the return of estimate_goffset_scale(self.spgr, self.detector)
as a float but the function returns a tuple (scale, p0, p1); update
test_estimate_goffset_scale to unpack the return like (scale, p0, p1) =
estimate_goffset_scale(self.spgr, self.detector) and then run the existing
assertions (assertIsInstance(scale, float), assertNotEqual(scale, 0.0),
assertTrue(np.isfinite(scale))) against the unpacked scale variable.

147-156: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test never creates the misalignment it claims to verify.

delta = 0 makes the move a no-op, so the test can pass even if recovery from a real offset regression is broken.

🤖 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/acq/align/test/goffset_test.py` around lines 147 - 156, The test
sets delta = 0 so no misalignment occurs; change delta to a non-zero value
(e.g., a small positive int/float) so the move actually shifts goffset, keep the
existing calculation of current = self.spgr.position.value["goffset"],
goffset_max = self.spgr.axes["goffset"].range[1], and direction = 1 if (current
+ delta < goffset_max) else -1, then call self.spgr.moveRelSync({"goffset":
delta * direction}) as before so the test produces a real misalignment to verify
recovery.
src/odemis/acq/align/goffset.py (4)

81-89: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the clipped weights consistently in the centroid fallback.

The denominator uses weights, but the numerator still uses window_data. Negative baseline samples can still pull the fallback estimate away from the positive lobe you already clipped for the zero-sum check.

🤖 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/acq/align/goffset.py` around lines 81 - 89, The centroid fallback
uses a clipped weights array but still multiplies window_idx by the original
window_data; change the numerator to use the clipped weights so the centroid
uses only non-negative contributions: when computing weighted_avg in the else
branch replace the numerator window_idx * window_data with window_idx * weights
(refer to variables weights, window_data, window_idx, weighted_avg and peak_idx)
so the weighted average is computed as sum(window_idx * weights)/sum(weights).

566-569: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve the Stream forward references.

Ruff is already flagging both List['Stream'] annotations as F821 here. Add a guarded import or a direct import so these signatures stop failing lint/type checks.

Also applies to: 610-614

🤖 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/acq/align/goffset.py` around lines 566 - 569, The F821 errors come
from using the forward-reference type List['Stream'] in the signatures of
auto_align_grating_detector_offsets (and the similar function at ~610-614); fix
by adding a guarded import: import TYPE_CHECKING from typing and inside if
TYPE_CHECKING: from <appropriate_module> import Stream (or the correct
module/class name where Stream is defined), so the Stream symbol is available
for type checkers but not at runtime; update both function signatures locations
to rely on that guarded import (or alternatively enable postponed evaluation via
from __future__ import annotations) to resolve the lint/type errors.

532-537: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return a boolean from _cancel_sparc_auto_grating_offset().

This helper is annotated as -> bool, but it returns None. That makes it impossible for the outer future to observe a successful cancellation through the task canceller contract.

🤖 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/acq/align/goffset.py` around lines 532 - 537, The function
_cancel_sparc_auto_grating_offset currently sets future._task_state under
future._task_lock but does not return any value; change it to return a boolean
indicating success of the cancellation (e.g., after acquiring future._task_lock
and setting future._task_state = CANCELLED, return True). Ensure you keep the
existing lock usage and symbol names (future._task_lock, future._task_state,
CANCELLED) so callers relying on the canceller contract receive a True/False
result.

272-278: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Always roll back the probe move in estimate_goffset_scale().

If detector.data.get() or find_peak_position() raises after the temporary moveRelSync, the spectrograph stays at the probe offset and the rest of the alignment continues from the wrong hardware state.

🤖 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/acq/align/goffset.py` around lines 272 - 278, The temporary probe
move in estimate_goffset_scale() must always be rolled back even if
detector.data.get() or find_peak_position() throws; wrap the move-and-measure
sequence (spgr.moveRelSync({"goffset": actual_delta}), detector.data.get(...),
find_peak_position(...)) in a try/finally and perform
spgr.moveRelSync({"goffset": -actual_delta}) in the finally block so the
spectrograph state is restored regardless of errors, keeping existing variables
like actual_delta and using the same spgr.moveRelSync call for rollback.
🤖 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/acq/align/test/goffset_alignment_test.py`:
- Around line 17-23: The test top-level import list references acquire_peak
which no longer exists in odemis.acq.align.goffset; update the import line in
goffset_alignment_test.py to remove acquire_peak (or replace it with the current
equivalent function name if renamed) so the module imports only existing symbols
(e.g., keep find_peak_position, estimate_goffset_scale,
sparc_auto_grating_offset, auto_align_grating_detector_offsets or the correct
replacement); ensure the import names exactly match the function names defined
in odemis.acq.align.goffset to avoid ImportError during test discovery.
- Around line 10-28: The test uses odemis.__file__ when building CONFIG_PATH but
only does "from odemis import model, acq", so add an explicit "import odemis"
(or change CONFIG_PATH to use model.__file__) so the package name is bound;
update the imports at the top to include "import odemis" and leave CONFIG_PATH
and SPARC_CONFIG intact, ensuring references to CONFIG_PATH, SPARC_CONFIG, and
the existing imported symbols (model, acq) remain correct.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 318-338: Replace uses of the non-existent attribute self.ccd with
the fixture-created self.detector: update the detectors list (currently
"detectors = [self.ccd, spccd]") to use self.detector, and update the membership
assertions that reference self.ccd.name to reference self.detector.name so the
test uses the correct fixture-created detector in
auto_align_grating_detector_offsets and subsequent checks.
- Line 180: The test calls sparc_auto_grating_offset with an unsupported keyword
single_detector_mode causing a TypeError; remove that argument (or replace it
with a supported parameter if intended) in the test invocation in
goffset_test.py so the call matches the function signature of
sparc_auto_grating_offset and lets the test execute normally.
- Around line 25-27: The test uses HOME_PATH and H5_FILE_2d_NO_PEAK hardcoded to
a user-specific home path; change these to repository-relative pathlib.Path
references by replacing HOME_PATH and H5_FILE_2d_NO_PEAK with a Path built from
the test file location (e.g. Path(__file__).resolve().parent) and then join the
relative fixture path using the / operator (e.g. Path(__file__).resolve().parent
/ "relative" / "path" / "to" / "grating 2 1024x256"); ensure H5_FILE_2d_NO_PEAK
is a pathlib.Path (not an os.path string) so downstream code uses Path methods
or cast to str only where required.
- Line 352: The test contains an incorrect assertion using the bound method
data.min instead of calling it, so update the assertion in goffset_test.py to
call data.min(): replace self.assertNotEqual(data.max(), data.min) with
self.assertNotEqual(data.max(), data.min()). Ensure any similar flat-image
checks in the same test function or nearby assertions also call .min() (and
.max() if needed) rather than referencing the method object.

In `@src/odemis/gui/cont/tabs/sparc2_align_tab.py`:
- Around line 1562-1564: The code creates and binds a new wx.Timer every time
the start path runs (creating multiple timers and EVT_TIMER handlers); modify
the logic so _progress_timer is allocated and bound only once (e.g., initialize
and Bind self._progress_timer to self._on_progress_timer in the constructor or
startup initialization), and change the start/cancel paths to reuse the existing
timer by calling Start/Stop on self._progress_timer (and call Stop and
optionally Destroy/unbind on explicit teardown) instead of creating a new
wx.Timer each click; update references to self._progress_timer, panel.Bind,
_on_progress_timer and Start/Stop accordingly.
- Line 48: The import path odemis.acq.align.goffset_ext is invalid; update the
import in sparc2_align_tab.py to import auto_align_grating_detector_offsets from
the actual module that defines it (either odemis.acq.align.goffset or
odemis.acq.align.goffset_alignment) — replace the faulty import with a direct
import of auto_align_grating_detector_offsets from the correct module (the one
where the function is implemented) so the tab can import at startup.
- Around line 1621-1636: The _on_auto_calibrate_done callback must stop the wx
timer on the main thread and include type hints and a reStructuredText
docstring: wrap the existing self._progress_timer.Stop() call in wx.CallAfter
(as you already do for SetLabel/SetValue) to ensure thread safety when accessing
the wx.Timer named _progress_timer; add type hints for the parameter and return
(e.g., def _on_auto_calibrate_done(self, f: "concurrent.futures.Future") ->
None) and add a short rst-style docstring explaining this callback handles the
auto-calibration completion and updates UI elements (referencing method name
_on_auto_calibrate_done and attributes _progress_timer,
panel.btn_auto_calibrate, panel.gauge_auto_calibrate).

---

Duplicate comments:
In `@src/odemis/acq/align/goffset_alignment.py`:
- Around line 17-25: The cancellation checker _checkCancelled reads non-existent
attributes _function_lock/_function_state causing AttributeError; update it to
use the actual fields initialized by the future—use future._task_lock and
future._task_state (and keep the same CANCELLED comparison and CancelledError
raise) so auto_align_grating_detector_offsets() can cancel cleanly.
- Around line 149-155: The loop currently only calls sparc_auto_grating_offset
and stores its result when selector is truthy, so in the allowed
single-detector/no-selector case the first grating (g0) never gets aligned or
added to results; change the logic so sparc_auto_grating_offset(spectrograph, d)
is invoked and awaited (via future._subfuture and .result()) regardless of
selector, but only call selector.moveAbsSync when selector is not None; ensure
results[(g0, d.name)] = success and the logging.info line run in both branches
so the alignment result for g0 is always recorded.
- Around line 45-48: The annotations use the forward reference 'Stream' and
cause F821; add a guarded import using typing.TYPE_CHECKING and import Stream
inside the TYPE_CHECKING block so the runtime is unchanged but the name is
available for linting/typing. Specifically, add "from typing import
TYPE_CHECKING" and then "if TYPE_CHECKING: from <appropriate_module> import
Stream" (replace <appropriate_module> with the module that defines Stream) near
the top of the file, and ensure the same guarded import covers the other
functions using 'Stream' (e.g., auto_align_grating_detector_offsets and the
functions around lines 93–97) so their annotations no longer raise F821.

In `@src/odemis/acq/align/goffset.py`:
- Around line 81-89: The centroid fallback uses a clipped weights array but
still multiplies window_idx by the original window_data; change the numerator to
use the clipped weights so the centroid uses only non-negative contributions:
when computing weighted_avg in the else branch replace the numerator window_idx
* window_data with window_idx * weights (refer to variables weights,
window_data, window_idx, weighted_avg and peak_idx) so the weighted average is
computed as sum(window_idx * weights)/sum(weights).
- Around line 566-569: The F821 errors come from using the forward-reference
type List['Stream'] in the signatures of auto_align_grating_detector_offsets
(and the similar function at ~610-614); fix by adding a guarded import: import
TYPE_CHECKING from typing and inside if TYPE_CHECKING: from <appropriate_module>
import Stream (or the correct module/class name where Stream is defined), so the
Stream symbol is available for type checkers but not at runtime; update both
function signatures locations to rely on that guarded import (or alternatively
enable postponed evaluation via from __future__ import annotations) to resolve
the lint/type errors.
- Around line 532-537: The function _cancel_sparc_auto_grating_offset currently
sets future._task_state under future._task_lock but does not return any value;
change it to return a boolean indicating success of the cancellation (e.g.,
after acquiring future._task_lock and setting future._task_state = CANCELLED,
return True). Ensure you keep the existing lock usage and symbol names
(future._task_lock, future._task_state, CANCELLED) so callers relying on the
canceller contract receive a True/False result.
- Around line 272-278: The temporary probe move in estimate_goffset_scale() must
always be rolled back even if detector.data.get() or find_peak_position()
throws; wrap the move-and-measure sequence (spgr.moveRelSync({"goffset":
actual_delta}), detector.data.get(...), find_peak_position(...)) in a
try/finally and perform spgr.moveRelSync({"goffset": -actual_delta}) in the
finally block so the spectrograph state is restored regardless of errors,
keeping existing variables like actual_delta and using the same spgr.moveRelSync
call for rollback.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 488-494: The test currently waits on the future returned by
sparc_auto_grating_offset(self.spgr, self.detector) and swallows all exceptions,
so it doesn't verify cancellation; modify the test to call cancel() on the
future (f.cancel()) after creating it, then assert that f.cancelled() is True
(or f.done()) and that calling f.result() raises the appropriate CancelledError
(use the async future/asyncio CancelledError or
concurrent.futures.CancelledError depending on which Future type
sparc_auto_grating_offset returns) instead of silently catching all exceptions.
- Around line 44-50: Uncomment and re-enable the tearDownClass class method so
the test suite restores spectrograph state after tests: implement a `@classmethod`
tearDownClass(cls) that calls cls.spgr.moveAbsSync(cls._original_position)
inside a try/except, logging any exception with logging.exception("Failed
restoring spectrograph position") to ensure the backend is returned to its
original position and tests remain order-independent; reference the existing
cls.spgr, cls._original_position, and tearDownClass identifiers when restoring
state.
- Around line 82-90: The test currently treats the return of
estimate_goffset_scale(self.spgr, self.detector) as a float but the function
returns a tuple (scale, p0, p1); update test_estimate_goffset_scale to unpack
the return like (scale, p0, p1) = estimate_goffset_scale(self.spgr,
self.detector) and then run the existing assertions (assertIsInstance(scale,
float), assertNotEqual(scale, 0.0), assertTrue(np.isfinite(scale))) against the
unpacked scale variable.
- Around line 147-156: The test sets delta = 0 so no misalignment occurs; change
delta to a non-zero value (e.g., a small positive int/float) so the move
actually shifts goffset, keep the existing calculation of current =
self.spgr.position.value["goffset"], goffset_max =
self.spgr.axes["goffset"].range[1], and direction = 1 if (current + delta <
goffset_max) else -1, then call self.spgr.moveRelSync({"goffset": delta *
direction}) as before so the test produces a real misalignment to verify
recovery.
🪄 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

Run ID: e7021f94-b4a6-4453-a5ca-13c7623d6a64

📥 Commits

Reviewing files that changed from the base of the PR and between 2825651 and d58b74f.

📒 Files selected for processing (5)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/goffset_alignment.py
  • src/odemis/acq/align/test/goffset_alignment_test.py
  • src/odemis/acq/align/test/goffset_test.py
  • src/odemis/gui/cont/tabs/sparc2_align_tab.py

Comment thread src/odemis/acq/align/test/goffset_alignment_test.py
Comment thread src/odemis/acq/align/test/goffset_alignment_test.py
Comment on lines +25 to +27
HOME_PATH = os.path.expanduser("~") + "/"
H5_FILE_2d_NO_PEAK = HOME_PATH + "development/odemis/grating 2 1024x256/"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the HDF5 fixtures repository-relative.

~/development/odemis/... only exists on one workstation, so this test will fail in CI and on other dev machines before it even reaches the assertions. Resolve the fixtures from the test file location instead.

As per coding guidelines, "For file paths, use pathlib.Path, unless interfacing with existing code that uses os.path strings".

🤖 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/acq/align/test/goffset_test.py` around lines 25 - 27, The test
uses HOME_PATH and H5_FILE_2d_NO_PEAK hardcoded to a user-specific home path;
change these to repository-relative pathlib.Path references by replacing
HOME_PATH and H5_FILE_2d_NO_PEAK with a Path built from the test file location
(e.g. Path(__file__).resolve().parent) and then join the relative fixture path
using the / operator (e.g. Path(__file__).resolve().parent / "relative" / "path"
/ "to" / "grating 2 1024x256"); ensure H5_FILE_2d_NO_PEAK is a pathlib.Path (not
an os.path string) so downstream code uses Path methods or cast to str only
where required.

Comment thread src/odemis/acq/align/test/goffset_test.py Outdated
Comment thread src/odemis/acq/align/test/goffset_test.py Outdated
Comment thread src/odemis/acq/align/test/goffset_test.py Outdated
Comment thread src/odemis/gui/cont/tabs/sparc2_align_tab.py Outdated
Comment thread src/odemis/gui/cont/tabs/sparc2_align_tab.py Outdated
Comment thread src/odemis/gui/cont/tabs/sparc2_align_tab.py
@yxkdejong yxkdejong force-pushed the add-automated-grating-alignment branch from d58b74f to 39c44f1 Compare May 7, 2026 12:19
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (9)
src/odemis/acq/align/test/goffset_test.py (6)

25-26: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use repository-relative fixture paths instead of ~/development/....

These fixtures only exist on one workstation, so both HDF5 tests fail before they exercise the code under test. Resolve them from Path(__file__) instead of a hardcoded home-directory path. As per coding guidelines, "For file paths, use pathlib.Path, unless interfacing with existing code that uses os.path strings".

🤖 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/acq/align/test/goffset_test.py` around lines 25 - 26, Replace the
hardcoded HOME_PATH and H5_FILE_2d_NO_PEAK string construction with
repository-relative pathlib resolution: use Path(__file__).resolve().parent (or
navigate from that directory) to build the HDF5 fixture path and assign it to
H5_FILE_2d_NO_PEAK as a Path object (or convert to str where needed); update any
code using HOME_PATH/H5_FILE_2d_NO_PEAK to use the new Path-based value so tests
no longer rely on ~/development/... and follow the project guideline to prefer
pathlib.

552-558: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Actually exercise the cancellation path.

This test waits on result() and swallows every exception, so it can pass on normal completion, timeout, or unrelated failures. Call cancel() and assert that result() raises CancelledError.

Possible fix
     def test_cancel(self):
         """
         Test cancelling alignment.
         """
         f = sparc_auto_grating_offset(self.spgr, self.detector)
-        # wait for the result or a timeout
-        try:
-            f.result(timeout=5)
-        except:
-            pass
+        self.assertTrue(f.cancel())
+        with self.assertRaises(CancelledError):
+            f.result(timeout=5)
         self.assertTrue(f.done())
🤖 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/acq/align/test/goffset_test.py` around lines 552 - 558, The test
currently swallows all exceptions from waiting on the future returned by
sparc_auto_grating_offset(self.spgr, self.detector) so it doesn't verify
cancellation; modify the test to explicitly cancel the future (call f.cancel()),
then assert the future reports cancellation (e.g., f.cancelled() or f.done())
and that calling f.result() raises asyncio.CancelledError (use an assertion like
assertRaises or context manager for CancelledError) instead of a blanket
try/except that swallows errors.

44-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-enable suite cleanup for the spectrograph state.

Leaving tearDownClass() commented out makes these hardware tests order-dependent and leaks the modified spectrograph position into later suites.

Possible fix
-    # `@classmethod`
-    # def tearDownClass(cls):
-    #     # restore original position
-    #     try:
-    #         cls.spgr.moveAbsSync(cls._original_position)
-    #     except Exception:
-    #         logging.exception("Failed restoring spectrograph position")
+    `@classmethod`
+    def tearDownClass(cls):
+        # restore original position
+        try:
+            cls.spgr.moveAbsSync(cls._original_position)
+        except Exception:
+            logging.exception("Failed restoring spectrograph position")
🤖 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/acq/align/test/goffset_test.py` around lines 44 - 50, Uncomment
and re-enable the class-level cleanup method tearDownClass so the spectrograph
position is restored after the test suite: implement/restore the previously
commented tearDownClass that calls cls.spgr.moveAbsSync(cls._original_position)
inside a try/except and uses logging.exception("Failed restoring spectrograph
position") on failure; ensure cls._original_position is captured during
setUpClass and that cls.spgr is available before attempting the move to avoid
AttributeError.

86-90: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Unpack estimate_goffset_scale() before asserting on scale.

The helper returns (scale, p0, p1), so scale is a tuple here and assertIsInstance(scale, float) will fail immediately.

Possible fix
-        scale = estimate_goffset_scale(self.spgr, self.detector)
+        scale, _p0, _p1 = estimate_goffset_scale(self.spgr, self.detector)
🤖 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/acq/align/test/goffset_test.py` around lines 86 - 90, The test is
treating the return of estimate_goffset_scale(self.spgr, self.detector) as a
float but the function returns a tuple (scale, p0, p1); update the call to
unpack the tuple (e.g., scale, p0, p1 = estimate_goffset_scale(...)) and then
run the existing assertions against scale (assertIsInstance(scale, float),
assertNotEqual(scale, 0.0), assertTrue(np.isfinite(scale))). Ensure you
reference estimate_goffset_scale and the local names scale, p0, p1 when making
the change.

337-337: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use self.detector, not undefined self.ccd.

setUpClass() creates self.detector; this assertion will raise AttributeError as written.

Possible fix
-        self.assertIn(self.ccd.name, dets_for_first_grating)
+        self.assertIn(self.detector.name, dets_for_first_grating)
🤖 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/acq/align/test/goffset_test.py` at line 337, The test uses an
undefined attribute self.ccd causing AttributeError; change the assertion in the
test (the line asserting membership of detector name in dets_for_first_grating)
to reference the correctly initialized attribute self.detector (i.e., use
self.detector.name) so the test uses the object created in setUpClass() instead
of the nonexistent self.ccd.

147-155: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test never creates the misalignment it claims to verify.

delta = 0 makes Line 155 a no-op, so the test can pass even if recovery from a real misalignment regresses.

🤖 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/acq/align/test/goffset_test.py` around lines 147 - 155, The test
sets delta = 0 so no misalignment occurs; change delta to a non-zero value (for
example 1 or the goffset axis step like self.spgr.axes["goffset"].step) so the
subsequent spgr.moveRelSync({"goffset": delta * direction}) actually moves the
axis and creates the intended misalignment; keep the existing bounds check using
current, goffset_max and direction to avoid moving out of range.
src/odemis/acq/align/goffset.py (3)

533-538: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return a boolean from _cancel_sparc_auto_grating_offset().

This canceller currently returns None, so callers can observe cancel() as a failure even though _task_state was flipped to CANCELLED.

Possible fix
 def _cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture) -> bool:
     """
     Canceller of _do_sparc_auto_grating_offset task.
     """
     with future._task_lock:
+        if future._task_state == FINISHED:
+            return False
         future._task_state = CANCELLED
+    return 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/acq/align/goffset.py` around lines 533 - 538, The canceller
_cancel_sparc_auto_grating_offset currently sets future._task_state to CANCELLED
under future._task_lock but returns None; change it to return a boolean success
value (e.g., return True) after successfully setting the state so callers see
cancellation succeeded. Locate _cancel_sparc_auto_grating_offset (signature: def
_cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture) -> bool),
keep the locking and state assignment using future._task_lock and CANCELLED,
then add a return True at the end (and optionally return False on any
early-failure path if added).

273-279: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Always roll back the probe move in estimate_goffset_scale().

If detector.data.get() or find_peak_position() raises after Line 274, the spectrograph stays at the temporary test offset and the next calibration step starts from mutated hardware state.

Possible fix
     # move and measure
     spgr.moveRelSync({"goffset": actual_delta})
-    data1 = detector.data.get(asap=False)
-    p1 = find_peak_position(data1)
-
-    # return back to start
-    spgr.moveRelSync({"goffset": -actual_delta})
+    try:
+        data1 = detector.data.get(asap=False)
+        p1 = find_peak_position(data1)
+    finally:
+        # return back to start
+        spgr.moveRelSync({"goffset": -actual_delta})
🤖 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/acq/align/goffset.py` around lines 273 - 279, In
estimate_goffset_scale(), ensure the temporary probe move via
spgr.moveRelSync({"goffset": actual_delta}) is always undone even if
detector.data.get() or find_peak_position() raises: wrap the measurement steps
(detector.data.get, find_peak_position) in a try/finally and call
spgr.moveRelSync({"goffset": -actual_delta}) in the finally block so the
spectrograph is restored to its original goffset before re-raising or returning;
reference the existing symbols estimate_goffset_scale, spgr.moveRelSync,
detector.data.get, and find_peak_position to locate where to add the
try/finally.

84-90: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the clipped weights consistently in the centroid fallback.

Line 90 still mixes window_data in the numerator with weights in the denominator. If the fitting window contains negative baseline values, the fallback centroid can shift away from the actual positive lobe and steer the alignment the wrong way.

Possible fix
     if weights.sum() == 0:
         weighted_avg = float(peak_idx)
         logging.info("Weighted average fallback: all window data <= 0, using peak_idx=%d as estimate", peak_idx)
     else:
-        weighted_avg = float(numpy.sum(window_idx * window_data) / numpy.sum(weights))
+        weighted_avg = float(numpy.sum(window_idx * weights) / numpy.sum(weights))
🤖 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/acq/align/goffset.py` around lines 84 - 90, The centroid fallback
mixes raw window_data in the numerator with clipped weights in the denominator;
change the numerator to use the same clipped weights (i.e., compute weighted_avg
= float(numpy.sum(window_idx * weights) / numpy.sum(weights)) or window_idx *
clipped_weights) so both numerator and denominator use the same weights variable
(referencing weights, window_idx, peak_idx, weighted_avg) and preserve the
zero-sum check that sets weighted_avg = float(peak_idx) when weights.sum() == 0.
🤖 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/acq/align/goffset.py`:
- Around line 766-778: The finally block must make hardware restores
best-effort: wrap the spectrograph.restore call
(spectrograph.moveAbsSync(original_pos)) in its own try/except that catches
Exception and calls logging.exception("Failed to restore spectrograph position
during alignment cleanup"); do the same for
selector.moveAbsSync(original_selector) only if selector is truthy, logging a
selector-specific message; ensure these try/excepts prevent exceptions from
propagating so the subsequent with future._task_lock: future._task_state =
FINISHED always executes (i.e., leave the FINISHED assignment outside those try
blocks or in its own guaranteed path).

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 279-280: The test calls to auto_align_grating_detector_offsets no
longer match its signature; update each invocation (e.g., the calls on lines
referenced) to pass the required arguments opm, align_mode, and bl — for example
provide the test's corresponding objects (e.g., self.opm, self.align_mode,
self.bl or the proper fixtures) along with spectrograph=self.spgr,
detectors=self.detector, selector=self.selector so the function is invoked with
the new parameters.

---

Duplicate comments:
In `@src/odemis/acq/align/goffset.py`:
- Around line 533-538: The canceller _cancel_sparc_auto_grating_offset currently
sets future._task_state to CANCELLED under future._task_lock but returns None;
change it to return a boolean success value (e.g., return True) after
successfully setting the state so callers see cancellation succeeded. Locate
_cancel_sparc_auto_grating_offset (signature: def
_cancel_sparc_auto_grating_offset(future: model.ProgressiveFuture) -> bool),
keep the locking and state assignment using future._task_lock and CANCELLED,
then add a return True at the end (and optionally return False on any
early-failure path if added).
- Around line 273-279: In estimate_goffset_scale(), ensure the temporary probe
move via spgr.moveRelSync({"goffset": actual_delta}) is always undone even if
detector.data.get() or find_peak_position() raises: wrap the measurement steps
(detector.data.get, find_peak_position) in a try/finally and call
spgr.moveRelSync({"goffset": -actual_delta}) in the finally block so the
spectrograph is restored to its original goffset before re-raising or returning;
reference the existing symbols estimate_goffset_scale, spgr.moveRelSync,
detector.data.get, and find_peak_position to locate where to add the
try/finally.
- Around line 84-90: The centroid fallback mixes raw window_data in the
numerator with clipped weights in the denominator; change the numerator to use
the same clipped weights (i.e., compute weighted_avg =
float(numpy.sum(window_idx * weights) / numpy.sum(weights)) or window_idx *
clipped_weights) so both numerator and denominator use the same weights variable
(referencing weights, window_idx, peak_idx, weighted_avg) and preserve the
zero-sum check that sets weighted_avg = float(peak_idx) when weights.sum() == 0.

In `@src/odemis/acq/align/test/goffset_test.py`:
- Around line 25-26: Replace the hardcoded HOME_PATH and H5_FILE_2d_NO_PEAK
string construction with repository-relative pathlib resolution: use
Path(__file__).resolve().parent (or navigate from that directory) to build the
HDF5 fixture path and assign it to H5_FILE_2d_NO_PEAK as a Path object (or
convert to str where needed); update any code using HOME_PATH/H5_FILE_2d_NO_PEAK
to use the new Path-based value so tests no longer rely on ~/development/... and
follow the project guideline to prefer pathlib.
- Around line 552-558: The test currently swallows all exceptions from waiting
on the future returned by sparc_auto_grating_offset(self.spgr, self.detector) so
it doesn't verify cancellation; modify the test to explicitly cancel the future
(call f.cancel()), then assert the future reports cancellation (e.g.,
f.cancelled() or f.done()) and that calling f.result() raises
asyncio.CancelledError (use an assertion like assertRaises or context manager
for CancelledError) instead of a blanket try/except that swallows errors.
- Around line 44-50: Uncomment and re-enable the class-level cleanup method
tearDownClass so the spectrograph position is restored after the test suite:
implement/restore the previously commented tearDownClass that calls
cls.spgr.moveAbsSync(cls._original_position) inside a try/except and uses
logging.exception("Failed restoring spectrograph position") on failure; ensure
cls._original_position is captured during setUpClass and that cls.spgr is
available before attempting the move to avoid AttributeError.
- Around line 86-90: The test is treating the return of
estimate_goffset_scale(self.spgr, self.detector) as a float but the function
returns a tuple (scale, p0, p1); update the call to unpack the tuple (e.g.,
scale, p0, p1 = estimate_goffset_scale(...)) and then run the existing
assertions against scale (assertIsInstance(scale, float), assertNotEqual(scale,
0.0), assertTrue(np.isfinite(scale))). Ensure you reference
estimate_goffset_scale and the local names scale, p0, p1 when making the change.
- Line 337: The test uses an undefined attribute self.ccd causing
AttributeError; change the assertion in the test (the line asserting membership
of detector name in dets_for_first_grating) to reference the correctly
initialized attribute self.detector (i.e., use self.detector.name) so the test
uses the object created in setUpClass() instead of the nonexistent self.ccd.
- Around line 147-155: The test sets delta = 0 so no misalignment occurs; change
delta to a non-zero value (for example 1 or the goffset axis step like
self.spgr.axes["goffset"].step) so the subsequent spgr.moveRelSync({"goffset":
delta * direction}) actually moves the axis and creates the intended
misalignment; keep the existing bounds check using current, goffset_max and
direction to avoid moving out of range.
🪄 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

Run ID: 25d1a706-7199-4b8e-b40e-36971f70c997

📥 Commits

Reviewing files that changed from the base of the PR and between d58b74f and 450a048.

📒 Files selected for processing (3)
  • src/odemis/acq/align/goffset.py
  • src/odemis/acq/align/test/goffset_test.py
  • src/odemis/gui/cont/tabs/sparc2_align_tab.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/odemis/gui/cont/tabs/sparc2_align_tab.py

Comment on lines +766 to +778
finally:
logging.info("Turning off brightlight")
try:
bl.power.value = (bl.power.range[0])
except Exception:
logging.exception("Failed to turn off the light during alignment cleanup")

spectrograph.moveAbsSync(original_pos)
if selector:
selector.moveAbsSync(original_selector)

with future._task_lock:
future._task_state = FINISHED
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the final hardware restore best-effort.

If either restore move fails here, the finally block raises a new exception, masks the original task outcome, and skips the FINISHED state update. The light shutdown above already handles cleanup this way; the position restores should do the same.

Possible fix
     finally:
         logging.info("Turning off brightlight")
         try:
             bl.power.value = (bl.power.range[0])
         except Exception:
             logging.exception("Failed to turn off the light during alignment cleanup")
 
-        spectrograph.moveAbsSync(original_pos)
-        if selector:
-            selector.moveAbsSync(original_selector)
+        try:
+            spectrograph.moveAbsSync(original_pos)
+        except Exception:
+            logging.exception("Failed to restore spectrograph position")
+        if selector:
+            try:
+                selector.moveAbsSync(original_selector)
+            except Exception:
+                logging.exception("Failed to restore selector position")
 
         with future._task_lock:
             future._task_state = FINISHED
🤖 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/acq/align/goffset.py` around lines 766 - 778, The finally block
must make hardware restores best-effort: wrap the spectrograph.restore call
(spectrograph.moveAbsSync(original_pos)) in its own try/except that catches
Exception and calls logging.exception("Failed to restore spectrograph position
during alignment cleanup"); do the same for
selector.moveAbsSync(original_selector) only if selector is truthy, logging a
selector-specific message; ensure these try/excepts prevent exceptions from
propagating so the subsequent with future._task_lock: future._task_state =
FINISHED always executes (i.e., leave the FINISHED assignment outside those try
blocks or in its own guaranteed path).

Comment on lines +279 to +280
f = auto_align_grating_detector_offsets(spectrograph=self.spgr, detectors=self.detector, selector=self.selector)
res = f.result(timeout=900)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

These calls no longer match auto_align_grating_detector_offsets().

The helper now requires opm, align_mode, and bl, so each of these invocations raises TypeError before the test reaches its assertions.

Also applies to: 299-300, 321-322

🤖 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/acq/align/test/goffset_test.py` around lines 279 - 280, The test
calls to auto_align_grating_detector_offsets no longer match its signature;
update each invocation (e.g., the calls on lines referenced) to pass the
required arguments opm, align_mode, and bl — for example provide the test's
corresponding objects (e.g., self.opm, self.align_mode, self.bl or the proper
fixtures) along with spectrograph=self.spgr, detectors=self.detector,
selector=self.selector so the function is invoked with the new parameters.

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