diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 136cd8b3..a050e298 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,10 @@ jobs: python-version: "3.10" - name: Install ruff - run: pip install ruff + # Pinned so CI never floats to a new ruff release mid-review. Keep in + # sync with the ruff-pre-commit rev in .pre-commit-config.yaml and the + # pin in pyproject.toml. + run: pip install ruff==0.16.0 - name: ruff check run: ruff check . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88476e0c..4c627f68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.17 # run `pre-commit autoupdate` to pin to latest + rev: v0.16.0 # keep in sync with the ruff pins in pyproject.toml + lint.yml; bump via `pre-commit autoupdate` hooks: - id: ruff args: [--fix] diff --git a/docs/HIERARCHICAL_CONTEXTS_DESIGN.md b/docs/HIERARCHICAL_CONTEXTS_DESIGN.md index e0903b08..3a11479b 100644 --- a/docs/HIERARCHICAL_CONTEXTS_DESIGN.md +++ b/docs/HIERARCHICAL_CONTEXTS_DESIGN.md @@ -106,27 +106,27 @@ class ContextScope: scope_id: str parent_id: Optional[str] scope_type: Literal["root", "focus", "timelapse", "embryo_perception", "ad_hoc"] - purpose: str # one-line task description - instructions: str # full brief, like a prompt to a colleague + purpose: str # one-line task description + instructions: str # full brief, like a prompt to a colleague conversation_history: List[Dict] allowed_tools: Set[str] - model: str # "opus" | "sonnet" | "haiku" + model: str # "opus" | "sonnet" | "haiku" status: Literal["active", "completed", "yielded", "cancelled", "failed"] - summary: Optional[str] # produced by Summarizer at end - key_findings: Dict[str, Any] # structured return value - children: List[str] # child scope_ids + summary: Optional[str] # produced by Summarizer at end + key_findings: Dict[str, Any] # structured return value + children: List[str] # child scope_ids created_at: datetime completed_at: Optional[datetime] - persist: bool # write to SQLite if True + persist: bool # write to SQLite if True # Runtime - inbox: asyncio.Queue # incoming messages from other scopes - wakeup: asyncio.Event # signaled when there is work + inbox: asyncio.Queue # incoming messages from other scopes + wakeup: asyncio.Event # signaled when there is work cancel_token: asyncio.Event cancel_reason: Optional[str] pending_queries: Dict[str, asyncio.Future] - peer_events: List[str] # event types this scope subscribes to - parent_snapshot_cache: Dict # parent's last known summary + findings + peer_events: List[str] # event types this scope subscribes to + parent_snapshot_cache: Dict # parent's last known summary + findings ``` A `ScopeManager` (lives on `MicroscopyCopilot`) owns the scope tree, dispatches the inner agent loop, and exposes the orchestrator-facing tools. @@ -198,22 +198,26 @@ async def run_scope(scope: ContextScope) -> SummaryResult: while not scope.inbox.empty(): items.append(scope.inbox.get_nowait()) if not items and not scope.cancel_token.is_set(): - continue # nothing to do, back to sleep, no API call + continue # nothing to do, back to sleep, no API call # CHECK CANCELLATION — give child one final turn to summarize if scope.cancel_token.is_set() and scope.status != "cancelling": scope.status = "cancelling" - scope.conversation_history.append({ - "role": "user", - "content": f"[CANCELLATION] {scope.cancel_reason}. " - f"Emit a final summary using `complete` and stop.", - }) + scope.conversation_history.append( + { + "role": "user", + "content": f"[CANCELLATION] {scope.cancel_reason}. " + f"Emit a final summary using `complete` and stop.", + } + ) # APPEND injected messages to history - scope.conversation_history.append({ - "role": "user", - "content": format_inbox_items(items), - }) + scope.conversation_history.append( + { + "role": "user", + "content": format_inbox_items(items), + } + ) # ONE LLM TURN response = await call_claude( @@ -222,23 +226,32 @@ async def run_scope(scope: ContextScope) -> SummaryResult: messages=scope.conversation_history, tools=tool_registry.filter(scope.allowed_tools), ) - scope.conversation_history.append({"role":"assistant","content":response}) + scope.conversation_history.append({"role": "assistant", "content": response}) # HANDLE TOOL CALLS if response.stop_reason == "tool_use": tool_results = [] for tool_call in response.tool_uses: match tool_call.name: - case "delegate": result = await spawn_and_run(scope, **tool_call.input) - case "yield_checkpoint": result = await emit_checkpoint(scope, **tool_call.input) - case "escalate": result = await escalate_to_root(scope, **tool_call.input) - case "query_parent": result = await ask_parent(scope, **tool_call.input, timeout=30) - case "read_parent_state": result = scope.parent_snapshot_cache - case "respond_to_query": result = await deliver_answer(**tool_call.input) - case "complete": scope.status = "completed"; scope.key_findings = tool_call.input - case _: result = await execute_tool(tool_call, scope) + case "delegate": + result = await spawn_and_run(scope, **tool_call.input) + case "yield_checkpoint": + result = await emit_checkpoint(scope, **tool_call.input) + case "escalate": + result = await escalate_to_root(scope, **tool_call.input) + case "query_parent": + result = await ask_parent(scope, **tool_call.input, timeout=30) + case "read_parent_state": + result = scope.parent_snapshot_cache + case "respond_to_query": + result = await deliver_answer(**tool_call.input) + case "complete": + scope.status = "completed" + scope.key_findings = tool_call.input + case _: + result = await execute_tool(tool_call, scope) tool_results.append(result) - scope.conversation_history.append({"role":"user","content":tool_results}) + scope.conversation_history.append({"role": "user", "content": tool_results}) if response.stop_reason == "end_turn": scope.status = "completed" diff --git a/docs/PERCEPTION_V2_IMPROVEMENT_STRATEGY.md b/docs/PERCEPTION_V2_IMPROVEMENT_STRATEGY.md index 2844265f..b6b6ada7 100644 --- a/docs/PERCEPTION_V2_IMPROVEMENT_STRATEGY.md +++ b/docs/PERCEPTION_V2_IMPROVEMENT_STRATEGY.md @@ -77,7 +77,7 @@ MISSING: hatching/ folder - NO HATCHING EXAMPLES! ```python def at_least_stage(self, stage: str) -> bool: """Check if embryo has reached at least the given stage.""" - order = ['early', 'bean', 'comma', '1.5fold', '2fold', '3fold', 'hatching', 'hatched'] + order = ["early", "bean", "comma", "1.5fold", "2fold", "3fold", "hatching", "hatched"] # ... simple linear comparison ``` @@ -155,28 +155,38 @@ Create a single source of truth: from enum import Enum from typing import List, Dict, Tuple + class DevelopmentalStage(str, Enum): """Unified C. elegans developmental stages.""" - EARLY = "early" # Gastrulation, ~100+ cells, oval shape - BEAN = "bean" # Early morphogenesis, slight asymmetry - COMMA = "comma" # Clear C-shape, head/tail distinguishable - FOLD_1_5 = "1.5fold" # Elongation, ~1.5x eggshell - FOLD_2 = "2fold" # Further elongation, folding back - FOLD_3 = "3fold" # Tight coil, maximum compaction (pretzel) - HATCHING = "hatching" # Active emergence, shell breach visible - HATCHED = "hatched" # Fully emerged L1 larva + + EARLY = "early" # Gastrulation, ~100+ cells, oval shape + BEAN = "bean" # Early morphogenesis, slight asymmetry + COMMA = "comma" # Clear C-shape, head/tail distinguishable + FOLD_1_5 = "1.5fold" # Elongation, ~1.5x eggshell + FOLD_2 = "2fold" # Further elongation, folding back + FOLD_3 = "3fold" # Tight coil, maximum compaction (pretzel) + HATCHING = "hatching" # Active emergence, shell breach visible + HATCHED = "hatched" # Fully emerged L1 larva @classmethod - def ordered_list(cls) -> List['DevelopmentalStage']: - return [cls.EARLY, cls.BEAN, cls.COMMA, cls.FOLD_1_5, - cls.FOLD_2, cls.FOLD_3, cls.HATCHING, cls.HATCHED] + def ordered_list(cls) -> List["DevelopmentalStage"]: + return [ + cls.EARLY, + cls.BEAN, + cls.COMMA, + cls.FOLD_1_5, + cls.FOLD_2, + cls.FOLD_3, + cls.HATCHING, + cls.HATCHED, + ] @classmethod - def get_order(cls, stage: 'DevelopmentalStage') -> int: + def get_order(cls, stage: "DevelopmentalStage") -> int: return cls.ordered_list().index(stage) @classmethod - def is_terminal(cls, stage: 'DevelopmentalStage') -> bool: + def is_terminal(cls, stage: "DevelopmentalStage") -> bool: return stage == cls.HATCHED @@ -339,15 +349,18 @@ from dataclasses import dataclass from typing import List, Optional, Tuple from .stages import DevelopmentalStage + @dataclass class TransitionState: """Represents the current transition state.""" + current_stage: DevelopmentalStage confidence: float is_transitioning: bool next_stage: Optional[DevelopmentalStage] transition_progress: float # 0.0 to 1.0 + class TransitionDetector: """ Detects and tracks stage transitions with temporal smoothing. @@ -481,12 +494,14 @@ from dataclasses import dataclass from typing import Dict from .stages import DevelopmentalStage + @dataclass class CalibratedConfidence: """Calibrated confidence with interpretable thresholds.""" - raw_score: float # 0.0 to 1.0 from VLM - calibrated_score: float # Adjusted based on stage difficulty - interpretation: str # "high", "medium", "low" + + raw_score: float # 0.0 to 1.0 from VLM + calibrated_score: float # Adjusted based on stage difficulty + interpretation: str # "high", "medium", "low" @property def is_reliable(self) -> bool: @@ -496,14 +511,14 @@ class CalibratedConfidence: # Stage-specific confidence calibration # Some stages are harder to classify, adjust thresholds accordingly STAGE_DIFFICULTY = { - DevelopmentalStage.EARLY: 0.0, # Easy - distinct morphology - DevelopmentalStage.BEAN: 0.3, # Hard - brief, subtle - DevelopmentalStage.COMMA: 0.2, # Medium - can overlap with bean - DevelopmentalStage.FOLD_1_5: 0.2, # Medium - DevelopmentalStage.FOLD_2: 0.2, # Medium - DevelopmentalStage.FOLD_3: 0.1, # Easy - distinct pretzel + DevelopmentalStage.EARLY: 0.0, # Easy - distinct morphology + DevelopmentalStage.BEAN: 0.3, # Hard - brief, subtle + DevelopmentalStage.COMMA: 0.2, # Medium - can overlap with bean + DevelopmentalStage.FOLD_1_5: 0.2, # Medium + DevelopmentalStage.FOLD_2: 0.2, # Medium + DevelopmentalStage.FOLD_3: 0.1, # Easy - distinct pretzel DevelopmentalStage.HATCHING: 0.25, # Medium-hard - brief window - DevelopmentalStage.HATCHED: 0.0, # Easy - distinct + DevelopmentalStage.HATCHED: 0.0, # Easy - distinct } @@ -587,7 +602,7 @@ Return JSON: class HatchingDetector: """Specialized detector for hatching stages.""" - def __init__(self, engine: 'PerceptionEngine'): + def __init__(self, engine: "PerceptionEngine"): self.engine = engine self.consecutive_hatching = 0 self.consecutive_hatched = 0 @@ -604,16 +619,16 @@ class HatchingDetector: ) # Track consecutive detections for confirmation - if result.get('classification') == 'hatching': + if result.get("classification") == "hatching": self.consecutive_hatching += 1 self.consecutive_hatched = 0 - elif result.get('classification') == 'hatched': + elif result.get("classification") == "hatched": self.consecutive_hatched += 1 # Require 2+ consecutive "hatched" to confirm # (hatching can look like hatched momentarily) if self.consecutive_hatched < 2: - result['classification'] = 'hatching' - result['note'] = 'Awaiting confirmation of hatched status' + result["classification"] = "hatching" + result["note"] = "Awaiting confirmation of hatched status" else: self.consecutive_hatching = 0 self.consecutive_hatched = 0 diff --git a/docs/PLAN_MODE_DESIGN.md b/docs/PLAN_MODE_DESIGN.md index ddf02c05..9e95e34d 100644 --- a/docs/PLAN_MODE_DESIGN.md +++ b/docs/PLAN_MODE_DESIGN.md @@ -79,8 +79,8 @@ The agent gets a `self.mode` attribute: ```python class AgentMode(str, Enum): - EXECUTION = "execution" # Current behavior - PLAN = "plan" # Experimental design mode + EXECUTION = "execution" # Current behavior + PLAN = "plan" # Experimental design mode ``` The main message loop (`_call_claude_stream()`) selects prompt and tools based on mode: @@ -323,23 +323,24 @@ The core tracking unit. Every task in the plan — imaging or not — is a PlanI @dataclass class PlanItem: """A single item in an experimental plan.""" + id: str - campaign_id: str # Which campaign/phase - type: str # imaging, bench, genetics, analysis, decision_point - title: str # "Pilot — rab-3p::GFP visibility test" - description: Optional[str] = None # Detailed notes, protocols, what to watch for - status: str = "planned" # planned → in_progress → completed | skipped | blocked + campaign_id: str # Which campaign/phase + type: str # imaging, bench, genetics, analysis, decision_point + title: str # "Pilot — rab-3p::GFP visibility test" + description: Optional[str] = None # Detailed notes, protocols, what to watch for + status: str = "planned" # planned → in_progress → completed | skipped | blocked depends_on: List[str] = field(default_factory=list) # PlanItem IDs - outcome: Optional[str] = None # What happened (filled after completion) + outcome: Optional[str] = None # What happened (filled after completion) # Specifications (type-dependent) - imaging_spec: Optional[ImagingSpec] = None # if type == "imaging" - bench_spec: Optional[BenchSpec] = None # if type in (bench, genetics, analysis) + imaging_spec: Optional[ImagingSpec] = None # if type == "imaging" + bench_spec: Optional[BenchSpec] = None # if type in (bench, genetics, analysis) # Linking - planned_session_id: Optional[str] = None # → PlannedSession (for imaging items) - session_id: Optional[str] = None # → Actual session (once executed) - inherit_from: Optional[str] = None # PlanItem ID to inherit spec from + planned_session_id: Optional[str] = None # → PlannedSession (for imaging items) + session_id: Optional[str] = None # → Actual session (once executed) + inherit_from: Optional[str] = None # PlanItem ID to inherit spec from # Ordering phase_order: int = 0 @@ -358,34 +359,34 @@ class ImagingSpec: """Complete specification for a planned imaging session.""" # ── Sample ────────────────────────────────────────── - strain: Optional[str] = None # "OH904" - genotype: Optional[str] = None # "otIs355[rab-3p::2xNLS::TagRFP]" - reporter: Optional[str] = None # "rab-3p::GFP (pan-neuronal)" - sample_prep: Optional[str] = None # "Standard egg prep, poly-lysine pads" - temperature_c: Optional[float] = None # 20.0 - num_embryos: Optional[int] = None # 4 + strain: Optional[str] = None # "OH904" + genotype: Optional[str] = None # "otIs355[rab-3p::2xNLS::TagRFP]" + reporter: Optional[str] = None # "rab-3p::GFP (pan-neuronal)" + sample_prep: Optional[str] = None # "Standard egg prep, poly-lysine pads" + temperature_c: Optional[float] = None # 20.0 + num_embryos: Optional[int] = None # 4 # ── Acquisition ───────────────────────────────────── - num_slices: Optional[int] = None # 80 - exposure_ms: Optional[float] = None # 10.0 - laser_wavelength_nm: Optional[int] = None # 488 - laser_power_pct: Optional[float] = None # 10.0 - galvo_amplitude: Optional[float] = None # 8.0 + num_slices: Optional[int] = None # 80 + exposure_ms: Optional[float] = None # 10.0 + laser_wavelength_nm: Optional[int] = None # 488 + laser_power_pct: Optional[float] = None # 10.0 + galvo_amplitude: Optional[float] = None # 8.0 piezo_amplitude_um: Optional[float] = None # 50.0 # ── Timing ────────────────────────────────────────── - interval_s: Optional[int] = None # 180 + interval_s: Optional[int] = None # 180 adaptive_intervals: Optional[Dict[str, int]] = None # e.g. {"early_to_comma": 300, "comma_to_2fold": 60, "after_2fold": 180} # ── Developmental Window ──────────────────────────── - target_window: Optional[str] = None # "comma → pretzel" - start_stage: Optional[str] = None # "comma" - stop_condition: Optional[str] = None # "pretzel" + target_window: Optional[str] = None # "comma → pretzel" + start_stage: Optional[str] = None # "comma" + stop_condition: Optional[str] = None # "pretzel" estimated_duration_h: Optional[float] = None # 4.0 # ── Detection ─────────────────────────────────────── - detectors: Optional[List[str]] = None # ["comma", "pretzel"] + detectors: Optional[List[str]] = None # ["comma", "pretzel"] pre_terminal_speedup: Optional[bool] = None # True # ── Validation ────────────────────────────────────── @@ -403,12 +404,13 @@ Specification for non-imaging tasks (bench work, genetics, analysis). @dataclass class BenchSpec: """Specification for bench/genetics/analysis tasks.""" - protocol: Optional[str] = None # "Standard chemotaxis assay" - reagents: Optional[List[str]] = None # ["anti-UNC-33", "secondary 568"] - strains: Optional[List[str]] = None # ["OH904", "N2"] - target_genotype: Optional[str] = None # "unc-6(ev400); otIs355" - estimated_days: Optional[int] = None # 14 - success_criteria: Optional[str] = None # "Homozygous GFP+ line established" + + protocol: Optional[str] = None # "Standard chemotaxis assay" + reagents: Optional[List[str]] = None # ["anti-UNC-33", "secondary 568"] + strains: Optional[List[str]] = None # ["OH904", "N2"] + target_genotype: Optional[str] = None # "unc-6(ev400); otIs355" + estimated_days: Optional[int] = None # 14 + success_criteria: Optional[str] = None # "Homozygous GFP+ line established" notes: Optional[str] = None ``` diff --git a/docs/README.md b/docs/README.md index 598ebf6a..a7e1878b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,7 @@ Create `gently/mmcore_wrapper.py` using the template in the Java-MMCore Interfac ```python from gently.mmcore_wrapper import GentlyProperties, GentlyDevices, DeviceKeys, PropertyKeys + def volume_scan_plan(core, num_slices=100, slice_step_um=0.5): # Initialize devices = GentlyDevices(core) diff --git a/docs/asidispim_camera_triggering.md b/docs/asidispim_camera_triggering.md index bfcf30bd..7d12ab91 100644 --- a/docs/asidispim_camera_triggering.md +++ b/docs/asidispim_camera_triggering.md @@ -129,6 +129,7 @@ for i in range(num_slices): img = core.popNextImage() try: import rpyc + img = rpyc.classic.obtain(img) except: pass @@ -252,19 +253,20 @@ core.setProperty(galvo_device, "SingleAxisYMode", "3 - Enabled with axes synced" ```python # TRIGGER_SOURCE property "INTERNAL" # For live/snap mode + "EXTERNAL" # For hardware-triggered acquisition # SENSOR_MODE property -"AREA" # Standard split readout (top/bottom simultaneous) +"AREA" # Standard split readout (top/bottom simultaneous) "PROGRESSIVE" # Rolling shutter for light sheet (slower but better for SPIM) # TRIGGER_ACTIVE property -"EDGE" # Edge trigger - single pulse starts exposure -"LEVEL" # Level trigger - TTL high duration = exposure time -"SYNCREADOUT" # Overlap mode - synchronous readout +"EDGE" # Edge trigger - single pulse starts exposure +"LEVEL" # Level trigger - TTL high duration = exposure time +"SYNCREADOUT" # Overlap mode - synchronous readout # TriggerPolarity property -"POSITIVE" # Trigger on rising edge (required) +"POSITIVE" # Trigger on rising edge (required) ``` **Configuration Sequence (from Cameras.java:231-260):** @@ -299,9 +301,10 @@ core.setExposure(camera_name, 10.0) # milliseconds ```python # Triggermode property -"Internal" # For live/snap mode -"External" # Edge trigger mode -"External Exp. Ctrl." # Level trigger mode +"Internal" # For live/snap mode + +"External" # Edge trigger mode +"External Exp. Ctrl." # Level trigger mode # PixelRate property "slow scan" # Slower pixel readout, higher quality @@ -327,17 +330,18 @@ core.setProperty(camera_name, "Triggermode", "External Exp. Ctrl.") ```python # TriggerMode property "Internal (Recommended for fast acquisitions)" # Live mode -"External" # Edge trigger -"External Exposure" # Level trigger + +"External" # Edge trigger +"External Exposure" # Level trigger # Overlap property -"On" # Overlap mode enabled +"On" # Overlap mode enabled "Off" # Standard mode # LightScanPlus-SensorReadoutMode property "Centre Out Simultaneous" # Standard split readout -"Bottom Up Sequential" # Rolling shutter for light sheet -"Bottom Up Simultaneous" # Split readout from bottom +"Bottom Up Sequential" # Rolling shutter for light sheet +"Bottom Up Simultaneous" # Split readout from bottom ``` **Configuration Sequence (from Cameras.java:284-331):** @@ -362,10 +366,11 @@ core.setProperty(camera_name, "Overlap", "Off") ```python # TriggerMode property "Internal Trigger" # Live mode -"Edge Trigger" # Hardware triggered + +"Edge Trigger" # Hardware triggered # ClearMode property -"Never" # No clearing between frames +"Never" # No clearing between frames "Pre-Exposure" # Clear before each exposure "Pre-Sequence" # Clear once before sequence ``` @@ -389,43 +394,43 @@ All properties are set on the **Micro-mirror (Galvo) card device**: galvo_device = "Scanner:AB:33" # Your device name # SPIM State Machine Control -"SPIMState" # Values: "Idle", "Armed", "Running" -"SPIMNumSlices" # Number of slices per side (e.g., 100) -"SPIMNumSides" # 1 = single side, 2 = dual view -"SPIMFirstSide" # "A" or "B" - which side starts +"SPIMState" # Values: "Idle", "Armed", "Running" +"SPIMNumSlices" # Number of slices per side (e.g., 100) +"SPIMNumSides" # 1 = single side, 2 = dual view +"SPIMFirstSide" # "A" or "B" - which side starts # SPIM Timing Properties (ALL IN MILLISECONDS, 0.25ms resolution) -"SPIMDelayBeforeScan(ms)" # Delay before scan mirror starts -"SPIMScanDuration(ms)" # Scan mirror sweep duration -"SPIMDelayBeforeLaser(ms)" # Delay before laser trigger -"SPIMLaserDuration(ms)" # Laser TTL pulse width -"SPIMDelayBeforeCamera(ms)" # Delay before camera trigger -"SPIMCameraDuration(ms)" # Camera TTL pulse width ← CRITICAL! +"SPIMDelayBeforeScan(ms)" # Delay before scan mirror starts +"SPIMScanDuration(ms)" # Scan mirror sweep duration +"SPIMDelayBeforeLaser(ms)" # Delay before laser trigger +"SPIMLaserDuration(ms)" # Laser TTL pulse width +"SPIMDelayBeforeCamera(ms)" # Delay before camera trigger +"SPIMCameraDuration(ms)" # Camera TTL pulse width ← CRITICAL! # SPIM Multi-Acquisition Properties -"SPIMNumRepeats" # Volumes per trigger (for hardware timepoints) -"SPIMDelayBeforeRepeat(ms)" # Delay between volumes -"SPIMDelayBeforeSide(ms)" # Delay between sides (for dual view) -"SPIMNumScansPerSlice" # Usually 1 -"SPIMNumSlicesPerPiezo" # For multichannel slice-by-slice -"SPIMAlternateDirectionsEnable" # "Yes" or "No" -"SPIMInterleaveSidesEnable" # For interleaved stage scan -"SPIMPiezoHomeDisable" # For stage scan mode +"SPIMNumRepeats" # Volumes per trigger (for hardware timepoints) +"SPIMDelayBeforeRepeat(ms)" # Delay between volumes +"SPIMDelayBeforeSide(ms)" # Delay between sides (for dual view) +"SPIMNumScansPerSlice" # Usually 1 +"SPIMNumSlicesPerPiezo" # For multichannel slice-by-slice +"SPIMAlternateDirectionsEnable" # "Yes" or "No" +"SPIMInterleaveSidesEnable" # For interleaved stage scan +"SPIMPiezoHomeDisable" # For stage scan mode # Scan Mirror Properties -"SingleAxisXAmplitude(deg)" # Light sheet width (X-axis) -"SingleAxisXOffset(deg)" # Light sheet position offset -"SingleAxisXPattern" # "0 - Ramp", "1 - Triangle" -"SingleAxisXMode" # "0 - Disabled", "1 - Enabled", "3 - Enabled with axes synced" +"SingleAxisXAmplitude(deg)" # Light sheet width (X-axis) +"SingleAxisXOffset(deg)" # Light sheet position offset +"SingleAxisXPattern" # "0 - Ramp", "1 - Triangle" +"SingleAxisXMode" # "0 - Disabled", "1 - Enabled", "3 - Enabled with axes synced" -"SingleAxisYAmplitude(deg)" # Slice stepping amplitude (Y-axis) -"SingleAxisYOffset(deg)" # Slice position offset -"SingleAxisYPattern" # "0 - Ramp", "1 - Triangle" -"SingleAxisYMode" # "0 - Disabled", "3 - Enabled with axes synced" +"SingleAxisYAmplitude(deg)" # Slice stepping amplitude (Y-axis) +"SingleAxisYOffset(deg)" # Slice position offset +"SingleAxisYPattern" # "0 - Ramp", "1 - Triangle" +"SingleAxisYMode" # "0 - Disabled", "3 - Enabled with axes synced" # Critical Output Configuration -"LaserOutputMode" # "shutter + side" ← MUST BE SET FOR TRIGGERS! -"BeamEnabled" # "Yes" or "No" - disable during SPIM acquisition +"LaserOutputMode" # "shutter + side" ← MUST BE SET FOR TRIGGERS! +"BeamEnabled" # "Yes" or "No" - disable during SPIM acquisition ``` ### Piezo (Z-drive) Properties @@ -435,12 +440,12 @@ galvo_device = "Scanner:AB:33" # Your device name piezo_device = "Piezo:A:37" # Your device name # Piezo Sweep Properties -"SPIMState" # "Idle", "Armed" ← Must arm before galvo trigger -"SPIMNumSlices" # Number of Z positions -"SA_AMPLITUDE" # Sweep amplitude in micrometers -"SA_OFFSET" # Center position in micrometers -"SA_PATTERN" # "0 - Ramp", "1 - Triangle" -"SA_MODE_Z" # "0 - Disabled", "1 - Enabled", "3 - Enabled with axes synced" +"SPIMState" # "Idle", "Armed" ← Must arm before galvo trigger +"SPIMNumSlices" # Number of Z positions +"SA_AMPLITUDE" # Sweep amplitude in micrometers +"SA_OFFSET" # Center position in micrometers +"SA_PATTERN" # "0 - Ramp", "1 - Triangle" +"SA_MODE_Z" # "0 - Disabled", "1 - Enabled", "3 - Enabled with axes synced" ``` ### PLogic Card Properties (Optional) @@ -450,9 +455,9 @@ piezo_device = "Piezo:A:37" # Your device name plogic_device = "PLogic:E:36" # Your device name # PLogic Control -"PLogicMode" # "Disp. Seq. positions" -"PLogicOutputChannel" # "6,7" for lasers on BNC6 & BNC7 -"PoLogicPreset" # "3 - cell 1 high" during acquisition +"PLogicMode" # "Disp. Seq. positions" +"PLogicOutputChannel" # "6,7" for lasers on BNC6 & BNC7 +"PoLogicPreset" # "3 - cell 1 high" during acquisition # Note: PLogic adds 0.25ms delay to all TTL outputs ``` @@ -510,9 +515,14 @@ def configure_camera_for_hardware_trigger(core, camera_name, camera_mode="EDGE", ### Phase 2: Timing Calculation ```python -def calculate_spim_timing(camera_exposure_ms, camera_reset_ms, camera_readout_ms, - scan_laser_buffer_ms=0.25, scan_filter_freq_khz=0.2, - has_plogic=False): +def calculate_spim_timing( + camera_exposure_ms, + camera_reset_ms, + camera_readout_ms, + scan_laser_buffer_ms=0.25, + scan_filter_freq_khz=0.2, + has_plogic=False, +): """ Calculate SPIM timing parameters following ASI diSPIM plugin logic. @@ -527,6 +537,7 @@ def calculate_spim_timing(camera_exposure_ms, camera_reset_ms, camera_readout_ms Returns: Dictionary of timing parameters """ + # Round to 0.25ms (Tiger controller resolution) def round_quarter_ms(val): return round(val * 4) / 4.0 @@ -547,14 +558,16 @@ def calculate_spim_timing(camera_exposure_ms, camera_reset_ms, camera_readout_ms scan_delay_filter -= 0.25 # PLogic adds 0.25ms delay timing = { - 'scanDelay': global_exposure_delay_max - scan_laser_buffer_ms - scan_delay_filter, - 'scanPeriod': scan_duration, - 'laserDelay': global_exposure_delay_max, - 'laserDuration': laser_duration, - 'cameraDelay': camera_readout_max, - 'cameraDuration': 1.0, # Short pulse for EDGE mode - 'cameraExposure': camera_exposure_ms + 0.1, # Add safety margin - 'sliceDuration': max(scan_duration, laser_duration, camera_readout_max + camera_exposure_ms) + "scanDelay": global_exposure_delay_max - scan_laser_buffer_ms - scan_delay_filter, + "scanPeriod": scan_duration, + "laserDelay": global_exposure_delay_max, + "laserDuration": laser_duration, + "cameraDelay": camera_readout_max, + "cameraDuration": 1.0, # Short pulse for EDGE mode + "cameraExposure": camera_exposure_ms + 0.1, # Add safety margin + "sliceDuration": max( + scan_duration, laser_duration, camera_readout_max + camera_exposure_ms + ), } # Round all values to 0.25ms @@ -567,9 +580,9 @@ def calculate_spim_timing(camera_exposure_ms, camera_reset_ms, camera_readout_ms ### Phase 3: Tiger Controller Configuration ```python -def configure_tiger_controller_for_spim(core, galvo_device, piezo_device, - num_slices=100, num_sides=1, first_side_a=True, - timing=None): +def configure_tiger_controller_for_spim( + core, galvo_device, piezo_device, num_slices=100, num_sides=1, first_side_a=True, timing=None +): """ Configure Tiger controller SPIM state machine. @@ -626,12 +639,12 @@ def configure_tiger_controller_for_spim(core, galvo_device, piezo_device, # ⚠️ CRITICAL: Set ALL timing properties explicitly if timing: - core.setProperty(galvo_device, "SPIMDelayBeforeScan(ms)", timing['scanDelay']) - core.setProperty(galvo_device, "SPIMScanDuration(ms)", timing['scanPeriod']) - core.setProperty(galvo_device, "SPIMDelayBeforeLaser(ms)", timing['laserDelay']) - core.setProperty(galvo_device, "SPIMLaserDuration(ms)", timing['laserDuration']) - core.setProperty(galvo_device, "SPIMDelayBeforeCamera(ms)", timing['cameraDelay']) - core.setProperty(galvo_device, "SPIMCameraDuration(ms)", timing['cameraDuration']) + core.setProperty(galvo_device, "SPIMDelayBeforeScan(ms)", timing["scanDelay"]) + core.setProperty(galvo_device, "SPIMScanDuration(ms)", timing["scanPeriod"]) + core.setProperty(galvo_device, "SPIMDelayBeforeLaser(ms)", timing["laserDelay"]) + core.setProperty(galvo_device, "SPIMLaserDuration(ms)", timing["laserDuration"]) + core.setProperty(galvo_device, "SPIMDelayBeforeCamera(ms)", timing["cameraDelay"]) + core.setProperty(galvo_device, "SPIMCameraDuration(ms)", timing["cameraDuration"]) else: # Use safe defaults core.setProperty(galvo_device, "SPIMDelayBeforeScan(ms)", 0.0) @@ -749,7 +762,7 @@ def wait_for_images(core, camera_name, num_expected, timeout_sec=30.0): img = core.popNextImage() img = rpyc.classic.obtain(img) # For rpyc remote objects images.append(img) - print(f" Image {i+1}/{count}: shape={img.shape}, range=[{img.min()}, {img.max()}]") + print(f" Image {i + 1}/{count}: shape={img.shape}, range=[{img.min()}, {img.max()}]") return images ``` @@ -757,8 +770,9 @@ def wait_for_images(core, camera_name, num_expected, timeout_sec=30.0): ### Complete Acquisition Function ```python -def acquire_spim_volume(core, camera_name, galvo_device, piezo_device, - num_slices=100, camera_exposure_ms=5.0): +def acquire_spim_volume( + core, camera_name, galvo_device, piezo_device, num_slices=100, camera_exposure_ms=5.0 +): """ Complete hardware-triggered SPIM volume acquisition. @@ -775,16 +789,16 @@ def acquire_spim_volume(core, camera_name, galvo_device, piezo_device, """ try: # Phase 1: Configure camera - configure_camera_for_hardware_trigger(core, camera_name, - camera_mode="EDGE", - exposure_ms=camera_exposure_ms) + configure_camera_for_hardware_trigger( + core, camera_name, camera_mode="EDGE", exposure_ms=camera_exposure_ms + ) # Phase 2: Calculate timing timing = calculate_spim_timing( camera_exposure_ms=camera_exposure_ms, - camera_reset_ms=3.0, # Hamamatsu Flash4 typical - camera_readout_ms=10.0, # Depends on ROI and scan mode - has_plogic=True + camera_reset_ms=3.0, # Hamamatsu Flash4 typical + camera_readout_ms=10.0, # Depends on ROI and scan mode + has_plogic=True, ) print("\nCalculated timing:") @@ -792,11 +806,15 @@ def acquire_spim_volume(core, camera_name, galvo_device, piezo_device, print(f" {key}: {val} ms") # Phase 3: Configure Tiger controller - configure_tiger_controller_for_spim(core, galvo_device, piezo_device, - num_slices=num_slices, - num_sides=1, - first_side_a=True, - timing=timing) + configure_tiger_controller_for_spim( + core, + galvo_device, + piezo_device, + num_slices=num_slices, + num_sides=1, + first_side_a=True, + timing=timing, + ) # Phase 4: Start camera sequence start_camera_sequence(core, camera_name, num_slices) @@ -805,7 +823,7 @@ def acquire_spim_volume(core, camera_name, galvo_device, piezo_device, trigger_spim_acquisition(core, galvo_device) # Phase 6: Wait for images - expected_time = num_slices * timing['sliceDuration'] / 1000.0 + expected_time = num_slices * timing["sliceDuration"] / 1000.0 timeout = expected_time * 2 + 10.0 images = wait_for_images(core, camera_name, num_slices, timeout) @@ -1143,7 +1161,7 @@ print(f" SPIMDelayBeforeCamera(ms): {core.getProperty(galvo_device, 'SPIMDelayB print(f" SPIMCameraDuration(ms): {core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')}") # CRITICAL CHECK -camera_duration = float(core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')) +camera_duration = float(core.getProperty(galvo_device, "SPIMCameraDuration(ms)")) if camera_duration <= 0: print("\n⚠️ WARNING: SPIMCameraDuration is 0 - NO TRIGGERS WILL BE GENERATED!") print(" You must set this property to > 0 (typically 1.0 ms)") @@ -1187,7 +1205,7 @@ print(f" SA_AMPLITUDE: {core.getProperty(piezo_device, 'SA_AMPLITUDE')} µm") print(f" SA_OFFSET: {core.getProperty(piezo_device, 'SA_OFFSET')} µm") # Piezo should be Armed before galvo is set to Running -piezo_state = core.getProperty(piezo_device, 'SPIMState') +piezo_state = core.getProperty(piezo_device, "SPIMState") assert piezo_state == "Armed", f"Piezo should be Armed, not {piezo_state}" ``` @@ -1369,7 +1387,7 @@ piezo_device = "Piezo:A:37" num_slices = 100 camera_exposure_ms = 5.0 # Light exposure time -camera_reset_ms = 3.0 # Hamamatsu Flash4 typical +camera_reset_ms = 3.0 # Hamamatsu Flash4 typical camera_readout_ms = 10.0 # Depends on ROI print("=" * 70) @@ -1412,6 +1430,7 @@ try: def ceil_quarter_ms(val): import math + return math.ceil(val * 4) / 4.0 camera_readout_max = ceil_quarter_ms(camera_readout_ms) @@ -1496,9 +1515,11 @@ try: core.setProperty(galvo_device, "SPIMDelayBeforeRepeat(ms)", 0.0) # Verify timing properties are set - print(f" SPIMCameraDuration(ms): {core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')} ← MUST BE > 0!") + print( + f" SPIMCameraDuration(ms): {core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')} ← MUST BE > 0!" + ) - camera_duration_check = float(core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')) + camera_duration_check = float(core.getProperty(galvo_device, "SPIMCameraDuration(ms)")) if camera_duration_check <= 0: raise Exception("SPIMCameraDuration is 0 - triggers will not be generated!") @@ -1547,13 +1568,14 @@ try: # Step 9: Retrieve images count = core.getRemainingImageCount() - print(f"\n{'='*70}") + print(f"\n{'=' * 70}") if count >= num_slices: print(f"✓ SUCCESS! Acquired {count} images") - print(f"{'='*70}") + print(f"{'=' * 70}") import rpyc + images = [] print(f"\nRetrieving {count} images...") for i in range(count): @@ -1561,26 +1583,29 @@ try: img = rpyc.classic.obtain(img) images.append(img) if i < 5 or i >= count - 5: # Print first and last 5 - print(f" Image {i+1}: shape={img.shape}, range=[{img.min()}, {img.max()}]") + print(f" Image {i + 1}: shape={img.shape}, range=[{img.min()}, {img.max()}]") volume = np.array(images) print(f"\nVolume shape: {volume.shape}") # Save as TIFF from PIL import Image + img_list = [Image.fromarray(img.astype(np.uint16)) for img in images] - img_list[0].save('spim_volume_fixed.tif', save_all=True, append_images=img_list[1:]) + img_list[0].save("spim_volume_fixed.tif", save_all=True, append_images=img_list[1:]) print(f"Saved to: spim_volume_fixed.tif") else: print(f"✗ FAILED - Got {count}/{num_slices} images") - print(f"{'='*70}") + print(f"{'=' * 70}") # Diagnostic output print("\nDiagnostics:") print(f" SPIMState: {core.getProperty(galvo_device, 'SPIMState')}") print(f" LaserOutputMode: {core.getProperty(galvo_device, 'LaserOutputMode')}") - print(f" SPIMCameraDuration(ms): {core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')}") + print( + f" SPIMCameraDuration(ms): {core.getProperty(galvo_device, 'SPIMCameraDuration(ms)')}" + ) print(f" Camera trigger: {core.getProperty(camera_name, 'TRIGGER SOURCE')}") print("\nPossible issues:") print(" - Check physical BNC cable connection to camera") @@ -1589,9 +1614,9 @@ try: finally: # Cleanup - print(f"\n{'='*70}") + print(f"\n{'=' * 70}") print("CLEANUP") - print(f"{'='*70}") + print(f"{'=' * 70}") try: if core.isSequenceRunning(camera_name): diff --git a/docs/asidispim_piezo_scanning_technical_report.md b/docs/asidispim_piezo_scanning_technical_report.md index 2570b495..89260f6b 100644 --- a/docs/asidispim_piezo_scanning_technical_report.md +++ b/docs/asidispim_piezo_scanning_technical_report.md @@ -500,6 +500,7 @@ def configure_piezo_for_scan(piezo_device, start_um, end_um, num_slices): core.waitForDevice(piezo_device) time.sleep(0.5) # Allow settling + # CALIBRATION (Lines 99-115) def piezo_to_galvo_y(piezo_pos_um, slope=100.306, offset=4.102): """Convert piezo position to galvo Y-axis angle.""" @@ -755,15 +756,15 @@ def configure_piezo_for_scan(piezo_device, start_um, end_um, num_slices): print(f" ✓ Piezo configured and armed for SPIM scanning") return { - 'start_um': start_um, - 'end_um': end_um, - 'center_um': piezo_center, - 'amplitude_um': piezo_amplitude, - 'step_size_um': step_size, - 'galvo_y_start': galvo_y_start, - 'galvo_y_end': galvo_y_end, - 'galvo_y_center': galvo_y_center, - 'galvo_y_amplitude': galvo_y_amplitude, + "start_um": start_um, + "end_um": end_um, + "center_um": piezo_center, + "amplitude_um": piezo_amplitude, + "step_size_um": step_size, + "galvo_y_start": galvo_y_start, + "galvo_y_end": galvo_y_end, + "galvo_y_center": galvo_y_center, + "galvo_y_amplitude": galvo_y_amplitude, } ``` @@ -773,16 +774,15 @@ Update the main acquisition function to use the calculated galvo values: ```python # Step 5: Configure piezo -piezo_config = configure_piezo_for_scan(PIEZO_DEVICE, piezo_start_um, - piezo_end_um, num_slices) +piezo_config = configure_piezo_for_scan(PIEZO_DEVICE, piezo_start_um, piezo_end_um, num_slices) # Step 6: Configure Tiger controller with synchronized galvo Y-axis configure_tiger_controller( GALVO_DEVICE, num_slices, timing, - galvo_y_amp=piezo_config['galvo_y_amplitude'], - galvo_y_offset=piezo_config['galvo_y_center'] + galvo_y_amp=piezo_config["galvo_y_amplitude"], + galvo_y_offset=piezo_config["galvo_y_center"], ) ``` diff --git a/docs/guides/build-a-plugin.md b/docs/guides/build-a-plugin.md index ce3ca4bd..d6c11872 100644 --- a/docs/guides/build-a-plugin.md +++ b/docs/guides/build-a-plugin.md @@ -25,14 +25,14 @@ Three protocols define the plugin contracts. All are in `gently/harness/protocol ```python @runtime_checkable class OrganismProtocol(Protocol): - ORGANISM_NAME: str # e.g. "drosophila" - ORGANISM_DISPLAY_NAME: str # e.g. "Drosophila melanogaster" - SAMPLE_TERM: str # e.g. "embryo", "cell", "organoid" - SAMPLE_TERM_PLURAL: str # e.g. "embryos" - STAGES: list # Developmental stages (ordered) - TERMINAL_STAGES: set # e.g. {"hatched"} - BIOLOGY_KNOWLEDGE: str # Markdown for LLM context - PERCEPTION_SYSTEM_PROMPT: str # VLM classification prompt + ORGANISM_NAME: str # e.g. "drosophila" + ORGANISM_DISPLAY_NAME: str # e.g. "Drosophila melanogaster" + SAMPLE_TERM: str # e.g. "embryo", "cell", "organoid" + SAMPLE_TERM_PLURAL: str # e.g. "embryos" + STAGES: list # Developmental stages (ordered) + TERMINAL_STAGES: set # e.g. {"hatched"} + BIOLOGY_KNOWLEDGE: str # Markdown for LLM context + PERCEPTION_SYSTEM_PROMPT: str # VLM classification prompt ``` ### HardwareProtocol @@ -40,10 +40,10 @@ class OrganismProtocol(Protocol): ```python @runtime_checkable class HardwareProtocol(Protocol): - HARDWARE_NAME: str # e.g. "twophoton" - HARDWARE_DISPLAY_NAME: str # e.g. "Two-Photon Microscope" - HARDWARE_DESCRIPTION: str # Markdown capabilities text - CAPABILITIES: set # e.g. {"xy_stage", "z_stack", "fluorescence"} + HARDWARE_NAME: str # e.g. "twophoton" + HARDWARE_DISPLAY_NAME: str # e.g. "Two-Photon Microscope" + HARDWARE_DESCRIPTION: str # Markdown capabilities text + CAPABILITIES: set # e.g. {"xy_stage", "z_stack", "fluorescence"} ``` Standard capability names: `xy_stage`, `z_control`, `volume`, `snap`, `z_stack`, `dual_view`, `autofocus`, `detection`, `fluorescence`, `transmitted`. @@ -80,6 +80,7 @@ def create_device_layer(config: dict): """Create the hardware control server. Returns a server with .run(port=N).""" ... + def create_client(http_url: str): """Create an HTTP client for the device layer. Returns a client with .connect().""" ... @@ -105,8 +106,10 @@ gently/organisms/drosophila/ # gently/organisms/drosophila/stages.py from enum import Enum + class DevelopmentalStage(str, Enum): """Drosophila embryo developmental stages.""" + SYNCYTIAL = "syncytial" CELLULARIZATION = "cellularization" GASTRULATION = "gastrulation" @@ -119,6 +122,7 @@ class DevelopmentalStage(str, Enum): ARRESTED = "arrested" NO_OBJECT = "no_object" + # Ordered list for the perception engine STAGES = list(DevelopmentalStage) @@ -298,14 +302,16 @@ CAPABILITIES = { def create_device_layer(config: dict): """Create the 2P device layer server.""" from .device_layer import TwoPhotonDeviceLayer + return TwoPhotonDeviceLayer( - config_path=config.get('config_path', 'config/config.yml'), + config_path=config.get("config_path", "config/config.yml"), ) def create_client(http_url: str): """Create an HTTP client for the 2P device layer.""" from .client import TwoPhotonClient + return TwoPhotonClient(http_url=http_url) ``` @@ -318,24 +324,26 @@ Each hardware type has its own calibration model. For 2P, it's simpler than diSP from dataclasses import dataclass from typing import Optional + @dataclass class TwoPhotonCalibration: """Z-axis calibration for a two-photon microscope.""" - z_top: float = 0.0 # Top of sample (µm) - z_bottom: float = 100.0 # Bottom of sample (µm) - optimal_z: float = 50.0 # Best focal plane (µm) + + z_top: float = 0.0 # Top of sample (µm) + z_bottom: float = 100.0 # Bottom of sample (µm) + optimal_z: float = 50.0 # Best focal plane (µm) optimal_power: float = 10.0 # Laser power (mW) def to_dict(self) -> dict: return { - 'z_top': self.z_top, - 'z_bottom': self.z_bottom, - 'optimal_z': self.optimal_z, - 'optimal_power': self.optimal_power, + "z_top": self.z_top, + "z_bottom": self.z_bottom, + "optimal_z": self.optimal_z, + "optimal_power": self.optimal_power, } @classmethod - def from_dict(cls, data: dict) -> 'TwoPhotonCalibration': + def from_dict(cls, data: dict) -> "TwoPhotonCalibration": return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data}) ``` @@ -370,16 +378,14 @@ Tools are registered with the `@tool` decorator from `gently/harness/tools/regis ```python from gently.harness.tools.registry import tool, ToolCategory, ToolExample + @tool( name="measure_wing_disc", description="Measure the size of a wing imaginal disc in the current image", category=ToolCategory.ANALYSIS, requires_microscope=False, examples=[ - ToolExample( - "Measure the wing disc in embryo 3", - {"embryo_id": "embryo_3"} - ), + ToolExample("Measure the wing disc in embryo 3", {"embryo_id": "embryo_3"}), ], ) async def measure_wing_disc( @@ -404,13 +410,12 @@ Hardware-specific tools (acquisition, calibration, focus) should live alongside ```python @tool(name="acquire_zstack", requires_microscope=True) -async def acquire_zstack(embryo_id: str, num_planes: int = 50, - z_step_um: float = 1.0, context: dict = None) -> str: +async def acquire_zstack( + embryo_id: str, num_planes: int = 50, z_step_um: float = 1.0, context: dict = None +) -> str: client = context.get("client") # This tool only works with a 2P client - result = await client.acquire_zstack( - num_planes=num_planes, z_step_um=z_step_um - ) + result = await client.acquire_zstack(num_planes=num_planes, z_step_um=z_step_um) ... ``` @@ -452,13 +457,13 @@ The harness provides a generic `FocusDataPoint` for tracking focus measurements: ```python @dataclass class FocusDataPoint: - z: float # Primary focus axis (µm) + z: float # Primary focus axis (µm) secondary_axis: float # Second axis (galvo for diSPIM, 0.0 for single-axis) - score: float # Focus quality - r_squared: float # Fit quality (0-1) + score: float # Focus quality + r_squared: float # Fit quality (0-1) timestamp: datetime - method: str # 'calibration', 'fine_focus', 'manual' - algorithm: str # 'fft_bandpass', 'gradient', etc. + method: str # 'calibration', 'fine_focus', 'manual' + algorithm: str # 'fft_bandpass', 'gradient', etc. ``` For single-axis systems (2P, confocal), set `secondary_axis=0.0`. The harness tracks focus history per-embryo and provides drift analysis and interpolation. diff --git a/docs/java-mmcore-interface-pattern.md b/docs/java-mmcore-interface-pattern.md index 120baffc..5c29f88d 100644 --- a/docs/java-mmcore-interface-pattern.md +++ b/docs/java-mmcore-interface-pattern.md @@ -581,6 +581,7 @@ logger = logging.getLogger(__name__) # Property Keys Enum # ============================================================================ + class PropertyKeys(Enum): """ Enum of all device adapter property names used in Gently. @@ -666,6 +667,7 @@ class PropertyKeys(Enum): # Property Values Enum # ============================================================================ + class PropertyValues(Enum): """ Enum of common property values. @@ -703,6 +705,7 @@ class PropertyValues(Enum): # Device Keys Enum # ============================================================================ + class DeviceKeys(Enum): """ Enum of device roles in the Gently system. @@ -735,6 +738,7 @@ class DeviceKeys(Enum): # Gently Devices Class # ============================================================================ + class GentlyDevices: """ Manages the mapping between device roles (DeviceKeys) and MMCore device names. @@ -838,6 +842,7 @@ class GentlyDevices: # Gently Properties Class # ============================================================================ + class GentlyProperties: """ Type-safe wrapper for MMCore property access. @@ -879,7 +884,7 @@ class GentlyProperties: device_key: DeviceKeys, property_key: PropertyKeys, value: Union[str, int, float, PropertyValues], - ignore_error: bool = False + ignore_error: bool = False, ): """ Set a device property via MMCore. @@ -915,7 +920,7 @@ class GentlyProperties: if not should_set: try: current_value = self.core.getProperty(mm_device, prop_name) - should_set = (str(value) != str(current_value)) + should_set = str(value) != str(current_value) except Exception: # If we can't read current value, set it anyway should_set = True @@ -937,7 +942,7 @@ class GentlyProperties: device_keys: List[DeviceKeys], property_key: PropertyKeys, value: Union[str, int, float, PropertyValues], - ignore_error: bool = False + ignore_error: bool = False, ): """ Set a property on multiple devices. @@ -951,11 +956,7 @@ class GentlyProperties: for device_key in device_keys: self.set_property(device_key, property_key, value, ignore_error) - def get_property_string( - self, - device_key: DeviceKeys, - property_key: PropertyKeys - ) -> str: + def get_property_string(self, device_key: DeviceKeys, property_key: PropertyKeys) -> str: """ Get a property value as a string. @@ -978,11 +979,7 @@ class GentlyProperties: logger.warning(f"Error getting {device_key}.{property_key}: {e}") return "" - def get_property_int( - self, - device_key: DeviceKeys, - property_key: PropertyKeys - ) -> int: + def get_property_int(self, device_key: DeviceKeys, property_key: PropertyKeys) -> int: """ Get a property value as an integer. @@ -1002,11 +999,7 @@ class GentlyProperties: logger.warning(f"Error parsing int from {device_key}.{property_key}: {e}") return 0 - def get_property_float( - self, - device_key: DeviceKeys, - property_key: PropertyKeys - ) -> float: + def get_property_float(self, device_key: DeviceKeys, property_key: PropertyKeys) -> float: """ Get a property value as a float. @@ -1041,8 +1034,11 @@ Example: Configure ASI diSPIM controller for volume acquisition using MMCore wra """ from gently.mmcore_wrapper import ( - GentlyDevices, GentlyProperties, - DeviceKeys, PropertyKeys, PropertyValues + GentlyDevices, + GentlyProperties, + DeviceKeys, + PropertyKeys, + PropertyValues, ) @@ -1093,10 +1089,7 @@ def configure_spim_volume_scan( # Step 1: Disable beam during configuration # ======================================================================== props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.BEAM_ENABLED, - PropertyValues.NO, - ignore_error=True + DeviceKeys.GALVO_A, PropertyKeys.BEAM_ENABLED, PropertyValues.NO, ignore_error=True ) # ======================================================================== @@ -1104,82 +1097,38 @@ def configure_spim_volume_scan( # ======================================================================== # Number of slices - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_NUM_SLICES, - num_slices - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_NUM_SLICES, num_slices) # Scan amplitude (determines slice coverage) - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SA_AMPLITUDE_Y_DEG, - galvo_amplitude_deg - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SA_AMPLITUDE_Y_DEG, galvo_amplitude_deg) # Timing - scan - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DELAY_SCAN, - delay_before_scan - ) - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DURATION_SCAN, - scan_duration_ms - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DELAY_SCAN, delay_before_scan) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_SCAN, scan_duration_ms) # Timing - camera - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DELAY_CAMERA, - delay_before_camera - ) - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DURATION_CAMERA, - camera_duration - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DELAY_CAMERA, delay_before_camera) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_CAMERA, camera_duration) # Timing - laser - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DELAY_LASER, - delay_before_laser - ) - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_DURATION_LASER, - laser_duration - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DELAY_LASER, delay_before_laser) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_LASER, laser_duration) # ======================================================================== # Step 3: Configure piezo SPIM properties # ======================================================================== # Piezo amplitude (total range of travel) - props.set_property( - DeviceKeys.PIEZO_A, - PropertyKeys.SA_AMPLITUDE, - piezo_amplitude_um - ) + props.set_property(DeviceKeys.PIEZO_A, PropertyKeys.SA_AMPLITUDE, piezo_amplitude_um) # Number of slices (shared with galvo) - props.set_property( - DeviceKeys.PIEZO_A, - PropertyKeys.SPIM_NUM_SLICES_PER_PIEZO, - num_slices - ) + props.set_property(DeviceKeys.PIEZO_A, PropertyKeys.SPIM_NUM_SLICES_PER_PIEZO, num_slices) # ======================================================================== # Step 4: Arm SPIM state machine # ======================================================================== - props.set_property( - DeviceKeys.GALVO_A, - PropertyKeys.SPIM_STATE, - PropertyValues.SPIM_ARMED - ) + props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, PropertyValues.SPIM_ARMED) print(f"✓ Configured SPIM volume scan:") print(f" - Slices: {num_slices}") @@ -1214,17 +1163,14 @@ def read_spim_status(core): print(f" - Amplitude: {amplitude}°") print(f" - State: {state}") - return { - 'num_slices': num_slices, - 'amplitude': amplitude, - 'state': state - } + return {"num_slices": num_slices, "amplitude": amplitude, "state": state} # ============================================================================ # Integration with existing Gently device classes # ============================================================================ + def integrate_with_existing_devices(): """ Example of how to integrate the wrapper with existing Gently device classes. @@ -1252,12 +1198,18 @@ def integrate_with_existing_devices(): def configure_spim(self, num_slices, amplitude_deg, scan_duration_ms): """Configure SPIM parameters using type-safe wrapper.""" self.props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_NUM_SLICES, num_slices) - self.props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SA_AMPLITUDE_Y_DEG, amplitude_deg) - self.props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_SCAN, scan_duration_ms) + self.props.set_property( + DeviceKeys.GALVO_A, PropertyKeys.SA_AMPLITUDE_Y_DEG, amplitude_deg + ) + self.props.set_property( + DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_SCAN, scan_duration_ms + ) def arm_spim(self): """Arm SPIM state machine.""" - self.props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, PropertyValues.SPIM_ARMED) + self.props.set_property( + DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, PropertyValues.SPIM_ARMED + ) def get_spim_state(self) -> str: """Get current SPIM state.""" @@ -1279,7 +1231,7 @@ if __name__ == "__main__": slice_step_um=0.5, galvo_amplitude_deg=5.0, scan_duration_ms=10.0, - camera_exposure_ms=8.0 + camera_exposure_ms=8.0, ) # Read status diff --git a/docs/lightsheet-creation-explained.md b/docs/lightsheet-creation-explained.md index ff17bf6c..72241694 100644 --- a/docs/lightsheet-creation-explained.md +++ b/docs/lightsheet-creation-explained.md @@ -200,8 +200,11 @@ props_.setPropValue(galvoDevice, Properties.Keys.SPIM_STATE, ```python from gently.mmcore_wrapper import ( - GentlyProperties, GentlyDevices, - DeviceKeys, PropertyKeys, PropertyValues + GentlyProperties, + GentlyDevices, + DeviceKeys, + PropertyKeys, + PropertyValues, ) # Initialize @@ -224,11 +227,9 @@ props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DELAY_CAMERA, 0.5) props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_DURATION_CAMERA, 9.0) # Arm and trigger -props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, - PropertyValues.SPIM_ARMED) +props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, PropertyValues.SPIM_ARMED) # ... then trigger via TTL or: -props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, - PropertyValues.SPIM_RUNNING) +props.set_property(DeviceKeys.GALVO_A, PropertyKeys.SPIM_STATE, PropertyValues.SPIM_RUNNING) ``` --- diff --git a/docs/superpowers/plans/2026-06-16-notebook-foundation.md b/docs/superpowers/plans/2026-06-16-notebook-foundation.md index 4d6a480c..4cb777e9 100644 --- a/docs/superpowers/plans/2026-06-16-notebook-foundation.md +++ b/docs/superpowers/plans/2026-06-16-notebook-foundation.md @@ -42,8 +42,8 @@ class TestNoteModel: n = Note(id="abc123", kind=NoteKind.OBSERVATION, body="dim rings at 10 ms") d = note_to_dict(n) assert d["kind"] == "observation" - assert d["author"] == "agent" # default - assert d["status"] == "confirmed" # default + assert d["author"] == "agent" # default + assert d["status"] == "confirmed" # default back = note_from_dict(d) assert back == n @@ -100,8 +100,8 @@ from .model import Confidence class NoteKind(str, Enum): OBSERVATION = "observation" # immutable record of what was seen/done/read/noted - FINDING = "finding" # revisable, supersedable believed claim - QUESTION = "question" # open inquiry; large ones are the thread spine + FINDING = "finding" # revisable, supersedable believed claim + QUESTION = "question" # open inquiry; large ones are the thread spine class Author(str, Enum): @@ -111,10 +111,10 @@ class Author(str, Enum): class NoteStatus(str, Enum): - OPEN = "open" # questions not yet answered - PROPOSED = "proposed" # agent-drafted finding awaiting human confirm - CONFIRMED = "confirmed" # accepted observation/finding (default) - ANSWERED = "answered" # question resolved + OPEN = "open" # questions not yet answered + PROPOSED = "proposed" # agent-drafted finding awaiting human confirm + CONFIRMED = "confirmed" # accepted observation/finding (default) + ANSWERED = "answered" # question resolved SUPERSEDED = "superseded" # replaced by a newer note @@ -131,9 +131,9 @@ class Note: embryos: list[str] = field(default_factory=list) sessions: list[str] = field(default_factory=list) threads: list[str] = field(default_factory=list) - basis: list[str] = field(default_factory=list) # note ids this rests on - links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] - artifacts: list[dict] = field(default_factory=list) # FileStore pointers + basis: list[str] = field(default_factory=list) # note ids this rests on + links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}] + artifacts: list[dict] = field(default_factory=list) # FileStore pointers superseded_by: str | None = None created_at: datetime = field(default_factory=datetime.now) updated_at: datetime = field(default_factory=datetime.now) @@ -341,15 +341,18 @@ class TestNotebookIndex: def test_index_updated_on_write(self, tmp_path): store = NotebookStore(tmp_path / "notebook") a = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="a", strains=["N2"])) - b = store.write_note(Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"])) + b = store.write_note( + Note(id="", kind=NoteKind.OBSERVATION, body="b", strains=["N2", "OH904"]) + ) assert set(store.ids_for_strain("N2")) == {a, b} assert store.ids_for_strain("OH904") == [b] assert store.ids_for_strain("missing") == [] def test_index_by_embryo_and_thread(self, tmp_path): store = NotebookStore(tmp_path / "notebook") - a = store.write_note(Note(id="", kind=NoteKind.FINDING, body="a", - embryos=["e1"], threads=["t1"])) + a = store.write_note( + Note(id="", kind=NoteKind.FINDING, body="a", embryos=["e1"], threads=["t1"]) + ) assert store.ids_for_embryo("e1") == [a] assert store.ids_for_thread("t1") == [a] @@ -371,46 +374,50 @@ Expected: FAIL — `AttributeError: 'NotebookStore' object has no attribute 'ids Modify `NotebookStore.__init__` to add index state + rebuild, extend `write_note` to update the index, and add the index methods. Replace the existing `__init__` and `write_note` with these versions and add the new methods: ```python - # --- replace __init__ --- - def __init__(self, notebook_dir: Path): - self.root = Path(notebook_dir) - self.notes_dir = self.root / "notes" - self.index_dir = self.root / "index" - self.notes_dir.mkdir(parents=True, exist_ok=True) - self.index_dir.mkdir(parents=True, exist_ok=True) - # reverse indexes: facet -> {value: [note_id, ...]} - self._index: dict[str, dict[str, list[str]]] = { - "strain": {}, "embryo": {}, "thread": {} - } - self.rebuild_index() - - # --- add: facet extraction + index maintenance --- - _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} - - def _index_note(self, note: Note) -> None: - for facet, attr in self._FACETS.items(): - for value in getattr(note, attr): - bucket = self._index[facet].setdefault(value, []) - if note.id not in bucket: - bucket.append(note.id) - - def rebuild_index(self) -> None: - """Rebuild reverse-indexes by scanning notes/ (the notes are authoritative; - indexes are disposable caches).""" - self._index = {"strain": {}, "embryo": {}, "thread": {}} - for f in sorted(self.notes_dir.glob("*.yaml")): - data = self._read_yaml(f) - if data: - self._index_note(note_from_dict(data)) - - def ids_for_strain(self, strain: str) -> list[str]: - return list(self._index["strain"].get(strain, [])) - - def ids_for_embryo(self, embryo: str) -> list[str]: - return list(self._index["embryo"].get(embryo, [])) - - def ids_for_thread(self, thread: str) -> list[str]: - return list(self._index["thread"].get(thread, [])) +# --- replace __init__ --- +def __init__(self, notebook_dir: Path): + self.root = Path(notebook_dir) + self.notes_dir = self.root / "notes" + self.index_dir = self.root / "index" + self.notes_dir.mkdir(parents=True, exist_ok=True) + self.index_dir.mkdir(parents=True, exist_ok=True) + # reverse indexes: facet -> {value: [note_id, ...]} + self._index: dict[str, dict[str, list[str]]] = {"strain": {}, "embryo": {}, "thread": {}} + self.rebuild_index() + + +# --- add: facet extraction + index maintenance --- +_FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"} + + +def _index_note(self, note: Note) -> None: + for facet, attr in self._FACETS.items(): + for value in getattr(note, attr): + bucket = self._index[facet].setdefault(value, []) + if note.id not in bucket: + bucket.append(note.id) + + +def rebuild_index(self) -> None: + """Rebuild reverse-indexes by scanning notes/ (the notes are authoritative; + indexes are disposable caches).""" + self._index = {"strain": {}, "embryo": {}, "thread": {}} + for f in sorted(self.notes_dir.glob("*.yaml")): + data = self._read_yaml(f) + if data: + self._index_note(note_from_dict(data)) + + +def ids_for_strain(self, strain: str) -> list[str]: + return list(self._index["strain"].get(strain, [])) + + +def ids_for_embryo(self, embryo: str) -> list[str]: + return list(self._index["embryo"].get(embryo, [])) + + +def ids_for_thread(self, thread: str) -> list[str]: + return list(self._index["thread"].get(thread, [])) ``` Then add an index-update at the end of `write_note` (just before `return note.id`): @@ -448,10 +455,19 @@ class TestNotebookQuery: def _seed(self, tmp_path): store = NotebookStore(tmp_path / "notebook") store.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="o", strains=["N2"])) - store.write_note(Note(id="f1", kind=NoteKind.FINDING, body="f", - status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) - store.write_note(Note(id="q1", kind=NoteKind.QUESTION, body="q", - status=NoteStatus.OPEN, threads=["t1"])) + store.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="f", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) + store.write_note( + Note(id="q1", kind=NoteKind.QUESTION, body="q", status=NoteStatus.OPEN, threads=["t1"]) + ) return store def test_query_by_kind(self, tmp_path): diff --git a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md index d518f621..27974225 100644 --- a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md +++ b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md @@ -31,9 +31,14 @@ from gently.harness.memory.notebook import learning_to_note, observation_to_note class TestConverters: def test_observation_to_note(self): obs = Observation( - id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), type="milestone", - content="nerve ring formed", embryo_id="e1", session_id="s1", - relates_to=["o0"], gently_refs={"kind": "projection", "t": 42}, + id="o1", + timestamp=_dt(2026, 6, 16, 9, 0, 0), + type="milestone", + content="nerve ring formed", + embryo_id="e1", + session_id="s1", + relates_to=["o0"], + gently_refs={"kind": "projection", "t": 42}, ) n = observation_to_note(obs) assert n.id == "o1" @@ -52,7 +57,7 @@ class TestConverters: assert n.id == "l1" assert n.kind == NoteKind.FINDING assert n.body == "rings form by comma" - assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm + assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm assert n.confidence == Confidence.HIGH ``` @@ -145,16 +150,16 @@ Expected: FAIL — `AttributeError: 'FileContextStore' object has no attribute ' In `gently/harness/memory/file_store.py`, add this property immediately **before** `def apply_updates(self, updates: ContextUpdates):` (line ~2178): ```python - @property - def notebook(self): - """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" - nb = getattr(self, "_notebook", None) - if nb is None: - from .notebook import NotebookStore - nb = NotebookStore(self.agent_dir / "notebook") - self._notebook = nb - return nb - +@property +def notebook(self): + """The shared lab notebook, rooted at agent_dir/notebook (lazy).""" + nb = getattr(self, "_notebook", None) + if nb is None: + from .notebook import NotebookStore + + nb = NotebookStore(self.agent_dir / "notebook") + self._notebook = nb + return nb ``` - [ ] **Step 4: Run test to verify it passes** @@ -186,8 +191,13 @@ class TestApplyUpdatesMirror: from gently.harness.memory.model import ContextUpdates cs = file_context_store - obs = Observation(id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), - type="milestone", content="ring formed", embryo_id="e1") + obs = Observation( + id="o1", + timestamp=_dt(2026, 6, 16, 9, 0, 0), + type="milestone", + content="ring formed", + embryo_id="e1", + ) lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH) cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn])) diff --git a/docs/superpowers/plans/2026-06-16-notebook-read-api.md b/docs/superpowers/plans/2026-06-16-notebook-read-api.md index f4a5f20d..9bd42e20 100644 --- a/docs/superpowers/plans/2026-06-16-notebook-read-api.md +++ b/docs/superpowers/plans/2026-06-16-notebook-read-api.md @@ -48,8 +48,16 @@ def _make_app(context_store): def _seed(cs): nb = cs.notebook nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"])) - nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma", - status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"])) + nb.write_note( + Note( + id="f1", + kind=NoteKind.FINDING, + body="rings by comma", + status=NoteStatus.PROPOSED, + strains=["N2"], + threads=["t1"], + ) + ) return cs diff --git a/docs/superpowers/plans/2026-06-17-notebook-ask.md b/docs/superpowers/plans/2026-06-17-notebook-ask.md index c03ea526..03d62f06 100644 --- a/docs/superpowers/plans/2026-06-17-notebook-ask.md +++ b/docs/superpowers/plans/2026-06-17-notebook-ask.md @@ -30,8 +30,12 @@ from gently.harness.memory.notebook_ask import select_notes def _seed(cs): nb = cs.notebook - nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"])) - nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"])) + nb.write_note( + Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"]) + ) + nb.write_note( + Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"]) + ) nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"])) return nb @@ -149,8 +153,12 @@ class TestAnswerQuestion: assert "o1" in text and "ring formed" in text and "what formed?" in text def test_answer_returns_structured_and_forces_tool(self): - canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}], - "suggested_next": [], "coverage": "covered"} + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring formed", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } client = _FakeClient(canned) notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")] out = asyncio.run(answer_question(client, "m", "what formed?", notes)) @@ -235,9 +243,7 @@ def _render_notes(notes: list[Note]) -> str: def build_ask_messages(question: str, notes: list[Note]) -> list[dict]: body = ( - "Notebook entries:\n" - + _render_notes(notes) - + f"\n\nQuestion: {question}\n\n" + "Notebook entries:\n" + _render_notes(notes) + f"\n\nQuestion: {question}\n\n" "Answer using only these entries, citing note ids." ) return [{"role": "user", "content": body}] @@ -334,8 +340,12 @@ def _make_app_with_client(context_store, client): class TestAsk: def test_ask_returns_grounded_answer(self, file_context_store): cs = _seed(file_context_store) - canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}], - "suggested_next": [], "coverage": "covered"} + canned = { + "answer": "A ring formed.", + "points": [{"text": "ring", "note_ids": ["o1"]}], + "suggested_next": [], + "coverage": "covered", + } client = TestClient(_make_app_with_client(cs, _AskClient(canned))) resp = client.post("/api/notebook/ask", json={"question": "what happened?"}) assert resp.status_code == 200 diff --git a/docs/superpowers/plans/2026-06-27-temperature-interface.md b/docs/superpowers/plans/2026-06-27-temperature-interface.md index cfdaa563..025e8236 100644 --- a/docs/superpowers/plans/2026-06-27-temperature-interface.md +++ b/docs/superpowers/plans/2026-06-27-temperature-interface.md @@ -39,20 +39,33 @@ def _new_session(file_store): return file_store.create_session(name="temp-test") # returns session_id + def test_append_and_read_roundtrip(file_store): sid = _new_session(file_store) - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}) - file_store.append_temperature_sample(sid, {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}, + ) + file_store.append_temperature_sample( + sid, + {"t": "2026-06-27T10:00:01+00:00", "water_c": 28.3, "setpoint_c": 32.0, "state": "heating"}, + ) rows = file_store.read_temperature_log(sid) assert [r["water_c"] for r in rows] == [28.0, 28.3] + def test_read_since_filters(file_store): sid = _new_session(file_store) - for i, t in enumerate(["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"]): - file_store.append_temperature_sample(sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"}) + for i, t in enumerate( + ["2026-06-27T10:00:00+00:00", "2026-06-27T10:00:01+00:00", "2026-06-27T10:00:02+00:00"] + ): + file_store.append_temperature_sample( + sid, {"t": t, "water_c": 28.0 + i, "setpoint_c": 32.0, "state": "heating"} + ) rows = file_store.read_temperature_log(sid, since="2026-06-27T10:00:01+00:00") assert [r["water_c"] for r in rows] == [29.0, 30.0] + def test_read_unknown_session_is_empty(file_store): assert file_store.read_temperature_log("does-not-exist") == [] ``` @@ -74,6 +87,7 @@ def append_temperature_sample(self, session_id: str, sample: dict) -> None: sd = self._require_session_dir(session_id) _append_jsonl(sd / "temperature.jsonl", sample) + def read_temperature_log(self, session_id: str, since: str | None = None) -> list[dict]: """Return temperature samples for a session, optionally filtered to t >= since (ISO-UTC string).""" sd = self._session_dir(session_id) @@ -114,9 +128,11 @@ git commit -m "feat(temperature): session-scoped temperature.jsonl store on File # tests/test_temperature_event.py from gently.core.event_bus import EventType, EventBus + def test_temperature_update_event_exists(): assert EventType.TEMPERATURE_UPDATE.value == "TEMPERATURE_UPDATE" + def test_temperature_update_publishes_to_subscriber(): bus = EventBus() seen = [] @@ -143,7 +159,7 @@ In `gently/core/event_bus.py`, add the member alongside the other `EventType` va And add it to the high-volume set so it is not retained in history (next to `DEVICE_STATE_UPDATE` in `_NO_HISTORY_TYPES` near `:187`): ```python - EventType.TEMPERATURE_UPDATE, +(EventType.TEMPERATURE_UPDATE,) ``` - [ ] **Step 4: Run test to verify it passes** @@ -189,6 +205,7 @@ class FakeScope: def __init__(self, resp): self.resp = resp self.calls = 0 + async def get_temperature(self): self.calls += 1 if isinstance(self.resp, Exception): @@ -204,8 +221,11 @@ def _capture(bus): async def test_tick_appends_emits_and_sets_latest(file_store): sid = file_store.create_session(name="s") - scope = FakeScope({"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) - bus = EventBus(); seen = _capture(bus) + scope = FakeScope( + {"success": True, "temperature_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) + bus = EventBus() + seen = _capture(bus) s = TemperatureSampler(scope, file_store, lambda: sid) await s._tick(bus) rows = file_store.read_temperature_log(sid) @@ -216,7 +236,8 @@ async def test_tick_appends_emits_and_sets_latest(file_store): async def test_tick_no_active_session_is_noop(file_store): scope = FakeScope({"success": True, "temperature_c": 1.0, "setpoint_c": 2.0, "state": "x"}) - bus = EventBus(); seen = _capture(bus) + bus = EventBus() + seen = _capture(bus) s = TemperatureSampler(scope, file_store, lambda: None) await s._tick(bus) assert scope.calls == 0 and s.latest is None and seen == [] @@ -237,8 +258,13 @@ async def test_tick_poll_failure_is_a_gap_not_a_crash(file_store): def test_temperature_stamp_shapes(): assert temperature_stamp(None) is None - assert temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) == { - "water_c": 28.4, "setpoint_c": 32.0, "state": "heating", "sampled_at": "2026-06-27T10:00:00+00:00", + assert temperature_stamp( + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) == { + "water_c": 28.4, + "setpoint_c": 32.0, + "state": "heating", + "sampled_at": "2026-06-27T10:00:00+00:00", } ``` @@ -259,6 +285,7 @@ session's temperature.jsonl, holds the latest reading (for acquisition stamping) and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a crash; with no active session the loop idles. """ + from __future__ import annotations import asyncio @@ -379,10 +406,12 @@ git commit -m "feat(temperature): TemperatureSampler service (poll/persist/emit # tests/test_temperature_sampler_wiring.py from gently.app.temperature_sampler import TemperatureSampler + def test_agent_initializes_temperature_sampler_attribute(): # The attribute must exist (None until start_viz_server runs with a microscope). import gently.app.agent as agent_mod - src = (agent_mod.__file__) + + src = agent_mod.__file__ text = open(src, encoding="utf-8").read() assert "temperature_sampler" in text assert "TemperatureSampler(" in text @@ -406,12 +435,13 @@ Near `:212` (with the other monitor attrs, e.g. `self.device_state_monitor = Non Inside `start_viz_server`, right after the `DeviceStateMonitor` start block (`:818-827`): ```python - if self.microscope is not None and self.temperature_sampler is None: - from .temperature_sampler import TemperatureSampler - self.temperature_sampler = TemperatureSampler( - self.microscope, self.store, lambda: self.session_id - ) - await self.temperature_sampler.start() +if self.microscope is not None and self.temperature_sampler is None: + from .temperature_sampler import TemperatureSampler + + self.temperature_sampler = TemperatureSampler( + self.microscope, self.store, lambda: self.session_id + ) + await self.temperature_sampler.start() ``` In the symmetric shutdown path (`:850-855`, where `device_state_monitor.stop()` is awaited): @@ -464,19 +494,32 @@ from gently.ui.web.routes.temperature import create_router def _server(samples, sessions=(("sess-1", True),)): store = MagicMock() store.list_sessions.return_value = [{"session_id": sid} for sid, _ in sessions] - store._session_dir.side_effect = lambda sid: Path("/x") if any(sid == s for s, _ in sessions) else None + store._session_dir.side_effect = lambda sid: ( + Path("/x") if any(sid == s for s, _ in sessions) else None + ) store.read_temperature_log.return_value = samples - srv = MagicMock(); srv.gently_store = store + srv = MagicMock() + srv.gently_store = store return srv, store def _client(server): - app = FastAPI(); app.include_router(create_router(server)) + app = FastAPI() + app.include_router(create_router(server)) return TestClient(app) def test_history_returns_samples(): - srv, store = _server([{"t": "2026-06-27T10:00:00+00:00", "water_c": 28.0, "setpoint_c": 32.0, "state": "heating"}]) + srv, store = _server( + [ + { + "t": "2026-06-27T10:00:00+00:00", + "water_c": 28.0, + "setpoint_c": 32.0, + "state": "heating", + } + ] + ) r = _client(srv).get("/api/temperature/sess-1/history") assert r.status_code == 200 body = r.json() @@ -519,6 +562,7 @@ Expected: FAIL — `ModuleNotFoundError: gently.ui.web.routes.temperature` Live updates ride the TEMPERATURE_UPDATE event channel; this route is backfill only. Mirrors routes/experiments.py session resolution. """ + from fastapi import APIRouter, HTTPException @@ -586,10 +630,13 @@ from gently.app.temperature_sampler import temperature_stamp def test_stamp_none_when_no_reading(): assert temperature_stamp(None) is None + def test_volume_metadata_carries_temperature(file_store): sid = file_store.create_session(name="s") emb = file_store.create_embryo(sid, position={"x": 0, "y": 0, "z": 0}) # confirm signature - stamp = temperature_stamp({"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"}) + stamp = temperature_stamp( + {"t": "2026-06-27T10:00:00+00:00", "water_c": 28.4, "setpoint_c": 32.0, "state": "heating"} + ) vol = np.zeros((2, 4, 4), dtype="uint16") file_store.put_volume(sid, emb, timepoint=0, volume=vol, metadata={"temperature": stamp}) meta = file_store.get_volume_meta(sid, emb, 0) # confirm accessor name @@ -609,6 +656,7 @@ Expected: FAIL — initially on import/accessor; fix the accessor name from the ```python from gently.app.temperature_sampler import temperature_stamp + # ... where `agent` (or self) holds the sampler and `metadata` is being built: stamp = temperature_stamp(getattr(getattr(agent, "temperature_sampler", None), "latest", None)) if stamp is not None: @@ -621,6 +669,7 @@ if stamp is not None: ```python from gently.app.temperature_sampler import temperature_stamp + _temp = temperature_stamp(self._temperature_provider() if self._temperature_provider else None) ``` diff --git a/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md index 8c84eeeb..abdd9fce 100644 --- a/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md +++ b/docs/superpowers/plans/2026-06-28-manual-mode-live-view.md @@ -39,12 +39,15 @@ # tests/test_lightsheet_event.py from gently.core.event_bus import EventType, EventBus, _NO_HISTORY_TYPES + def test_lightsheet_frame_event_exists(): assert EventType.LIGHTSHEET_FRAME.name == "LIGHTSHEET_FRAME" + def test_lightsheet_frame_excluded_from_history(): assert EventType.LIGHTSHEET_FRAME in _NO_HISTORY_TYPES + def test_lightsheet_frame_publishes_to_subscriber(): bus = EventBus() seen = [] @@ -68,7 +71,7 @@ In `gently/core/event_bus.py`, add the member next to `BOTTOM_CAMERA_FRAME` (`:8 ``` Add to `_NO_HISTORY_TYPES` (next to `BOTTOM_CAMERA_FRAME`): ```python - EventType.LIGHTSHEET_FRAME, # high-volume live frames — keep out of history +(EventType.LIGHTSHEET_FRAME,) # high-volume live frames — keep out of history ``` In `gently/ui/web/static/js/websocket.js`, extend the exclusion guard (`:104-107`) so the frame skips the Events table but still reaches `ClientEventBus.emit`: ```javascript @@ -114,58 +117,104 @@ git commit -m "feat(manual-mode): add LIGHTSHEET_FRAME event type + frontend exc import asyncio, numpy as np, pytest from gently.hardware.dispim.device_layer import DeviceLayer # confirm class name/import + class FakeCore: def __init__(self): - self.running = False; self.exposure = None; self.cam = None + self.running = False + self.exposure = None + self.cam = None self._frame = np.full((64, 64), 1000, dtype=np.uint16) - self.started = 0; self.stopped = 0 - def setCameraDevice(self, n): self.cam = n - def getCameraDevice(self): return self.cam - def setExposure(self, n, ms): self.exposure = ms - def startContinuousSequenceAcquisition(self, interval): self.running = True; self.started += 1 - def stopSequenceAcquisition(self): self.running = False; self.stopped += 1 - def isSequenceRunning(self): return self.running - def getLastImage(self): return self._frame + self.started = 0 + self.stopped = 0 + + def setCameraDevice(self, n): + self.cam = n + + def getCameraDevice(self): + return self.cam + + def setExposure(self, n, ms): + self.exposure = ms + + def startContinuousSequenceAcquisition(self, interval): + self.running = True + self.started += 1 + + def stopSequenceAcquisition(self): + self.running = False + self.stopped += 1 + + def isSequenceRunning(self): + return self.running + + def getLastImage(self): + return self._frame + class FakeAxis: - def __init__(self): self.pos = None - def setPosition(self, v): self.pos = v + def __init__(self): + self.pos = None + + def setPosition(self, v): + self.pos = v + class FakeScanner: - def __init__(self): self.sa_offset_y = FakeAxis(); self.name="Scanner"; self.state=None - def set_spim_state(self, s): self.state = s + def __init__(self): + self.sa_offset_y = FakeAxis() + self.name = "Scanner" + self.state = None + + def set_spim_state(self, s): + self.state = s + class FakePiezo(FakeAxis): - def __init__(self): super().__init__(); self.name="Piezo"; self.state=None - def set_spim_state(self, s): self.state = s + def __init__(self): + super().__init__() + self.name = "Piezo" + self.state = None + + def set_spim_state(self, s): + self.state = s + def _streamer(dl): dl.system = type("S", (), {"core": FakeCore()})() - dl.devices = {"camera": type("C", (), {"name": "HamCam1"})(), - "scanner": FakeScanner(), "piezo": FakePiezo()} + dl.devices = { + "camera": type("C", (), {"name": "HamCam1"})(), + "scanner": FakeScanner(), + "piezo": FakePiezo(), + } return dl + async def test_grab_parks_and_peeks(monkeypatch): dl = _streamer(DeviceLayer.__new__(DeviceLayer)) dl._state_pause_counter = 0 - dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_target_max_dim = 512 + dl._ls_jpeg_quality = 70 dl._ls_params = {"galvo": 1.5, "piezo": 40.0, "exposure": 20.0} - dl._ls_seq_started = False; dl._ls_applied = {} + dl._ls_seq_started = False + dl._ls_applied = {} img = await asyncio.to_thread(dl._grab_lightsheet_frame_sync) assert img is not None and img.shape == (64, 64) - assert dl.system.core.running is True # sequence started - assert dl.devices["piezo"].pos == 40.0 # piezo parked + assert dl.system.core.running is True # sequence started + assert dl.devices["piezo"].pos == 40.0 # piezo parked assert dl.devices["scanner"].sa_offset_y.pos == 1.5 # galvo parked + async def test_exposure_change_restarts_sequence(): dl = _streamer(DeviceLayer.__new__(DeviceLayer)) dl._state_pause_counter = 0 - dl._ls_target_max_dim = 512; dl._ls_jpeg_quality = 70 + dl._ls_target_max_dim = 512 + dl._ls_jpeg_quality = 70 dl._ls_params = {"galvo": 0.0, "piezo": 50.0, "exposure": 10.0} - dl._ls_seq_started = False; dl._ls_applied = {} + dl._ls_seq_started = False + dl._ls_applied = {} await asyncio.to_thread(dl._grab_lightsheet_frame_sync) starts = dl.system.core.started - dl._ls_params["exposure"] = 30.0 # exposure change + dl._ls_params["exposure"] = 30.0 # exposure change await asyncio.to_thread(dl._grab_lightsheet_frame_sync) assert dl.system.core.stopped >= 1 and dl.system.core.started == starts + 1 assert dl.system.core.exposure == 30.0 @@ -180,162 +229,185 @@ Expected: FAIL — `AttributeError: _grab_lightsheet_frame_sync` (or import). Add config attrs in `__init__` (near `:169`, after the `_cam_*` block): ```python - # Lightsheet (SPIM) live stream — continuous sequence acquisition. - self._ls_subscribers: list[asyncio.Queue] = [] - self._ls_task: asyncio.Task | None = None - self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows - self._ls_target_max_dim: int = 512 - self._ls_jpeg_quality: int = 70 - self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} - self._ls_seq_started: bool = False - self._ls_applied: dict = {} # last-applied galvo/piezo/exposure +# Lightsheet (SPIM) live stream — continuous sequence acquisition. +self._ls_subscribers: list[asyncio.Queue] = [] +self._ls_task: asyncio.Task | None = None +self._ls_interval_sec: float = 0.0 # peek as fast as exposure allows +self._ls_target_max_dim: int = 512 +self._ls_jpeg_quality: int = 70 +self._ls_params: dict = {"galvo": 0.0, "piezo": 50.0, "exposure": 20.0} +self._ls_seq_started: bool = False +self._ls_applied: dict = {} # last-applied galvo/piezo/exposure ``` Add the grab/park/peek (mirrors the sequence-acq calls from `acquisition.py`; uses `self.system.core`): ```python - def _park_lightsheet_sync(self) -> None: - """Park scanner galvo + imaging piezo at the current live params (static sheet).""" - p = self._ls_params - scanner = self.devices.get("scanner") - piezo = self.devices.get("piezo") - if scanner is not None: - try: scanner.set_spim_state("Idle") - except Exception: pass - scanner.sa_offset_y.setPosition(float(p["galvo"])) - if piezo is not None: - try: piezo.set_spim_state("Idle") - except Exception: pass - piezo.setPosition(float(p["piezo"])) - - def _ensure_lightsheet_sequence_sync(self) -> None: - """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" - core = self.system.core - cam = self.devices.get("camera") - if cam is None: - raise RuntimeError("No lightsheet camera configured") - p = self._ls_params - need_restart = ( - not self._ls_seq_started - or self._ls_applied.get("exposure") != p["exposure"] - ) - if need_restart: - if core.isSequenceRunning(): - core.stopSequenceAcquisition() - if core.getCameraDevice() != cam.name: - core.setCameraDevice(cam.name) - core.setExposure(cam.name, float(p["exposure"])) - core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) - self._ls_seq_started = True - self._ls_applied["exposure"] = p["exposure"] - - def _grab_lightsheet_frame_sync(self): - """Park → ensure sequence running → peek the latest frame (never drain).""" +def _park_lightsheet_sync(self) -> None: + """Park scanner galvo + imaging piezo at the current live params (static sheet).""" + p = self._ls_params + scanner = self.devices.get("scanner") + piezo = self.devices.get("piezo") + if scanner is not None: try: - self._park_lightsheet_sync() # galvo/piezo applied live - self._ensure_lightsheet_sequence_sync() # start / restart on exposure - from gently.hardware.dispim.devices.acquisition import _safe_obtain - core = self.system.core - img = core.getLastImage() - try: img = _safe_obtain(img) - except (ImportError, AttributeError): pass - return np.asarray(img) - except Exception as exc: - logger.debug("Lightsheet grab failed: %s", exc) - return None - - def _stop_lightsheet_sequence_sync(self) -> None: + scanner.set_spim_state("Idle") + except Exception: + pass + scanner.sa_offset_y.setPosition(float(p["galvo"])) + if piezo is not None: try: - if self.system.core.isSequenceRunning(): - self.system.core.stopSequenceAcquisition() + piezo.set_spim_state("Idle") except Exception: - logger.debug("stop lightsheet sequence failed", exc_info=True) - self._ls_seq_started = False - self._ls_applied = {} + pass + piezo.setPosition(float(p["piezo"])) + + +def _ensure_lightsheet_sequence_sync(self) -> None: + """Start (or restart on exposure change) the continuous sequence on the SPIM camera.""" + core = self.system.core + cam = self.devices.get("camera") + if cam is None: + raise RuntimeError("No lightsheet camera configured") + p = self._ls_params + need_restart = not self._ls_seq_started or self._ls_applied.get("exposure") != p["exposure"] + if need_restart: + if core.isSequenceRunning(): + core.stopSequenceAcquisition() + if core.getCameraDevice() != cam.name: + core.setCameraDevice(cam.name) + core.setExposure(cam.name, float(p["exposure"])) + core.startContinuousSequenceAcquisition(self._ls_interval_sec * 1000.0) + self._ls_seq_started = True + self._ls_applied["exposure"] = p["exposure"] + + +def _grab_lightsheet_frame_sync(self): + """Park → ensure sequence running → peek the latest frame (never drain).""" + try: + self._park_lightsheet_sync() # galvo/piezo applied live + self._ensure_lightsheet_sequence_sync() # start / restart on exposure + from gently.hardware.dispim.devices.acquisition import _safe_obtain + + core = self.system.core + img = core.getLastImage() + try: + img = _safe_obtain(img) + except (ImportError, AttributeError): + pass + return np.asarray(img) + except Exception as exc: + logger.debug("Lightsheet grab failed: %s", exc) + return None + + +def _stop_lightsheet_sequence_sync(self) -> None: + try: + if self.system.core.isSequenceRunning(): + self.system.core.stopSequenceAcquisition() + except Exception: + logger.debug("stop lightsheet sequence failed", exc_info=True) + self._ls_seq_started = False + self._ls_applied = {} ``` Add the streamer loop + broadcast (mirror `_bottom_camera_streamer`/`_broadcast_camera`, reusing `_encode_frame_for_stream`): ```python - async def _lightsheet_streamer(self): - logger.info("Lightsheet streamer started") +async def _lightsheet_streamer(self): + logger.info("Lightsheet streamer started") + try: + while self._ls_subscribers: + if self._state_pause_counter > 0: + # Heavy plan owns MMCore: release the sequence and back off. + if self._ls_seq_started: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + await asyncio.sleep(0.1) + continue + tick = time.monotonic() + img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) + payload = self._encode_frame_for_stream(img) if img is not None else None + if payload is not None: + await self._broadcast_lightsheet(payload) + elapsed = time.monotonic() - tick + # Pace to at least the exposure; peek-rate caps near the camera rate. + floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) + await asyncio.sleep(max(0.0, floor - elapsed)) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Lightsheet streamer crashed") + finally: + await asyncio.to_thread(self._stop_lightsheet_sequence_sync) + logger.info("Lightsheet streamer exiting") + + +async def _broadcast_lightsheet(self, payload): + if not self._ls_subscribers: + return + dead = [] + for q in self._ls_subscribers: try: - while self._ls_subscribers: - if self._state_pause_counter > 0: - # Heavy plan owns MMCore: release the sequence and back off. - if self._ls_seq_started: - await asyncio.to_thread(self._stop_lightsheet_sequence_sync) - await asyncio.sleep(0.1) - continue - tick = time.monotonic() - img = await asyncio.to_thread(self._grab_lightsheet_frame_sync) - payload = self._encode_frame_for_stream(img) if img is not None else None - if payload is not None: - await self._broadcast_lightsheet(payload) - elapsed = time.monotonic() - tick - # Pace to at least the exposure; peek-rate caps near the camera rate. - floor = max(self._ls_interval_sec, self._ls_params["exposure"] / 1000.0) - await asyncio.sleep(max(0.0, floor - elapsed)) - except asyncio.CancelledError: - raise - except Exception: - logger.exception("Lightsheet streamer crashed") - finally: - await asyncio.to_thread(self._stop_lightsheet_sequence_sync) - logger.info("Lightsheet streamer exiting") - - async def _broadcast_lightsheet(self, payload): - if not self._ls_subscribers: - return - dead = [] - for q in self._ls_subscribers: + q.put_nowait(payload) + except asyncio.QueueFull: try: + _ = q.get_nowait() q.put_nowait(payload) - except asyncio.QueueFull: - try: - _ = q.get_nowait(); q.put_nowait(payload) - except Exception: - dead.append(q) - for q in dead: - try: self._ls_subscribers.remove(q) - except ValueError: pass + except Exception: + dead.append(q) + for q in dead: + try: + self._ls_subscribers.remove(q) + except ValueError: + pass ``` > `_encode_frame_for_stream` uses `self._cam_target_max_dim`/`self._cam_jpeg_quality`. To get the 512/70 lightsheet settings without duplicating the encoder, add an optional override: change its signature to `_encode_frame_for_stream(self, img, max_dim=None, quality=None)` defaulting to the `_cam_*` values, and call it `self._encode_frame_for_stream(img, self._ls_target_max_dim, self._ls_jpeg_quality)` from the lightsheet loop. (One-line change to the encoder; bottom-camera behavior unchanged.) Add the SSE handler + params handler (mirror `handle_bottom_camera_stream`; the params handler updates `self._ls_params` — galvo/piezo apply on the next grab, exposure triggers the restart path): ```python - async def handle_lightsheet_stream(self, request): - response = web.StreamResponse(status=200, reason="OK", headers={ - "Content-Type": "text/event-stream", "Cache-Control": "no-cache", - "Connection": "keep-alive", "X-Accel-Buffering": "no"}) - await response.prepare(request) - queue: asyncio.Queue = asyncio.Queue(maxsize=4) - self._ls_subscribers.append(queue) - if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): - self._ls_task = asyncio.create_task(self._lightsheet_streamer(), name="lightsheet-streamer") +async def handle_lightsheet_stream(self, request): + response = web.StreamResponse( + status=200, + reason="OK", + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + queue: asyncio.Queue = asyncio.Queue(maxsize=4) + self._ls_subscribers.append(queue) + if len(self._ls_subscribers) == 1 and (self._ls_task is None or self._ls_task.done()): + self._ls_task = asyncio.create_task(self._lightsheet_streamer(), name="lightsheet-streamer") + try: + await response.write(b": connected\n\n") + while True: + try: + payload = await asyncio.wait_for(queue.get(), timeout=10.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if payload is None: + break + await response.write(f"data: {json.dumps(payload)}\n\n".encode()) + except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + pass + except Exception: + logger.exception("Lightsheet SSE writer failed") + finally: try: - await response.write(b": connected\n\n") - while True: - try: - payload = await asyncio.wait_for(queue.get(), timeout=10.0) - except asyncio.TimeoutError: - await response.write(b": keepalive\n\n"); continue - if payload is None: break - await response.write(f"data: {json.dumps(payload)}\n\n".encode()) - except (asyncio.CancelledError, ConnectionResetError, ConnectionAbortedError): + self._ls_subscribers.remove(queue) + except ValueError: pass - except Exception: - logger.exception("Lightsheet SSE writer failed") - finally: - try: self._ls_subscribers.remove(queue) - except ValueError: pass - return response - - async def handle_lightsheet_params(self, request): - body = await request.json() - for k in ("galvo", "piezo", "exposure"): - if k in body and body[k] is not None: - self._ls_params[k] = float(body[k]) - return web.json_response({"params": self._ls_params}) + return response + + +async def handle_lightsheet_params(self, request): + body = await request.json() + for k in ("galvo", "piezo", "exposure"): + if k in body and body[k] is not None: + self._ls_params[k] = float(body[k]) + return web.json_response({"params": self._ls_params}) ``` Register both routes near `:2806`: @@ -374,20 +446,27 @@ git commit -m "feat(manual-mode): device-layer lightsheet live streamer (continu import pytest from gently.hardware.dispim.client import DiSPIMMicroscope + async def test_set_params_posts_body(monkeypatch): m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) sent = {} + async def fake_post(path, body): - sent["path"] = path; sent["body"] = body; return {"params": body} + sent["path"] = path + sent["body"] = body + return {"params": body} + m._api_post = fake_post # confirm the real low-level POST helper name res = await m.set_lightsheet_live_params(galvo=1.0, piezo=42.0, exposure=15.0) assert sent["path"] == "/api/lightsheet/live/params" assert sent["body"] == {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0} assert res == {"params": {"galvo": 1.0, "piezo": 42.0, "exposure": 15.0}} + def test_stream_lightsheet_is_async_generator(): m = DiSPIMMicroscope.__new__(DiSPIMMicroscope) import inspect + assert inspect.isasyncgenfunction(m.stream_lightsheet) ``` @@ -400,45 +479,50 @@ Run: `pytest tests/test_lightsheet_client.py -v` → FAIL (no such methods). - [ ] **Step 3: Implement** ```python - async def stream_lightsheet(self, timeout: float | None = None): - """Async generator yielding JPEG frames from the lightsheet live SSE stream. - - Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. - """ - self._ensure_connected() - client_timeout = aiohttp.ClientTimeout(total=None, sock_read=timeout, sock_connect=10.0) - url = f"{self.http_url}/api/lightsheet/stream" - async with self._session.get(url, timeout=client_timeout) as resp: - resp.raise_for_status() - buf = b"" - async for chunk in resp.content.iter_any(): - if not chunk: - continue - buf += chunk - while b"\n\n" in buf: - event_block, buf = buf.split(b"\n\n", 1) - data_lines = [] - for line in event_block.splitlines(): - if not line or line.startswith(b":"): - continue - if line.startswith(b"data:"): - data_lines.append(line[5:].lstrip()) - if not data_lines: +async def stream_lightsheet(self, timeout: float | None = None): + """Async generator yielding JPEG frames from the lightsheet live SSE stream. + + Mirrors :meth:`stream_bottom_camera`; subscriber-gated on the device layer. + """ + self._ensure_connected() + client_timeout = aiohttp.ClientTimeout(total=None, sock_read=timeout, sock_connect=10.0) + url = f"{self.http_url}/api/lightsheet/stream" + async with self._session.get(url, timeout=client_timeout) as resp: + resp.raise_for_status() + buf = b"" + async for chunk in resp.content.iter_any(): + if not chunk: + continue + buf += chunk + while b"\n\n" in buf: + event_block, buf = buf.split(b"\n\n", 1) + data_lines = [] + for line in event_block.splitlines(): + if not line or line.startswith(b":"): continue - raw = b"\n".join(data_lines).decode("utf-8", errors="replace") - try: - import json as _json - yield _json.loads(raw) - except Exception as exc: - logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) - - async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: - """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" - body = {} - if galvo is not None: body["galvo"] = float(galvo) - if piezo is not None: body["piezo"] = float(piezo) - if exposure is not None: body["exposure"] = float(exposure) - return await self._api_post("/api/lightsheet/live/params", body) + if line.startswith(b"data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + raw = b"\n".join(data_lines).decode("utf-8", errors="replace") + try: + import json as _json + + yield _json.loads(raw) + except Exception as exc: + logger.warning("Malformed lightsheet SSE payload skipped: %s", exc) + + +async def set_lightsheet_live_params(self, galvo=None, piezo=None, exposure=None) -> dict: + """POST live galvo/piezo/exposure to the device-layer lightsheet streamer.""" + body = {} + if galvo is not None: + body["galvo"] = float(galvo) + if piezo is not None: + body["piezo"] = float(piezo) + if exposure is not None: + body["exposure"] = float(exposure) + return await self._api_post("/api/lightsheet/live/params", body) ``` > If the real POST helper isn't `_api_post`, adapt this one call site (and the test) to the real helper. `stream_lightsheet` copies `stream_bottom_camera` verbatim except the URL. @@ -476,12 +560,14 @@ import asyncio from gently.core.event_bus import EventType, get_event_bus from gently.app.lightsheet_monitor import LightSheetStreamMonitor + class FakeScope: async def stream_lightsheet(self): for i in range(3): yield {"t": float(i), "jpeg_b64": f"f{i}"} await asyncio.sleep(0) + async def test_monitor_publishes_frames(): bus = get_event_bus() seen = [] @@ -500,14 +586,15 @@ async def test_monitor_publishes_frames(): Agent wiring in `gently/app/agent.py`: add `self.lightsheet_monitor = None` next to `self.bottom_camera_monitor = None`; in `start_viz_server` (after the bottom-camera monitor construction `:849-860`): ```python - if self.microscope is not None and self.lightsheet_monitor is None: - try: - from .lightsheet_monitor import LightSheetStreamMonitor - self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) - logger.info("Lightsheet monitor ready (not started)") - except Exception as e: - logger.warning(f"Failed to construct lightsheet monitor: {e}") - self.lightsheet_monitor = None +if self.microscope is not None and self.lightsheet_monitor is None: + try: + from .lightsheet_monitor import LightSheetStreamMonitor + + self.lightsheet_monitor = LightSheetStreamMonitor(self.microscope) + logger.info("Lightsheet monitor ready (not started)") + except Exception as e: + logger.warning(f"Failed to construct lightsheet monitor: {e}") + self.lightsheet_monitor = None ``` In `stop_viz_server` (near `:862`): ```python @@ -551,28 +638,38 @@ from fastapi.testclient import TestClient from gently.ui.web.routes.data import create_router import gently.ui.web.auth as auth + def _app(client=None, monitor=None): server = MagicMock() server.agent_bridge.agent.client = client server.agent_bridge.agent.lightsheet_monitor = monitor - app = FastAPI(); app.include_router(create_router(server)) + app = FastAPI() + app.include_router(create_router(server)) # legacy localhost = CONTROL; TestClient client.host is "testclient" → force CONTROL: app.dependency_overrides[auth.require_control] = lambda: True return TestClient(app) + def test_live_params_forwards(): - client = MagicMock(); client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) - r = _app(client=client).post("/api/devices/lightsheet/live/params", - json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0}) + client = MagicMock() + client.set_lightsheet_live_params = AsyncMock(return_value={"params": {}}) + r = _app(client=client).post( + "/api/devices/lightsheet/live/params", json={"galvo": 1.0, "piezo": 40.0, "exposure": 20.0} + ) assert r.status_code == 200 client.set_lightsheet_live_params.assert_awaited_once_with(galvo=1.0, piezo=40.0, exposure=20.0) + def test_acquire_burst_forwards(): - client = MagicMock(); client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) - r = _app(client=client).post("/api/devices/acquire/burst", - json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}) + client = MagicMock() + client.acquire_burst = AsyncMock(return_value={"success": True, "request_id": "b1"}) + r = _app(client=client).post( + "/api/devices/acquire/burst", + json={"frames": 60, "mode": "1hz", "num_slices": 1, "exposure_ms": 5.0}, + ) assert r.status_code == 200 and r.json().get("request_id") == "b1" + def test_live_start_requires_monitor(): r = _app(monitor=None).post("/api/devices/lightsheet/live/start") assert r.status_code == 503 @@ -584,75 +681,95 @@ def test_live_start_requires_monitor(): - [ ] **Step 3: Implement** — in `create_router`, add (mirroring the referenced routes). Live start/stop/status copy the bottom-camera versions verbatim, swapping `bottom_camera_monitor` → `lightsheet_monitor`. Then: ```python - @router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) - async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: - raise HTTPException(status_code=503, detail="Microscope not connected") - try: - res = await client.set_lightsheet_live_params( - galvo=payload.get("galvo"), piezo=payload.get("piezo"), - exposure=payload.get("exposure")) - except Exception as exc: - logger.exception("lightsheet live params failed") - raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc - return res - - @router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) - async def led_set(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: return await client.set_led(str(payload.get("state", "Closed"))) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc - - @router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) - async def laser_off(): - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: return await client.set_laser_power(488, 0) # confirm signature; force off - except Exception as exc: - raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc - - @router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) - async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: return await client.set_camera_led_mode(bool(payload.get("use_led", False))) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc - - @router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) - async def stage_move(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: return await client.move_to_position(float(payload["x"]), float(payload["y"])) - except KeyError: raise HTTPException(status_code=400, detail="x and y required") - except Exception as exc: - raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc - - @router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) - async def acquire_burst(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: - return await client.acquire_burst( - frames=int(payload.get("frames", 60)), mode=str(payload.get("mode", "1hz")), - num_slices=int(payload.get("num_slices", 1)), - exposure_ms=float(payload.get("exposure_ms", 5.0))) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc - - @router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) - async def acquire_volume(payload: dict = Body(...)): # noqa: B008 - client = _resolve_client() - if client is None: raise HTTPException(status_code=503, detail="Microscope not connected") - try: - return await client.acquire_volume( - num_slices=int(payload.get("num_slices", 50)), - exposure_ms=float(payload.get("exposure_ms", 10.0))) - except Exception as exc: - raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc +@router.post("/api/devices/lightsheet/live/params", dependencies=[Depends(require_control)]) +async def lightsheet_live_params(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + res = await client.set_lightsheet_live_params( + galvo=payload.get("galvo"), piezo=payload.get("piezo"), exposure=payload.get("exposure") + ) + except Exception as exc: + logger.exception("lightsheet live params failed") + raise HTTPException(status_code=502, detail=f"params failed: {exc}") from exc + return res + + +@router.post("/api/devices/led/set", dependencies=[Depends(require_control)]) +async def led_set(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_led(str(payload.get("state", "Closed"))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"led failed: {exc}") from exc + + +@router.post("/api/devices/laser/off", dependencies=[Depends(require_control)]) +async def laser_off(): + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_laser_power(488, 0) # confirm signature; force off + except Exception as exc: + raise HTTPException(status_code=502, detail=f"laser off failed: {exc}") from exc + + +@router.post("/api/devices/camera/led_mode", dependencies=[Depends(require_control)]) +async def camera_led_mode(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.set_camera_led_mode(bool(payload.get("use_led", False))) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"camera led mode failed: {exc}") from exc + + +@router.post("/api/devices/stage/move", dependencies=[Depends(require_control)]) +async def stage_move(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.move_to_position(float(payload["x"]), float(payload["y"])) + except KeyError: + raise HTTPException(status_code=400, detail="x and y required") + except Exception as exc: + raise HTTPException(status_code=502, detail=f"stage move failed: {exc}") from exc + + +@router.post("/api/devices/acquire/burst", dependencies=[Depends(require_control)]) +async def acquire_burst(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_burst( + frames=int(payload.get("frames", 60)), + mode=str(payload.get("mode", "1hz")), + num_slices=int(payload.get("num_slices", 1)), + exposure_ms=float(payload.get("exposure_ms", 5.0)), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"burst failed: {exc}") from exc + + +@router.post("/api/devices/acquire/volume", dependencies=[Depends(require_control)]) +async def acquire_volume(payload: dict = Body(...)): # noqa: B008 + client = _resolve_client() + if client is None: + raise HTTPException(status_code=503, detail="Microscope not connected") + try: + return await client.acquire_volume( + num_slices=int(payload.get("num_slices", 50)), + exposure_ms=float(payload.get("exposure_ms", 10.0)), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"volume failed: {exc}") from exc ``` Add the live start/stop/status routes by copying `start_bottom_camera_stream`/`stop_bottom_camera_stream`/`get_bottom_camera_status` (`data.py:260-311`) under `/api/devices/lightsheet/live/...` with `getattr(agent, "lightsheet_monitor", None)`. diff --git a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md index 8c740b3f..4d8b0bf4 100644 --- a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md +++ b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md @@ -30,12 +30,15 @@ # tests/test_temp_protocol_events.py from gently.core.event_bus import EventType + def test_new_event_types_exist(): for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"): assert getattr(EventType, n).name == n + def test_timeline_maps_the_subtypes(): from gently.harness.session import timeline as tl + src = open(tl.__file__, encoding="utf-8").read() for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"): assert sub in src @@ -43,9 +46,9 @@ def test_timeline_maps_the_subtypes(): - [ ] **Step 2: run, expect FAIL** — `pytest tests/test_temp_protocol_events.py -v` - [ ] **Step 3: implement** — in `event_bus.py`, alongside the burst events: ```python - TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline) - TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began - TEMP_PROTOCOL_COMPLETED = auto() # protocol ended +TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline) +TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began +TEMP_PROTOCOL_COMPLETED = auto() # protocol ended ``` In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add: ```python @@ -71,9 +74,15 @@ In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add: import asyncio from gently.app.orchestration.exclusive import BurstAcquisition + class FakeClient: - def __init__(self): self.calls = [] - async def acquire_burst(self, **kw): self.calls.append(kw); return {"success": True, "request_id": "b1", "frames": []} + def __init__(self): + self.calls = [] + + async def acquire_burst(self, **kw): + self.calls.append(kw) + return {"success": True, "request_id": "b1", "frames": []} + async def test_burst_passes_laser_config(monkeypatch): b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF") @@ -100,16 +109,23 @@ async def test_burst_passes_laser_config(monkeypatch): # tests/test_wait_for_lock.py from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock + class FakeClient: - def __init__(self, states): self.states = list(states); self.calls = 0 + def __init__(self, states): + self.states = list(states) + self.calls = 0 + async def get_temperature(self): - i = min(self.calls, len(self.states) - 1); self.calls += 1 + i = min(self.calls, len(self.states) - 1) + self.calls += 1 return {"state": self.states[i]} + async def test_returns_true_when_locked(): c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"]) assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True + async def test_returns_false_on_timeout(): c = FakeClient(["[ HEATING ]"]) assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False @@ -119,8 +135,10 @@ async def test_returns_false_on_timeout(): ```python # gently/app/orchestration/temperature_protocol.py import asyncio, logging + logger = logging.getLogger(__name__) + async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool: """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'.""" loop = asyncio.get_event_loop() @@ -129,7 +147,8 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. try: resp = await client.get_temperature() except Exception as exc: - logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {} + logger.warning("wait_for_temperature_lock poll failed: %s", exc) + resp = {} if "LOCKED" in str(resp.get("state", "")): return True if loop.time() - t0 >= timeout_s: @@ -153,32 +172,65 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2. from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol from gently.core.event_bus import EventType + class FakeClient: - def __init__(self): self.laser=None; self.led=None; self.setpoint=None; self._poll=0 - async def set_laser_config(self, c): self.laser=c - async def set_led(self, s): self.led=s - async def set_temperature(self, t): self.setpoint=t + def __init__(self): + self.laser = None + self.led = None + self.setpoint = None + self._poll = 0 + + async def set_laser_config(self, c): + self.laser = c + + async def set_led(self, s): + self.led = s + + async def set_temperature(self, t): + self.setpoint = t + async def get_temperature(self): self._poll += 1 return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"} + class FakeOrch: - def __init__(self, client): self._client=client; self._temperature_provider=lambda: None; self.events=[] + def __init__(self, client): + self._client = client + self._temperature_provider = lambda: None + self.events = [] + @property - def client(self): return self._client - def _emit_event(self, et, data): self.events.append((et, data)) + def client(self): + return self._client + + def _emit_event(self, et, data): + self.events.append((et, data)) + async def test_phase_order_and_brightfield(monkeypatch): - client = FakeClient(); orch = FakeOrch(client) + client = FakeClient() + orch = FakeOrch(client) bursts = [] - async def runner(b): bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + + async def runner(b): + bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config}) + res = await run_temp_change_burst_protocol( - orch, "emb1", 25.0, frames=3, bursts_before=1, bursts_after=1, - lock_timeout_s=5.0, poll_s=0.001, burst_runner=runner) + orch, + "emb1", + 25.0, + frames=3, + bursts_before=1, + bursts_after=1, + lock_timeout_s=5.0, + poll_s=0.001, + burst_runner=runner, + ) assert client.laser == "ALL OFF" and client.led == "Open" assert client.setpoint == 25.0 - assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield - assert len(bursts) >= 3 # before + >=1 during + after + assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield + assert len(bursts) >= 3 # before + >=1 during + after ets = [e[0] for e in orch.events] assert EventType.TEMP_PROTOCOL_STARTED in ets assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets @@ -191,33 +243,64 @@ async def test_phase_order_and_brightfield(monkeypatch): from gently.app.orchestration.exclusive import BurstAcquisition from gently.core.event_bus import EventType -async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, - frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, - lock_timeout_s=600.0, poll_s=2.0, burst_runner=None): + +async def run_temp_change_burst_protocol( + orchestrator, + embryo_id, + target_setpoint_c, + *, + frames=60, + mode="1hz", + num_slices=1, + bursts_before=1, + bursts_after=1, + lock_timeout_s=600.0, + poll_s=2.0, + burst_runner=None, +): client = orchestrator.client if burst_runner is None: - async def burst_runner(b): await b.run(orchestrator) + + async def burst_runner(b): + await b.run(orchestrator) async def one_burst(phase): - b = BurstAcquisition(embryo_id, frames=frames, mode=mode, num_slices=num_slices, - temperature_provider=getattr(orchestrator, "_temperature_provider", None), - laser_config="ALL OFF") + b = BurstAcquisition( + embryo_id, + frames=frames, + mode=mode, + num_slices=num_slices, + temperature_provider=getattr(orchestrator, "_temperature_provider", None), + laser_config="ALL OFF", + ) b._phase = phase await burst_runner(b) - locked = False; error = None; cancelled = False + locked = False + error = None + cancelled = False try: await client.set_laser_config("ALL OFF") await client.set_led("Open") - orchestrator._emit_event(EventType.TEMP_PROTOCOL_STARTED, - {"embryo_id": embryo_id, "target_setpoint_c": target_setpoint_c, - "frames": frames, "bursts_before": bursts_before, "bursts_after": bursts_after}) + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_STARTED, + { + "embryo_id": embryo_id, + "target_setpoint_c": target_setpoint_c, + "frames": frames, + "bursts_before": bursts_before, + "bursts_after": bursts_after, + }, + ) for _ in range(bursts_before): await one_burst("before") await client.set_temperature(target_setpoint_c) - orchestrator._emit_event(EventType.TEMPERATURE_SETPOINT_CHANGED, - {"embryo_id": embryo_id, "to": target_setpoint_c}) - loop = asyncio.get_event_loop(); t0 = loop.time() + orchestrator._emit_event( + EventType.TEMPERATURE_SETPOINT_CHANGED, + {"embryo_id": embryo_id, "to": target_setpoint_c}, + ) + loop = asyncio.get_event_loop() + t0 = loop.time() while True: await one_burst("during") try: @@ -225,18 +308,23 @@ async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoin except Exception: st = "" if "LOCKED" in st: - locked = True; break + locked = True + break if loop.time() - t0 >= lock_timeout_s: break for _ in range(bursts_after): await one_burst("after") except asyncio.CancelledError: - cancelled = True; raise + cancelled = True + raise except Exception as exc: - error = str(exc); logger.exception("temp-change burst protocol failed") + error = str(exc) + logger.exception("temp-change burst protocol failed") finally: - orchestrator._emit_event(EventType.TEMP_PROTOCOL_COMPLETED, - {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}) + orchestrator._emit_event( + EventType.TEMP_PROTOCOL_COMPLETED, + {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error}, + ) return {"locked": locked, "cancelled": cancelled, "error": error} ``` - [ ] **Step 4: run, expect PASS**; `pytest -q` clean. diff --git a/docs/volume-scan-implementation-notes.md b/docs/volume-scan-implementation-notes.md index a5ea208e..44a53da5 100644 --- a/docs/volume-scan-implementation-notes.md +++ b/docs/volume-scan-implementation-notes.md @@ -221,8 +221,13 @@ class DiSPIMScanner: # ... existing code ... # SPIM Hardware Mode Methods - def configure_spim(self, num_slices: int, scan_duration_ms: float, - camera_duration_ms: float, delay_ms: float = 0.2): + def configure_spim( + self, + num_slices: int, + scan_duration_ms: float, + camera_duration_ms: float, + delay_ms: float = 0.2, + ): """Configure hardware SPIM parameters""" self.core.setProperty(self.device_name, "SPIMNumSlices", num_slices) self.core.setProperty(self.device_name, "SPIMScanDuration(ms)", scan_duration_ms) @@ -246,13 +251,22 @@ class DiSPIMScanner: return self.core.getProperty(self.device_name, "SPIMState") # Single Axis Mode Methods - def configure_sam(self, axis: str, mode: int, pattern: int, - amplitude_deg: float, offset_deg: float, period_ms: float): + def configure_sam( + self, + axis: str, + mode: int, + pattern: int, + amplitude_deg: float, + offset_deg: float, + period_ms: float, + ): """Configure Single Axis Mode for continuous scanning""" axis_prop = "X" if axis.upper() in ["X", "A"] else "Y" self.core.setProperty(self.device_name, f"SingleAxis{axis_prop}Mode", str(mode)) self.core.setProperty(self.device_name, f"SingleAxis{axis_prop}Pattern", str(pattern)) - self.core.setProperty(self.device_name, f"SingleAxis{axis_prop}Amplitude(deg)", amplitude_deg) + self.core.setProperty( + self.device_name, f"SingleAxis{axis_prop}Amplitude(deg)", amplitude_deg + ) self.core.setProperty(self.device_name, f"SingleAxis{axis_prop}Offset(deg)", offset_deg) self.core.setProperty(self.device_name, f"SingleAxis{axis_prop}Period(ms)", period_ms) @@ -305,10 +319,15 @@ class DiSPIMPiezo: #### 1. Sequential Volume Scan ```python -def volume_scan_sequential(piezo, scanner, camera, z_positions: List[float], - calibration: ReferenceMap, - galvo_axis: str = 'A', - metadata: Optional[Dict] = None): +def volume_scan_sequential( + piezo, + scanner, + camera, + z_positions: List[float], + calibration: ReferenceMap, + galvo_axis: str = "A", + metadata: Optional[Dict] = None, +): """ Device-agnostic sequential volume scan @@ -339,14 +358,14 @@ def volume_scan_sequential(piezo, scanner, camera, z_positions: List[float], from .coordinates import piezo_to_galvo md = { - 'plan_name': 'volume_scan_sequential', - 'piezo': piezo.name, - 'scanner': scanner.name, - 'camera': camera.name, - 'num_slices': len(z_positions), - 'z_range': (min(z_positions), max(z_positions)), - 'calibration_slope': calibration.piezo_galvo_slope, - 'calibration_offset': calibration.piezo_galvo_offset, + "plan_name": "volume_scan_sequential", + "piezo": piezo.name, + "scanner": scanner.name, + "camera": camera.name, + "num_slices": len(z_positions), + "z_range": (min(z_positions), max(z_positions)), + "calibration_slope": calibration.piezo_galvo_slope, + "calibration_offset": calibration.piezo_galvo_offset, } if metadata: md.update(metadata) @@ -354,34 +373,38 @@ def volume_scan_sequential(piezo, scanner, camera, z_positions: List[float], @bpp.run_decorator(md=md) def inner(): for i, z_pos in enumerate(z_positions): - print(f"Volume scan: slice {i+1}/{len(z_positions)}, z={z_pos:.2f} µm") + print(f"Volume scan: slice {i + 1}/{len(z_positions)}, z={z_pos:.2f} µm") # Move piezo to z position yield from bps.mv(piezo, z_pos) # Calculate synchronized galvo position galvo_pos = piezo_to_galvo( - z_pos, - calibration.piezo_galvo_slope, - calibration.piezo_galvo_offset + z_pos, calibration.piezo_galvo_slope, calibration.piezo_galvo_offset ) # Move galvo (maintain other axis at 0) - if galvo_axis.upper() == 'A': + if galvo_axis.upper() == "A": yield from bps.mv(scanner, [galvo_pos, 0.0]) else: yield from bps.mv(scanner, [0.0, galvo_pos]) # Acquire image - yield from bps.trigger_and_read([camera, piezo, scanner], - name=f'volume_slice_{i:04d}') + yield from bps.trigger_and_read([camera, piezo, scanner], name=f"volume_slice_{i:04d}") yield from inner() -def volume_scan_bidirectional(piezo, scanner, camera, z_start: float, z_end: float, - num_slices: int, calibration: ReferenceMap, - metadata: Optional[Dict] = None): +def volume_scan_bidirectional( + piezo, + scanner, + camera, + z_start: float, + z_end: float, + num_slices: int, + calibration: ReferenceMap, + metadata: Optional[Dict] = None, +): """ Bidirectional volume scan (forward then reverse) @@ -392,20 +415,34 @@ def volume_scan_bidirectional(piezo, scanner, camera, z_start: float, z_end: flo # Forward scan yield from volume_scan_sequential( - piezo, scanner, camera, z_forward, calibration, - metadata={**(metadata or {}), 'scan_direction': 'forward'} + piezo, + scanner, + camera, + z_forward, + calibration, + metadata={**(metadata or {}), "scan_direction": "forward"}, ) # Reverse scan z_reverse = z_forward[::-1] yield from volume_scan_sequential( - piezo, scanner, camera, z_reverse, calibration, - metadata={**(metadata or {}), 'scan_direction': 'reverse'} + piezo, + scanner, + camera, + z_reverse, + calibration, + metadata={**(metadata or {}), "scan_direction": "reverse"}, ) -def volume_scan_continuous_sam(piezo, scanner, camera, z_positions: List[float], - sam_config: Dict, metadata: Optional[Dict] = None): +def volume_scan_continuous_sam( + piezo, + scanner, + camera, + z_positions: List[float], + sam_config: Dict, + metadata: Optional[Dict] = None, +): """ Volume scan with continuous Single Axis Mode galvo scanning @@ -415,8 +452,8 @@ def volume_scan_continuous_sam(piezo, scanner, camera, z_positions: List[float], SAM configuration: {'pattern': 3, 'amplitude': 0.5, 'period': 10, ...} """ md = { - 'plan_name': 'volume_scan_continuous_sam', - 'sam_config': sam_config, + "plan_name": "volume_scan_continuous_sam", + "sam_config": sam_config, } if metadata: md.update(metadata) @@ -425,17 +462,17 @@ def volume_scan_continuous_sam(piezo, scanner, camera, z_positions: List[float], def inner(): # Configure SAM scanner.configure_sam( - axis='A', + axis="A", mode=1, # Enabled - pattern=sam_config['pattern'], - amplitude_deg=sam_config['amplitude'], - offset_deg=sam_config.get('offset', 0.0), - period_ms=sam_config['period'] + pattern=sam_config["pattern"], + amplitude_deg=sam_config["amplitude"], + offset_deg=sam_config.get("offset", 0.0), + period_ms=sam_config["period"], ) try: # Enable SAM - scanner.enable_sam('A', True) + scanner.enable_sam("A", True) # Scan through z positions for i, z_pos in enumerate(z_positions): @@ -447,7 +484,7 @@ def volume_scan_continuous_sam(piezo, scanner, camera, z_positions: List[float], finally: # Always disable SAM when done - scanner.enable_sam('A', False) + scanner.enable_sam("A", False) yield from inner() ``` @@ -455,11 +492,15 @@ def volume_scan_continuous_sam(piezo, scanner, camera, z_positions: List[float], #### 2. Hardware SPIM Volume Scan ```python -def volume_scan_hardware_spim(scanner, piezo, camera, - num_slices: int, - scan_duration_ms: float, - camera_duration_ms: float, - metadata: Optional[Dict] = None): +def volume_scan_hardware_spim( + scanner, + piezo, + camera, + num_slices: int, + scan_duration_ms: float, + camera_duration_ms: float, + metadata: Optional[Dict] = None, +): """ Hardware-timed SPIM volume scan using ASI controller state machine @@ -481,10 +522,10 @@ def volume_scan_hardware_spim(scanner, piezo, camera, Camera exposure time """ md = { - 'plan_name': 'volume_scan_hardware_spim', - 'num_slices': num_slices, - 'scan_duration_ms': scan_duration_ms, - 'camera_duration_ms': camera_duration_ms, + "plan_name": "volume_scan_hardware_spim", + "num_slices": num_slices, + "scan_duration_ms": scan_duration_ms, + "camera_duration_ms": camera_duration_ms, } if metadata: md.update(metadata) @@ -496,7 +537,7 @@ def volume_scan_hardware_spim(scanner, piezo, camera, num_slices=num_slices, scan_duration_ms=scan_duration_ms, camera_duration_ms=camera_duration_ms, - delay_ms=0.2 + delay_ms=0.2, ) # Arm SPIM @@ -525,11 +566,7 @@ def volume_scan_hardware_spim(scanner, piezo, camera, **Existing Infrastructure:** `gently/coordinates.py:78-168` ```python -from gently.coordinates import ( - piezo_to_galvo, - galvo_to_piezo, - calculate_piezo_galvo_calibration -) +from gently.coordinates import piezo_to_galvo, galvo_to_piezo, calculate_piezo_galvo_calibration # Use calibration in volume scan galvo_pos = piezo_to_galvo(piezo_pos, slope, offset) @@ -543,20 +580,21 @@ galvo_pos = piezo_to_galvo(piezo_pos, slope, offset) @dataclass class VolumeScanTiming: """Timing configuration for volume scans""" + # Sequential scan timing - piezo_settle_time_ms: float = 50.0 # Time for piezo to settle - galvo_settle_time_ms: float = 5.0 # Time for galvo to settle - camera_exposure_ms: float = 10.0 # Camera exposure + piezo_settle_time_ms: float = 50.0 # Time for piezo to settle + galvo_settle_time_ms: float = 5.0 # Time for galvo to settle + camera_exposure_ms: float = 10.0 # Camera exposure # Hardware SPIM timing - spim_scan_duration_ms: float = 10.0 # Total scan time per slice - spim_camera_duration_ms: float = 9.5 # Camera exposure + spim_scan_duration_ms: float = 10.0 # Total scan time per slice + spim_camera_duration_ms: float = 9.5 # Camera exposure spim_delay_before_camera_ms: float = 0.2 # Settling before trigger - spim_delay_before_laser_ms: float = 0.0 # Laser trigger delay + spim_delay_before_laser_ms: float = 0.0 # Laser trigger delay # SAM continuous scan timing - sam_period_ms: float = 10.0 # Scan period - sam_camera_phase: float = 0.25 # Trigger at 25% of cycle + sam_period_ms: float = 10.0 # Scan period + sam_camera_phase: float = 0.25 # Trigger at 25% of cycle ``` ### Multi-Device Coordination @@ -577,10 +615,10 @@ def create_volume_scan_devices(core: pymmcore.CMMCore) -> Dict: # Create devices devices = { - 'scanner': DiSPIMScanner("Scanner:AB:33", core, name='lightsheet_scanner'), - 'piezo': DiSPIMPiezo("PiezoStage:P:34", core, name='objective_piezo'), - 'camera': DiSPIMCamera("HamCam1", core, name='spim_camera'), - 'calibration': calib + "scanner": DiSPIMScanner("Scanner:AB:33", core, name="lightsheet_scanner"), + "piezo": DiSPIMPiezo("PiezoStage:P:34", core, name="objective_piezo"), + "camera": DiSPIMCamera("HamCam1", core, name="spim_camera"), + "calibration": calib, } return devices @@ -601,12 +639,14 @@ def test_volume_scan_sequential(): # Execute plan # Verify positions, images acquired + def test_piezo_galvo_sync(): """Test piezo-galvo synchronization""" # Given calibration # Test position calculations # Verify accuracy + def test_spim_configuration(): """Test SPIM property setting""" # Configure SPIM params diff --git a/docs/volume_bluesky_refactoring_plan.md b/docs/volume_bluesky_refactoring_plan.md index bdd3199b..398cd5c8 100644 --- a/docs/volume_bluesky_refactoring_plan.md +++ b/docs/volume_bluesky_refactoring_plan.md @@ -64,6 +64,7 @@ def stage(self): return super().stage() + def unstage(self): """Cleanup after acquisition series.""" try: @@ -87,6 +88,7 @@ def trigger(self): Duration: ~2.7s (1s config + 1.7s acquisition) """ + def acquisition_thread(): try: start_time = time.time() @@ -104,7 +106,7 @@ def trigger(self): self.camera_name, self._num_slices, 0, # intervalMs - True # stopOnOverflow + True, # stopOnOverflow ) # 4. Trigger SPIM state machine (~1.7s for 100 slices @ 59fps) @@ -139,9 +141,7 @@ def trigger(self): self._last_volume = None thread = threading.Thread( - target=acquisition_thread, - daemon=True, - name=f"SPIM-Trigger-{self.name}" + target=acquisition_thread, daemon=True, name=f"SPIM-Trigger-{self.name}" ) thread.start() @@ -180,12 +180,7 @@ def read(self): timestamp = time.time() - return { - self.name: { - 'value': volume, - 'timestamp': timestamp - } - } + return {self.name: {"value": volume, "timestamp": timestamp}} ``` ### 4. Add Helper Methods @@ -205,9 +200,7 @@ def _wait_for_buffer_fill(self, expected_count, timeout): while self.core.isSequenceRunning(self.camera_name): if time.time() - start_time > timeout: self.core.stopSequenceAcquisition(self.camera_name) - raise TimeoutError( - f"Sequence acquisition timeout after {timeout:.1f}s" - ) + raise TimeoutError(f"Sequence acquisition timeout after {timeout:.1f}s") time.sleep(0.01) # Verify buffer has all images @@ -223,6 +216,7 @@ def _wait_for_buffer_fill(self, expected_count, timeout): if actual_count > expected_count: print(f"Warning: Buffer has {actual_count} images, expected {expected_count}") + def _retrieve_volume_from_buffer(self): """ Retrieve all images from MM circular buffer. @@ -234,20 +228,19 @@ def _retrieve_volume_from_buffer(self): for i in range(self._num_slices): if self.core.getRemainingImageCount() == 0: - raise RuntimeError( - f"Buffer underrun at slice {i}/{self._num_slices}" - ) + raise RuntimeError(f"Buffer underrun at slice {i}/{self._num_slices}") # Pop and transfer over rpyc (SLOW: ~220ms per image) img = self.core.popNextImage() import rpyc + img = rpyc.classic.obtain(img) images.append(img) # Progress logging if (i + 1) % 10 == 0: - print(f"Retrieved {i+1}/{self._num_slices} slices") + print(f"Retrieved {i + 1}/{self._num_slices} slices") return np.array(images) ``` diff --git a/notes/REALTIME_HATCHING_DETECTION.md b/notes/REALTIME_HATCHING_DETECTION.md index e4efe763..55de6f83 100644 --- a/notes/REALTIME_HATCHING_DETECTION.md +++ b/notes/REALTIME_HATCHING_DETECTION.md @@ -52,14 +52,14 @@ Edit `HATCHING_DETECTION_CONFIG` in `run_multi_embryo_volumes_with_detection.py` ```python HATCHING_DETECTION_CONFIG = { - 'enabled': True, # Enable/disable detection - 'min_timepoints_before_detection': 50, # Don't check before this (~100min at 2min/tp) - 'confidence_threshold': 'HIGH', # HIGH/MEDIUM/LOW - 'image_history_window': 10, # Recent images to send to Claude - 'stop_when_all_hatched': True, # End when all embryos hatched - 'continue_after_hatching': 5, # Confirmation timepoints after hatching - 'save_processed_images': True, # Save max projections for debugging - 'detection_log_file': 'hatching_detection_log.json' + "enabled": True, # Enable/disable detection + "min_timepoints_before_detection": 50, # Don't check before this (~100min at 2min/tp) + "confidence_threshold": "HIGH", # HIGH/MEDIUM/LOW + "image_history_window": 10, # Recent images to send to Claude + "stop_when_all_hatched": True, # End when all embryos hatched + "continue_after_hatching": 5, # Confirmation timepoints after hatching + "save_processed_images": True, # Save max projections for debugging + "detection_log_file": "hatching_detection_log.json", } ``` @@ -235,9 +235,9 @@ Reduce images sent to Claude as development progresses: if timepoint < 100: window_size = 10 # Early: more context elif timepoint < 200: - window_size = 6 # Mid: less context needed + window_size = 6 # Mid: less context needed else: - window_size = 4 # Late: minimal context + window_size = 4 # Late: minimal context ``` ### Batch Processing (Advanced) @@ -307,14 +307,16 @@ Edit `_create_detection_content` in `realtime_hatching_detector.py`: ```python # Add embryo-specific context -content.append({ - "type": "text", - "text": f""" +content.append( + { + "type": "text", + "text": f""" This is embryo #{embryo_number} from position {position}. Previous detection showed pre-hatching signs. Focus on eggshell breach in upper-right quadrant. - """ -}) + """, + } +) ``` ### Add Pre-hatching Detection @@ -362,7 +364,7 @@ annotations = load_manual_annotations() # Compare with detector results for embryo_id in annotations: - manual_tp = annotations[embryo_id]['hatching_timepoint'] + manual_tp = annotations[embryo_id]["hatching_timepoint"] detected_tp = detector.get_hatching_timepoint(embryo_id) diff = abs(manual_tp - detected_tp) if detected_tp else None print(f"{embryo_id}: Manual={manual_tp}, Detected={detected_tp}, Diff={diff}") diff --git a/pyproject.toml b/pyproject.toml index c9a96005..ece78ed9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,7 +88,10 @@ torch-cpu = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", - "ruff>=0.4.0", + # Pinned so local runs, the pre-commit hook, and CI all use the same + # ruff. Keep in sync with the ruff-pre-commit rev in .pre-commit-config.yaml + # and the install step in .github/workflows/lint.yml. + "ruff==0.16.0", "pre-commit>=3.7.0", # Pinned so local runs, the pre-commit hook, and CI all use the same # mypy. Keep in sync with the mirrors-mypy rev in .pre-commit-config.yaml