Merge branch 'stage-dir-calibrate' into 'v3'

Stage direction calibrate

Closes #626 and #647

See merge request openflexure/openflexure-microscope-server!633
This commit is contained in:
Julian Stirling 2026-07-07 17:28:11 +00:00
commit 6e19c73dd3
10 changed files with 240 additions and 4 deletions

View file

@ -166,6 +166,15 @@ class BaseStage(lt.Thing):
)
"""Used to convert coordinates between the program frame and the hardware frame."""
@lt.property
def calibration_required(self) -> bool:
"""Whether the stage requires calibration.
Currently the only calibration method for the stage is the z-gear rotation
direction in the sangaboard stage.
"""
return False
def update_position(self) -> None:
"""Update the position property from the stage."""
# Copy the position before the move.

View file

@ -80,6 +80,37 @@ class SangaboardThing(BaseStage):
)
"""Used to convert coordinates between the program frame and the hardware frame."""
z_axis_calibrated: bool = lt.setting(default=False, readonly=True)
@lt.property
def calibration_required(self) -> bool:
"""Whether the stage requires calibration.
Return False only if z-direction has been calibrated.
"""
return not self.z_axis_calibrated
@lt.action
def calibrate_z_direction(
self, positive_motion: Literal["clockwise", "anti-clockwise"]
) -> None:
"""Calibrate the z motor direction based on the gear rotation direction.
Positive z motion should turn the exposed gear anti-clockwise. Calling this
action marks the z motor as calibrated.
:param positive_motion: The direction the gear moved during pisitive z motion.
"""
if positive_motion == "clockwise":
# Wrong direction, mark as such.
self.invert_axis_direction(axis="z")
elif positive_motion != "anti-clockwise":
raise ValueError(
"Motion should be reported as either 'clockwise' or 'anti-clockwise'."
)
# Set z_axis_calibrated to true once this function is called.
self.z_axis_calibrated = True
def _hardware_update_position(self) -> None:
"""Read position from the stage and set internal attribute _hardware_position.

View file

@ -603,10 +603,13 @@ def test_thing_description_equivalence(dummy_stage, mocker):
dummy_properties = set(dummy_td.properties.keys())
sanga_td = create_thing_without_server(SangaboardThing).thing_description()
# Remove known extra actions
sanga_actions = list(sanga_td.actions.keys())
sanga_actions = set(sanga_actions)
sanga_properties = set(sanga_td.properties.keys())
# Sangaboard has one extra action for calibrating z-motor direction. This is
# handled in the webapp calibration wizard, and an extra setting for persisting it
sanga_extra_actions = {"calibrate_z_direction"}
sanga_extra_properties = {"z_axis_calibrated"}
sanga_actions = set(sanga_td.actions.keys()) - sanga_extra_actions
sanga_properties = set(sanga_td.properties.keys()) - sanga_extra_properties
assert sanga_actions == dummy_actions == base_actions
assert sanga_properties == dummy_properties == base_properties

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -28,6 +28,7 @@
<script>
import singleStepTask from "./calibrationWizardComponents/singleStepTask.vue";
import welcomeStep from "./calibrationWizardComponents/welcomeStep.vue";
import zMotorCalibrationTask from "./calibrationWizardComponents/zMotorCalibrationTask.vue";
import cameraCalibrationTask from "./calibrationWizardComponents/cameraCalibrationTask.vue";
import cameraStageMappingTask from "./calibrationWizardComponents/cameraStageMappingTask.vue";
import finalStep from "./calibrationWizardComponents/finalStep.vue";
@ -68,6 +69,7 @@ export default {
this.$refs["calibrationModalEl"].addEventListener("hidden", this.onHide);
// Check which Things are available on mount.
const allCalibrationTasks = {
stage: markRaw(zMotorCalibrationTask),
camera: markRaw(cameraCalibrationTask),
camera_stage_mapping: markRaw(cameraStageMappingTask),
};

View file

@ -29,6 +29,7 @@ gets very confusing.
:key="stepIndex"
@awaiting-user="handleAwaitingUser"
@prevent-navigation="handlePreventNavigation"
@advance="nextStep"
/>
<p class="uk-text-right">
<button

View file

@ -0,0 +1,29 @@
<template>
<singleStepTask
title="Stage Calibration"
:step-component="zMotorDirectionStep"
@next="$emit('next')"
@back="$emit('back')"
/>
</template>
<script>
import singleStepTask from "./singleStepTask.vue";
import zMotorDirectionStep from "./zMotorDirectionStep.vue";
export default {
name: "ZMotorCalibrationTask",
components: {
singleStepTask,
},
emits: ["next", "back"],
data() {
return {
zMotorDirectionStep,
};
},
};
</script>

View file

@ -0,0 +1,148 @@
<template>
<div>
<p>Look at the exposed z gear from above while holding the <b>Turn z motor</b> button.</p>
<div class="uk-margin uk-flex uk-flex-center">
<button
class="uk-button uk-button-primary jog-z-btn"
type="button"
@pointerdown="jogZ"
@pointerup="jogZStop"
@pointercancel="jogZStop"
>
<span>Turn z motor</span>
</button>
</div>
<p>Which way does the z gear turn?</p>
<!-- Direction selection -->
<div class="uk-margin uk-flex">
<!-- Anti-clockwise column -->
<div class="uk-width-1-2 dir-option">
<img
src="/direction_AC.png"
alt="Anti-clockwise"
class="dir-image clickable"
@click="reportDirection('anti-clockwise')"
/>
<button
class="uk-button uk-button-default uk-width-1-1 uk-button-primary"
type="button"
@click="reportDirection('anti-clockwise')"
>
Anti-clockwise
</button>
</div>
<!-- Clockwise column -->
<div class="uk-width-1-2 dir-option">
<img
src="/direction_CW.png"
alt="Clockwise"
class="dir-image clickable"
@click="reportDirection('clockwise')"
/>
<button
class="uk-button uk-button-default uk-width-1-1 uk-button-primary"
type="button"
@click="reportDirection('clockwise')"
>
Clockwise
</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "ZMotorDirectionStep",
emits: ["advance", "awaiting-user"],
data: function () {
return {
jogIntervalId: null,
jogDistance: 100,
jogTime: 300,
};
},
async mounted() {
this.checkCalibrationState();
},
methods: {
jogZ(pointerEvent) {
if (pointerEvent.button !== 0) return;
if (this.jogIntervalId) {
clearInterval(this.jogIntervalId);
}
pointerEvent.target.setPointerCapture(pointerEvent.pointerId);
let invokeJog = () => this.invokeAction("stage", "jog", { z: this.jogDistance });
invokeJog();
this.jogIntervalId = setInterval(invokeJog, this.jogTime);
},
jogZStop() {
if (this.jogIntervalId) {
clearInterval(this.jogIntervalId);
}
this.invokeAction("stage", "jog", { stop: true });
},
/**
* Report the direction to the microscope then advance to next pane in wizard.
*/
async reportDirection(direction) {
await this.invokeAction("stage", "calibrate_z_direction", { positive_motion: direction });
this.$emit("advance");
},
async checkCalibrationState() {
let needsCal = await this.readThingProperty("stage", "calibration_required");
if (needsCal) {
// If it needs calibration check the relevant calibration action exists.
const actions = this.thingDescription("stage").actions;
if (!("calibrate_z_direction" in actions)) {
console.error(
"Stage is requesting calibration but has no 'calibrate_z_direction' action",
);
needsCal = false;
}
}
this.$emit("awaiting-user", needsCal);
},
},
};
</script>
<style lang="less">
.jog-z-btn {
display: flex;
align-items: center;
padding: 0 30px;
gap: 4px;
}
.dir-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 5px;
}
.dir-image {
width: 70%;
height: auto;
}
.clickable {
cursor: pointer;
}
.dir-option button {
text-align: center;
}
</style>

View file

@ -133,4 +133,17 @@ export const componentOverrides = {
"cameraCalibrationSettings.vue": {
props: { cameraUri: "" },
},
"zMotorDirectionStep.vue": {
mocks: {
thingDescription: () => ({
properties: {
test_property_1: {},
test_property_2: {},
},
actions: {
calibrate_z_direction: {},
},
}),
},
},
};