diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java b/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java index fc26338..778f82c 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/components/TextField.java @@ -67,6 +67,17 @@ public void changedUpdate(DocumentEvent e) { getDocument().addDocumentListener(docListener); } + /** + * Marks the field valid or invalid by setting its background color. + *

+ * The caller decides what valid means and owns any tooltip explaining it. + * + * @param isValid {@code false} to show the error color + */ + public void setValid(final boolean isValid) { + setBackground(isValid ? defaultColor_ : ERROR_COLOR); + } + /** * Returns {@code true} if the filename is valid on Windows. * diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java index bb10243..8c6fcf4 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/acquisition/SavePanel.java @@ -97,7 +97,7 @@ public void createUserInterface() { txtSaveDirectory_ = new TextField(); txtSaveDirectory_.setEditable(false); txtSaveDirectory_.setColumns(18); - txtSaveDirectory_.setForeground(Color.BLACK); + txtSaveDirectory_.setForeground(Color.WHITE); txtSaveDirectory_.setText(acqSettings.saveDirectory()); txtSaveFileName_ = new TextField(); @@ -118,6 +118,7 @@ public void createUserInterface() { btnLoadSettings_ = new Button("Load", 60, 20); btnConvertSettings_ = new Button("Convert", 72, 20); + txtSaveDirectory_.setToolTipText("The directory to save images to."); btnBrowse_.setToolTipText("Select the save directory with the file browser."); btnOpen_.setToolTipText("Open the file explorer to the save directory."); btnSaveSettings_.setToolTipText("Save the current acquisition settings to JSON."); @@ -149,9 +150,13 @@ public void createEventHandlers() { if (result != null) { model_.acquisitions().settingsBuilder().saveDirectory(result.toString()); txtSaveDirectory_.setText(result.toString()); + validateSaveDirectory(); } }); + // flag a saved directory that no longer exists as soon as the plugin opens + validateSaveDirectory(); + // use the text field so we don't need to update settings btnOpen_.registerListener( () -> openDirectory(txtSaveDirectory_.getText())); @@ -214,6 +219,31 @@ public void createEventHandlers() { }); } + // Colors the save directory field and explains why in its tooltip when the path is unusable. + // Only called where the value arrives from outside the field (plugin load, browse, settings + // change); this touches the filesystem, and checking a dead network path can block the EDT. + private void validateSaveDirectory() { + final String path = txtSaveDirectory_.getText(); + final String problem; + if (path == null || path.trim().isEmpty()) { + problem = "The save directory is not set."; + } else { + final File directory = new File(path); + if (!directory.exists()) { + problem = "This directory does not exist: " + path; + } else if (!directory.isDirectory()) { + problem = "This is a file, not a directory: " + path; + } else if (!directory.canWrite()) { + problem = "This directory is not writable: " + path; + } else { + problem = null; + } + } + txtSaveDirectory_.setValid(problem == null); + txtSaveDirectory_.setToolTipText( + problem == null ? "The directory to save images to." : problem); + } + // Opens the file explorer to the save directory private void openDirectory(final String path) { final File directory = new File(path); @@ -223,21 +253,22 @@ private void openDirectory(final String path) { try { desktop.open(directory); } catch (IOException e) { - model_.studio().logs().logError( - "Could not open the save directory."); + model_.studio().logs().showError( + "Could not open the save directory:\n\n" + path); } } else { - model_.studio().logs().logError( - "Desktop is not supported on this platform."); + model_.studio().logs().showError( + "Opening a file explorer is not supported on this platform."); } } else { - model_.studio().logs().logError("Directory does not exist."); + model_.studio().logs().showError("The save directory does not exist:\n\n" + path); } } @Override public void onSettingsChanged(final AcquisitionSettings settings) { txtSaveDirectory_.setText(settings.saveDirectory()); + validateSaveDirectory(); txtSaveFileName_.setText(settings.saveNamePrefix()); cbxSaveMode_.setSelected(settings.saveMode()); cbxSaveWhileAcquiring_.setSelected(settings.isSavingImagesDuringAcquisition()); diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java index 60a29c0..bd33365 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java @@ -484,9 +484,9 @@ public boolean prepareControllerForAcquisitionSide( // The galvo/sheet offset must always land on the true imaging plane. For a stage scan the // piezo convention parks piezoCenter at home (~0); using that here would place the sheet on // the home plane instead of the imaging center, so the acquired volume is offset from the - // stage sweep -- the #404 "scan not centered" symptom. diSPIM 1.4 SCOPE never writes the - // galvo offset during a stage scan (it holds the value the Setup tab computed from the - // imaging center); LSM writes it at acq time, so derive it from the imaging center here. + // stage sweep. diSPIM 1.4 SCOPE never writes the galvo offset during a stage scan (it holds + // the value the Setup tab computed from the imaging center); LSM writes it at acq time, so + // derive it from the imaging center here. final double galvoCenter = settings.stageScan().enabled() ? imagingCenter : piezoCenter; double sliceCenter = (galvoCenter - sliceOffset) / sliceRate; @@ -727,7 +727,7 @@ public boolean setupHardwareChannelSwitching(final ScapeAcquisitionSettings sett // 2. (newer) with 7-channel TTL-triggered on PLogic card shared with single camera trigger output (i.e. not dual-view system) // however they share some things like using cells 17-24 and building a 3-input LUT which code is just copy/paste right now final boolean isSevenChannelShutter = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_SHUTTER; - final boolean isSevenChannelShutterTTL = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_SHUTTER; + final boolean isSevenChannelShutterTTL = plcLaser_.getShutterMode() == ASIPLogic.ShutterMode.SEVEN_CHANNEL_TTL_SHUTTER; if (isSevenChannelShutter) { // original special 7-channel case 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 a8c3586..8560152 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java @@ -24,6 +24,7 @@ import org.micromanager.lightsheetmanager.model.channels.ChannelSpec; import org.micromanager.lightsheetmanager.model.devices.cameras.CameraBase; +import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -56,6 +57,64 @@ public abstract class AcquisitionEngine implements AcquisitionManager, MMAcquist protected final LightSheetManager model_; + /** + * Validates that the acquisition can actually be written to disk, before anything is acquired. + *

+ * The images are written by {@code finish()}, i.e. only AFTER the run completes, so without this + * check an unusable save location is discovered at the very end and the data is lost. Observed + * 2026-07-27: a 41 s dual-camera run ended in "could not save the acquisition data to: + * D:\SCOPE\test\Test" because {@code D:\SCOPE\test} did not exist. The same path also silently + * drops {@code acq_settings.json} and {@code position_list.pos}, which are written up front. + *

+ * Called from both geometry engines' {@code setup()} before any hardware is touched, so a failure + * costs nothing and leaves the microscope untouched. + * + * @return true if saving is off, or the save location is usable; false to abort setup + */ + protected boolean validateSaveLocation() { + if (!acqSettings_.isSavingImagesDuringAcquisition()) { + return true; // not saving => nothing to validate + } + + final String saveNamePrefix = acqSettings_.saveNamePrefix(); + if (saveNamePrefix == null || saveNamePrefix.trim().isEmpty()) { + studio_.logs().showError("The save name prefix is empty.\n\n" + + "Set a name on the Datastore panel, or uncheck \"Save images during acquisition\"."); + return false; + } + + final String saveDirectory = acqSettings_.saveDirectory(); + if (saveDirectory == null || saveDirectory.trim().isEmpty()) { + studio_.logs().showError("The save directory is not set.\n\n" + + "Set a directory on the Datastore panel, or uncheck " + + "\"Save images during acquisition\"."); + return false; + } + + final File directory = new File(saveDirectory); + if (!directory.exists()) { + studio_.logs().showError("The save directory does not exist:\n\n" + saveDirectory + + "\n\nCreate it, or choose another directory on the Datastore panel."); + return false; + } + if (!directory.isDirectory()) { + studio_.logs().showError("The save directory is a file, not a directory:\n\n" + + saveDirectory); + return false; + } + // canWrite() is advisory on Windows (it reports the read-only attribute, not the ACL), so it + // catches the common cases without being authoritative; a real write failure still surfaces + // at finish(). Cheap enough to be worth keeping. + if (!directory.canWrite()) { + studio_.logs().showError("The save directory is not writable:\n\n" + saveDirectory); + return false; + } + + studio_.logs().logMessage("save location validated: " + saveDirectory + + File.separator + saveNamePrefix); + return true; + } + public AcquisitionEngine(final LightSheetManager model) { model_ = Objects.requireNonNull(model); studio_ = model.studio(); @@ -146,7 +205,9 @@ public Future requestRun(boolean speedTest) { try { if (!setup()) { - studio_.logs().showError("Error during setup!"); + // every setup() failure path already showed its own specific error, + // so log this rather than stacking a second dialog on top of it + studio_.logs().logMessage("Error during setup!"); return; // early exit => stop acquisition } } catch (Exception e) { 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 d72fdd7..01c43d0 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java @@ -65,6 +65,12 @@ boolean setup() { // make settings current updateSettings(); + // 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()) { + return false; // early exit => save location unusable + } + return true; } @@ -248,7 +254,7 @@ boolean run() { //////////// Acquisition hooks //////////////////// // These functions will be run on different threads during the acquisition process - // Hooks will run on the Acquisition Engine thread--the one that controls all hardware + // Hooks will run on the Acquisition Engine thread, the one that controls all hardware // TODO add any code that needs to be executed on the acquisition thread (i.e. the one // that controls hardware) 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 bbcadd1..907851d 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -81,6 +81,12 @@ boolean setup() { // make settings current updateSettings(); + // 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()) { + return false; // early exit => save location unusable + } + // // check pixel size // if (core_.getPixelSizeUm() < 1e-6) { // studio_.logs().showError( @@ -321,7 +327,7 @@ boolean run() { //////////// Acquisition hooks //////////////////// // These functions will be run on different threads during the acquisition process - // Hooks will run on the Acquisition Engine thread--the one that controls all hardware + // Hooks will run on the Acquisition Engine thread, the one that controls all hardware // TODO add any code that needs to be executed on the acquisition thread (i.e. the one // that controls hardware) @@ -628,7 +634,7 @@ public void close() { // software "Every Volume" multichannel submits ONE event // iterator PER channel, so AcqEngJ flushes a SequenceEnd between // channels (Engine.java:187) and the controller re-fires once per - // channel-volume -- mirroring 1.4's per-channel loop and LSM's own + // channel-volume, mirroring 1.4's per-channel loop and LSM's own // per-timepoint loop. Submitting all channels in a single iterator lets // AcqEngJ merge identical-preset channels into one sequence that fires the // controller once, collapsing the channel dimension (hang on hardware, @@ -643,7 +649,7 @@ public void close() { } else { // Hardware channel modes (SLICE_HW / VOLUME_HW with hardware timepoints // off): the controller emits all channels in one trigger, so a single - // merged iterator is correct here -- unchanged. + // merged iterator is correct here, unchanged. currentAcquisition_.submitEventIterator( LightSheetEventAdapter.createChannelAcqEvents( baseEvent.copy(), acqSettings_, cameraNames, null)); @@ -959,10 +965,11 @@ private boolean doHardwareCalculations(PLogicScape plc) { // TODO: implement multiple positions using hardware time points, currently // set hardware time points to false if using multiple positions if (acqSettings_.isUsingMultiplePositions()) { - if (acqSettings_.isUsingHardwareTimePoints()) { + if (isUsingHardwareTimePoints) { // || acqSettings_.numTimePoints() > 1) // && (timepointIntervalMs < timepointDuration*1.2)) { asb_.useHardwareTimePoints(false); + isUsingHardwareTimePoints = false; // studio_.logs().showError("Time point interval may not be sufficient " // + "depending on actual time required to change positions. " // + "Proceed at your own risk."); @@ -970,9 +977,10 @@ private boolean doHardwareCalculations(PLogicScape plc) { } // only use hardware time points when use time points is checked - if (acqSettings_.isUsingHardwareTimePoints()) { + if (isUsingHardwareTimePoints) { if (!acqSettings_.isUsingTimePoints()) { asb_.useHardwareTimePoints(false); + isUsingHardwareTimePoints = false; } } diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java index 5c72f61..c4cfa3a 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java @@ -22,12 +22,16 @@ */ public final class LightSheetEventAdapter { + // These strings are MM's, not ours: AcqEngJ keys event coordinates on them and the datastore and + // viewer size/display dimensions by them, so the VALUES cannot be changed; an axis MM does not + // know is dropped by TIFF storage and never displayed. + // CAMERA_AXIS is deliberately AcqEngJ's "channel" axis: LSM packs the combined + // (channelIndex * numCameras + cameraIndex) slot into it, which is why the name here says camera + // while the value says channel. Note setChannelName() writes this same axis. public static final String TIME_AXIS = "time"; public static final String POSITION_AXIS = "position"; public static final String CAMERA_AXIS = "channel"; - // TODO: put this in the channel iterator (should not be global) - public static int currentChannelIndex_ = 0; public static boolean isUsingMultipleCameras = false; /** @@ -213,14 +217,8 @@ public static Iterator createSingleChannelVolumeAcqEvents( } baseEvent.setZ(baseEvent.getZIndex(), zPos); - // Pass this channel's channel-axis base to cameras() EXPLICITLY It cannot come from - // currentChannelIndex_: that static is assigned only by channels(), which this path does not - // use, and submission here is eager while iterator consumption is lazy, by the time AcqEngJ - // pulls these events the loop in run() has already advanced, so every channel would observe - // the last one. It also must not be smuggled onto the event: CAMERA_AXIS is literally AcqEngJ's - // "channel" axis (see cameras()), so a base written there is indistinguishable from the channel - // index setChannelName() just wrote, which is exactly how collapsed dual-camera - // coordinates on the channels()-composed paths. + // cameras() would now derive the same base from CAMERA_AXIS (setChannelName above put the + // channel index there); passing it explicitly keeps this path independent of that coupling. final int channelAxisBase = isUsingMultipleCameras ? channelIndex * cameraDeviceNames.length : channelIndex; @@ -236,9 +234,9 @@ public static Iterator createSingleChannelVolumeAcqEvents( } /** - * Fan an event out over the cameras, deriving the channel-axis base the legacy way. + * Fan an event out over the cameras, deriving the channel-axis base from the event. *

- * Used by the {@link #channels}-composed factories, whose behavior this leaves exactly as it was. + * Used by the {@link #channels}-composed factories. */ public static Function> cameras(String[] cameraDeviceNames) { return cameras(cameraDeviceNames, null); @@ -247,7 +245,7 @@ public static Function> cameras(Str /** * Fan an event out over the cameras, assigning each one a {@link #CAMERA_AXIS} coordinate. *

- * {@code CAMERA_AXIS} is AcqEngJ's {@code "channel"} axis ({@code AcqEngMetadata.CHANNEL_AXIS}) -- + * {@code CAMERA_AXIS} is AcqEngJ's {@code "channel"} axis ({@code AcqEngMetadata.CHANNEL_AXIS}): * {@code AcquisitionEvent.setChannelName(s)} compiles to {@code setAxisPosition("channel", s)}. So the * channel index and the camera index share one axis, and the coordinate written here is the combined * slot {@code channelIndex * numCameras + cameraIndex} that {@code addMMSummaryMetadata} names and @@ -255,9 +253,9 @@ public static Function> cameras(Str * you put it there: on the {@code channels()}-composed paths it is the raw channel index written by * {@code setChannelName}. * - * @param channelAxisBase the channel-axis base for this fan-out, or {@code null} to derive it the - * legacy way: {@code currentChannelIndex_ * numCameras} when using multiple - * cameras, else the channel index {@code channels()} left on the axis + * @param channelAxisBase the channel-axis base for this fan-out, or {@code null} to derive it from + * the event: the channel index {@code channels()} left on the axis, times + * the camera count when using multiple cameras */ public static Function> cameras( String[] cameraDeviceNames, Integer channelAxisBase) { @@ -280,11 +278,11 @@ public AcquisitionEvent next() { if (channelAxisBase != null) { // Caller-supplied base (createSingleChannelVolumeAcqEvents, per-channel path). baseIndex = channelAxisBase; - } else if (isUsingMultipleCameras) { - baseIndex = currentChannelIndex_ * cameraDeviceNames_.length; } else { - // Single camera: the base is the channel index channels() wrote onto this axis via - // setChannelName (absent => 0 when channels are disabled). + // The base is the channel index channels() wrote onto this axis via + // setChannelName (absent => 0 when channels are disabled). Read it off the event + // rather than from shared state: run() submits one iterator per position per + // timepoint eagerly, and AcqEngJ consumes them lazily and interleaved. Object position = event.getAxisPosition(CAMERA_AXIS); int parsed = 0; if (position != null) { @@ -294,7 +292,8 @@ public AcquisitionEvent next() { // ignore => number already assigned } } - baseIndex = parsed; + baseIndex = isUsingMultipleCameras + ? parsed * cameraDeviceNames_.length : parsed; } cameraEvent.setAxisPosition(CAMERA_AXIS, baseIndex + cameraIndex_); @@ -373,7 +372,6 @@ public AcquisitionEvent next() { channelEvent.setConfigGroup(channelList[index].getGroup()); channelEvent.setConfigPreset(channelList[index].getName()); channelEvent.setChannelName(Integer.toString(index)); - currentChannelIndex_ = index; double zPos; if (channelEvent.getZPosition() == null) { diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/devices/vendor/ASIPLogic.java b/src/main/java/org/micromanager/lightsheetmanager/model/devices/vendor/ASIPLogic.java index cda7631..39607d5 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/devices/vendor/ASIPLogic.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/devices/vendor/ASIPLogic.java @@ -261,7 +261,7 @@ public enum CellType { D_FLOP("1 - D flop"), OUTPUT_OPEN_DRAIN("1 - output (open-drain)"), LUT2("2 - 2-input LUT"), - OUTPUT_PUSH_PULL("2 - output (push-pull"), + OUTPUT_PUSH_PULL("2 - output (push-pull)"), LUT3("3 - 3-input LUT"), LUT4("4 - 4-input LUT"), AND2("5 - 2-input AND"),