Source code for ClearMap.pipeline_orchestrators.experiment_controller

"""
experiment_controller
=====================

Coordinator for application flow between configuration, workspace/sample state,
and processing workers.

This module wires together three main subsystems:

- **ConfigCoordinator** — in-memory working model for all config files with
  atomic commit and validation semantics.
- **SampleManager** — runtime view of the sample + Workspace2 (assets on disk),
  reconciled from config and updated via events.
- **EventBus** — decoupled, typed pub/sub used by the UI and back-end.

Responsibilities
----------------
- Boot an experiment (open/clone/new), seed defaults, run adjusters, validate,
  and commit configuration.
- Construct and cache worker instances (per pipeline and channel), reconcile
  them when channels change, and expose a simple `get_worker(...)` API.
- Bridge UI patches into `ConfigCoordinator.submit_patch(...)` so all edits are
  validated and persisted atomically.
- Optionally seal and snapshot the working config for reproducible runs.
- Listen to UI/domain events (e.g., channel rename/changed) and keep worker
  topology consistent.

Thread-safety
-------------
- `ConfigCoordinator` provides its own locking; reads of `get_config_view()`
  are safe against mid-edit states.
- `EventBus` is thread-safe and resilient to dead weakrefs.

Design Notes
------------
- All persistence flows through **ConfigCoordinator**
- **SampleManager** keeps the Workspace2 in sync by subscribing to domain
  events (e.g., `ChannelRenamed`) and by reading the validated config view.
- Worker creation is **lazy**; reconciliation removes stale workers while new
  ones are built on first access.
"""
from collections import defaultdict
from enum import Enum
from pathlib import Path
from typing import Callable, Dict, Iterable, Optional, Tuple, Any, Union

from ClearMap.Utils.event_bus import EventBus, BusSubscriberMixin
from ClearMap.Utils.events import WorkspaceChanged, UiChannelRenamed, UiChannelsChanged
from ClearMap.config.config_adjusters.type_hints import AdjusterScope
from ClearMap.config.config_coordinator import ConfigCoordinator
from ClearMap.config.defaults_provider import DefaultsProvider
from ClearMap.pipeline_orchestrators.group_orchestrators import DensityGroupAnalysisOrchestrator
from ClearMap.pipeline_orchestrators.processor_launcher import ProcessorLauncher

from ClearMap.pipeline_orchestrators.sample_info_management import SampleManager


# REFACTOR: move to a common place
ChannelKey = Optional[Union[str, Tuple[str, ...]]]  # str | (str,...) | None
RealChannelKey = Union[str, Tuple[str, ...]]  # str | (str,...) (skip None)
FactoryKey = Tuple[str, Optional[str]]           # (pipeline, substep)
WorkerKey  = Tuple[ChannelKey, Optional[str]]    # (channel_key, substep)


[docs] class AppMode(Enum): EXPERIMENT = 'experiment' # single sample GROUP = 'group' # cohort / study
[docs] def swap_old_channel(ch_key: ChannelKey, old_chan: RealChannelKey, new_chan: RealChannelKey) -> ChannelKey: """ Swap old_chan by new_chan in ch_key if present. If ch_key is a simple channel (str) or None -> str.replace() or None If ch_key is a tuple -> replace old_chan by new_chan in the tuple Parameters ---------- ch_key: ChannelKey ChannelKey to update old_chan: RealChannelKey Channel name to replace new_chan: RealChannelKey Channel name to use as replacement Returns ------- ChannelKey: new_ch_key with old_chan replaced by new_chan, or None if key was None """ new_ch_key = None if ch_key == old_chan: new_ch_key = new_chan elif isinstance(ch_key, tuple) and old_chan in ch_key: # Compound channels, keep other channel and replace old by new new_ch_key = tuple(new_chan if x == old_chan else x for x in ch_key) return new_ch_key
def _channel_was_removed(ch_key: ChannelKey, removed: set[RealChannelKey]) -> bool: if ch_key is None: return False if isinstance(ch_key, tuple): return any(x in removed for x in ch_key) return ch_key in removed
[docs] class ExperimentController(BusSubscriberMixin): """ Orchestrates experiment lifecycle, config edits, and worker topology. This controller is the single entrypoint for app flow. It owns the `ConfigCoordinator` and `SampleManager`, constructs per-pipeline workers, applies UI patches through the coordinator, and reacts to bus events that affect worker/channel state. Parameters ---------- cfg_coordinator : ConfigCoordinator Central configuration manager (working view + validation + commit). sample_manager : SampleManager Provides sample semantics and manages the Workspace2. proc_launcher : ProcessorLauncher, optional Used for sealing/snapshotting and launching processors. evt_bus : EventBus Event bus for decoupled UI/backend communication. use_materializers : bool, default True Reserved for future materialization hooks (kept for compatibility). use_snapshots : bool, default False When True, runs seal+snapshot before launching processors. Attributes ---------- _exp_dir : Optional[pathlib.Path] Current experiment directory (None until `set_experiment_dir()`). _workers : Dict[str, Dict[Tuple[ChannelKey, Optional[str]], object]] Cached workers keyed by pipeline and (channel_key, substep). _factories : Dict[Tuple[str, Optional[str]], Callable] Factory registry: (pipeline, substep) -> factory(sm, coord, channel_key). Signals ------- - UiChannelRenamed -> :meth:`on_channel_renamed` - UiChannelsChanged -> :meth:`on_channels_changed` Published Events ---------------- - (optional) WorkspaceChanged(exp_dir=...) when the experiment directory is set. Ensure the event class exists before enabling. Key Operations -------------- boot_open(exp_dir) Load configs, seed defaults, run adjusters, validate, and commit. boot_new(dest_dir, template_dir=None) Create from defaults or clone template, then open. apply_ui_patch(patch) Validate+commit a UI patch via `ConfigCoordinator.submit_patch(...)`. get_worker(pipeline, channel=None, substep=None) Lazy construction of workers. Cached until reconciliation. reconcile_workers_after_channel_change(before, after) Remove stale workers after channel removals; new ones remain lazy. seal_and_snapshot() Ensure committed config; optionally write an immutable snapshot. Invariants ---------- - All config mutations go through `ConfigCoordinator.submit(...)` or `submit_patch(...)`; direct edits to files are disallowed. - `SampleManager` derives runtime state from committed config and bus events. - Workers never mutate global config directly; they request patches through controller-provided paths. """ def __init__(self, *, cfg_coordinator: ConfigCoordinator, sample_manager: SampleManager, proc_launcher: Optional[ProcessorLauncher] = None, evt_bus: EventBus, use_materializers: bool = True, use_snapshots: bool = False) -> None: self.cfg_coordinator = cfg_coordinator self.sample_manager = sample_manager self.launcher = proc_launcher super().__init__(evt_bus) self.use_materializers = use_materializers self.use_snapshots = use_snapshots self._exp_dir: Optional[Path] = None self._workers: Dict[str, Dict[WorkerKey, object]] = defaultdict(dict) self._factories: Dict[tuple[str, Optional[str]], Callable] = {} # pipeline(/sub) -> factory(sm, coord, key=None) self._factory_scope: Dict[tuple[str, Optional[str]], str] = {} # "global" | "per_channel" | "per_pair" self._register_default_factories() self._hydrating: bool = False self.subscribe(UiChannelRenamed, self.on_channel_renamed) self.subscribe(UiChannelsChanged, self.on_channels_changed) # FIXME: also subscribe to dtype changed in config -> reconcile workers (pipelines) @property def hydrating(self) -> bool: return self._hydrating def _register_default_factories(self): # WARNING: Lazy imports for snappy boot and to avoid cycles # simple/global def stitching_factory(sm, coord, key=None): from ClearMap.pipeline_orchestrators.stitching_orchestrator import StitchingProcessor return StitchingProcessor(sm, coord) def registration_factory(sm, coord, key=None): from ClearMap.pipeline_orchestrators.registration_orchestrator import RegistrationProcessor return RegistrationProcessor(sm, coord) # per-channel def cell_map_factory(sm, coord, key): from ClearMap.pipeline_orchestrators.cell_map import CellDetector return CellDetector(sm, coord, channel=key, registration_processor=reg_proc) def tract_map_factory(sm, coord, key): from ClearMap.pipeline_orchestrators.tract_map import TractMapProcessor return TractMapProcessor(sm, coord, channel=key, registration_processor=reg_proc) # per-pair (key is a tuple) def colocalization_factory(sm, coord, key): from ClearMap.pipeline_orchestrators.colocalization import ColocalizationProcessor return ColocalizationProcessor(sm, coord, channels=key, registration_processor=reg_proc) # vasculature as substeps (single pipeline, two steps) def binary_vessel_processor_factory(sm, coord, key=None): from ClearMap.pipeline_orchestrators.tube_map import BinaryVesselProcessor return BinaryVesselProcessor(sm, coord) def vessel_graph_processor_factory(sm, coord, key=None): from ClearMap.pipeline_orchestrators.tube_map import VesselGraphProcessor return VesselGraphProcessor(sm, coord, registration_processor=reg_proc) self.register_worker_factory('stitching', stitching_factory, scope='global') self.register_worker_factory('registration', registration_factory, scope='global') # pull registration_processor now for factories that depend on it # OPTIMISE: find a way to avoid instantiating too early reg_proc = self.get_worker('registration') self.register_worker_factory('cell_map', cell_map_factory, scope='per_channel') self.register_worker_factory('tract_map', tract_map_factory, scope='per_channel') self.register_worker_factory('colocalization', colocalization_factory, scope='per_pair') self.register_worker_factory('vasculature', binary_vessel_processor_factory, substep='binary', scope='global') self.register_worker_factory('vasculature', vessel_graph_processor_factory, substep='graph', scope='global')
[docs] def register_worker_factory(self, pipeline: str, factory: Callable, substep: Optional[str] = None, scope: str = 'global') -> None: """ Register a factory function that creates workers for a given pipeline (and optionally substep). The factory will be called with (sample_manager, cfg_coordinator, channel_key). Parameters ---------- pipeline : str Name of the processing pipeline (e.g., "registration", "cell_map"). factory : Callable Factory function to create the worker. (built with the ctor of the worker) substep : Optional[str], default None Optional substep identifier within the pipeline. scope : str, default "global" Scope of the worker: "global", "per_channel", or "per_pair". """ key = (pipeline, substep) self._factories[key] = factory self._factory_scope[key] = scope
[docs] def set_workers_progress_watcher(self, watcher): # REFACTOR: define watcher signature """Attach watcher to SampleManager and every existing worker.""" # self.sample_manager.set_progress_watcher(watcher) for pipeline_workers in self._workers.values(): for worker in pipeline_workers.values(): # each worker keyed by (channel, substep) if hasattr(worker, "set_progress_watcher"): worker.set_progress_watcher(watcher)
@property def exp_dir(self) -> Optional[Path]: return self._exp_dir
[docs] def set_experiment_dir(self, exp_dir: str | Path) -> None: # Clear cached workers if directory changed (they hold old workspace refs) if self._exp_dir is not None and self._exp_dir != exp_dir: self._workers.clear() self._exp_dir = Path(exp_dir).expanduser().resolve() self.cfg_coordinator.set_base_dir(self._exp_dir) self.sample_manager.setup(self._exp_dir) self.publish(WorkspaceChanged(exp_dir=str(self._exp_dir)))
@staticmethod def _make_defaults_provider() -> DefaultsProvider: """ Build a DefaultsProvider pointing to packaged defaults & schemas. Adjust paths to match your repo layout if needed. """ base = Path(__file__).resolve().parent schemas_dir = base / 'schemas' # put JSON Schemas here return DefaultsProvider(schemas_dir=schemas_dir, code_defaults={}, tabs_only=True) # TODO: check if tabs_only=True is OK
[docs] def sample_path_exists(self) -> bool: return self.cfg_coordinator.config_exists_any('sample')
[docs] def set_sample_id(self, sample_id: str) -> None: if self._exp_dir is not None: self.cfg_coordinator.set_base_dir(self._exp_dir) self.cfg_coordinator.submit_patch({'sample': {'sample_id': sample_id}}, sample_manager=self.sample_manager, origin='ExperimentController.set_sample_id',)
def _get_sample_cfg(self): sample_cfg = self.cfg_coordinator.get_config_view('sample') if not sample_cfg: # Load sample only because might be partial experiment sample_cfg = self.cfg_coordinator.load('sample') return sample_cfg
[docs] def read_sample_version(self) -> str: return self._get_sample_cfg().get('clearmap_version', '')
[docs] def get_or_init_sample_id(self): sample_id = self._get_sample_cfg().get('sample_id') return sample_id or None # leave creation to UI prompt (app will call set_sample_id)
[docs] def upgrade_configs(self, from_version: str, to_version: str) -> None: """ Convert the config to the current ClearMap version in the current exp_dir. Reload all configs into the working model afterwards. Parameters ---------- from_version: str The version the config is currently in. to_version: str The version to convert the config to. """ from ClearMap.config.convert_config_versions import convert_versions convert_versions(from_version, to_version, exp_dir=self._exp_dir) self.cfg_coordinator.load_all()
# ---- boot / workspace ----------------------------------------------------
[docs] def boot_open(self, exp_dir: Path) -> None: """ Open an existing experiment directory: load all configs, build working model, materialize/validate/commit (to normalize), and build tabs. """ self._hydrating = True try: self.set_experiment_dir(exp_dir) print(f'Opening experiment at {self._exp_dir}') self._refresh_relevant_sections() self.cfg_coordinator.load_all() defaults_provider = self._make_defaults_provider() self.cfg_coordinator.set_defaults_provider(defaults_provider) # Ensure configs will never have missing fields self.cfg_coordinator.seed_missing_from_defaults(tabs_only=True) # Normalize once (may add derived sections), then persist atomically self.cfg_coordinator.submit(sample_manager=self.sample_manager, do_run_adjusters=True, validate=True, commit=True) finally: self._hydrating = False stitching_worker = self.get_worker('stitching') return stitching_worker.prepare_all_channels_raw_data()
[docs] def boot_new(self, dest_dir: Optional[Path] = None, template_dir: Optional[Path] = None) -> None: """ Create a new experiment from defaults or clone a template, then open it. """ if not dest_dir: dest_dir = self._exp_dir # FIXME: check this fallback dest = Path(dest_dir).expanduser().resolve() if template_dir: template_dir = Path(template_dir).expanduser().resolve() self.cfg_coordinator.clone_from(template_dir, dest) else: self.cfg_coordinator.copy_from_defaults(dest) self.sample_manager.setup(dest) self.boot_open(dest)
[docs] def clone_from(self, template_dir: Path, dest_dir: Path) -> None: """ Explicit clone API (alias); then open the new experiment. """ self.boot_new(dest_dir, template_dir)
[docs] def ensure_config_present(self, name: str) -> Path | None: """ Ensure the named config file exists in the current experiment directory, copying from defaults if needed. Returns the path or None if no default exists for that name. Parameters ---------- name: str Logical config name, e.g. "sample", "registration", etc. Returns ------- Path | None The path to the ensured config file, or None if no default exists. """ return self.cfg_coordinator.ensure_present(name)
[docs] def get_worker(self, pipeline: str, channel: ChannelKey = None, substep: Optional[str] = None): factory_key: FactoryKey = (pipeline, substep) try: factory = self._factories[factory_key] except KeyError: raise KeyError(f'No factory registered for pipeline "{pipeline}" ({substep=})') scope = self._factory_scope.get(factory_key, 'global') worker_key: WorkerKey = (channel, substep) pipeline_workers = self._workers[pipeline] # what if pipeline not registered yet? worker = pipeline_workers.get(worker_key) if worker is not None: return worker else: # FIXME: add pipeline to workspace or inside worker ctor? if scope == 'global': if channel is not None: raise ValueError( f'Pipeline "{pipeline}" is global (scope=global) but {channel=} was requested.') worker = factory(self.sample_manager, self.cfg_coordinator) elif scope in ('per_channel', 'per_pair'): if channel is None: raise ValueError(f'Pipeline "{pipeline}" ({scope=}) requires a channel key, got channel=None.') worker = factory(self.sample_manager, self.cfg_coordinator, channel) else: raise ValueError(f'Unknown worker scope "{scope}" for pipeline "{pipeline}".') pipeline_workers[worker_key] = worker return worker
[docs] def get_workers(self, pipeline: str, channels: Iterable[ChannelKey], substep: Optional[str] = None): return {ch: self.get_worker(pipeline, ch, substep=substep) for ch in channels}
[docs] def worker_is_ready(self, worker_name): if worker_name != 'sample' and self.sample_manager.workspace is None: return False else: registration_worker = self.get_worker('registration') try: return not self.sample_manager.needs_registering(registration_worker) except Exception: return False
[docs] def reconcile_workers(self, pipeline: str, desired_channels: Optional[Iterable[ChannelKey]], substep: Optional[str] = None, keep_global: bool = True) -> Dict[ChannelKey, object]: """ Ensure all desired workers exist and remove obsolete ones for this (pipeline, substep). desired_channels=None means 'global-only' (i.e., {None} if keep_global). """ desired = set(desired_channels or []) factory_key = (pipeline, substep) scope = self._factory_scope.get(factory_key, 'global') if keep_global and scope == 'global': desired.add(None) # build missing (lazy via get_worker) for ch in desired: self.get_worker(pipeline, ch, substep=substep) pipeline_workers = self._workers[pipeline] # prune stale to_remove = [] for (ch, current_substep), _worker in list(pipeline_workers.items()): if current_substep != substep: continue if ch not in desired: to_remove.append((ch, current_substep)) for key in to_remove: pipeline_workers.pop(key, None) # return current (without substep key in API, map by channel) return {ch: pipeline_workers[(ch, substep)] for ch in desired if (ch, substep) in pipeline_workers}
[docs] def reconcile_workers_after_channel_change(self, before: list[str], after: list[str]) -> None: """ Prune workers that reference removed channels by delegating to reconcile_workers. Creation of workers for *new* channels is left lazy (on first use). """ removed = set(c for c in (before or []) if c not in (after or [])) if not removed: return for pipeline, workers in self._workers.items(): # Keep all existing non-removed channel keys for this pipeline/substep by_sub: dict[Optional[str], set[ChannelKey]] = {} for (ch_key, substep) in list(workers.keys()): if not _channel_was_removed(ch_key, removed): by_sub.setdefault(substep, set()).add(ch_key) # Reconcile per substep using the desired (filtered) set for substep, desired in by_sub.items(): scope = self._factory_scope.get((pipeline, substep), 'global') keep_global = (scope == 'global') self.reconcile_workers(pipeline, desired_channels=desired, substep=substep, keep_global=keep_global)
[docs] def rename_workers_channels(self, old_chan: RealChannelKey, new_chan: RealChannelKey): for pipeline, pipeline_workers in self._workers.items(): # work on a copy of keys because we mutate the dict for (ch_key, substep) in list(pipeline_workers.keys()): new_ch_key = swap_old_channel(ch_key, old_chan, new_chan) if new_ch_key is not None: worker = pipeline_workers.pop((ch_key, substep)) pipeline_workers[(new_ch_key, substep)] = worker
[docs] def on_channel_renamed(self, evt: UiChannelRenamed) -> None: # FIXME: shouldn't it be ChannelRenamed (sent by config coordinator AFTER)? """ Apply a rename everywhere (workers, config), then publish one snapshot event. """ try: self.rename_workers_channels(evt.old, evt.new) except Exception: pass
[docs] def on_channels_changed(self, evt: UiChannelsChanged) -> None: """ Handle add/remove; seed/drop per-channel sections, purge artifacts, then publish. """ self._refresh_relevant_sections() self.reconcile_workers_after_channel_change(before=evt.before, after=evt.after)
[docs] def channel_snapshot(self): """ Single “truth” event: current channel list + registration partner defaults, derived centrally via the registration worker. """ channels = self.sample_manager.channels # derive partner defaults via registration rules (no UI logic here) partners = {} try: reg = self.get_worker('registration') for ch in channels: partners[ch] = { 'align_with': reg.get_align_with(ch), 'moving': reg.get_moving_channel(ch), } except Exception: # keep UI alive even if reg worker not ready partners = {ch: {'align_with': None, 'moving': None} for ch in channels} return channels, partners # , 'renamed': rename_map}
# ---- UI edits / persistence ---------------------------------------------
[docs] def apply_ui_patch(self, patch: Dict[str, Any]) -> None: """ Single write entrypoint: Params call this with a patch (persisted fields only). We merge it, derive dependent sections, validate, and atomically commit. """ if not patch: return # print(f'Applying UI patch keys: {list(patch.keys())}') self.cfg_coordinator.submit_patch(patch, sample_manager=self.sample_manager, do_run_adjusters=True, validate=True, commit=True)
# self.evt_bus.publish(ConfigChanged({"rev": self.cfg_coordinator._rev})) # ---- run path (snapshot + launch) ---------------------------------------
[docs] def seal_and_snapshot(self) -> Path: """ Ensure the on-disk config is fully materialized & valid; take an immutable snapshot directory used by pipeline_orchestrators for reproducibility. """ self.cfg_coordinator.submit(sample_manager=self.sample_manager, do_run_adjusters=True, validate=True, commit=True) if not self.use_snapshots: if self._exp_dir is None: raise ValueError('Workspace not set') return self._exp_dir # legacy behavior: use working dir # FIXME: this is redundant with ConfigCoordinator.snapshot_to() snap_dir = self.launcher.seal_and_snapshot(self.cfg_coordinator) # expects coordinator to expose working view print(f'Sealed snapshot at {snap_dir}') return snap_dir
[docs] def launch_processor(self, processor_class, **opts): """ Typical run: seal snapshot, then launch processor with config-dir=<snapshot>. """ snap = self.seal_and_snapshot() return self.launcher.launch(processor_class, snap, **opts)
# ---- read-only views for UI ---------------------------------------------
[docs] def get_config_view(self) -> Dict[str, Any]: """ Dict-like snapshot the UI can read to populate widgets. Do not expose repository paths here—UI shouldn’t hit disk. """ return self.cfg_coordinator.get_config_view()
def _refresh_relevant_sections(self) -> None: sections = self.sample_manager.compute_required_sections() self.cfg_coordinator.set_active_sections(sections) # Seed defaults only for active sections self.cfg_coordinator.seed_missing_from_defaults(tabs_only=True)
####################### Multi experiment controller ###########################
[docs] class AnalysisGroupController: """ Manages multiple ExperimentControllers, one per experiment root (src_dir). Also acts as a factory/router to fetch workers tied to the correct sample. """ def __init__(self, cfg_coordinator_factory, event_bus, exp_controller_factory): """ cfg_coordinator_factory: callable(base_dir: Path) -> ConfigCoordinator exp_controller_factory: callable(sample_manager, cfg_coordinator, event_bus) -> ExperimentController """ self._cfg_coordinator_factory = cfg_coordinator_factory # Built for each sample self._exp_controller_factory = exp_controller_factory # Built for each sample self._bus = event_bus self._controllers: dict[Path, ExperimentController] = {} self._group_base_dir: Path | None = None self._group_cfg_coordinator = None # type: Optional[ConfigCoordinator] self._analysis_worker: Optional["DensityGroupAnalysisOrchestrator"] = None self._progress_watcher = None self._thread_wrapper = None @property def group_cfg_coordinator(self): if self._group_cfg_coordinator is None: raise ValueError("Group config not initialised (results_folder not set?)") return self._group_cfg_coordinator # ---------- external state ----------
[docs] def set_groups(self, groups: dict[str, list[str]]): normalised = {k: [str(Path(p)) for p in v] for k, v in groups.items()} self.apply_patch({'group_analysis': {'groups': normalised}})
# FIXME: or ['batch_processing']? We need prefix= @property def groups(self) -> dict[str, list[str]]: return self.get_config_view()['group_analysis']['groups'] # FIXME: or ['batch_processing']? We need prefix=
[docs] def set_group_base_dir(self, results_folder: str | Path): self._group_base_dir = Path(results_folder).expanduser().resolve() if self._group_cfg_coordinator is None: self._group_cfg_coordinator = self._cfg_coordinator_factory(base_dir=self._group_base_dir, scope=AdjusterScope.GROUP) self._group_cfg_coordinator.set_active_sections(self.infer_required_sections()) self._group_cfg_coordinator.load_all() self._group_cfg_coordinator.seed_missing_from_defaults(tabs_only=True)
[docs] def set_progress_watcher(self, watcher): self._progress_watcher = watcher
[docs] def set_thread_wrapper(self, wrapper): self._thread_wrapper = wrapper
@property def group_base_dir(self) -> Path: if self._group_base_dir is None: raise ValueError("results_folder not set") return self._group_base_dir def _get_or_create_exp_controller(self, sample_src_dir: str | Path) -> "ExperimentController": root = Path(sample_src_dir).expanduser().resolve() # Return if already cached if root in self._controllers: return self._controllers[root] # Otherwise, create new, cache, and return cfg_coordinator = self._cfg_coordinator_factory(base_dir=root) # isolated config view for this sample sample_mgr = SampleManager(config_coordinator=cfg_coordinator, src_dir=root) exp_controller = self._exp_controller_factory(cfg_coordinator=cfg_coordinator, sample_manager=sample_mgr, evt_bus=self._bus) self._controllers[root] = exp_controller return exp_controller
[docs] def get_worker(self, sample_src_dir: str | Path, pipeline: str, *, channel=None, substep=None): exp_controller = self._get_or_create_exp_controller(sample_src_dir) return exp_controller.get_worker(pipeline, channel=channel, substep=substep)
[docs] def get_sample_manager(self, sample_src_dir: str | Path) -> SampleManager: exp_controller = self._get_or_create_exp_controller(sample_src_dir) return exp_controller.sample_manager
@property def density_orchestrator(self) -> "DensityGroupAnalysisOrchestrator": if self._analysis_worker is None: analysis_worker = DensityGroupAnalysisOrchestrator(group_controller=self, pipeline=self._infer_pipeline()) if self._progress_watcher: analysis_worker.set_progress_watcher(self._progress_watcher) if self._thread_wrapper: analysis_worker.set_thread_wrapper(self._thread_wrapper) self._analysis_worker = analysis_worker return self._analysis_worker # FIXME: check this
[docs] def infer_required_sections(self) -> set[str]: return {'group_analysis', 'batch_processing'}
[docs] def set_pipeline(self, pipeline: str) -> None: """ Single entry point for pipeline changes. Updates config and orchestrator atomically. """ self.apply_patch({'group_analysis': {'pipeline': pipeline}}) self.density_orchestrator.pipeline = pipeline # setter handles invalidation
def _infer_pipeline(self) -> str: """ Read pipeline from group config. Falls back to 'CellMap' if not set. """ try: cfg = self.get_config_view() pipeline = (cfg.get('group_analysis', {}).get('pipeline') or cfg.get('batch_processing', {}).get('pipeline')) if pipeline: return pipeline except Exception: return 'CellMap' # fallback
[docs] def get_config_view(self) -> dict[str, Any]: return self.group_cfg_coordinator.get_config_view()
[docs] def apply_patch(self, patch: dict[str, Any]) -> None: if not patch: return self.group_cfg_coordinator.submit_patch( patch, sample_manager=None, # group scope: no SampleManager do_run_adjusters=True, validate=True, commit=True)