Source code for stilt.observations.chemistry
"""Generic chemistry/lifetime interfaces for observation workflows."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
import numpy as np
import pandas as pd
if TYPE_CHECKING:
from .observation import Observation
_TIME_UNIT_TO_HOURS = {
"s": 1.0 / 3600.0,
"sec": 1.0 / 3600.0,
"second": 1.0 / 3600.0,
"seconds": 1.0 / 3600.0,
"m": 1.0 / 60.0,
"min": 1.0 / 60.0,
"minute": 1.0 / 60.0,
"minutes": 1.0 / 60.0,
"h": 1.0,
"hr": 1.0,
"hour": 1.0,
"hours": 1.0,
"d": 24.0,
"day": 24.0,
"days": 24.0,
}
@dataclass(frozen=True, slots=True)
class ChemistryContext:
"""
Context passed into a chemistry model.
The first pass stays intentionally small: chemistry models operate on the
particle table and may optionally use observation/species metadata plus the
transport-age column. This keeps the interface portable across later chemistry
implementations without importing application orchestration into core.
"""
observation: Observation | None = None
species: str | None = None
time_column: str = "time"
time_unit: str = "min"
metadata: dict[str, Any] = field(default_factory=dict)
class ChemistryModel(Protocol):
"""Behavioral interface for chemistry transforms on particle sensitivities."""
def apply(
self,
particles: pd.DataFrame,
*,
context: ChemistryContext | None = None,
) -> pd.DataFrame:
"""Return particles after applying chemistry or lifetime logic."""
...
class NoOpChemistry:
"""Chemistry model that returns an unchanged copy of the particles."""
def apply(
self,
particles: pd.DataFrame,
*,
context: ChemistryContext | None = None,
) -> pd.DataFrame:
"""Return an unchanged copy of the particles."""
return particles.copy()
[docs]
class FirstOrderLifetimeChemistry:
"""
Apply a first-order exponential lifetime decay to particle ``foot``.
This is a small portable chemistry model rather than a domain-specific NO2
workflow. It uses the transport-age column (minutes by default) and a
species lifetime in hours:
``foot <- foot * exp(-abs(age) / tau)``
where ``age`` is the particle transport time and ``tau`` is the lifetime.
"""
def __init__(self, lifetime_hours: float) -> None:
if lifetime_hours <= 0:
raise ValueError("lifetime_hours must be > 0.")
self.lifetime_hours = lifetime_hours
[docs]
def apply(
self,
particles: pd.DataFrame,
*,
context: ChemistryContext | None = None,
) -> pd.DataFrame:
"""Apply first-order lifetime decay to the particle ``foot`` column."""
p = particles.copy()
if "foot_before_chemistry" in p.columns:
p["foot"] = p["foot_before_chemistry"]
p = p.drop(columns=["foot_before_chemistry"])
time_column = context.time_column if context is not None else "time"
time_unit = context.time_unit if context is not None else "min"
if time_column not in p.columns:
raise ValueError(
f"Particle DataFrame has no column {time_column!r} required for "
"chemistry/lifetime weighting."
)
if time_unit not in _TIME_UNIT_TO_HOURS:
raise ValueError(
"Unsupported chemistry time unit "
f"{time_unit!r}. Expected one of: "
f"{', '.join(sorted(_TIME_UNIT_TO_HOURS))}."
)
ages = np.abs(p[time_column].to_numpy(dtype=float))
tau = self.lifetime_hours / _TIME_UNIT_TO_HOURS[time_unit]
scale = np.exp(-ages / float(tau))
p["foot_before_chemistry"] = p["foot"]
p["foot"] = p["foot"] * scale
return p
def apply_chemistry(
particles: pd.DataFrame,
chemistry: ChemistryModel,
*,
context: ChemistryContext | None = None,
) -> pd.DataFrame:
"""Apply a chemistry model to a particle DataFrame."""
return chemistry.apply(particles, context=context)
__all__ = [
"ChemistryContext",
"ChemistryModel",
"FirstOrderLifetimeChemistry",
"NoOpChemistry",
"apply_chemistry",
]