Repository navigation
feat(laser-engine): Cephla laser engine v2 (400 µm standard and 50 µm Dragonfly variants) - #693
Open
hongquanli wants to merge 46 commits into
Open
hongquanli wants to merge 46 commits into
hongquanli wants to merge 46 commits into
Conversation
…col fake for the rev 1 engine Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rce status) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ect-time reset, cold-boot retry, 1 Hz STAT? heartbeat Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…on use, line map from the TTL port map Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ty on the TTL port's line, ramp-settled, wake on use Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rt at its minimum, clamp, idle-off, AOM shutter modes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…check-then-act race on enable/disable Review round 1 findings (deliberate deviation from the task-6 brief's code): - A failed disable() was logged and forgotten: _after_poll returned early whenever _source_want_on was False, so the source kept emitting and idle-off/disarm/L3-error-off silently stopped working. _after_poll now re-queues a disable (guarded by a new _source_disable_pending flag, cleared by source_step whether the disable succeeded or raised) whenever the source still reads ready/starting but is not wanted, and also retries closing the held-open shutter in that branch. - _source_enable_pending was cleared before the post-enable poll refreshed _source_status, so a wake_up/wait_until_ready call landing in that window still saw SOURCE_OFF and queued a second enable. It is now cleared only after judge_source, in a finally so an exception in poll() cannot leave it stuck. - The check-then-set in _wake_source spanned a serial round-trip (LINE3:SET), letting a concurrent _disable_source/_set_source_power interleave. Added _source_lock and moved the flag/queue bookkeeping in _wake_source, _disable_source, _set_source_power and source_step under it, holding it only around state changes, never around source I/O. Covering tests added to tests/control/test_laser_engine_rev1_source.py (24 total): test_failed_disable_is_retried, test_enable_is_not_repeated_while_the_first_start_is_polled (t6_probe3 idea) and test_wake_and_disable_interleaved_stay_consistent; the first two fail against the pre-fix code, confirmed by re-running them with the fix stashed out. test_acquisition_use_keeps_the_source_on now rewinds engine._source_last_use instead of sleeping, removing a scheduling dependency. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ighten the enable/disable race guards Review round 2 findings (controller-directed deviation from round 1's code): - Important: the "source unwanted but still emitting/starting" reconcile lived in _after_poll, which the poll thread stops calling once poll_once() returns None (link lost). A disable queued by _on_lost that then fails was never retried. Moved the reconcile to the end of source_step, using that step's own fresh status, so it keeps running on the source thread after the link is gone. _after_poll now only closes the held-open shutter (which needs the engine link). - Minor: _source_disable_pending is now cleared in source_step's finally, after the post-step poll/judge, the same deferred pattern as _source_enable_pending/ran_enable, so a disable that source_step itself just ran uses this step's own fresh status (rather than a stale one) before deciding whether to re-queue. - Minor: _wake_source now captures `src = self._source` once at the top and widens the enable_pending reset-on-exception guard to cover the whole body, so a concurrent _close_source() cannot leave the flag stuck True via a `self._source is None` race. Covering tests in tests/control/test_laser_engine_rev1_source.py (26 total): replaced test_wake_and_disable_interleaved_stay_consistent (passed before and after round 1, per the re-review) with test_concurrent_wake_during_line3_set_enables_once and test_disable_racing_the_enable_put_stays_consistent (probe_rr's B/C ideas); added test_failed_disable_after_link_loss_is_retried, confirmed to fail against the round-1 code (source stays enabled after 3 source_step() calls with no poll_once() in between) and pass with this fix. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… wiring, per-FOV use hook, simulation mode Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…otices, startup, 560 idle-off, AOM shutter mode) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…construction The tab only filled startup_label on the next status_updated, so it showed nothing for up to a poll even though the engine's startup bring-up had usually already begun before the tab was built. Seed it from engine.bringup_state in __init__, same as the notices already do from engine.notices. Factor the "startup: <state>"/"" formatting into _startup_text() so __init__ and _on_status share it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…um (off by default; needs 560_aom.csv) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…USED lines refused at once; half-open link reset - _poll_loop: any exception from one poll is logged and reported through _signal_lost (banner, 560 off, waits return False) instead of ending the heartbeat thread with only a stderr traceback. - wait_until_ready raises at once for a line with nothing on it in this variant (UNUSED). - open(): a failure after *IDN? (VAR?, HOST:TIMEOUT, line configuration, connect reset) closes the link and clears it, so a later open() configures from scratch. - close(): the poll thread join outlasts one STAT? at the link's 3 s read timeout. - Startup drop notice reports the refusal itself, not the line's status text. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts 200 mW minimum
Ruling F-0: exactly 0 % on the DF source line switches the source off and L3 reads READY (dark by request,
like any other line at 0 %); any request above 0 % but below the minimum keeps the clamp + warn-once
(ruling 4). The first 0 % posts one notice.
- _set_source_power: 0 % sets _source_dark, clears the request, disables the source; above 0 % clears it
and re-judges the source's last reading, so the wake that follows sees SOURCE_OFF.
- _wake_source: never enables a dark source (checked before LINE3:SET and again under the lock before the
enable is queued); a refused LINE3:SET is raised as LaserEngineRev1Error("L3", reason).
- _judge_source: a dark source that reads off is READY; uses the minimum power of the source being judged
(close() may clear self._source). The source_step reconcile keeps acting on the source's own reading.
- LightSource.set_intensity: on the DF source line the request is sent first and 0 % wakes nothing, so
0 -> 10 % starts the source again; docstring states how long it can block.
- Source comments state behaviour instead of the review history; the source_step docstring says a
redundant disable is possible and harmless.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…deterministic race and idle tests - test_l3_error_disables_source and test_put_to_sleep_l3_disables_source cover two of the 560 off triggers. - test_disable_racing_the_enable_put_stays_consistent joins its helper thread after wake_up, before the source step, so the assertion does not depend on scheduling. - test_acquisition_use_keeps_the_source_on uses a 0.5 s idle window (the 1 s rewinds still exceed it). - Test docstrings, section headers and the FakeSource.fail_disable comment state behaviour instead of the review history. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ocking helper Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…AOM when it is in the path (replaces 0 % = dark) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cepts it; AOM lock; stronger 0 % tests Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rectly Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… the AOM; warn on every below-minimum API request; fake mirrors the firmware pause fix Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ceiled floor for slider and spinbox Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ource reports no limits; floor tests describe behaviour, count check has a lower bound Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Engine tab, service panel, log) for testing the engine without a microscope Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…intensity the engine did not accept; wheel guard; link-lost handling Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s in the path; gate boxes follow the engine's reported gate; link lost during connect greys the panel Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nsity, shutter safety-only; no-AOM paths and the GUI floor removed Owner rulings 2026-10-06 (replace the 560 parts of 09-27/28): - 560 laser power set in mW in the Laser Engine tab, clamped to the source's limits, remembered in the machine .ini ([GENERAL] laser_engine_rev1_source_power_mw); unset = the source's minimum. New: EngineOptions.source_power_mw, _def.LASER_ENGINE_REV1_SOURCE_POWER_MW, set_source_power_mw(), source_power_setpoint_mw, save_source_power_mw(). Start stays "enable at the minimum, then the setting". - Squid's 560 intensity drives the AOM amplitude (LINE3:SET, 5 V x % / 100, or 560_aom.csv when present); never the laser power. The wake sets the AOM to the requested % (0 V until one is asked). - Shutter is safety only: SHUT:SRC MCU at connect; open while L3 is READY, closed whenever the source is off. - Removed: aom_in_path / shutter_with_aom / aom_attenuation (+ _def flags), set_shutter_with_aom, intensity_floor_percent and the GUI floor (live_controller.py and widgets.py back to master), test_intensity_floor.py, the below-minimum clamp warning. - Laser Engine tab redesigned: state pill, lines table (wavelength / state / set-point / max / reason), 560 nm laser box with the power control. Bench: AOM options removed; bench TTL map for the tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h any AOM calibration; simulation never saves the 560 power; Set takes a typed value Review of 2d45724: - Shutter: _after_poll opens it at line 3 READY, keeps it during STARTING (every AOM LINE3:SET ramps), closes it in every other state (NEEDS KEY, source off by itself, line off, paused). _wake_source closes it before queuing the enable, so a restart is never behind an open shutter. - 0 % -> 0 V even when 560_aom.csv starts at a transmitting row (np.interp clamped to that row); the display inverse gives 0 % for 0 V. - build_simulated_engine marks the engine simulated; the tab's default saver then leaves the machine .ini alone. - Set calls interpretText() first: a value typed without Enter is the one applied and saved. - tests/control/test_intensity_cap.py back to origin/master (dead floor stub). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ost; a source starting by itself closes the shutter Re-review of d70c360: - N1: _set_held_shutter swallows a connection loss, so _wake_source went on to queue the enable after _on_lost had switched the source off (and no poll thread was left to idle it off). The enable is now refused under _source_lock when _lost is set. - N2: STARTING keeps the shutter only while the source itself reads ready (a line-3 ramp from an AOM set-point); a source that reads starting (off and on again between two polls) closes it until READY. - Docstring and warning text match; the refusal-based restart test replaced by the link-loss case (DF firmware never refuses a close). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…scrolls instead of squashing The tab needed 540 px; in a full Squid column the panel gave it ~340, so every row was squeezed (spinboxes to nothing). - Compact: the 560 box in three rows (state + power; power setting, limits, idle-off; the shutter note), the Lines table in a plain frame with tight rows, the latest two notices shown (ten in the tooltip), the startup line hidden once it reads done. Size hint 548 -> 312 px (macOS). - The content sits in a frameless QScrollArea: a panel shorter than the tab scrolls, rows keep their height. - sizeHint = the content at the tab's width (wrapped lines included) + an allowance for the tab pane, which Squid's resizeCurrentTab leaves out of its cap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ne v2 The modules, classes and tests were named after the carrier board revision (rev 1). The product is Cephla laser engine v2; the board revision stays only in the firmware ID string (Cephla,LaserEngineCarrier-rev1). - control/laser_engine_rev1*.py -> control/laser_engine_v2*.py, the bench tool and the tests likewise (git mv) - LaserEngineRev1* -> LaserEngineV2*, EngineRev1Status -> EngineV2Status, LightSourceType.CephlaLaserEngineRev1 -> CephlaLaserEngineV2 - operator-facing texts say "laser engine v2" No behaviour change; the machine .ini keys follow in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the machine .ini [GENERAL] laser_engine = v1 | v2 (blank or none = no engine) and laser_engine_sn = <USB serial number> replace use_laser_engine_rev1 and laser_engine_rev1_sn. The legacy v1 keys are still read: use_squid_laser_engine = True alone selects v1 (with one warning to use laser_engine = v1 instead), squid_laser_engine_sn is the v1 serial number when laser_engine_sn is not set, and use_squid_laser_engine = True with laser_engine = v2 is refused at startup, as is any other laser_engine value. _def._resolve_laser_engine replaces _validate_laser_engine_flags on both config paths (.ini and the legacy configuration*.txt). microscope.py and gui_hcs.py now select on LASER_ENGINE and open LASER_ENGINE_SN; nothing on master reads the legacy names at call time, so no aliases are kept. The 560 nm source key laser_engine_rev1_source_sn goes: the optional driver module (control.laser_engine_v2_560_driver, open_560_source(), supplied separately and not in this repository) finds the source by its own USB IDs. build_from_config(sn, options); the bench app's "560 source SN" field goes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… cache, not the machine .ini They are operator settings changed in the Laser Engine tab, so they now live in cache/laser_engine_v2.yaml (keys power_560_mw, idle_off_560_min) next to Squid's other GUI state. control/laser_engine_v2_settings.py loads them (defaults when the file or a value is missing or unusable: the source's minimum power, 30 min idle-off, 0 = 24 h) and saves them (never raises: logs and returns False, keeps the other key). - the tab saves the power on Set (as before) and the idle-off whenever its spinbox changes; a simulated engine still saves nothing, with the same "not saved ... this session only" notice - Squid and the bench app build the engine's options from the cache (options_from_cache); the bench's own idle-off spinbox goes, the tab's control is the one that is kept - removed: laser_engine_rev1_source_power_mw, laser_engine_rev1_source_idle_off_min and the machine .ini writer save_source_power_mw - every test points the cache at a temporary directory Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng references
Comments and docstrings referred to internal decision and task numbers that a
public reviewer cannot look up ("ruling 4", "Task 6", dated decisions, "fix
round 1"). Each now states the reason itself, e.g. the idle-off: the 560 source
switches off after this many minutes unused, 0 = 24 h, so it is never left on
indefinitely; the shutter: open only at line 3 READY so the beam is blocked
whenever the source is not known to be ready. The module header names the
variants (400R / 400HP, DF). Comments only: no code change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ys), cabling, the tab, the DF 560 line, simulation, bench app software/docs/laser-engine.md, in the style of docs/filter-wheel.md. It notes that the 560 nm source driver is supplied separately and is not in this repository. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Its changes are now saved to the cache, so scrolling the tab past it must not change it (the power spinbox already ignored the wheel). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…id; idle-off applied on Enter; shutter wording - laser_engine_v2_settings: any error reading cache/laser_engine_v2.yaml (I/O, YAML, not UTF-8) gives the defaults, and an int too large for a float is not a number, so a v2 machine still starts and the next save replaces the file. - The idle-off spinbox has keyboard tracking off: typing 120 applies and saves 120 on Enter, not 1, 12, 120. - Module docstring and docs/laser-engine.md: the shutter stays open through the line-3 ramp of an AOM set-point while the source reads ready (as the code does), closed in every other state. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…I lint) formats it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…when its window is deleted CI (one pytest process) segfaulted in test_objectives_widget.py: the deferred delete of a bench window ran in that test's event loop, and LogPane's destroyed -> lambda slot had already been freed by PyQt (PyQtSlot::call on freed memory). LogPane no longer hooks its own destroyed signal: its log handler holds the pane weakly and goes quiet once the pane is gone (qtpy.compat.isalive), and a new LogPane removes such handlers from the squid logger. detach() on window close is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… live counts as 560 use; variant after cold start; remap re-sends intensity - Shutter race: a poll that read L3 READY could reopen the shutter after another thread restarted the 560 (source still starting). The open decision + SHUT:OPEN, a restart's enable-pending + close and a switch-off now share _shutter_lock, and the open re-checks at send time: source wanted, no restart pending, source reads ready (not starting), and no restart or switch-off since that poll's snapshot (_shutter_gen). Lock order _aom_lock, _shutter_lock, _source_lock. - Live idle-off: live has no per-frame call into the engine, so a long live session idled the 560 off. The engine polls in_use_provider every heartbeat; gui_hcs wires LiveController.illumination_wavelengths_in_use (new, [] when not live). MultiPointWorker also notes use per image, for single FOVs longer than the idle-off. - Cold start: VAR? read in open() can say UNPROGRAMMED before the expanders answer (firmware reads the straps then), which skipped SHUT:SRC MCU and the 560 source. The variant is read again once the line commands succeed. The simulator answers VAR? the same way. - Port remap: IlluminationController skipped the light source when only the TTL port changed, leaving the set-point on the old engine line. The TTL port is part of its cached identity now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…witch-offs never wait; variant after the straps - A restart sends SHUT:OPEN 0 even when the cached STAT? says closed: that reply can be older than an open, and the source must never start behind an open shutter. - _disable_source no longer takes _shutter_lock (it waited behind a shutter round trip, up to the link timeout); an open racing a switch-off is harmless, the next poll closes. _shutter_gen changes under _source_lock only. - Cold start: the line writes succeed as soon as the expanders ACK, but the firmware reads the ID straps on its 1 s initMissing tick: VAR? is read once STAT? chips.mcp is 1 (up to EXPANDER_INIT_WAIT_S = 2.5 s, two ticks). The simulator reports chips.mcp, VAR? and STAT? var from one expander state, with a straps_delay_replies knob. - The open re-check uses the status code's READY test; a not-armed snapshot from before a restart no longer switches the new start off; the provider's log-once resets after a success. - The live-view provider is wired in Microscope (it owns live_controller) instead of gui_hcs, so it also covers users other than the GUI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…estart of the 560 A heartbeat that read L3 FAULT and was delayed could switch the source off after another caller had reset the fault, restarted the 560 and seen READY; the source then stayed off while live still used it. The fault / not-armed switch-off now needs a current snapshot, checked again under _source_lock where _disable_source(gen) queues the disable (an obsolete snapshot does nothing; the next poll decides). Idle-off is measured now and is unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…calibration files are not read The engine no longer reads <wavelength>.csv or 560_aom.csv: those files record nothing about the engine they were measured on, and engine lines will be calibrated with Utils > Illumination Power Calibration (a follow-up PR), with files tied to the engine's serial number, line and ceiling. Until then an engine line's intensity is linear in its drive: % of the line's current ceiling, on DF line 3 % of the AOM's 0-5 V input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
…oint back after another one used the line Two wavelengths on one port (470 and 488 nm on D2, as the default port map has them) drive one engine line. In Live Control, 488 at 50 %, then 470 at 20 %, then 488 again sent nothing on the third switch: the controller's unchanged-intensity check is per wavelength, and 488's entry still said 50 % while the line held 470's 20 %. The display said 488 at 50 %; the line stayed at 20 %. The controller now also remembers which channel's intensity was last sent while on each TTL port, and on a laser engine v2 (whose line is the port) sends again when another channel has set the line since. An unchanged % of the channel the line already holds is still not sent again (each set-point waits out the line's ramp). Other Software-mode light sources are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
hongquanli
added a commit
that referenced
this pull request
Oct 16, 2026
…o the DF laser engine integration branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FLTvGd4x3j4ujpqZtCAMf9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Squid support for the Cephla laser engine v2: a multi-line engine (carrier board rev 1, Teensy 4.1 firmware, text protocol over USB) in two variants, the 400 µm fiber standard engine and the 50 µm Dragonfly engine, which adds a 560 nm fiber laser behind an AOM. The legacy v1 engine (
control/squid_laser_engine.py) keeps working unchanged.Configuration
The machine
.iniselects the engine with one key and gives its USB serial number with another, for every version:use_squid_laser_engine/squid_laser_engine_snare still read (as v1, with one warning to switch);use_squid_laser_engine = Truetogether withlaser_engine = v2is an error. Full setup:software/docs/laser-engine.md.How Squid drives the engine
LightSource(LaserEngineV2LightSource): intensity over USB (per-line set-points, linear in the line's drive; calibration to % of power comes in a follow-up PR), exposure on the Squid controller TTL lines D1–D5 cabled to the engine's TTL1–5. Software shutter mode is refused: a software gate would leave a line emitting between exposures.MultiPointWorkertells the engine which channels an acquisition uses (note_use; v1 has none), so the 560 idle-off cannot trip mid-run.lighting.pymaps 560 nm to D3 by default and addsLightSourceType.CephlaLaserEngineV2;gui_hcs.pyandmicroscope.pyselect onLASER_ENGINE; the v1 engine gains anon_startup()hook that does whatprepare_for_usedid before (wake every channel).Dragonfly 560 nm line
cache/laser_engine_v2.yaml, not in the machine.ini. A simulated session never saves them.control.laser_engine_v2_560_driver, supplied separately and not part of this repository. Without it the 560 line reports "not configured" and the other lines work normally.UI and tools
--simulation: a fake engine and a fake 560 source.python3 tools/laser_engine_v2_bench.py(connection, service panel, the same tab), with a simulate option.Testing
tests/control/test_laser_engine_v2*.py), each file in its own process: all pass.test_squid_laser_engine.py(v1) passes unchanged.test_MultiPointController4,test_laser_af_settings4,widgets_fluidics/test_protocol_tab2,squid/test_stage1) are unchanged.test_HighContentScreeningGui.pywith a display: 7/7, as on master.--simulationwithlaser_engine = v2: the engine connects and every line comes up ready.38 commits including review rounds; squash-merging is suggested.
🤖 Generated with Claude Code