Source code for openairclim.gui.tabs.results

"""Results tab: explore OpenAirClim simulation output.

Loading is explicit (two buttons: "Load from config" / "Browse...") rather than
automatic on every config edit. Files are also loaded eagerly rather than
lazily, so the underlying file handle is closed immediately after reading and
never blocks a later run.
"""

from pathlib import Path

import panel as pn

from .. import config_io
from ..components.utils import COLORS, MARKERS, auto_scale

TITLE = """
### Results
This is a **viewing** tab — it does not change the configuration. Load a
results NetCDF file to explore it below.

**Load from config** opens the file that the current configuration's output
directory/name point to, if it already exists. **Browse...** lets you pick a
different results file. Either way, the file is read once into memory rather
than kept open, so it won't block OpenAirClim from overwriting it when you next
click "Run".
"""


# ======================================================================
# Output structure helpers
# ======================================================================

# Known variable-name prefixes that group into physical categories.
# The order here determines the display order in the variable dropdown.
_CATEGORY_PREFIXES = [
    ("dT", "Temperature response"),
    ("RF", "Radiative forcing"),
    ("AGWP", "AGWP"),
    ("AGTP", "AGTP"),
    ("ATR", "ATR"),
    ("conc", "Concentration"),
]


def _load_results(filepath):
    """Read a results NetCDF file into memory and return an xarray Dataset.

    Uses the eager `xr.load_dataset` to ensure that the results file is not
    blocked, thereby preventing an OpenAirClim run. Promotes `ac` to a
    coordinate if stored as a data variable, mirroring the inventory loading
    pattern.

    Args:
        filepath (str or Path): Path to the NetCDF file.

    Returns:
        xarray.Dataset: Loaded dataset.

    Raises:
        Exception: Propagated from xarray if the file cannot be read.
    """
    import xarray as xr

    ds = xr.load_dataset(filepath)
    promote = [c for c in ("ac",) if c in ds.data_vars]
    if promote:
        ds = ds.set_coords(promote)
    return ds


def _candidate_results_path(state):
    """Return the results file path implied by the current config, or None.

    Args:
        state (AppState): Shared application state.

    Returns:
        Path or None: `output.dir/output.name.nc`, resolved against
            `state.working_dir`, or None if the config has no output
            dir/name set yet.
    """
    config = state.edited_config
    if not config:
        return None
    output_cfg = config.get("output", {})
    out_dir = output_cfg.get("dir", "")
    out_name = output_cfg.get("name", "")
    if not (out_dir and out_name):
        return None
    out_dir_path = (
        config_io.resolve_dir(state.working_dir, out_dir)
        if state.working_dir else Path(out_dir)
    )
    return out_dir_path / f"{out_name}.nc"


def _get_time_coord(ds):
    """Find the name of the time coordinate in a dataset.

    Args:
        ds (xarray.Dataset): Results dataset.

    Returns:
        str or None: Name of the time coordinate, or None if not found.
    """
    for name in ("time", "t", "year", "years"):
        if name in ds.coords or name in ds.dims:
            return name
    # Fall back to the first integer/float coordinate
    for name, coord in ds.coords.items():
        if coord.dtype.kind in ("i", "f"):
            return name
    return None


def _categorise_variables(ds):
    """Group data variables by physical category based on name prefixes.

    Variables that don't match any known prefix land in "Other".

    Args:
        ds (xarray.Dataset): Results dataset.

    Returns:
        dict: Mapping category_label -> list of variable names.
    """
    categories = {label: [] for _, label in _CATEGORY_PREFIXES}
    categories["Other"] = []

    for varname in ds.data_vars:
        matched = False
        for prefix, label in _CATEGORY_PREFIXES:
            if varname.startswith(prefix):
                categories[label].append(varname)
                matched = True
                break
        if not matched:
            categories["Other"].append(varname)

    return {k: sorted(v) for k, v in categories.items() if v}


def _build_figure(ds, time_coord, variables, selected_ac, legend_loc):
    """Create a Bokeh line plot of selected variables over time.

    If the variables have an `ac` dimension, one line is drawn per
    selected aircraft.  Otherwise one line per variable.

    Args:
        ds (xarray.Dataset): Results dataset.
        time_coord (str): Name of the time coordinate.
        variables (list): Variable names to plot.
        selected_ac (list): Aircraft identifiers to show.  Ignored if
            the variables have no `ac` dimension.
        legend_loc (str): Bokeh legend location string.

    Returns:
        bokeh.plotting.Figure: The assembled figure.
    """
    import numpy as np
    from bokeh.plotting import figure

    time_vals = ds[time_coord].values.tolist()

    # Build the list of (label, data_array) series to plot
    series = []
    for varname in variables:
        var = ds[varname]
        if "ac" in var.dims and selected_ac:
            for ac in selected_ac:
                try:
                    data = var.sel(ac=ac).values.tolist()
                    series.append((f"{varname} [{ac}]", data))
                except KeyError:
                    pass
        else:
            # Sum or squeeze out the ac dim if present but not selected
            if "ac" in var.dims:
                try:
                    data = var.sel(ac="TOTAL").values.tolist()
                except KeyError:
                    data = var.isel(ac=0).values.tolist()
            else:
                data = var.values.tolist()
            series.append((varname, data))

    if not series:
        return None

    # Determine y-axis label from variable units (use first variable)
    first_var = ds[variables[0]]
    unit = first_var.attrs.get("units", "")
    long_name = first_var.attrs.get("long_name", variables[0])
    y_label = f"{long_name} [{unit}]" if unit else long_name

    all_vals = [v for _, vals in series for v in vals
                if v is not None and np.isfinite(float(v))]
    y_max = max(all_vals) if all_vals else 1.0
    y_min = min(all_vals) if all_vals else 0.0
    scale, prefix = auto_scale(max(abs(y_max), abs(y_min)))

    if prefix:
        y_label = f"{long_name} [{prefix}{unit}]" if unit else f"{long_name} [{prefix}]"

    fig = figure(
        title=", ".join(variables),
        x_axis_label="Year",
        y_axis_label=y_label,
        height=420,
        sizing_mode="stretch_width",
        tools="pan,wheel_zoom,box_zoom,reset,save,hover",
        tooltips=[("Year", "$x{0}"), ("Value", "$y")],
    )

    for i, (label, vals) in enumerate(series):
        c = COLORS[i % len(COLORS)]
        m = MARKERS[i % len(MARKERS)]
        scaled = [v / scale if v is not None and np.isfinite(float(v)) else float("nan")
                  for v in vals]
        fig.line(time_vals, scaled, color=c, line_width=2,
                 legend_label=label, name=label)
        fig.scatter(time_vals, scaled, marker=m, color=c, size=5, name=label)

    fig.legend.click_policy = "hide"
    fig.legend.location = legend_loc

    return fig


# ======================================================================
# Tab layout
# ======================================================================


[docs] def panel(state): """Return the results tab content. Args: state (AppState): Shared application state. """ # ── internal state ──────────────────────────────────────────────── _ds = {"dataset": None} # ── widgets ─────────────────────────────────────────────────────── load_from_config_btn = pn.widgets.Button( name="Load from config", button_type="primary" ) browse_btn = pn.widgets.Button(name="Browse...", button_type="default") status_pane = pn.pane.Markdown( "⚠️ Load a results file first." ) category_select = pn.widgets.Select( name="Category", options=[], width=200, ) variable_select = pn.widgets.CheckBoxGroup( name="Variables", options=[], value=[], ) ac_select = pn.widgets.CheckBoxGroup( name="Aircraft", options=[], value=[], ) ac_card_title = pn.pane.Markdown("**Aircraft**") ac_section = pn.Column(ac_card_title, ac_select) _legend_locations = [ "top_left", "top_center", "top_right", "center_left", "center", "center_right", "bottom_left", "bottom_center", "bottom_right", ] legend_select = pn.widgets.Select( name="Legend location", options=_legend_locations, value="top_left", ) # Persistent Bokeh pane plot_pane = pn.pane.Bokeh(None, sizing_mode="stretch_width") # ── helpers ─────────────────────────────────────────────────────── def _update_plot(): """Redraw the plot for the current widget selections.""" ds = _ds["dataset"] if ds is None: plot_pane.object = None return variables = variable_select.value if not variables: plot_pane.object = None return time_coord = _get_time_coord(ds) if time_coord is None: status_pane.object = "⚠️ No time coordinate found in results." plot_pane.object = None return selected_ac = ac_select.value try: fig = _build_figure(ds, time_coord, variables, selected_ac, legend_select.value) plot_pane.object = fig except Exception as e: # pylint: disable=broad-exception-caught status_pane.object = f"❌ Plot error: {e}" plot_pane.object = None def _load_from_path(path): """Load a results file and refresh all widgets. Args: path (str): Absolute path to the NetCDF file. """ if not path: return try: ds = _load_results(path) _ds["dataset"] = ds except Exception as e: # pylint: disable=broad-exception-caught status_pane.object = f"❌ Could not load results: {e}" return # set the variable list directly here rather than relying on # _on_category_changed firing cats = _categorise_variables(ds) cat_options = list(cats.keys()) category_select.options = cat_options if cat_options: category_select.value = cat_options[0] variable_select.options = cats[cat_options[0]] variable_select.value = list(cats[cat_options[0]]) else: variable_select.options = [] variable_select.value = [] # Populate aircraft selector if relevant if "ac" in ds.coords: ac_ids = [str(v) for v in ds["ac"].values] ac_select.options = ac_ids ac_select.value = ac_ids ac_section.visible = True else: ac_select.options = [] ac_select.value = [] ac_section.visible = False status_pane.object = ( f"✅ Loaded `{Path(path).name}` — " f"{len(ds.data_vars)} variable(s), " f"{len(ds.coords)} coordinate(s)" ) # Explicit redraw - don't rely solely on the widget watchers above, # for the same reason: they're a no-op when values didn't change. _update_plot() def _on_category_changed(event): """Update the variable checkboxes when the category changes. Args: event: Param event. """ ds = _ds["dataset"] if ds is None: return cats = _categorise_variables(ds) new_cat = event.new options = cats.get(new_cat, []) variable_select.options = options # Pre-select all variables in the new category variable_select.value = list(options) def _on_load_from_config_click(_event=None): """Load the results file implied by the current config, if any.""" candidate = _candidate_results_path(state) if candidate is None: status_pane.object = ( "⚠️ Current configuration has no output directory/name set." ) return if not candidate.exists(): status_pane.object = f"⚠️ No results file found at `{candidate}`." return state.results_path = str(candidate) _load_from_path(str(candidate)) def _on_browse_click(_event=None): import tkinter as tk from tkinter import filedialog root = tk.Tk() root.withdraw() root.attributes("-topmost", True) selected = filedialog.askopenfilename( title="Select results file", filetypes=[("NetCDF files", "*.nc"), ("All files", "*.*")], initialdir=state.working_dir or None, ) root.destroy() if selected: path = str(Path(selected).resolve()) state.results_path = path _load_from_path(path) load_from_config_btn.on_click(_on_load_from_config_click) browse_btn.on_click(_on_browse_click) category_select.param.watch(_on_category_changed, "value") variable_select.param.watch(lambda e: _update_plot(), "value") ac_select.param.watch(lambda e: _update_plot(), "value") legend_select.param.watch(lambda e: _update_plot(), "value") # ── initial state ───────────────────────────────────────────────── # Only loads if a results file was passed explicitly via --results on # the command line — no automatic loading from the config otherwise if state.results_path: _load_from_path(state.results_path) ac_section.visible = False # ── layout ──────────────────────────────────────────────────────── card_variables = pn.Card( category_select, variable_select, title="Variables", collapsible=False, sizing_mode="stretch_width", ) card_aircraft = pn.Card( ac_section, title="Aircraft", collapsible=False, sizing_mode="stretch_width", ) card_display = pn.Card( legend_select, title="Display options", collapsible=False, sizing_mode="stretch_width", ) card_plot = pn.Card( plot_pane, title="Results", collapsible=False, sizing_mode="stretch_width", ) return pn.Column( pn.pane.Markdown(TITLE), status_pane, pn.Row(load_from_config_btn, browse_btn), pn.Row( card_variables, card_aircraft, card_display, sizing_mode="stretch_width", styles={"gap": "10px", "align-items": "stretch"}, ), card_plot, sizing_mode="stretch_width", styles={"gap": "10px", "margin-top": "15px"}, )