Source code for stilt.project
"""
A STILT project root and its file layout.
A project is one root — a local directory or an object-store URI — holding::
config.yaml
receptors.csv
simulations/by-id/<sim_id>/<sim_id>_traj.parquet
simulations/by-id/<sim_id>/<sim_id>_error.parquet
simulations/by-id/<sim_id>/<sim_id>_<footprint>_foot.nc
simulations/by-id/<sim_id>/<sim_id>_<footprint>_foot.empty
simulations/by-id/<sim_id>/stilt.log
Everything is addressed by store key relative to the root. ``config.yaml`` and
``receptors.csv`` together *are* the project: the registered simulation set is
their receptors crossed with the configured met streams.
"""
from __future__ import annotations
import re
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
from stilt.store import Store, is_uri, make_store
if TYPE_CHECKING:
from stilt.config import ModelConfig
from stilt.receptors import Receptor
CONFIG_KEY = "config.yaml"
RECEPTORS_KEY = "receptors.csv"
SIMULATIONS_PREFIX = "simulations/by-id"
SIMULATION_LOG_FILENAME = "stilt.log"
SIMULATION_MET_DIRNAME = "met"
def resolve_directory(
directory: str | Path | None = None, *, prefix: str = "pystilt_"
) -> Path:
"""Return a resolved directory path, creating a temp root when omitted."""
if directory is None:
return Path(tempfile.mkdtemp(prefix=prefix))
directory = Path(directory)
if directory.parent == Path("."):
directory = directory.resolve()
return directory
[docs]
def project_slug(root: str) -> str:
"""Derive a DNS-safe / filename-safe slug from a project path or URI."""
raw = root.rstrip("/")
if is_uri(raw):
raw = raw.split("://", 1)[1]
parts = [part for part in raw.split("/") if part]
candidate = parts[-1] if parts else "project"
slug = candidate.lower().replace("_", "-")
slug = re.sub(r"[^a-z0-9-]+", "-", slug)
slug = re.sub(r"-{2,}", "-", slug).strip("-")
return slug or "project"
[docs]
def simulation_prefix(sim_id: str) -> str:
"""Return the store key prefix for one simulation's outputs."""
return f"{SIMULATIONS_PREFIX}/{sim_id}"
[docs]
class Project:
"""
One STILT project root plus its store.
Parameters
----------
root
Local directory or object-store URI. A temporary directory is created
when omitted.
cache_dir
Local cache for downloads from a remote store.
"""
def __init__(
self,
root: str | Path | None = None,
*,
cache_dir: str | Path | None = None,
) -> None:
if root is None or not is_uri(root):
self.root = str(resolve_directory(root))
self.is_cloud = False
else:
self.root = str(root).rstrip("/")
self.is_cloud = True
self.store: Store = make_store(self.root, cache_dir=cache_dir)
def __repr__(self) -> str:
return f"Project({self.root!r})"
def __str__(self) -> str:
return self.root
def __fspath__(self) -> str:
if self.is_cloud:
raise TypeError(f"Cloud project {self.root!r} has no local path.")
return self.root
@property
def name(self) -> str:
"""Human-readable project name (directory basename or URI slug)."""
return project_slug(self.root) if self.is_cloud else Path(self.root).name
@property
def directory(self) -> Path:
"""Local project directory. Raises for cloud projects."""
if self.is_cloud:
raise TypeError(f"Cloud project {self.root!r} has no local directory.")
return Path(self.root)
@property
def simulations_dir(self) -> Path:
"""Local ``simulations/by-id`` directory. Raises for cloud projects."""
return self.directory / SIMULATIONS_PREFIX
# -- inputs ----------------------------------------------------------------
@property
def has_config(self) -> bool:
"""Return whether a config has been written to the project."""
return self.store.exists(CONFIG_KEY)
@property
def has_receptors(self) -> bool:
"""Return whether receptors have been written to the project."""
return self.store.exists(RECEPTORS_KEY)
[docs]
def load_config(self) -> ModelConfig:
"""Load ``config.yaml`` from the store."""
from stilt.config import ModelConfig
if not self.has_config:
raise FileNotFoundError(
f"No config.yaml found in {self.root}. "
"Create one with ModelConfig.to_yaml()."
)
return ModelConfig.from_yaml(self.store.local_path(CONFIG_KEY))
[docs]
def save_config(self, config: ModelConfig) -> None:
"""Write ``config.yaml`` to the store."""
with tempfile.TemporaryDirectory(prefix="pystilt_config_") as tmp:
path = Path(tmp) / CONFIG_KEY
config.to_yaml(path)
self.store.publish_file(path, CONFIG_KEY)
[docs]
def load_receptors(self) -> list[Receptor] | None:
"""Load ``receptors.csv`` from the store, or ``None`` when absent."""
from stilt.receptors import read_receptors
if not self.has_receptors:
return None
return read_receptors(self.store.local_path(RECEPTORS_KEY))
[docs]
def save_receptors(self, receptors: list[Receptor]) -> None:
"""Write *receptors* to ``receptors.csv`` in the store."""
from stilt.receptors import receptors_to_csv
self.store.write_bytes(RECEPTORS_KEY, receptors_to_csv(receptors).encode())
[docs]
def copy_receptors(self, source: str | Path) -> None:
"""Copy an existing receptors CSV byte-for-byte into the store."""
self.store.publish_file(source, RECEPTORS_KEY)
__all__ = [
"CONFIG_KEY",
"RECEPTORS_KEY",
"SIMULATIONS_PREFIX",
"SIMULATION_LOG_FILENAME",
"SIMULATION_MET_DIRNAME",
"Project",
"project_slug",
"resolve_directory",
"simulation_prefix",
]