"""Scenario tab: visualise the emission scenario over time.
Two plots, each driven by its own variable dropdown:
- Left ("Inventories") — any numeric variable found in the loaded
emission inventories, plotted as a raw sum per inventory year. Can
be split by aircraft type and shown as a relative to yearly total.
- Right ("Time evolution") — whatever variables are actually present
in the loaded time evolution file, each overlaid with the matching
inventory-derived quantities.
Unit reconciliation (needed only when overlaying two sources on one
axis) uses `pint`, via `openairclim.core.utils`. Time evolution's "fuel"
is a rate (e.g. "Tg yr-1") that represents a total accumulated over
exactly one year — handled by multiplying its unit by "yr" before
converting, which cancels the rate exactly.
"""
import os
from pathlib import Path
import panel as pn
from ..components.utils import COLORS, MARKERS, auto_scale, load_inventory, get_numeric_vars
from ...core.interpolate_time import KEY_TABLE
TITLE = """
### View emission scenario over time
This is a **viewing** tab — it does not change the configuration. Two plots
are shown, each covering the full simulated time range:
- **Inventories** (left) — any numeric variable from the loaded emission
inventories, summed per inventory year and shown as a stacked bar,
optionally split by aircraft type.
- **Time evolution** (right) — whatever variables are present in the configured
time evolution file (if present), overlaid with the matching
inventory-derived values for comparison.
The shaded region marks the simulated period (`time.range` in the Config
tab). To change the underlying inventories or time evolution file, go to
the Config tab.
"""
# Canonical unit each combined (evolution-side) variable is converted to
# before plotting, regardless of what a given file happens to declare —
# keeps axis labels predictable across different loaded files.
CANONICAL_UNITS = {
"fuel": "Tg",
"EI_CO2": "1",
"EI_H2O": "1",
"EI_NOx": "1",
"dis_per_fuel": "km kg-1",
}
# ======================================================================
# Unit helpers
# ======================================================================
def _unit_str(raw):
"""Return a pint-parseable unit string.
Args:
raw (str or None): Unit string from a NetCDF attribute — may be
missing or "" for a dimensionless quantity (e.g. an emission
index), which pint doesn't treat as dimensionless unless given
"1" explicitly.
Returns:
str: "1" if raw is blank, else raw unchanged.
"""
return raw if raw else "1"
def _display_unit(unit_str):
"""Return a unit string suitable for an axis label.
Args:
unit_str (str): Unit string (possibly "1"/"" for dimensionless).
Returns:
str: "-" for dimensionless, else unit_str unchanged.
"""
return "-" if unit_str in ("1", "", None) else unit_str
def _convert_value(value, src_units, target_units, per_year=False):
"""Convert a single value between unit strings using pint.
Args:
value (float): Value to convert.
src_units (str): Source unit string, as declared in the file.
target_units (str): Target unit string.
per_year (bool): If True, src_units is a rate per year (e.g.
"Tg yr-1") representing a total accumulated over exactly
one year — multiplied by a "yr" unit to cancel the rate
(exact, via unit algebra) before converting, rather than
converting the rate itself.
Returns:
float: Converted value.
Raises:
ValueError: If the units aren't convertible.
"""
from ...core.utils import quantity, to_value
src = quantity(value, _unit_str(src_units))
if per_year:
src = src * quantity(1, "yr")
return to_value(src, _unit_str(target_units))
def _convert_ratio(value, numerator_units, denominator_units, target_units):
"""Convert a numerator/denominator ratio to a target unit.
Args:
value (float): Ratio value (e.g. sum(CO2) / sum(fuel)).
numerator_units (str): Numerator's unit string.
denominator_units (str): Denominator's unit string.
target_units (str): Target unit string.
Returns:
float: Converted value.
Raises:
ValueError: If the units aren't convertible.
"""
from ...core.utils import quantity, to_value
src = quantity(value, _unit_str(numerator_units)) / quantity(
1, _unit_str(denominator_units)
)
return to_value(src, _unit_str(target_units))
# ======================================================================
# Plot builders
# ======================================================================
def _build_inventory_bar_figure(
variable_name, unit, categories, years, values_by_category,
t_start, t_end, relative=False, show_legend=True,
legend_location="top_left", show_period=True, period_color="#808080",
):
"""Create a stacked-bar Bokeh figure of an inventory variable per year.
One bar per inventory year, its segments the variable's sum for
each aircraft-type ("ac") category — or a single "Total" segment
if not split by category.
Args:
variable_name (str): Variable name, for title/axis label.
unit (str): Unit string, for the y-axis label (ignored if
relative=True, since relative values are percentages).
categories (list): Category names (stack order), e.g. the
sorted "ac" values, or ["Total"] if not split.
years (list): Inventory years (x positions), sorted.
values_by_category (dict): category -> list of values, aligned
with `years`.
t_start (int or None): Simulation start year, for the shaded
period annotation — skipped if either is None.
t_end (int or None): Simulation end year (exclusive) — shading
drawn up to t_end - 1, the last year actually simulated.
relative (bool): If True, values are already percentages of
each year's total (0-100), and the axis is labelled
accordingly rather than with `unit`.
show_legend (bool): Whether the legend is visible.
legend_location (str): Bokeh legend location string.
show_period (bool): Whether the simulation period is shaded.
period_color (str): Fill color for the simulation period shading.
Returns:
bokeh.plotting.Figure or None: The assembled figure, or None
if there's no data to plot.
"""
from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Range1d
from bokeh.plotting import figure
if not years or not categories:
return None
y_label = f"{variable_name} [% of yearly total]" if relative else f"{variable_name} [{unit}]"
if relative:
scale, prefix = 1.0, ""
max_total = 100.0
else:
totals = [
sum(values_by_category[cat][i] for cat in categories) for i in range(len(years))
]
scale, prefix = auto_scale(max(totals) if totals else 1.0)
max_total = max(totals) / scale if totals else 1.0
y_label = f"{variable_name} [{prefix}{unit}]"
fig = figure(
title=f"Global {variable_name} sum" + (" by aircraft type" if len(categories) > 1 else ""),
x_axis_label="Year",
y_axis_label=y_label,
height=420,
sizing_mode="stretch_width",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
fig.y_range = Range1d(start=0, end=max_total * 1.1)
has_period = show_period and t_start is not None and t_end is not None
if has_period:
fig.add_layout(BoxAnnotation(
left=t_start, right=t_end - 1, fill_alpha=0.08, fill_color=period_color,
line_color=None, level="underlay",
))
source = ColumnDataSource({
"x": years,
**{cat: [v / scale for v in values_by_category[cat]] for cat in categories},
})
colors = [COLORS[i % len(COLORS)] for i in range(len(categories))]
bars = fig.vbar_stack(
categories, x="x", width=0.8, color=colors, source=source,
legend_label=categories,
)
fig.add_tools(HoverTool(
tooltips=[("Aircraft type", "$name"), ("Year", "@x"), ("Value", "@$name{0.00}")],
renderers=bars,
))
if has_period:
# BoxAnnotation isn't a glyph renderer, so it doesn't get a
# legend entry on its own — a zero-area dummy glyph matching
# its fill stands in for it, purely for the legend swatch.
fig.quad(
top=0, bottom=0, left=t_start, right=t_end - 1,
fill_color=period_color, fill_alpha=0.3, line_color=None,
legend_label="Simulation period",
)
if fig.legend:
fig.legend.click_policy = "hide"
fig.legend.location = legend_location
fig.legend.visible = show_legend and (len(categories) > 1 or has_period)
return fig
def _build_figure(
title, variable_name, unit, t_start, t_end, evo_points=None, inv_points=None,
show_legend=True, legend_location="top_left", show_period=True, period_color="#808080",
):
"""Create a Bokeh figure showing an evolution line and/or inventory scatter.
Args:
title (str): Figure title.
variable_name (str): Variable name, for the y-axis label.
unit (str): Unit string, for the y-axis label (e.g. "Tg").
t_start (int or None): Simulation start year, for the shaded
period annotation — skipped if either is None.
t_end (int or None): Simulation end year (exclusive) — the
shaded region is drawn up to t_end - 1, the last year
actually simulated.
evo_points (tuple, optional): (years, values) for the time
evolution line+markers.
inv_points (tuple, optional): (years, values) for the
inventory scatter.
show_legend (bool): Whether the legend is visible.
legend_location (str): Bokeh legend location string.
show_period (bool): Whether the simulation period is shaded.
period_color (str): Fill color for the simulation period shading.
Returns:
bokeh.plotting.Figure or None: The assembled figure, or None
if neither evo_points nor inv_points has any data.
"""
from bokeh.models import BoxAnnotation, HoverTool, Range1d
from bokeh.plotting import figure
all_vals = []
if evo_points:
all_vals.extend(evo_points[1])
if inv_points:
all_vals.extend(inv_points[1])
if not all_vals:
return None
scale, prefix = auto_scale(max(abs(v) for v in all_vals))
fig = figure(
title=title,
x_axis_label="Year",
y_axis_label=f"{variable_name} [{prefix}{unit}]",
height=420,
sizing_mode="stretch_width",
tools="pan,wheel_zoom,box_zoom,reset,save",
)
fig.y_range = Range1d(start=0, end=max(v / scale for v in all_vals) * 1.1)
has_period = show_period and t_start is not None and t_end is not None
if has_period:
fig.add_layout(BoxAnnotation(
left=t_start, right=t_end - 1, fill_alpha=0.08, fill_color=period_color,
line_color=None, level="underlay",
))
# Hover is restricted to the marker (scatter) renderers below, not the
# connecting line — hovering along a line otherwise reports whatever
# interpolated point sits under the cursor (e.g. "2051" between two
# yearly markers) rather than only the actual discrete data points.
marker_renderers = []
if evo_points:
years, vals = evo_points
scaled = [v / scale for v in vals]
fig.line(years, scaled, color=COLORS[0], line_width=2, legend_label="Time evolution")
marker_renderers.append(fig.scatter(
years, scaled, marker=MARKERS[0], color=COLORS[0], size=7,
legend_label="Time evolution", name="Time evolution",
))
if inv_points:
years, vals = inv_points
scaled = [v / scale for v in vals]
marker_renderers.append(fig.scatter(
years, scaled, marker=MARKERS[1], color=COLORS[1], size=10,
legend_label="Inventories", name="Inventories",
))
fig.add_tools(HoverTool(
tooltips=[("Series", "$name"), ("Year", "@x{0}"), ("Value", "@y")],
renderers=marker_renderers,
))
if has_period:
# BoxAnnotation isn't a glyph renderer, so it doesn't get a
# legend entry on its own — a zero-area dummy glyph matching
# its fill stands in for it, purely for the legend swatch.
fig.quad(
top=0, bottom=0, left=t_start, right=t_end - 1,
fill_color=period_color, fill_alpha=0.3, line_color=None,
legend_label="Simulation period",
)
if fig.legend:
fig.legend.click_policy = "hide"
fig.legend.location = legend_location
fig.legend.visible = show_legend
return fig
# ======================================================================
# Tab layout
# ======================================================================
[docs]
def panel(state):
"""Return the scenario tab content.
Args:
state (AppState): Shared application state.
"""
# ── widgets ──────────────────────────────────────────────────────
variable_select = pn.widgets.Select(
name="Inventory variable",
options=[],
)
split_ac_cb = pn.widgets.Checkbox(name="Split by aircraft type (ac)", value=False)
relative_cb = pn.widgets.Checkbox(
name="Show relative to yearly total", value=False, visible=False,
)
norm_var_select = pn.widgets.Select(
name="Time evolution variable",
options=[],
visible=False,
)
status_left = pn.pane.Markdown("")
status_right = pn.pane.Markdown("")
# Display options (applied to both plots)
_legend_locations = [
"top_left", "top_center", "top_right",
"center_left", "center", "center_right",
"bottom_left", "bottom_center", "bottom_right",
]
show_legend_cb = pn.widgets.Checkbox(name="Show legend", value=True)
legend_loc_select = pn.widgets.Select(
name="Legend location", options=_legend_locations, value="top_left",
)
show_period_cb = pn.widgets.Checkbox(name="Show simulation period", value=True)
period_color_picker = pn.widgets.ColorPicker(
name="Simulation period colour", value="#808080",
)
# Persistent panes to avoid "dropping a patch" warnings
plot_pane_sum = pn.pane.Bokeh(None, sizing_mode="stretch_width")
plot_pane_norm = pn.pane.Bokeh(None, sizing_mode="stretch_width")
# ── internal state ────────────────────────────────────────────────
# Inventory cache: filename -> xarray.Dataset (main inventories only)
_cache = {}
# Time evolution file state
_evo = {"ds": None, "type": None}
# What was last loaded/plotted (for change detection in
# _on_edited_config_changed, so unrelated config edits elsewhere —
# e.g. the aircraft tab — don't trigger a full plot rebuild).
_loaded = {"inv_files": [], "evo_path": None, "sim_range": (None, None)}
# ── helpers ───────────────────────────────────────────────────────
def _sim_range():
"""Return (t_start, t_end) from the config, or (None, None).
Returns:
tuple: (int or None, int or None).
"""
config = state.edited_config
if not config:
return None, None
t_cfg = config.get("time", {}).get("range", [None, None, 1])
return t_cfg[0], t_cfg[1]
def _evo_path_from_config():
"""Return the absolute path to the time evolution file, or None.
Returns:
str or None: Absolute path if a file is configured, else None.
"""
config = state.edited_config
if not config:
return None
time_cfg = config.get("time", {})
evo_file = time_cfg.get("file")
if not evo_file:
return None
evo_dir = time_cfg.get("dir", "")
# time.dir is normally already absolute (canonicalized by the
# Config tab as soon as a folder's picked) — but right after a
# Load, edited_config briefly holds the file's own (possibly
# relative) dir until the Config tab rebuilds. Path's "/" with
# an absolute right-hand side ignores the left, so this is
# correct either way, matching how load_inventory() resolves
# inventories.dir.
return str(Path(state.working_dir) / evo_dir / evo_file)
def _load_inventories():
"""Load main inventory files from the current config into _cache.
Updates _cache in place and refreshes the variable dropdown.
"""
config = state.edited_config
if not config:
return
inv_cfg = config.get("inventories", {})
inv_dir = inv_cfg.get("dir", "")
inv_files = list(inv_cfg.get("files", []))
old_cwd = os.getcwd()
try:
if state.working_dir:
os.chdir(state.working_dir)
for f in inv_files:
if f not in _cache:
_cache[f] = load_inventory(state.working_dir, inv_dir, f)
except Exception as e: # pylint: disable=broad-exception-caught
status_left.object = f"⚠️ Failed to load inventory: {e}"
return
finally:
os.chdir(old_cwd)
# Prune stale entries
for f in list(_cache):
if f not in inv_files:
del _cache[f]
# Update variable dropdown from the union of all loaded inventories
all_vars: set = set()
for f in inv_files:
if f in _cache:
all_vars.update(get_numeric_vars(_cache[f]))
numeric_vars = sorted(all_vars)
prev = variable_select.value
variable_select.options = numeric_vars
if prev in numeric_vars:
variable_select.value = prev
elif numeric_vars:
variable_select.value = numeric_vars[0]
# Only offer splitting by aircraft type if at least one loaded
# inventory actually has an "ac" variable to split by.
any_has_ac = any(
"ac" in _cache[f].data_vars for f in inv_files if f in _cache
)
split_ac_cb.visible = any_has_ac
if not any_has_ac:
split_ac_cb.value = False
_loaded["inv_files"] = list(inv_files)
status_left.object = ""
def _load_evo():
"""Load the time evolution file (if configured) into _evo.
Updates _evo in place, sets the norm variable dropdown, and
writes a status message if the file is missing or invalid.
"""
import xarray as xr
evo_path = _evo_path_from_config()
_loaded["evo_path"] = evo_path
if not evo_path:
_evo["ds"] = None
_evo["type"] = None
norm_var_select.visible = False
norm_var_select.options = []
status_right.object = ""
return
try:
ds = xr.load_dataset(evo_path)
except Exception as e: # pylint: disable=broad-exception-caught
status_right.object = (
f"⚠️ Could not load time evolution file: {e}"
)
_evo["ds"] = None
_evo["type"] = None
norm_var_select.visible = False
return
evo_type = ds.attrs.get("Type")
_evo["ds"] = ds
_evo["type"] = evo_type
if evo_type is None:
status_right.object = (
"⚠️ Time evolution file has no **Type** attribute "
"(expected `norm` or `scaling`)."
)
norm_var_select.visible = False
norm_var_select.options = []
elif evo_type == "norm":
status_right.object = ""
evo_vars = sorted(ds.data_vars)
prev = norm_var_select.value
norm_var_select.options = evo_vars
norm_var_select.value = prev if prev in evo_vars else (evo_vars[0] if evo_vars else "")
norm_var_select.visible = True
elif evo_type == "scaling":
status_right.object = (
"ℹ️ Scaling time evolution — "
"visualisation not yet supported."
)
norm_var_select.visible = False
norm_var_select.options = []
else:
status_right.object = (
f"⚠️ Unknown time evolution type: `{evo_type}`."
)
norm_var_select.visible = False
norm_var_select.options = []
# ── plot updaters ─────────────────────────────────────────────────
def _update_sum_plot():
"""Redraw the inventories-only stacked-bar plot for the selected variable."""
config = state.edited_config
variable = variable_select.value
if not config or not variable:
plot_pane_sum.object = None
return
inv_files = list(config.get("inventories", {}).get("files", []))
split = split_ac_cb.value
# One (year, {category: value}) entry per inventory year.
year_data = []
raw_unit = "?"
for f in inv_files:
if f not in _cache:
continue
ds = _cache[f]
if variable not in ds.data_vars:
continue
year = ds.attrs.get("Inventory_Year")
if year is None:
continue
raw_unit = ds[variable].attrs.get("units", "?")
if split and "ac" in ds.data_vars:
grouped = ds[variable].groupby(ds["ac"]).sum()
cat_values = {
str(cat): float(v)
for cat, v in zip(grouped["ac"].values, grouped.values)
}
elif split:
# No "ac" variable in this inventory — matches
# read_netcdf.py's fallback: every row is treated as
# aircraft type "DEFAULT".
cat_values = {"DEFAULT": float(ds[variable].sum().item())}
else:
cat_values = {"Total": float(ds[variable].sum().item())}
year_data.append((int(year), cat_values))
if not year_data:
plot_pane_sum.object = None
return
year_data.sort(key=lambda p: p[0])
years = [p[0] for p in year_data]
categories = sorted({cat for _, cat_values in year_data for cat in cat_values})
values_by_category = {
cat: [cat_values.get(cat, 0.0) for _, cat_values in year_data]
for cat in categories
}
relative = relative_cb.value and len(categories) > 1
if relative:
totals = [sum(cat_values.values()) for _, cat_values in year_data]
for cat in categories:
values_by_category[cat] = [
(v / t * 100 if t else 0.0)
for v, t in zip(values_by_category[cat], totals)
]
t_start, t_end = _sim_range()
try:
plot_pane_sum.object = _build_inventory_bar_figure(
variable, raw_unit, categories, years, values_by_category,
t_start, t_end, relative=relative,
show_legend=show_legend_cb.value,
legend_location=legend_loc_select.value,
show_period=show_period_cb.value,
period_color=period_color_picker.value,
)
except Exception as e: # pylint: disable=broad-exception-caught
status_left.object = f"❌ Plot error: {e}"
plot_pane_sum.object = None
def _inventory_ratio_points(evo_variable):
"""Compute per-inventory-year points matching an evolution variable.
For "fuel", this is the raw inventory fuel sum. For every other
KEY_TABLE entry (EI_CO2, EI_H2O, EI_NOx, dis_per_fuel), it's the
inventory species-sum divided by the inventory fuel-sum for
that year — the same emission-index calculation core itself
performs (see calc_inv_quantities in core/interpolate_time.py)
— converted into CANONICAL_UNITS[evo_variable].
A given inventory year is skipped (not an error) if it's
missing the species and/or fuel needed for this variable.
Args:
evo_variable (str): Time evolution data variable name.
Returns:
tuple: (years, values) — possibly empty if nothing could
be computed.
"""
target_unit = CANONICAL_UNITS.get(evo_variable)
if target_unit is None:
return [], []
inv_species = KEY_TABLE.get(evo_variable)
config = state.edited_config
inv_files = list(config.get("inventories", {}).get("files", [])) if config else []
points = []
for f in inv_files:
ds = _cache.get(f)
if ds is None or "fuel" not in ds.data_vars:
continue
year = ds.attrs.get("Inventory_Year")
if year is None:
continue
fuel_sum = float(ds["fuel"].sum().item())
fuel_units = ds["fuel"].attrs.get("units", "?")
try:
if evo_variable == "fuel":
value = _convert_value(fuel_sum, fuel_units, target_unit)
else:
if inv_species is None or inv_species not in ds.data_vars:
continue
if fuel_sum == 0:
continue
spec_sum = float(ds[inv_species].sum().item())
spec_units = ds[inv_species].attrs.get("units", "?")
ratio = spec_sum / fuel_sum
value = _convert_ratio(ratio, spec_units, fuel_units, target_unit)
except ValueError:
# Incompatible units for this file — skip this year
# rather than failing the whole plot.
continue
points.append((int(year), value))
points.sort(key=lambda p: p[0])
years = [p[0] for p in points]
values = [p[1] for p in points]
return years, values
def _update_norm_plot():
"""Redraw the time evolution plot, overlaid with inventory data."""
import xarray as xr
if _evo["type"] != "norm" or _evo["ds"] is None:
plot_pane_norm.object = None
return
norm_variable = norm_var_select.value
ds: xr.Dataset = _evo["ds"]
if not norm_variable or norm_variable not in ds.data_vars:
plot_pane_norm.object = None
return
evo_unit = ds[norm_variable].attrs.get("units", "")
target_unit = CANONICAL_UNITS.get(norm_variable, evo_unit)
try:
evo_raw = ds[norm_variable].values.tolist()
per_year = norm_variable == "fuel"
evo_vals = [
_convert_value(v, evo_unit, target_unit, per_year=per_year)
for v in evo_raw
]
except ValueError as e:
status_right.object = f"❌ Could not convert units: {e}"
plot_pane_norm.object = None
return
evo_years = ds["time"].values.tolist()
inv_years, inv_vals = _inventory_ratio_points(norm_variable)
t_start, t_end = _sim_range()
try:
plot_pane_norm.object = _build_figure(
f"{norm_variable} — time evolution",
norm_variable, _display_unit(target_unit), t_start, t_end,
evo_points=(evo_years, evo_vals),
inv_points=(inv_years, inv_vals) if inv_years else None,
show_legend=show_legend_cb.value,
legend_location=legend_loc_select.value,
show_period=show_period_cb.value,
period_color=period_color_picker.value,
)
except Exception as e: # pylint: disable=broad-exception-caught
status_right.object = f"❌ Plot error: {e}"
plot_pane_norm.object = None
def _update_plots():
"""Redraw both plots."""
_update_sum_plot()
_update_norm_plot()
# ── config change watcher ─────────────────────────────────────────
def _on_edited_config_changed(event):
"""React to live edits in the sidebar configuration.
Reloads inventories if the file list changed, reloads the time
evolution file if the path changed, then redraws all plots — but
only if something this tab actually cares about changed. This
watcher fires on *every* edited_config trigger app-wide (e.g.
every single aircraft-tab table edit), so redrawing unconditionally
would rebuild both plots on edits that have nothing to do with
them.
Args:
event: Param event carrying the current edited_config dict.
"""
config = event.new
if config is None:
_cache.clear()
_evo["ds"] = None
_evo["type"] = None
_loaded["inv_files"] = []
_loaded["evo_path"] = None
_loaded["sim_range"] = (None, None)
variable_select.options = []
norm_var_select.options = []
norm_var_select.visible = False
plot_pane_sum.object = None
plot_pane_norm.object = None
status_left.object = "⚠️ Create or load a configuration first."
status_right.object = ""
return
inv_files = list(config.get("inventories", {}).get("files", []))
inv_changed = inv_files != _loaded["inv_files"]
if inv_changed:
_load_inventories()
evo_path = _evo_path_from_config()
evo_changed = evo_path != _loaded["evo_path"]
if evo_changed:
_load_evo()
sim_range = _sim_range()
range_changed = sim_range != _loaded["sim_range"]
_loaded["sim_range"] = sim_range
if inv_changed or evo_changed or range_changed:
_update_plots()
state.param.watch(_on_edited_config_changed, "edited_config")
variable_select.param.watch(lambda e: _update_sum_plot(), "value")
norm_var_select.param.watch(lambda e: _update_norm_plot(), "value")
show_legend_cb.param.watch(lambda e: _update_plots(), "value")
legend_loc_select.param.watch(lambda e: _update_plots(), "value")
show_period_cb.param.watch(lambda e: _update_plots(), "value")
period_color_picker.param.watch(lambda e: _update_plots(), "value")
relative_cb.param.watch(lambda e: _update_sum_plot(), "value")
def _on_split_ac_changed(event):
relative_cb.visible = event.new
_update_sum_plot()
split_ac_cb.param.watch(_on_split_ac_changed, "value")
# ── initial state ─────────────────────────────────────────────────
if state.edited_config is None:
status_left.object = "⚠️ Create or load a configuration first."
else:
_load_inventories()
_load_evo()
_loaded["sim_range"] = _sim_range()
_update_plots()
# ── layout ────────────────────────────────────────────────────────
card_variable = pn.Card(
variable_select,
split_ac_cb,
relative_cb,
status_left,
title="Inventories",
collapsible=False,
sizing_mode="stretch_width",
)
card_evo = pn.Card(
norm_var_select,
status_right,
title="Time evolution",
collapsible=False,
sizing_mode="stretch_width",
)
card_extra = pn.Card(
show_legend_cb,
legend_loc_select,
show_period_cb,
period_color_picker,
title="Additional options",
collapsible=False,
sizing_mode="stretch_width",
)
card_plots = pn.Card(
pn.Row(plot_pane_sum, plot_pane_norm, sizing_mode="stretch_width"),
title="Scenario",
collapsible=False,
sizing_mode="stretch_width",
)
return pn.Column(
pn.pane.Markdown(TITLE),
pn.Row(
card_variable,
card_evo,
card_extra,
sizing_mode="stretch_width",
styles={"gap": "10px", "align-items": "stretch"},
),
card_plots,
sizing_mode="stretch_width",
styles={"gap": "10px", "margin-top": "15px"},
)