1111from iohub import open_ome_zarr
1212from pydantic import BaseModel , Field
1313
14+ from viscy_data .meta_csv import read_meta_rows_csv
15+
1416logger = logging .getLogger (__name__ )
1517
1618# ---------------------------------------------------------------------------
@@ -58,6 +60,7 @@ class SlurmStageConfig(BaseModel):
5860 time : str = "06:00:00"
5961 gres : str | None = None
6062 constraint : str | None = None
63+ qos : str | None = None
6164
6265
6366class SlurmConfig (BaseModel ):
@@ -92,6 +95,8 @@ class PrepareConfig(BaseModel):
9295 nfs_root : Path = Path ("/hpc/projects/intracellular_dashboard/organelle_dynamics" )
9396 vast_root : Path = Path ("/hpc/projects/organelle_phenotyping/datasets" )
9497 workspace_dir : Path = Path ("/hpc/mydata/eduardo.hirata/repos/viscy" )
98+ csv_dir : Path | None = None
99+ jobs_dir : Path | None = None
95100 concatenate : ConcatenateConfig = Field (default_factory = ConcatenateConfig )
96101 qc : QCParams = Field (default_factory = QCParams )
97102 preprocess : PreprocessParams = Field (default_factory = PreprocessParams )
@@ -195,19 +200,28 @@ def check_zarr_version(zarr_path: Path) -> dict[str, int | str | None]:
195200 return result
196201
197202
198- def check_preprocessed (zarr_path : Path ) -> bool :
199- """Check if normalization metadata has been written to the zarr store.
203+ def check_preprocessed (zarr_path : Path , csv_dir : Path | None = None ) -> bool :
204+ """Check if normalization metadata has been written for the zarr store.
200205
201206 Parameters
202207 ----------
203208 zarr_path : Path
204209 Path to the zarr store root.
210+ csv_dir : Path or None
211+ If given, check the per-store CSV sidecar under this directory
212+ (written by ``viscy preprocess --csv_dir ...``) instead of the
213+ store's ``.zattrs``/``zarr.json`` — for datasets prepared without
214+ write access.
205215
206216 Returns
207217 -------
208218 bool
209219 True if normalization stats are present.
210220 """
221+ if csv_dir is not None :
222+ sidecar = read_meta_rows_csv (csv_dir , zarr_path )
223+ return sidecar is not None and bool ((sidecar ["field_name" ] == "normalization" ).any ())
224+
211225 zarr_json = zarr_path / "zarr.json"
212226 zattrs = zarr_path / ".zattrs"
213227
@@ -223,6 +237,34 @@ def check_preprocessed(zarr_path: Path) -> bool:
223237 return False
224238
225239
240+ # ---------------------------------------------------------------------------
241+ # Batch discovery (filesystem glob, no NFS/Airtable dependency)
242+ # ---------------------------------------------------------------------------
243+
244+
245+ def discover_zarr_stores (data_root : Path ) -> list [Path ]:
246+ """Find dataset zarr stores under a root laid out like VAST's convention.
247+
248+ Matches ``{data_root}/{dataset_name}/{dataset_name}.zarr`` exactly (the
249+ zarr's own basename must match its containing directory's name) — this
250+ excludes sibling ``tracking.zarr`` dirs as well as any other variant
251+ zarr stores (e.g. ``{dataset_name}_chunked.zarr``,
252+ ``{dataset_name}_sharded.zarr``) that may sit alongside the canonical
253+ store for rechunking/testing purposes.
254+
255+ Parameters
256+ ----------
257+ data_root : Path
258+ Root directory containing one subdirectory per dataset.
259+
260+ Returns
261+ -------
262+ list[Path]
263+ Sorted paths to each dataset's zarr store.
264+ """
265+ return sorted (p for p in Path (data_root ).glob ("*/*.zarr" ) if p .stem == p .parent .name )
266+
267+
226268# ---------------------------------------------------------------------------
227269# Discovery (reads NFS zarr via iohub)
228270# ---------------------------------------------------------------------------
@@ -339,7 +381,7 @@ def generate_crop_concat_config(
339381 }
340382
341383
342- def generate_qc_config (data_path : Path , qc_params : QCParams ) -> dict :
384+ def generate_qc_config (data_path : Path , qc_params : QCParams , csv_dir : Path | None = None ) -> dict :
343385 """Build a QC config dict compatible with ``qc run -c``.
344386
345387 Parameters
@@ -348,6 +390,9 @@ def generate_qc_config(data_path: Path, qc_params: QCParams) -> dict:
348390 Path to the VAST zarr (target of QC).
349391 qc_params : QCParams
350392 Focus-slice QC parameters.
393+ csv_dir : Path or None
394+ If given, ``qc run`` writes results to a per-store CSV sidecar
395+ under this directory (read-only store) instead of ``.zattrs``.
351396
352397 Returns
353398 -------
@@ -357,6 +402,7 @@ def generate_qc_config(data_path: Path, qc_params: QCParams) -> dict:
357402 return {
358403 "data_path" : str (data_path ),
359404 "num_workers" : qc_params .num_workers ,
405+ "csv_dir" : str (csv_dir ) if csv_dir else None ,
360406 "focus_slice" : {
361407 "channel_names" : qc_params .channel_names ,
362408 "NA_det" : qc_params .NA_det ,
@@ -412,6 +458,8 @@ def _slurm_header(job_name: str, output_dir: Path, cfg: SlurmStageConfig) -> str
412458 lines .append (f"#SBATCH --gres={ cfg .gres } " )
413459 if cfg .constraint :
414460 lines .append (f'#SBATCH --constraint="{ cfg .constraint } "' )
461+ if cfg .qos :
462+ lines .append (f"#SBATCH --qos={ cfg .qos } " )
415463 return "\n " .join (lines )
416464
417465
@@ -532,7 +580,7 @@ def generate_qc_slurm(
532580 export PYTHONNOUSERSITE=1
533581
534582 echo "=== QC: focus slice detection ==="
535- uv run --project "{ workspace_dir } " --package qc \
583+ uv run --project "{ workspace_dir } " --package viscy- qc \
536584 qc run -c "{ qc_config_path } "
537585 echo "QC complete."
538586 """ )
@@ -546,6 +594,7 @@ def generate_preprocess_slurm(
546594 workspace_dir : Path ,
547595 preprocess_params : PreprocessParams ,
548596 slurm_cfg : SlurmStageConfig ,
597+ csv_dir : Path | None = None ,
549598) -> str :
550599 """Generate SLURM script for normalization preprocessing (CPU only).
551600
@@ -563,6 +612,10 @@ def generate_preprocess_slurm(
563612 Normalization preprocessing parameters.
564613 slurm_cfg : SlurmStageConfig
565614 SLURM resource parameters.
615+ csv_dir : Path or None
616+ If given, ``viscy preprocess`` writes results to a per-store CSV
617+ sidecar under this directory (read-only store) instead of
618+ ``.zattrs``.
566619
567620 Returns
568621 -------
@@ -576,6 +629,7 @@ def generate_preprocess_slurm(
576629 ch_flag = f"--channel_names={ ch_arg } "
577630 else :
578631 ch_flag = " " .join (f"--channel_names={ c } " for c in ch_arg )
632+ csv_dir_flag = f' --csv_dir "{ csv_dir } "' if csv_dir else ""
579633
580634 body = dedent (f"""\
581635
@@ -586,7 +640,7 @@ def generate_preprocess_slurm(
586640 uv run --project "{ workspace_dir } " --package dynaclr \
587641 viscy preprocess --data_path "{ vast_zarr_path } " \
588642 { ch_flag } --num_workers { preprocess_params .num_workers } \
589- --block_size { preprocess_params .block_size }
643+ --block_size { preprocess_params .block_size } { csv_dir_flag }
590644 echo "Preprocess complete."
591645 """ )
592646 return header + "\n " + body
@@ -597,7 +651,9 @@ def generate_preprocess_slurm(
597651# ---------------------------------------------------------------------------
598652
599653
600- def check_dataset_status (dataset_name : str , nfs_root : Path , vast_root : Path ) -> dict [str , str ]:
654+ def check_dataset_status (
655+ dataset_name : str , nfs_root : Path , vast_root : Path , csv_dir : Path | None = None
656+ ) -> dict [str , str ]:
601657 """Check existence and version info for a dataset across NFS and VAST.
602658
603659 Parameters
@@ -608,6 +664,9 @@ def check_dataset_status(dataset_name: str, nfs_root: Path, vast_root: Path) ->
608664 NFS root directory.
609665 vast_root : Path
610666 VAST root directory.
667+ csv_dir : Path or None
668+ If given, check the ``preprocessed`` status via the CSV sidecar
669+ under this directory instead of ``.zattrs`` (see ``check_preprocessed``).
611670
612671 Returns
613672 -------
@@ -629,7 +688,7 @@ def check_dataset_status(dataset_name: str, nfs_root: Path, vast_root: Path) ->
629688 ver = check_zarr_version (vast ["zarr" ])
630689 zarr_fmt = str (ver ["zarr_format" ]) if ver ["zarr_format" ] else "?"
631690 ome_ver = str (ver ["ome_version" ]) if ver ["ome_version" ] else "?"
632- preprocessed = "yes" if check_preprocessed (vast ["zarr" ]) else "no"
691+ preprocessed = "yes" if check_preprocessed (vast ["zarr" ], csv_dir = csv_dir ) else "no"
633692
634693 return {
635694 "dataset" : dataset_name ,
0 commit comments