diff --git a/2016-04-25_diagnostics_summary.txt b/2016-04-25_diagnostics_summary.txt new file mode 100644 index 0000000..f25592d --- /dev/null +++ b/2016-04-25_diagnostics_summary.txt @@ -0,0 +1,32 @@ +Diagnostics summary +=================== + +Total N (FIMS, cm^-3): 847.085 +Total N (pop, cm^-3): 847.085 +Ratio (pop/FIMS): 1 + +AMS mass fractions (observed, normalized over available keys): + SO4: 0.3376 + NO3: 0.0352 + OC: 0.5160 + NH4: 0.1112 + +AMS mass fractions (reconstructed, normalized over available keys): + SO4: 0.5251 + NO3: 0.0085 + OC: 0.1477 + NH4: 0.3187 + +miniSPLAT number fractions (observed, normalized): + AS: 0.6115 + BB: 0.2991 + BC: 0.0409 + IEPOX: 0.0414 + OIN: 0.0072 + +miniSPLAT number fractions (reconstructed / builder metadata, normalized): + AS: 0.6115 + BB: 0.2991 + BC: 0.0409 + IEPOX: 0.0414 + OIN: 0.0072 diff --git a/README.pypi.md b/README.pypi.md index c57525f..ef2f836 100644 --- a/README.pypi.md +++ b/README.pypi.md @@ -51,6 +51,17 @@ print(pop) More examples are available in the project repository. +## Interactive viewer + +An experimental Streamlit UI lets you build populations through the factory registries and render existing visualization builders. + +```bash +pip install -e . +streamlit run scripts/launch_viewer.py +``` + +The viewer source lives under `viewer/`. The sidebar lists every registered population and plot type; choose any combination, adjust the metadata-driven controls, and the figure plus diagnostics render inline. + ## Contributing `part2pop` is designed so that **all extensibility happens through factories**. diff --git a/diagnostics_summary.txt b/diagnostics_summary.txt new file mode 100644 index 0000000..ece6bf5 --- /dev/null +++ b/diagnostics_summary.txt @@ -0,0 +1,44 @@ +Diagnostics summary +=================== + +Fitted size distribution (Dpg_nm, sigma): + Mode 0: 73.9811, 1.682 + Mode 1: 207.28, 1.357 + +Optimized fraction of particles in each mode: + OC: 0.209 0.791 + SO4: 0.569 0.431 + NO3: 0.853 0.147 + IEPOX_SOA: 0.239 0.761 + +Total N (FIMS, cm^-3): 1582.01 +Total N (pop, cm^-3): 1707.48 +Ratio (pop/FIMS): 1.07931 + +AMS mass fractions (observed, normalized over available keys): + OC: 0.4764 + NO3: 0.0670 + SO4: 0.3183 + NH4: 0.1383 + +AMS mass fractions (reconstructed, normalized over available keys): + OC: 0.5267 + NO3: 0.0481 + SO4: 0.3226 + NH4: 0.1026 + +miniSPLAT number fractions (observed, normalized): + BC: 0.0108 + OIN: 0.0042 + OC: 0.4683 + SO4: 0.3496 + NO3: 0.1454 + IEPOX_SOA: 0.0217 + +miniSPLAT number fractions (reconstructed, normalized): + BC: 0.0108 + OIN: 0.0042 + OC: 0.4683 + SO4: 0.3496 + NO3: 0.1454 + IEPOX_SOA: 0.0217 diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..d5a3001 --- /dev/null +++ b/environment.yml @@ -0,0 +1,16 @@ +name: part2pop +channels: + - conda-forge +dependencies: + - python>=3.10 + - numpy + - scipy + - matplotlib + - netCDF4 + - importlib-resources + - pymiescatt + - tqdm + - streamlit + - pip + - pip: + - -r requirements.txt \ No newline at end of file diff --git a/examples/.DS_Store b/examples/.DS_Store deleted file mode 100644 index 9d3b7f2..0000000 Binary files a/examples/.DS_Store and /dev/null differ diff --git a/examples/example_data/hiscale_population_config.json b/examples/example_data/hiscale_population_config.json new file mode 100644 index 0000000..ec3f579 --- /dev/null +++ b/examples/example_data/hiscale_population_config.json @@ -0,0 +1,100 @@ +{ + "type": "hiscale_observations", + "aimms_file": "/Users/fier887/Library/CloudStorage/OneDrive-PNNL/Code/multipart_archived2/multipart_archived/separate_tools/datasets/HISCALE_data_0425/AIMMS20_G1_20160425155810_R2_HISCALE020h.txt", + "splat_file": "/Users/fier887/Library/CloudStorage/OneDrive-PNNL/Code/multipart_archived2/multipart_archived/separate_tools/datasets/HISCALE_data_0425/Splat_Composition_25-Apr-2016.txt", + "ams_file": "/Users/fier887/Library/CloudStorage/OneDrive-PNNL/Code/multipart_archived2/multipart_archived/separate_tools/datasets/HISCALE_data_0425/HiScaleAMS_G1_20160425_R0.txt", + "fims_file": "/Users/fier887/Library/CloudStorage/OneDrive-PNNL/Code/multipart_archived2/multipart_archived/separate_tools/datasets/HISCALE_data_0425/FIMS_G1_20160425_R1_HISCALE_001s.txt", + "fims_bins_file": "/Users/fier887/Library/CloudStorage/OneDrive-PNNL/Code/multipart_archived2/multipart_archived/separate_tools/datasets/HISCALE_data_0425/HISCALE_FIMS_bins_R1.txt", + "z": 1000.0, + "dz": 100.0, + "splat_species": { + "BC": [ + "soot" + ], + "OIN": [ + "Dust" + ], + "OC": [ + "org28", + "org30_43", + "BB_SOA", + "org_amines", + "BB", + "pyridine" + ], + "SO4": [ + "sulfate_nitrate_org" + ], + "NO3": [ + "nitrate_amine_org" + ], + "IEPOX_SOA": [ + "IEPOX_SOA" + ] + }, + "mass_thresholds": { + "IEPOX_SOA": [ + [ + 0.3, + 0.5, + 0.1 + ], + [ + "IEPOX_OS", + "tetrol", + "tetrol_olig", + "IEPOX_OH_SOA" + ] + ], + "SO4": [ + [ + 0.5, + 0.7, + 0.1 + ], + [ + "SO4" + ] + ], + "NO3": [ + [ + 0.5, + 0.7, + 0.1 + ], + [ + "NO3" + ] + ], + "OC": [ + [ + 0.5, + 0.7, + 0.1 + ], + [ + "OC" + ] + ], + "BC": [ + [ + 0.5, + 0.7, + 0.1 + ], + [ + "BC" + ] + ], + "OIN": [ + [ + 0.5, + 0.7, + 0.1 + ], + [ + "OIN" + ] + ] + } +} \ No newline at end of file diff --git a/launch_viewer.py b/launch_viewer.py new file mode 100644 index 0000000..c78028a --- /dev/null +++ b/launch_viewer.py @@ -0,0 +1,5 @@ +from viewer import run_viewer + + +if __name__ == "__main__": + run_viewer() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4fee19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +numpy +scipy +matplotlib +netCDF4 +importlib-resources +PyMieScatt +pyBCabs +tqdm +streamlit +pytest>=7 +pytest-cov>=4 \ No newline at end of file diff --git a/scripts/launch_viewer.py b/scripts/launch_viewer.py new file mode 100644 index 0000000..2a3eef8 --- /dev/null +++ b/scripts/launch_viewer.py @@ -0,0 +1,19 @@ +"""Entry point for running the Streamlit viewer in a source checkout.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = REPO_ROOT / "src" +if SRC_DIR.is_dir() and str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from viewer import run_viewer + + +if __name__ == "__main__": + run_viewer() diff --git a/src/part2pop/analysis/defaults.py b/src/part2pop/analysis/defaults.py index 7f50d32..24cffc3 100644 --- a/src/part2pop/analysis/defaults.py +++ b/src/part2pop/analysis/defaults.py @@ -80,7 +80,12 @@ "T": 298.15, }, - # relative-humidity axis + # temperature / humidity axes + "T_grid": { + "T_grid": np.asarray([298.15]), + "T": 298.15, + "T_units": "K", + }, "rh_grid": {"rh_grid": np.asarray([0.0])}, # other/legacy entries (keep for compatibility) @@ -110,4 +115,3 @@ def get_defaults_for_variable(name: str) -> Dict[str, Any]: def all_defaults() -> Dict[str, Dict[str, Any]]: """Return a copy of the whole defaults mapping (diagnostic).""" return {k: dict(v) for k, v in _DEFAULTS_BY_VAR.items()} - diff --git a/src/part2pop/analysis/distributions.py b/src/part2pop/analysis/distributions.py index c52dce2..60f48b3 100644 --- a/src/part2pop/analysis/distributions.py +++ b/src/part2pop/analysis/distributions.py @@ -2,6 +2,16 @@ import numpy as np +_TRAPEZOID_FUNC = getattr(np, "trapezoid", None) +_TRAPZ_FUNC = getattr(np, "trapz", None) + +if _TRAPZ_FUNC is None and _TRAPEZOID_FUNC is not None: + _TRAPZ_FUNC = _TRAPEZOID_FUNC + np.trapz = _TRAPZ_FUNC + +if _TRAPEZOID_FUNC is None and _TRAPZ_FUNC is not None: + _TRAPEZOID_FUNC = _TRAPZ_FUNC + np.trapezoid = _TRAPEZOID_FUNC def _trapezoid_integrate(y, x=None, dx=1.0, axis=-1): """ @@ -11,12 +21,17 @@ def _trapezoid_integrate(y, x=None, dx=1.0, axis=-1): releases only expose `np.trapz`. This helper always calls whichever function exists so the rest of the module can rely on a single name. """ - func = getattr(np, "trapezoid", None) - if func is None: - func = getattr(np, "trapz", None) - if func is None: - raise AttributeError("NumPy installation lacks trapezoid/trapz integrators") - return func(y, x=x, dx=dx, axis=axis) + if _TRAPEZOID_FUNC is not None: + return _TRAPEZOID_FUNC(y, x=x, dx=dx, axis=axis) + if _TRAPZ_FUNC is not None: + return _TRAPZ_FUNC(y, x=x, dx=dx, axis=axis) + raise AttributeError("NumPy installation lacks trapezoid/trapz integrators") + # func = getattr(np, "trapezoid", None) + # if func is None: + # func = getattr(np, "trapz", None) + # if func is None: + # raise AttributeError("NumPy installation lacks trapezoid/trapz integrators") + # return func(y, x=x, dx=dx, axis=axis) try: from scipy.interpolate import PchipInterpolator as _PCHIP diff --git a/src/part2pop/analysis/particle/factory/registry.py b/src/part2pop/analysis/particle/factory/registry.py index f1915ab..fec1357 100644 --- a/src/part2pop/analysis/particle/factory/registry.py +++ b/src/part2pop/analysis/particle/factory/registry.py @@ -110,13 +110,23 @@ def describe_particle_variable(name: str): if not meta: raise UnknownParticleVariableError(name, suggestions=None) + axis_names = meta.axis_names + if isinstance(axis_names, str): + axis_names = [axis_names] + else: + axis_names = list(axis_names) + + units = getattr(meta, "units", None) + if isinstance(units, dict): + units = dict(units) + return { "name": meta.name, "value_key": meta.name, - "axis_keys": list(meta.axis_names), + "axis_keys": list(axis_names), + "axis_names": list(axis_names), "description": meta.description, "aliases": list(meta.aliases), "defaults": dict(meta.default_cfg), - "units": dict(meta.units) if getattr(meta, "units", None) else None, + "units": units, } - diff --git a/src/part2pop/analysis/population/factory/dNdlnD.py b/src/part2pop/analysis/population/factory/dNdlnD.py index 944f4f8..36beff9 100644 --- a/src/part2pop/analysis/population/factory/dNdlnD.py +++ b/src/part2pop/analysis/population/factory/dNdlnD.py @@ -59,7 +59,7 @@ def compute(self, population, as_dict: bool = False): - The variable is *always* defined w.r.t. ln(D), i.e. measure="ln". """ cfg = self.cfg - method = cfg.get("method", "hist") + method = cfg.get("method", "kde") measure = "ln" # this variable is per dlnD by definition # ------------------------------------------------------------------ @@ -201,7 +201,7 @@ def build(cfg=None) -> DNdlnDVar: Factory function used by the analysis population registry. Config keys (common ones): - - "method": "hist" (default) or "kde" + - "method": "kde" (default) or "hist" - "wetsize": bool (True = use wet diameters, False = dry) - "N_bins": int - "D_min", "D_max": floats in meters diff --git a/src/part2pop/population/factory/hiscale_observations.py b/src/part2pop/population/factory/hiscale_observations.py index 6e58fba..253b02d 100644 --- a/src/part2pop/population/factory/hiscale_observations.py +++ b/src/part2pop/population/factory/hiscale_observations.py @@ -288,12 +288,12 @@ def _read_fims_bins_file(fims_bins_file: str, expected_bins: int) -> Tuple[np.nd Parse a bins file and return (Dp_lowers_nm, Dp_uppers_nm) with length expected_bins. Works for: - 2-column tables (lower upper) + - 3-column tables (min, mean, max) where we use first/last columns - numeric streams that are interleaved lo,hi,lo,hi... - numeric streams that are concatenated all-lo then all-hi """ lines = _read_lines(fims_bins_file) - # Try per-line 2-col parse first lo_list: List[float] = [] hi_list: List[float] = [] for line in lines: @@ -303,7 +303,10 @@ def _read_fims_bins_file(fims_bins_file: str, expected_bins: int) -> Tuple[np.nd vals = re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", s) if len(vals) >= 2: lo_list.append(float(vals[0])) - hi_list.append(float(vals[1])) + hi_list.append(float(vals[-1])) + if len(vals) >= 3 and vals[0] != vals[-1]: + lo_list[-1] = float(vals[0]) + hi_list[-1] = float(vals[-1]) if len(lo_list) == expected_bins and len(hi_list) == expected_bins: lo = np.array(lo_list, dtype="float64") hi = np.array(hi_list, dtype="float64") diff --git a/src/part2pop/viz/factory/state_scatter.py b/src/part2pop/viz/factory/state_scatter.py index a128f66..ef1c2f5 100644 --- a/src/part2pop/viz/factory/state_scatter.py +++ b/src/part2pop/viz/factory/state_scatter.py @@ -1,4 +1,6 @@ # viz/factory/state_scatter.py +import numpy as np + from .registry import register from ..base import Plotter from ...analysis import build_variable @@ -32,6 +34,8 @@ def __init__(self, config: dict): self.yname = config.get("yvar") self.cname = config.get("cvar",None) self.sname = config.get("svar",None) + self.xscale = config.get("xscale") + self.yscale = config.get("yscale") if not (self.xname and self.yname): raise ValueError("StateScatterPlotter requires 'xvar' and 'yvar' in config.") @@ -43,8 +47,18 @@ def prep(self, population): # fixme: make these particle variables -- different function? xvar = build_variable(self.xname, 'particle', self.var_cfg) yvar = build_variable(self.yname, 'particle', self.var_cfg) - x = xvar.compute_all(population) - y = yvar.compute_all(population) + x = np.asarray(xvar.compute_all(population)) + y = np.asarray(yvar.compute_all(population)) + + if y.ndim > 1 and x.ndim == 1: + target_len = len(x) + candidate_axes = [axis for axis, size in enumerate(y.shape) if size == target_len] + if candidate_axes: + axis_to_keep = candidate_axes[0] + other_axes = [axis for axis in range(y.ndim) if axis != axis_to_keep] + for axis in sorted(other_axes, reverse=True): + y = np.take(y, 0, axis=axis) + y = np.asarray(y) if len(x) != len(y): raise ValueError(f"x and y must be same length, got {len(x)} vs {len(y)}.") @@ -70,7 +84,8 @@ def prep(self, population): "xlabel": self._fmt_label(xvar.meta.long_label, getattr(xvar.meta, "units", "")), "ylabel": self._fmt_label(yvar.meta.long_label, getattr(yvar.meta, "units", "")), "clabel": self.config.get("clabel", clabel), - "xscale": xvar.meta.scale, "yscale": yvar.meta.scale + "xscale": self.xscale or getattr(xvar.meta, "scale", "linear"), + "yscale": self.yscale or getattr(yvar.meta, "scale", "linear"), } def plot(self, population, ax, **kwargs): @@ -98,4 +113,4 @@ def plot(self, population, ax, **kwargs): return ax def build(cfg): - return StateScatterPlotter(cfg) \ No newline at end of file + return StateScatterPlotter(cfg) diff --git a/summary.txt b/summary.txt new file mode 100644 index 0000000..206033a --- /dev/null +++ b/summary.txt @@ -0,0 +1,3 @@ +N_fims_m3 8.470850e+08 +N_pop_m3 8.470850e+08 +ratio 1.000000 diff --git a/viewer/__init__.py b/viewer/__init__.py new file mode 100644 index 0000000..334ef29 --- /dev/null +++ b/viewer/__init__.py @@ -0,0 +1,3 @@ +from .app import run_viewer + +__all__ = ["run_viewer"] \ No newline at end of file diff --git a/viewer/app.py b/viewer/app.py new file mode 100644 index 0000000..ba417c0 --- /dev/null +++ b/viewer/app.py @@ -0,0 +1,285 @@ +"""Streamlit viewer for part2pop populations and plots.""" + +from pathlib import Path +import sys +import json +import re + +from typing import Any, Dict, Iterable + +import matplotlib.pyplot as plt +import streamlit as st + +VIEWER_ROOT = Path(__file__).resolve().parent +SRC_ROOT = VIEWER_ROOT.parent / "src" +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from part2pop.population.builder import PopulationBuilder +from part2pop.viz.builder import PlotBuilder +from part2pop.population.factory import registry as pop_registry +from part2pop.viz.factory import registry as viz_registry +from .metadata import list_population_types, list_state_line_variables +from part2pop.analysis.defaults import get_defaults_for_variable +from part2pop.analysis.particle import describe_particle_variable, list_particle_variables +from .ui import render_population_controls, render_var_controls + + +STATE_LINE_VARS = list_state_line_variables() + + +def parse_float_list(text: str) -> list[float]: + return [float(v.strip()) for v in text.split(",") if v.strip()] + + +def _normalize_value(value: Any) -> Any: + if isinstance(value, dict): + return {k: _normalize_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_normalize_value(item) for item in value] + if hasattr(value, "tolist") and not isinstance(value, str): + try: + converted = value.tolist() + except Exception: + return value + if isinstance(converted, (list, tuple)): + return [_normalize_value(item) for item in converted] + return _normalize_value(converted) + return value + + +def _merge_dicts(*dicts: Iterable[Dict[str, Any]]) -> Dict[str, Any]: + merged: Dict[str, Any] = {} + for cfg in dicts: + for key, value in cfg.items(): + merged[key] = _normalize_value(value) + return merged + + +def _parse_number_list(text: str, fallback: Iterable[float]) -> list[float]: + tokens = re.split(r"[\s,;]+", text.strip()) + values: list[float] = [] + for tok in tokens: + if not tok: + continue + try: + values.append(float(tok)) + except ValueError: + return list(fallback) + return values if values else list(fallback) + + +def _render_config_controls(prefix: str, base_cfg: Dict[str, Any]) -> Dict[str, Any]: + resolved: Dict[str, Any] = {} + for key, value in base_cfg.items(): + widget_key = f"{prefix}_{key}" + if isinstance(value, bool): + resolved[key] = st.checkbox(key, value=value, key=widget_key) + elif isinstance(value, (int, float)): + resolved[key] = st.number_input(key, value=float(value), key=widget_key) + elif isinstance(value, str): + resolved[key] = st.text_input(key, value=value, key=widget_key) + elif isinstance(value, Iterable): + display = ", ".join(map(str, value)) + text = st.text_input(key, value=display, key=widget_key) + resolved[key] = _parse_number_list(text, value) + else: + resolved[key] = value + return resolved + + +def _collect_particle_var_cfg(xvar: str | None, yvar: str | None) -> tuple[list[Dict[str, Any]], Dict[str, Any]]: + metadata_list: list[Dict[str, Any]] = [] + merged_defaults: Dict[str, Any] = {} + axis_keys: list[str] = [] + for var in (xvar, yvar): + if not var: + continue + meta = describe_particle_variable(var) + metadata_list.append(meta) + merged_defaults = _merge_dicts(merged_defaults, meta.get("defaults", {})) + axis_keys.extend([axis for axis in meta.get("axis_keys", []) if axis]) + axis_defaults: Dict[str, Any] = {} + for axis in axis_keys: + axis_defaults.update(get_defaults_for_variable(axis)) + merged_defaults = _merge_dicts(axis_defaults, merged_defaults) + return metadata_list, merged_defaults + + +def _normalize_numeric_key_dict(value: Any) -> Any: + if isinstance(value, dict): + digit_keys = [k for k in value if isinstance(k, str) and k.isdigit()] + if digit_keys and len(digit_keys) == len(value): + sorted_keys = sorted(digit_keys, key=lambda key: int(key)) + return [_normalize_numeric_key_dict(value[key]) for key in sorted_keys] + return {key: _normalize_numeric_key_dict(val) for key, val in value.items()} + if isinstance(value, list): + return [_normalize_numeric_key_dict(item) for item in value] + return value + + +def parse_species_list(text: str) -> list[str]: + return [name.strip() for name in text.split(",") if name.strip()] + + +def parse_population_field(cfg: dict, field: str, default: str) -> list[float]: + return parse_float_list(cfg.get(field, default)) + + +def load_config_file(path: str) -> dict: + try: + with open(path, "r", encoding="utf-8") as fp: + return json.load(fp) + except Exception as exc: + st.error(f"Failed to load config file '{path}': {exc}") + return {} + + +def finalize_population_config(raw: dict) -> dict: + cfg = _normalize_numeric_key_dict(dict(raw)) + config_path = cfg.pop("config_file", None) + if config_path: + file_cfg = load_config_file(config_path) + if file_cfg: + return _normalize_numeric_key_dict(file_cfg) + st.warning("Using inline entries after failing to load config file.") + pop_type = cfg.get("type") + if pop_type == "monodisperse": + try: + return { + "type": "monodisperse", + "N": parse_population_field(cfg, "N", "1e3"), + "D": parse_population_field(cfg, "D", "0.1"), + "aero_spec_names": [parse_species_list(cfg.get("species", "BC, OC"))], + "aero_spec_fracs": [parse_population_field(cfg, "fracs", "0.1, 0.9")], + } + except ValueError as exc: + st.error(f"Invalid monodisperse values: {exc}") + return {} + if pop_type == "binned_lognormals": + base = { + "type": "binned_lognormals", + "N": [float(cfg.get("N", 1e4))], + "GMD": [float(cfg.get("GMD", 0.15e-6))], + "GSD": [float(cfg.get("GSD", 1.4))], + "N_bins": int(cfg.get("N_bins", 30)), + "aero_spec_names": [parse_species_list(cfg.get("species", "BC, OC"))], + "aero_spec_fracs": [parse_population_field(cfg, "fracs", "0.1, 0.9")], + } + extras = {key: val for key, val in cfg.items() if key not in base} + base.update(extras) + return base + if pop_type in ("partmc", "mam4"): + return {key: val for key, val in cfg.items() if val is not None} + return _normalize_numeric_key_dict(cfg) + + +def build_population_options() -> list[str]: + return list(pop_registry.discover_population_types().keys()) + + +def build_plot_options() -> list[str]: + return list(viz_registry.discover_plotter_types().keys()) + + +def _sanitize_hiscale_config(cfg: Dict[str, Any]) -> None: + if "splat_species" in cfg: + cfg["splat_species"] = _normalize_numeric_key_dict(cfg["splat_species"]) + if "mass_thresholds" in cfg: + cfg["mass_thresholds"] = _normalize_numeric_key_dict(cfg["mass_thresholds"]) + + +def run_viewer() -> None: + st.set_page_config(page_title="part2pop sandbox viewer", layout="wide") + st.title("part2pop Sandbox Viewer") + + population_types = build_population_options() + plot_types = build_plot_options() + + with st.sidebar: + st.header("Population configuration") + population_type = st.selectbox("Population type", population_types) + raw_population_cfg = render_population_controls(population_type) + population_config = finalize_population_config(raw_population_cfg) + if population_config.get("type") == "hiscale_observations": + _sanitize_hiscale_config(population_config) + + st.header("Visualization") + plot_type = st.selectbox("Plot type", plot_types) + xvar = yvar = None + state_line_var = None + var_cfg: Dict[str, Any] = {} + scales = {"xscale": "linear", "yscale": "linear"} + scatter_metadata: list[Dict[str, Any]] = [] + if plot_type == "state_scatter": + scatter_vars = list_particle_variables() + if scatter_vars: + default_y_index = 1 if len(scatter_vars) > 1 else 0 + xvar = st.selectbox("State scatter X variable", scatter_vars, index=0, key="state_scatter_xvar") + yvar = st.selectbox( + "State scatter Y variable", + scatter_vars, + index=default_y_index, + key="state_scatter_yvar", + ) + scatter_metadata, merged_defaults = _collect_particle_var_cfg(xvar, yvar) + overrides = _render_config_controls("state_scatter", merged_defaults) + var_cfg = _merge_dicts(merged_defaults, overrides) + scales["xscale"] = st.selectbox("X axis scale", ["linear", "log"], index=0, key="state_scatter_xscale") + scales["yscale"] = st.selectbox("Y axis scale", ["linear", "log"], index=0, key="state_scatter_yscale") + # with st.expander("State scatter debug", expanded=True): + # st.write("Selected xvar", xvar) + # st.write("Selected yvar", yvar) + # st.write("Particle metadata", [{"name": meta.get("name"), "axis_keys": meta.get("axis_keys"), "defaults": meta.get("defaults")} for meta in scatter_metadata]) + # st.write("Merged defaults", merged_defaults) + # st.write("Resolved var_cfg", var_cfg) + # st.write("Resolved plot_config", {"xvar": xvar, "yvar": yvar, "var_cfg": var_cfg, **scales}) + else: + state_line_var = st.selectbox("State line variable", STATE_LINE_VARS) + var_cfg = render_var_controls(state_line_var) + st.button("Refresh plot") + show_diagnostics = st.checkbox("Show diagnostics", value=False) + + if not population_config: + st.warning("Define a population configuration to render the plot.") + return + + dump_path = VIEWER_ROOT / "population_config_dump.json" + with dump_path.open("w", encoding="utf-8") as fp: + json.dump(population_config, fp, indent=2) + + try: + with st.spinner("Building population..."): + population = PopulationBuilder(population_config).build() + if plot_type == "state_scatter": + plot_config = { + "xvar": xvar, + "yvar": yvar, + "var_cfg": var_cfg, + **scales, + } + else: + plot_config = {"varname": state_line_var, "var_cfg": var_cfg} + plotter = PlotBuilder(plot_type, plot_config).build() + + fig, ax = plt.subplots() + plotter.plot(population, ax) + st.pyplot(fig) + except Exception as exc: + st.error(f"Failed to render plot: {exc}") + return + + + if show_diagnostics: + st.markdown("### Population stats") + st.write("Total concentration", float(population.get_Ntot())) + st.write("Total dry mass", float(population.get_tot_dry_mass())) + + with st.expander("Diagnostics", expanded=True): + st.write("population_config", population_config) + st.write("state_line_var", state_line_var) + st.write("var_cfg", var_cfg) + + +if __name__ == "__main__": + run_viewer() \ No newline at end of file diff --git a/viewer/metadata.py b/viewer/metadata.py new file mode 100644 index 0000000..8a90ae2 --- /dev/null +++ b/viewer/metadata.py @@ -0,0 +1,163 @@ +"""Metadata helpers for the tmp_viewer Streamlit sandbox.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import numpy as np +from part2pop.analysis.defaults import get_defaults_for_variable, all_defaults + + +STATE_LINE_VARIABLES: Dict[str, Dict[str, Any]] = { + "Nccn": { + "type": "supersat", + "axes": ["s_grid"], + "s_range": (0.01, 10.0), + "s_points": 100, + "default_T": 298.15, + "notes": "Supersaturation curve", + }, + "frac_ccn": { + "type": "supersat", + "axes": ["s_grid"], + "s_range": (0.01, 10.0), + "s_points": 100, + "default_T": 298.15, + "notes": "CCN activation fraction", + }, + "avg_Jhet": {"type": "temperature", "default_T": 298.15}, + "nucleating_sites": {"type": "temperature", "default_T": 273.15}, + "frozen_frac": {"type": "temperature", "default_T": 273.15}, + "b_abs": { + "type": "optics", + "morphology_options": ["core-shell", "homogeneous", "fractal"], + "default_morphology": "core-shell", + "rh_range": (0.0, 1.0), + "wvl_range": (350e-9, 1150e-9), + "rh_points": 5, + "wvl_points": 10, + }, + "b_scat": { + "type": "optics", + "morphology_options": ["core-shell", "homogeneous", "fractal"], + "default_morphology": "core-shell", + "rh_range": (0.0, 1.0), + "wvl_range": (350e-9, 1150e-9), + "rh_points": 5, + "wvl_points": 10, + }, + "b_ext": { + "type": "optics", + "morphology_options": ["core-shell", "homogeneous", "fractal"], + "default_morphology": "core-shell", + "rh_range": (0.0, 1.0), + "wvl_range": (350e-9, 1150e-9), + "rh_points": 5, + "wvl_points": 10, + }, + "dNdlnD": { + "type": "distribution", + "method_options": ["kde", "hist"], + "N_bins_range": (20, 200), + "D_min": 1e-9, + "D_max": 2e-6, + "default_method": "kde", + "notes": "Size distribution vs. diameter", + }, +} + + +POPULATION_METADATA: Dict[str, Dict[str, Any]] = { + "monodisperse": { + "label": "Monodisperse (inline inputs)", + "fields": [ + {"name": "N", "label": "Number concentrations", "widget": "text", "default": "1e3"}, + {"name": "D", "label": "Diameter (microns)", "widget": "text", "default": "0.1"}, + {"name": "species", "label": "Species names", "widget": "text", "default": "BC, OC"}, + {"name": "fracs", "label": "Species fractions", "widget": "text", "default": "0.1, 0.9"}, + ], + }, + "binned_lognormals": { + "label": "Binned lognormal", + "fields": [ + {"name": "N", "label": "Total concentration", "widget": "number", "default": 1e4}, + {"name": "GMD", "label": "Geometric mean diameter (m)", "widget": "number", "default": 0.15e-6}, + {"name": "GSD", "label": "Geometric std dev", "widget": "number", "default": 1.4}, + {"name": "N_bins", "label": "Number of bins", "widget": "number", "default": 30}, + {"name": "species", "label": "Species names", "widget": "text", "default": "BC, OC"}, + {"name": "fracs", "label": "Species fractions", "widget": "text", "default": "0.1, 0.9"}, + ], + }, + "partmc": { + "label": "PartMC output", + "config_modes": ["inline", "config_file"], + "fields": [ + {"name": "partmc_dir", "label": "PARTMC output directory", "widget": "text", "default": "."}, + {"name": "timestep", "label": "Timestep index", "widget": "number", "default": 1, "int": True, "min": 1, "step": 1}, + {"name": "repeat", "label": "Repeat index", "widget": "number", "default": 1, "int": True, "min": 1, "step": 1}, + {"name": "n_particles", "label": "Particles to sample", "widget": "number", "default": 1000, "int": True, "min": 1, "step": 1}, + {"name": "particle_selection", "label": "Particle sampling", "widget": "select", "default": "all", "options": ["all", "sub-select"]}, + ], + "config_file_label": "PartMC JSON config", + }, + "mam4": { + "label": "MAM4 namelist", + "config_modes": ["inline", "config_file"], + "fields": [ + {"name": "mam4_dir", "label": "MAM4 directory", "widget": "text", "default": "."}, + {"name": "timestep", "label": "Timestep (>=1)", "widget": "number", "default": 1}, + {"name": "N_bins", "label": "Number of bins", "widget": "number", "default": 20}, + {"name": "GSD", "label": "GSD list", "widget": "number_list", "default": [1.3, 1.5, 1.5, 1.5]}, + {"name": "GMD_init", "label": "Initial GMDs (m)", "widget": "number_list", "default": [1.1e-7, 2.6e-8, 2e-6, 5e-8]}, + ], + "config_file_label": "MAM4 JSON config", + }, + "hiscale_observations": { + "label": "HI-SCALE observations", + "config_modes": ["inline", "config_file"], + "fields": [ + {"name": "aimms_file", "label": "AIMMS file", "widget": "text", "default": "path/to/aimms.dat"}, + {"name": "splat_file", "label": "miniSPLAT file", "widget": "text", "default": "path/to/splat.txt"}, + {"name": "ams_file", "label": "AMS file", "widget": "text", "default": "path/to/ams.dat"}, + {"name": "fims_file", "label": "FIMS file", "widget": "text", "default": "path/to/fims.dat"}, + {"name": "fims_bins_file", "label": "FIMS bins file", "widget": "text", "default": "path/to/bins.txt"}, + {"name": "z", "label": "Altitude (m)", "widget": "number", "default": 1000}, + {"name": "dz", "label": "Altitude window (m)", "widget": "number", "default": 100}, + {"name": "splat_species", "label": "miniSPLAT species mapping", "widget": "json", "default": {"BC": ["soot"], "OIN": ["Dust"]}}, + {"name": "mass_thresholds", "label": "Mass thresholds", "widget": "json", "default": {"BC": [[0.0, 1e-16, 1e-17], ["BC"]], "OIN": [[0.0, 1e-16, 1e-17], ["OIN"]]}}, + ], + "config_file_label": "HI-SCALE JSON config", + }, +} + + +def get_variable_metadata(varname: str) -> Dict[str, Any]: + """Return metadata merged with defaults for a state_line variable.""" + + entry = STATE_LINE_VARIABLES.get(varname, {}) + defaults = get_defaults_for_variable(varname) + merged = {**entry, "defaults": defaults} + return merged + + +def list_state_line_variables() -> List[str]: + """Return the list of supported state_line variables.""" + return list(STATE_LINE_VARIABLES) + + +STATE_SCATTER_VARIABLES = sorted([name for name in all_defaults().keys() if name != "__fallback__"]) + + +def list_state_scatter_variables() -> List[str]: + """Return the list of supported state_scatter variables.""" + return list(STATE_SCATTER_VARIABLES) + + +def get_population_metadata() -> Dict[str, Dict[str, Any]]: + """Return the population metadata catalog.""" + return POPULATION_METADATA + + +def list_population_types() -> List[str]: + """Return the available population types in the metadata catalog.""" + return list(POPULATION_METADATA) diff --git a/viewer/population_config_dump.json b/viewer/population_config_dump.json new file mode 100644 index 0000000..9bce99c --- /dev/null +++ b/viewer/population_config_dump.json @@ -0,0 +1,27 @@ +{ + "type": "binned_lognormals", + "N": [ + 10000.0 + ], + "GMD": [ + 1.5e-07 + ], + "GSD": [ + 1.4 + ], + "N_bins": 30, + "aero_spec_names": [ + [ + "BC", + "OC" + ] + ], + "aero_spec_fracs": [ + [ + 0.1, + 0.9 + ] + ], + "species": "BC, OC", + "fracs": "0.1, 0.9" +} \ No newline at end of file diff --git a/viewer/ui.py b/viewer/ui.py new file mode 100644 index 0000000..919bfd4 --- /dev/null +++ b/viewer/ui.py @@ -0,0 +1,285 @@ +"""UI helpers for Streamlit controls in the tmp_viewer sandbox.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import streamlit as st + +from .metadata import STATE_LINE_VARIABLES, POPULATION_METADATA, get_variable_metadata + + +def _normalize_numeric_key_dict(value: Any) -> Any: + if isinstance(value, dict): + digit_keys = [k for k in value if isinstance(k, str) and k.isdigit()] + if digit_keys and len(digit_keys) == len(value): + sorted_keys = sorted(digit_keys, key=lambda key: int(key)) + return [_normalize_numeric_key_dict(value[key]) for key in sorted_keys] + return {key: _normalize_numeric_key_dict(val) for key, val in value.items()} + if isinstance(value, list): + return [_normalize_numeric_key_dict(item) for item in value] + return value + + +def slider_grid(label: str, lo: float, hi: float, points: int) -> List[float]: + return list(np.linspace(lo, hi, num=points)) + + +def parse_number_list(text: str, fallback: List[float]) -> List[float]: + cleaned = ",".join(map(str, fallback)) if not text else text + tokens = re.split(r"[\s,;]+", cleaned.strip()) + values: List[float] = [] + for tok in tokens: + if not tok: + continue + try: + values.append(float(tok)) + except ValueError: + st.warning(f"Could not parse '{tok}' as a float; using fallback grid.") + return list(fallback) + if not values: + return list(fallback) + return values + + +def guess_partmc_final_timestep(partmc_dir: str) -> int: + try: + out_dir = Path(partmc_dir or ".") / "out" + if not out_dir.is_dir(): + return 1 + candidate = 1 + pattern = re.compile(r"_(\d{4})_(\d{8})\.nc$") + for entry in out_dir.iterdir(): + if not entry.is_file(): + continue + match = pattern.search(entry.name) + if not match: + continue + timestep = int(match.group(2)) + candidate = max(candidate, timestep) + return candidate + except Exception: + return 1 + + +def render_var_controls(varname: str) -> Dict[str, Any]: + meta = get_variable_metadata(varname) + defaults: Dict[str, Any] = meta.get("defaults", {}) + cfg: Dict[str, Any] = dict(defaults) + + st.subheader(f"Variable: {varname}") + if meta.get("type") == "supersat": + s_range = tuple(float(val) for val in meta.get("s_range", (0.01, 10.0))) + if len(s_range) < 2: + s_range = (s_range[0], s_range[0]) + slider_result = st.slider( + "Supersaturation range", + min_value=s_range[0], + max_value=s_range[1], + value=(s_range[0], s_range[1]), + key=f"{varname}_srange", + ) + if isinstance(slider_result, tuple): + lo, hi = slider_result + else: + lo = slider_result + hi = slider_result + points = st.slider("Points", meta.get("s_points", 20), 400, value=meta.get("s_points", 100), key=f"{varname}_spoints") + cfg["s_grid"] = slider_grid("s_grid", lo, hi, points) + cfg["s_eval"] = cfg["s_grid"] + cfg.setdefault("T", meta.get("default_T", 298.15)) + elif meta.get("type") == "temperature": + t_range = tuple(float(val) for val in defaults.get("T_range", (273.15, 258.15))) + if len(t_range) < 2: + t_range = (t_range[0], t_range[0]) + slider_res = st.slider( + "Temperature range (K)", + min_value=t_range[1], + max_value=t_range[0], + value=(t_range[0], t_range[1]), + key=f"{varname}_trange", + ) + if isinstance(slider_res, tuple): + t_lo, t_hi = slider_res + else: + t_lo = slider_res + t_hi = slider_res + points = st.slider("Points", 5, 200, value=defaults.get("T_points", 20), key=f"{varname}_Tpoints") + cfg["T_grid"] = slider_grid("T_grid", t_hi, t_lo, points) + cfg["cooling_rate"] = st.number_input("Cooling rate (K/s)", value=float(defaults.get("cooling_rate", 0.1)), key=f"{varname}_cooling") + cfg.setdefault("T_units", defaults.get("T_units", "K")) + elif meta.get("type") == "optics": + cfg.setdefault("T", defaults.get("T", 298.15)) + morphology = st.selectbox("Morphology", meta.get("morphology_options", [meta.get("default_morphology")]), index=0, key=f"{varname}_morph") + cfg["morphology"] = morphology + cfg.setdefault("species_modifications", {}) + sweep_mode = st.radio("Sweep mode", ["RH", "Wavelength"], index=0, key=f"{varname}_sweep_mode") + if sweep_mode == "RH": + rh_lo = st.number_input("RH min", value=meta.get("rh_range", (0.0, 1.0))[0], key=f"{varname}_rh_min") + rh_hi = st.number_input("RH max", value=meta.get("rh_range", (0.0, 1.0))[1], key=f"{varname}_rh_max") + rh_points = st.number_input("RH points", value=meta.get("rh_points", 5), min_value=2, step=1, key=f"{varname}_rh_points", format="%d") + cfg["rh_grid"] = slider_grid("RH grid", min(rh_lo, rh_hi), max(rh_lo, rh_hi), int(rh_points)) + fixed_wvl = st.text_input("Fixed wavelength", value=str(defaults.get("wvl_grid", [350e-9])[0]), key=f"{varname}_fixed_wvl") + try: + wvl_val = float(fixed_wvl) + except ValueError: + wvl_val = defaults.get("wvl_grid", [350e-9])[0] + cfg["wvl_grid"] = [wvl_val] + cfg["wvls"] = [wvl_val] + cfg["rh_grid"] = cfg["rh_grid"] + cfg.pop("wvl_grid_only", None) + else: + wvl_lo = st.number_input("Wavelength min", value=meta.get("wvl_range", (350e-9, 1150e-9))[0], key=f"{varname}_wvl_min") + wvl_hi = st.number_input("Wavelength max", value=meta.get("wvl_range", (350e-9, 1150e-9))[1], key=f"{varname}_wvl_max") + wvl_points = st.number_input("Wavelength points", value=meta.get("wvl_points", 6), min_value=2, step=1, key=f"{varname}_wvl_points", format="%d") + cfg["wvl_grid"] = slider_grid("Wavelength grid", min(wvl_lo, wvl_hi), max(wvl_lo, wvl_hi), int(wvl_points)) + cfg["wvls"] = list(cfg["wvl_grid"]) + fixed_rh = st.text_input("Fixed RH", value=str(defaults.get("rh_grid", [0.0])[0]), key=f"{varname}_fixed_rh") + try: + rh_val = float(fixed_rh) + except ValueError: + rh_val = defaults.get("rh_grid", [0.0])[0] + cfg["rh_grid"] = [rh_val] + cfg["sweep_mode"] = sweep_mode + elif varname == "dNdlnD": + method = st.selectbox("Method", meta.get("method_options", [meta.get("default_method")]), index=0, key="dNdlnD_method") + cfg["method"] = method + cfg.setdefault("N_bins", st.slider("Bins", *meta.get("N_bins_range", (20, 200)), value=80, key="dNdlnD_bins")) + cfg.setdefault("D_min", meta.get("D_min", 1e-9)) + cfg.setdefault("D_max", meta.get("D_max", 2e-6)) + cfg.setdefault("normalize", False) + cfg.setdefault("wetsize", defaults.get("wetsize", True)) + return cfg + + +def _render_interactive_fields(pop_type: str, meta: Dict[str, Any]) -> Dict[str, Any]: + cfg: Dict[str, Any] = {"type": pop_type} + for field in meta.get("fields", []): + widget = field["widget"] + name = field["name"] + label = field["label"] + default = field.get("default") + key = f"{pop_type}_{name}" + if pop_type == "partmc" and name == "N_sampled": + continue + if pop_type == "partmc" and name == "n_particles": + # Render this control conditionally below when subset sampling is selected + continue + if widget == "number": + is_int = field.get("int", False) + default_val = default if default is not None else (1 if is_int else 0) + if is_int: + cfg[name] = st.number_input( + label, + value=int(default_val), + min_value=field.get("min"), + step=field.get("step", 1), + key=key, + format="%d", + ) + else: + cfg[name] = st.number_input( + label, + value=float(default_val), + min_value=field.get("min"), + step=field.get("step", 0.1), + key=key, + ) + elif widget == "select": + options = field.get("options", []) + cfg[name] = st.selectbox(label, options, index=options.index(default) if default in options else 0, key=key) + elif widget == "json": + text_val = json.dumps(default, indent=2) if default is not None else "" + user_input = st.text_area(label, value=text_val, key=key) + try: + parsed = json.loads(user_input) if user_input else {} + cfg[name] = _normalize_numeric_key_dict(parsed) + except json.JSONDecodeError as exc: + st.error(f"Invalid JSON for {field['name']}: {exc}") + cfg[name] = default or {} + elif widget == "number_list": + values = default or [] + display = ", ".join(map(str, values)) + user_input = st.text_input(label, value=display, key=key) + cfg[name] = [float(v) for v in re.split(r"[\s,;]+", user_input.strip()) if v] + else: + cfg[name] = st.text_input(label, value=str(default or ""), key=key) + return cfg + + +def _load_json_file(path: str) -> Dict[str, Any]: + try: + with open(path, "r", encoding="utf-8") as fp: + return json.load(fp) + except Exception as exc: + st.error(f"Failed to load config file '{path}': {exc}") + return {} + + +def _default_interactive_cfg(pop_type: str, meta: Dict[str, Any]) -> Dict[str, Any]: + cfg: Dict[str, Any] = {"type": pop_type} + for field in meta.get("fields", []): + name = field["name"] + default = field.get("default") + if field.get("widget") == "select": + options = field.get("options", []) + cfg[name] = default if default in options else (options[0] if options else default) + else: + cfg[name] = default + return cfg + + +def render_population_controls(pop_type: str) -> Dict[str, Any]: + meta = POPULATION_METADATA.get(pop_type, {}) + st.subheader(f"Population: {meta.get('label', pop_type)}") + mode = st.selectbox( + "Configuration mode", + ["Interactive", "JSON text", "JSON file path"], + index=0, + key=f"{pop_type}_mode", + ) + + final_cfg: Dict[str, Any] = {} + if mode == "Interactive": + interactive_cfg = _render_interactive_fields(pop_type, meta) + final_cfg = dict(interactive_cfg) + + if pop_type == "partmc": + selection = interactive_cfg.get("particle_selection") + n_particles_field = next((field for field in meta.get("fields", []) if field.get("name") == "n_particles"), {}) + n_particles_default = int(n_particles_field.get("default", 1000)) + if selection == "sub-select": + final_cfg["n_particles"] = st.number_input( + "Particles to sample", + value=final_cfg.get("n_particles", n_particles_default), + min_value=1, + step=1, + key=f"{pop_type}_n_particles", + format="%d", + ) + else: + final_cfg.pop("n_particles", None) + elif mode == "JSON text": + text_defaults = _default_interactive_cfg(pop_type, meta) + text = st.text_area( + "Population JSON", + value=json.dumps(text_defaults, indent=2), + key=f"{pop_type}_json_text", + height=300, + ) + if text: + try: + overrides = json.loads(text) + final_cfg.update(_normalize_numeric_key_dict(overrides)) + except json.JSONDecodeError as exc: + st.error(f"Invalid JSON: {exc}") + else: + path = st.text_input("JSON config file", value="", key=f"{pop_type}_json_path") + if path: + overrides = _load_json_file(path) + final_cfg.update(_normalize_numeric_key_dict(overrides)) + return final_cfg \ No newline at end of file