diff --git a/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java b/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java index 86edba9..72074cd 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java +++ b/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java @@ -14,7 +14,7 @@ public class LightSheetManagerPlugin implements MenuPlugin, SciJavaPlugin { public static final String copyright = "Applied Scientific Instrumentation (ASI), 2022-2026"; public static final String description = "A plugin to control various types of light sheet microscopes."; public static final String menuName = "Light Sheet Manager"; - public static final String version = "0.7.6"; + public static final String version = "0.7.7"; private Studio studio_; private LightSheetManager model_; diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java index aa2649c..e31fdd8 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java @@ -17,6 +17,7 @@ import org.micromanager.data.internal.PropertyKey; import org.micromanager.lightsheetmanager.LightSheetManager; import org.micromanager.lightsheetmanager.api.AcquisitionManager; +import org.micromanager.lightsheetmanager.api.TimingSettings; import org.micromanager.lightsheetmanager.api.data.AcquisitionMode; import org.micromanager.lightsheetmanager.api.internal.ScapeAcquisitionSettings; import org.micromanager.lightsheetmanager.gui.tabs.acquisition.DurationPanel; @@ -145,6 +146,74 @@ protected boolean validateCameraFrameSizes() { return false; } + /** + * Refuses to arm when the computed slice timing is not physically realizable. + * + *

In EDGE mode the solver derives the camera exposure by subtracting the camera's reset and + * readout time from the sample exposure, and nothing stops the result going negative. A sample + * exposure shorter than the camera needs to reset and read out therefore yields a negative + * exposure, which is handed to {@code setExposure()} and accepted by the device without + * complaint, so the run proceeds and produces nothing usable. + * + *

Deliberately conservative: only the two values that are meaningless at or below zero are + * required to be positive. Delays and the remaining durations are allowed to be zero. + * + *

Note "minimize slice period" does not rescue this: it is only consulted on the galvo path + * ({@code getTimingFromPeriodAndLightExposure}), while the stage-scan path derives the exposure + * as sample exposure minus the camera's reset plus readout regardless. + * + *

Called from both geometry engines' {@code setup()} before any hardware is touched. + * + * @return true if the timing is usable; false to abort setup + */ + protected boolean validateSliceTiming() { + final String problems = describeUnusableTiming(acqSettings_.timing()); + if (problems == null) { + return true; + } + model_.logging().reportError("The computed slice timing cannot be used: " + problems + + ".\n\nThis happens when the sample exposure is shorter than the time the camera " + + "needs to reset and read out, so the acquisition was not started.\n\nRaise the " + + "sample exposure, or shorten the readout with a smaller camera ROI or higher " + + "binning, then try again."); + return false; + } + + /** + * Describes what is wrong with a computed timing schedule, or returns null when it is usable. + * + * @param timing the computed timing settings + * @return a comma-separated description with no trailing punctuation, or null if usable + */ + private static String describeUnusableTiming(final TimingSettings timing) { + if (timing == null) { + return "no timing has been computed"; + } + final StringBuilder problems = new StringBuilder(); + // must be strictly positive: a slice that exposes for zero time images nothing + appendTimingProblem(problems, "camera exposure", timing.cameraExposure(), true); + appendTimingProblem(problems, "slice duration", timing.sliceDuration(), true); + // may legitimately be zero, so only reject negatives + appendTimingProblem(problems, "scan duration", timing.scanDuration(), false); + appendTimingProblem(problems, "laser trigger duration", timing.laserTriggerDuration(), false); + appendTimingProblem(problems, "camera trigger duration", timing.cameraTriggerDuration(), false); + appendTimingProblem(problems, "delay before scan", timing.delayBeforeScan(), false); + appendTimingProblem(problems, "delay before laser", timing.delayBeforeLaser(), false); + appendTimingProblem(problems, "delay before camera", timing.delayBeforeCamera(), false); + return problems.length() == 0 ? null : problems.toString(); + } + + private static void appendTimingProblem(final StringBuilder problems, final String name, + final double valueMs, final boolean mustBePositive) { + if (mustBePositive ? valueMs > 0.0 : valueMs >= 0.0) { + return; + } + if (problems.length() > 0) { + problems.append(", "); + } + problems.append(name).append(" is ").append(valueMs).append(" ms"); + } + public AcquisitionEngine(final LightSheetManager model) { model_ = Objects.requireNonNull(model); studio_ = model.studio(); diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java index f344960..8fa38c1 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java @@ -85,6 +85,12 @@ boolean setup() { return false; // early exit => cameras disagree on frame size } + // an impossible slice period yields a negative camera exposure, which the device accepts + // without complaint and then images nothing; refuse rather than run it + if (!validateSliceTiming()) { + return false; // early exit => computed timing is not realizable + } + return true; } diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java index eac12c7..efaff40 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -78,6 +78,15 @@ boolean setup() { // make settings current updateSettings(); + // initialize stage scanning so we can restore state + // set before any validation below can return early: finish() runs on every exit from setup(), + // including the refusals, and restores these unconditionally. Initialized further down they + // are still at their field defaults on those paths, so finish() writes 0.0. The stage rejects + // that for speed but ACCEPTS it for acceleration, leaving it unable to move properly. + xyPosUm_ = new Point2D.Double(); + origSpeedX_ = 1.0; // don't want 0 in case something goes wrong + origAccelX_ = 1.0; // don't want 0 in case something goes wrong + // fail before touching any hardware: the datastore is written by finish(), so an unusable // save location would otherwise cost a full acquisition before it is discovered if (!validateSaveLocation()) { @@ -99,6 +108,12 @@ boolean setup() { return false; // early exit => cameras disagree on frame size } + // an impossible slice period yields a negative camera exposure, which the device accepts + // without complaint and then images nothing; refuse rather than run it + if (!validateSliceTiming()) { + return false; // early exit => computed timing is not realizable + } + // // check pixel size // if (core_.getPixelSizeUm() < 1e-6) { // studio_.logs().showError( @@ -151,11 +166,6 @@ boolean run() { // used to detect if the plugin is using ASI hardware final boolean isUsingPLC = model_.devices().isUsingPLogic(); - // initialize stage scanning so we can restore state - xyPosUm_ = new Point2D.Double(); - origSpeedX_ = 1.0; // don't want 0 in case something goes wrong - origAccelX_ = 1.0; // don't want 0 in case something goes wrong - // make sure stage scan is supported if selected if (acqSettings_.stageScan().enabled()) { final ASIXYStage xyStage = model_.devices().device("SampleXY"); @@ -1450,6 +1460,17 @@ public DefaultTimingSettings.Builder getTimingFromPeriodAndLightExposure() { @Override public void updateDurationLabels() { + // TODO(IMMUTABLE-RUN): sync the snapshot to the builder before recomputing; real fix = the + // derive/arm split so the timing math reads one source. + // + // Every caller has just written the user's edit to the BUILDER, but the timing math reads + // the frozen snapshot (getTimingFromExposure and getTimingFromPeriodAndLightExposure both + // take sampleExposure, cameraMode, period and stageScan.enabled off acqSettings_). Without + // this sync the recompute runs on the previous edit: for most controls that leaves the + // labels one edit stale, and for the acquisition-mode dropdown it selects the wrong branch + // outright, computing galvo timing for a stage-scan run. That pair is then frozen and the + // next Run fails validation comparing one mode's exposure against the other's duration. + model_.acquisitions().updateSettings(); model_.acquisitions().recalculateSliceTiming(); model_.acquisitions().updateSettings(); // update durations now that settings are current