Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions .importlinter
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Import Linter configuration for ADAPT
# Import Linter configuration for Adapt
#
# Purpose:
# Enforce architectural boundaries so the codebase stays modular,
Expand All @@ -19,22 +19,14 @@ root_packages =
include_external_packages = False

# ==========================================================
# 1. Scientific modules must remain independent
# 1. Scientific module independence — enforced by pytest
# ==========================================================
#
# No module may import from any other module — directly or
# transitively. Shared types belong in adapt.contracts.
# Inter-module imports and upward imports from modules/ are
# caught by tests/test_architecture.py, which auto-discovers
# subpackages at runtime. No explicit module list needed there.
# Run: pytest tests/test_architecture.py

[importlinter:contract:independent_modules]
name = Adapt modules remain independent
type = independence
modules =
adapt.modules.acquisition
adapt.modules.analysis
adapt.modules.detection
adapt.modules.ingest
adapt.modules.projection
adapt.modules.tracking

# ==========================================================
# 2. Modules do not depend on runtime orchestration
Expand Down Expand Up @@ -68,12 +60,39 @@ type = forbidden
source_modules =
adapt.modules
forbidden_modules =
adapt.execution
adapt.persistence
adapt.api
adapt.gui
adapt.visualization
adapt.configuration

# ==========================================================
# 3b. Utils package imports no adapt internals
# ==========================================================
#
# adapt.utils contains pure stdlib functions shared across
# the codebase. It must not depend on any adapt layer or
# it creates import cycles and loses its zero-dependency value.

[importlinter:contract:utils_are_pure]
name = Utils package imports no adapt internals
type = forbidden
source_modules =
adapt.utils
forbidden_modules =
adapt.modules
adapt.contracts
adapt.execution
adapt.runtime
adapt.persistence
adapt.configuration
adapt.api
adapt.gui
adapt.visualization
adapt.cli
adapt.extensions

# ==========================================================
# 4. Persistence is infrastructure — no science, no orchestration
# ==========================================================
Expand Down
8 changes: 2 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,13 @@
[![CI](https://github.com/ARM-DOE/Adapt/actions/workflows/ci.yml/badge.svg)](https://github.com/ARM-DOE/Adapt/actions?query=workflow%3ACI)
[![Codecov](https://img.shields.io/codecov/c/github/ARM-DOE/Adapt.svg?logo=codecov)](https://codecov.io/gh/ARM-DOE/Adapt)
[![CodeFactor](https://www.codefactor.io/repository/github/arm-doe/adapt/badge)](https://www.codefactor.io/repository/github/arm-doe/adapt)
[![Security](https://github.com/ARM-DOE/Adapt/actions/workflows/security-analysis.yml/badge.svg)](https://arm-doe.github.io/Adapt/)
[![Virus](https://github.com/ARM-DOE/Adapt/actions/workflows/virus.yml/badge.svg)](https://arm-doe.github.io/Adapt/)


[![Docs](https://github.com/ARM-DOE/Adapt/actions/workflows/docs.yml/badge.svg)](https://arm-doe.github.io/Adapt/)
[![PyPi release](https://github.com/ARM-DOE/Adapt/actions/workflows/pypi-release.yml/badge.svg)](https://arm-doe.github.io/Adapt/)
[![PyPI - Version](https://img.shields.io/pypi/v/arm-adapt)](https://pypi.org/project/arm-adapt/)
[![PyPI Downloads](https://static.pepy.tech/personalized-badge/arm-adapt?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pypi.org/project/arm-adapt/)

[![Security](https://github.com/ARM-DOE/Adapt/actions/workflows/security-analysis.yml/badge.svg)](https://arm-doe.github.io/Adapt/)
[![Virus](https://github.com/ARM-DOE/Adapt/actions/workflows/virus.yml/badge.svg)](https://arm-doe.github.io/Adapt/)


[![PyPI - License](https://img.shields.io/pypi/l/arm-adapt)](https://github.com/ARM-DOE/Adapt?tab=License-1-ov-file#)
[![ARM](https://img.shields.io/badge/Sponsor-ARM-blue.svg?colorA=00c1de&colorB=00539c)](https://www.arm.gov/)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ adapt = "adapt.cli:main"
dev = [
"pytest>=7.0",
"pytest-cov",
"import-linter>=2.0",
]
docs = [
"sphinx>=7.0",
Expand Down
12 changes: 8 additions & 4 deletions src/adapt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""

import argparse
import contextlib
import logging
import os
import signal
import sys
Expand All @@ -33,6 +33,8 @@
# Single-instance enforcement
# ---------------------------------------------------------------------------

logger = logging.getLogger(__name__)

_PID_FILE = Path.home() / '.adapt' / 'pipeline.pid'


Expand All @@ -58,8 +60,10 @@ def _write_pid() -> None:


def _remove_pid() -> None:
with contextlib.suppress(Exception):
try:
_PID_FILE.unlink(missing_ok=True)
except OSError as exc:
logger.warning("Could not remove PID file %s: %s", _PID_FILE, exc)


# ---------------------------------------------------------------------------
Expand All @@ -82,8 +86,8 @@ def _build_run_nexrad_parser(sub: argparse.ArgumentParser) -> None:
help='Processing mode',
)
sub.add_argument('--start-time', dest='start_time', help='Start time (ISO 8601)')
sub.add_argument('--end-time', dest='end_time', help='End time (ISO 8601)')
sub.add_argument('--base-dir', dest='base_dir', help='Repository output directory')
sub.add_argument('--end-time', dest='end_time', help='End time (ISO 8601)')
sub.add_argument('--base-dir', dest='base_dir', help='Repository output directory')
sub.add_argument(
'--run-id',
dest='run_id',
Expand Down
10 changes: 5 additions & 5 deletions src/adapt/configuration/defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

pipeline:
modules:
- adapt.modules.ingest.module
- adapt.modules.detection.module
- adapt.modules.projection.module
- adapt.modules.analysis.module
- adapt.modules.tracking.module
- adapt.execution.nodes.ingest
- adapt.execution.nodes.detection
- adapt.execution.nodes.projection
- adapt.execution.nodes.analysis
- adapt.execution.nodes.tracking
39 changes: 34 additions & 5 deletions src/adapt/contracts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,35 @@

All pipeline stage validators and the ContractViolation exception live here.
Import from this package — never from individual contract submodules.

Naming convention
-----------------
assert_* : primitive validators that may take extra arguments (e.g. variable
names). Call these from other validators or tests.
check_* : bound, zero-extra-arg wrappers. Register these directly in a
module's ``input_contracts`` or ``output_contracts`` dict.
"""

from adapt.contracts.analysis import assert_analysis_output, assert_cell_adjacency
from adapt.contracts.grid import assert_gridded
from adapt.contracts.analysis import (
assert_analysis_output,
assert_cell_adjacency,
check_cell_adjacency,
check_cell_stats,
)
from adapt.contracts.grid import assert_gridded, check_grid_ds_2d
from adapt.contracts.pipeline import ContractViolation, require
from adapt.contracts.projection import assert_projected
from adapt.contracts.segmentation import assert_segmented
from adapt.contracts.tracking import assert_cell_events, assert_tracked_cells
from adapt.contracts.projection import assert_projected, check_projected_ds
from adapt.contracts.segmentation import assert_segmented, check_segmented_ds
from adapt.contracts.time import assert_time_normalized, check_time_normalized
from adapt.contracts.tracking import (
assert_cell_events,
assert_tracked_cells,
check_cell_events,
check_tracked_cells,
)

__all__ = [
# primitives
"ContractViolation",
"require",
"assert_gridded",
Expand All @@ -24,4 +43,14 @@
"assert_cell_adjacency",
"assert_tracked_cells",
"assert_cell_events",
"assert_time_normalized",
# bound checks — register these in input_contracts / output_contracts
"check_grid_ds_2d",
"check_segmented_ds",
"check_projected_ds",
"check_cell_stats",
"check_cell_adjacency",
"check_tracked_cells",
"check_cell_events",
"check_time_normalized",
]
10 changes: 10 additions & 0 deletions src/adapt/contracts/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,13 @@ def assert_cell_adjacency(df: pd.DataFrame) -> None:
(df["touching_boundary_pixels"] >= 1).all(),
"Cell adjacency contract violated: touching_boundary_pixels must be >= 1",
)


def check_cell_stats(df: pd.DataFrame) -> None:
"""Bound contract for cell statistics DataFrame."""
assert_analysis_output(df)


def check_cell_adjacency(df: pd.DataFrame) -> None:
"""Bound contract for cell adjacency DataFrame."""
assert_cell_adjacency(df)
5 changes: 5 additions & 0 deletions src/adapt/contracts/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ def assert_gridded(ds: xr.Dataset, reflectivity_var: str) -> None:
refl.ndim == 2,
f"Grid contract violated: '{reflectivity_var}' has {refl.ndim} dims, expected 2",
)


def check_grid_ds_2d(ds: xr.Dataset) -> None:
"""Bound contract for the standard 2D grid output (reflectivity variable name fixed)."""
assert_gridded(ds, "reflectivity")
5 changes: 5 additions & 0 deletions src/adapt/contracts/projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ def assert_projected(ds: xr.Dataset, max_steps: int = 5) -> None:
f"Projection contract violated: found {num_steps} steps, expected {expected_steps} "
f"(1 registration + {max_steps_actual} projections from config)",
)


def check_projected_ds(ds: xr.Dataset) -> None:
"""Bound contract for the standard projected dataset."""
assert_projected(ds)
5 changes: 5 additions & 0 deletions src/adapt/contracts/segmentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ def assert_segmented(ds: xr.Dataset, labels_name: str) -> None:
labels.ndim == 2,
f"Segmentation contract violated: '{labels_name}' has {labels.ndim} dims, expected 2",
)


def check_segmented_ds(ds: xr.Dataset) -> None:
"""Bound contract for the standard segmented dataset (cell_labels variable name fixed)."""
assert_segmented(ds, "cell_labels")
59 changes: 59 additions & 0 deletions src/adapt/contracts/time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Copyright © 2026, UChicago Argonne, LLC
# See LICENSE for terms and disclaimer.

"""Time coordinate contract.

Enforces that datasets crossing module boundaries carry a normalized time
coordinate — numpy datetime64, not cftime — so no module needs its own
conversion logic. Modules that read raw radar data (e.g. ingest) are
responsible for calling adapt.utils.time.normalize_time_scalar before
returning a dataset to the context.
"""

import numpy as np
import xarray as xr

from adapt.contracts.pipeline import require


def assert_time_normalized(ds: xr.Dataset) -> None:
"""Enforce that the dataset time coordinate is numpy-compatible, not cftime.

Parameters
----------
ds : xr.Dataset
Dataset crossing a module boundary.

Raises
------
ContractViolation
If the dataset has no time coordinate, or if the time coordinate
uses cftime objects instead of numpy datetime64.
"""
require(
"time" in ds.coords or hasattr(ds, "attrs") and "time" in ds.attrs,
"Time contract violated: dataset has no 'time' coordinate or attribute. "
"Every dataset crossing a module boundary must carry a time stamp.",
)

if "time" in ds.coords:
raw = ds.coords["time"].values
tv = raw.flat[0] if isinstance(raw, np.ndarray) and raw.ndim > 0 else raw
# unwrap numpy scalar wrapper if needed
if hasattr(tv, "item"):
try:
tv = tv.item()
except Exception:
pass
module = getattr(type(tv), "__module__", "")
require(
not module.startswith("cftime"),
f"Time contract violated: time coordinate is cftime ({type(tv).__name__}). "
"Normalize with adapt.utils.time.normalize_time_scalar before returning "
"the dataset from the ingest or any adapter layer.",
)


def check_time_normalized(ds: xr.Dataset) -> None:
"""Bound contract for time normalization — use in module input_contracts."""
assert_time_normalized(ds)
12 changes: 12 additions & 0 deletions src/adapt/contracts/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,15 @@ def assert_cell_events(df: pd.DataFrame) -> None:
f"Cell events contract violated: invalid event_type present "
f"(valid={sorted(_VALID_EVENT_TYPES)})",
)


def check_tracked_cells(df: pd.DataFrame) -> None:
"""Bound contract for tracked cells DataFrame (skips validation on empty frame)."""
if not df.empty:
assert_tracked_cells(df)


def check_cell_events(df: pd.DataFrame) -> None:
"""Bound contract for cell events DataFrame (skips validation on empty frame)."""
if not df.empty:
assert_cell_events(df)
2 changes: 1 addition & 1 deletion src/adapt/execution/graph/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def run(self, context: dict) -> dict:

while len(completed) < len(self.nodes):
iteration += 1
if iteration > max_iterations:
if iteration > max_iterations: # pragma: no cover — preempted by no-progress check
pending = [n.name for n in self.nodes if n.name not in completed]
raise RuntimeError(
f"Execution graph appears to contain a cycle or unresolvable "
Expand Down
2 changes: 2 additions & 0 deletions src/adapt/execution/nodes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright © 2026, UChicago Argonne, LLC
# See LICENSE for terms and disclaimer.
55 changes: 55 additions & 0 deletions src/adapt/execution/nodes/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright © 2026, UChicago Argonne, LLC
# See LICENSE for terms and disclaimer.

from adapt.contracts import check_cell_adjacency, check_cell_stats
from adapt.execution.module_registry import registry
from adapt.modules.analysis.module import RadarCellAnalyzer
from adapt.modules.base import BaseModule


class AnalysisModule(BaseModule):
"""BaseModule wrapper for RadarCellAnalyzer.

Extracts per-cell statistics (area, reflectivity, motion, centroids)
from a segmented/projected 2D dataset. Pure compute — no I/O.
Persistence is the processor's responsibility.

Context inputs
--------------
projected_ds : xr.Dataset
2D dataset with projections (output of ProjectionModule).
config : InternalConfig
Runtime configuration.
scan_time : datetime
Radar scan timestamp (from LoadModule).

Context outputs
---------------
cell_stats : pd.DataFrame
Per-cell statistics DataFrame.
cell_adjacency : pd.DataFrame
Touching-cell pairs DataFrame.
"""

name = "analysis"
inputs = ["projected_ds", "analysis_config", "scan_time"]
outputs = ["cell_stats", "cell_adjacency"]
output_contracts = {"cell_stats": check_cell_stats, "cell_adjacency": check_cell_adjacency}

def __init__(self) -> None:
self._analyzer = None

def run(self, context: dict) -> dict:
config = context["analysis_config"]
ds_2d = context["projected_ds"]

if self._analyzer is None:
self._analyzer = RadarCellAnalyzer(config)

df_cells = self._analyzer.extract(ds_2d, z_level=config.z_level)
df_adjacency = self._analyzer.extract_adjacency(ds_2d)

return {"cell_stats": df_cells, "cell_adjacency": df_adjacency}


registry.register(AnalysisModule)
Loading
Loading