"""Aircraft tab: define aircraft identifiers and per-aircraft contrail
parameters. This data can live in two places:
- inline in the config, under `config["aircraft"][<ac_id>]`; or
- in a separate CSV file (`config["aircraft"]["dir"]`/`["file"]`.
Both sources support deriving `G_250` from sub-values (`SAC_eq`, `Q_h`, `eta`,
`eta_elec`, `EIH2O`, `R`) and `PMrel` from `PM`, when the direct value is
missing: csv rows via `load_ac_data`, config-inline entries via
`_derive_missing_ac_params`. This tab lets either be edited directly or
derived: the"Calculate G_250/PMrel from sub-values" button can be used to fill
in any blank G_250/PMrel cells from their sub-values.
A direct value and its sub-values may both be present at once (e.g. right after
"Calculate", or because a user keeps sub-values on hand for reference) - core
always silently prefers the direct value and ignores the sub-values. This tab
never deletes sub-values on the user's behalf; the completeness check only
warns when they're both present *and* actually disagree (compares the
sub-value-derived value against the stored direct value, within the same
rounding tolerance core uses when deriving one itself).
Table edits are synced into `edited_config`/the local CSV state immediately,
but the completeness/consistency check ("Check completeness" button) and the
sub-value calculation only run on button-press, rather than on every keystroke,
to keep large tables responsive.
Core does not tolerate an aircraft identifier defined in-line in the config
*and* in a linked csv file. Therefore, this tab keeps the two mutually
exclusive per identifier: one row per aircraft in a single table, with a
"source" column. Changing a row's source moves its data between `edited_config`
and a locally-held CSV DataFrame, which is only written to disk on Save
(mirroring the sidebar's config Save). `edited_config["aircraft"]["types"]` is
kept in sync automatically.
"""
import math
from pathlib import Path
import pandas as pd
import panel as pn
from pydantic import ValidationError
from ..components.schema import literal_choices, is_string_like_field
from ..components.utils import load_inventory
from ...core.config_model import AircraftEntry, AIRCRAFT_DERIVATION_MAP
# Field names/order, SAC_eq's valid values, and the G_250/PMrel sub-value
# groupings all come from config_model.AircraftEntry - the same schema core
# validates aircraft data against - rather than being duplicated here.
_DATA_FIELDS = list(AircraftEntry.model_fields)
G250_SUBCOLS = AIRCRAFT_DERIVATION_MAP["G_250"]
PMREL_SUBCOLS = AIRCRAFT_DERIVATION_MAP["PMrel"]
SAC_EQ_OPTIONS = ["", *literal_choices(AircraftEntry, "SAC_eq")]
CSV_COLUMNS = ["ac", *_DATA_FIELDS]
TABLE_COLUMNS = [*CSV_COLUMNS, "source"]
SOURCE_OPTIONS = ["config", "csv"]
_STR_COLS = ["ac", *(f for f in _DATA_FIELDS if is_string_like_field(AircraftEntry, f))]
_FLOAT_COLS = [f for f in _DATA_FIELDS if f not in _STR_COLS]
TITLE = """
### Edit aircraft data
This is an **editing** tab - changes you make here update the shared working
configuration immediately.
Use this tab to view and edit the aircraft data. If the main or base emission
inventories include different aircraft identifiers (`ac` data variable), then
all aircraft IDs must be defined below. The corresponding variables `b`,
`PMrel` and `G_250` are required only if contrails are to be calculated. This
data can be provided in-line in the config file itself, or in an accompanying
csv file — the "source" column controls which, per aircraft. An appropriate
csv file can be created using the buttons below.
For more information, see Megill (2026) and the OpenAirClim
[docs](https://openairclim.org/user_guide/contrails.html).
"""
def _empty_table_df():
"""Return an empty, correctly-typed table DataFrame."""
cols = {
c: pd.Series(dtype="object" if c in _STR_COLS else "float64")
for c in TABLE_COLUMNS if c != "source"
}
cols["source"] = pd.Series(dtype="object")
return pd.DataFrame(cols)[TABLE_COLUMNS]
def _empty_csv_df():
"""Return an empty, correctly-typed CSV DataFrame."""
cols = {
c: pd.Series(dtype="object" if c in _STR_COLS else "float64")
for c in CSV_COLUMNS
}
return pd.DataFrame(cols)[CSV_COLUMNS]
def _is_blank(value):
"""Return True if a table/CSV cell counts as "not filled in"."""
if value is None:
return True
if isinstance(value, float) and pd.isna(value):
return True
if isinstance(value, str) and not value.strip():
return True
return False
def _derive_entry(row):
"""Validate a row's G250_SUBCOLS/PMREL_SUBCOLS through AircraftEntry,
deriving G_250/PMrel the same way core does (config_model.AircraftEntry),
so the preview always matches what core would actually compute.
Args:
row (pandas.Series or dict): Row with sub-value columns.
Returns:
AircraftEntry or None: The validated entry, or None if the given
sub-values are absent or aren't enough to derive anything.
"""
kwargs = {
c: row.get(c) for c in (*G250_SUBCOLS, *PMREL_SUBCOLS)
if not _is_blank(row.get(c))
}
if not kwargs:
return None
try:
return AircraftEntry.model_validate(kwargs)
except ValidationError:
return None
def _compute_g250_preview(row):
"""Return G_250 computed from sub-values, or None if not derivable.
Args:
row (pandas.Series or dict): Row with G250_SUBCOLS values.
Returns:
float or None: Computed G_250, or None if not derivable.
"""
entry = _derive_entry(row)
return entry.G_250 if entry is not None else None
def _compute_pmrel_preview(row):
"""Return PMrel computed from PM (PMrel = PM / 1.5e15), or None if not
derivable.
Args:
row (pandas.Series or dict): Row with a "PM" value.
Returns:
float or None: Computed PMrel, or None if not derivable.
"""
entry = _derive_entry(row)
return entry.PMrel if entry is not None else None
def _build_table_df(edited, csv_df):
"""Merge config-sourced and csv-sourced aircraft data into one table.
Args:
edited (dict): Working configuration dict.
csv_df (pandas.DataFrame): Locally-held CSV data (CSV_COLUMNS).
Returns:
pandas.DataFrame: One row per aircraft identifier, TABLE_COLUMNS.
"""
aircraft = edited.get("aircraft", {}) if edited else {}
types = [t for t in aircraft.get("types", []) if not _is_blank(t)]
config_rows = {
k: v for k, v in aircraft.items()
if k not in ("types", "dir", "file") and isinstance(v, dict)
}
csv_lookup = {}
if csv_df is not None and not csv_df.empty:
for _, row in csv_df.iterrows():
if not _is_blank(row.get("ac")):
csv_lookup[str(row["ac"])] = row
data_cols = _DATA_FIELDS
seen = set()
rows = []
def _row_from_config(ac):
d = config_rows.get(ac, {})
row = {c: d.get(c) for c in data_cols}
row["ac"] = ac
row["source"] = "config"
return row
def _row_from_csv(ac):
r = csv_lookup[ac]
row = {c: r.get(c) for c in data_cols}
row["ac"] = ac
row["source"] = "csv"
return row
for ac in types:
if ac in seen:
continue
seen.add(ac)
if ac in config_rows:
rows.append(_row_from_config(ac))
elif ac in csv_lookup:
rows.append(_row_from_csv(ac))
else:
# A bare identifier with no data anywhere yet - valid as
# long as contrails aren't being computed.
row = {c: None for c in data_cols}
row["ac"] = ac
row["source"] = "config"
rows.append(row)
# Data present in a source but not (yet) listed in "types" - shouldn't
# happen once this tab is the one maintaining "types", but a
# hand-edited or externally-loaded config could have this.
for ac in config_rows:
if ac not in seen:
seen.add(ac)
rows.append(_row_from_config(ac))
for ac in csv_lookup:
if ac not in seen:
seen.add(ac)
rows.append(_row_from_csv(ac))
if not rows:
return _empty_table_df()
return pd.DataFrame(rows)[TABLE_COLUMNS]
[docs]
def panel(state):
"""Return the aircraft tab content.
Args:
state (AppState): Shared application state.
"""
_csv = {"df": _empty_csv_df()}
_inv_ac_cache = {"key": None, "values": set()}
_rebuilding = {"flag": False}
pending_action = {"type": None}
status_pane = pn.pane.Markdown("")
csv_status = pn.pane.Markdown("")
check_status = pn.pane.Markdown("")
open_btn = pn.widgets.Button(name="Open CSV…", button_type="primary")
new_btn = pn.widgets.Button(name="New CSV", button_type="default")
save_btn = pn.widgets.Button(name="Save CSV", button_type="success")
save_as_btn = pn.widgets.Button(name="Save CSV As…", button_type="default")
unlink_btn = pn.widgets.Button(
name="Unlink CSV from config", button_type="warning"
)
add_row_btn = pn.widgets.Button(name="Add row", button_type="default")
delete_row_btn = pn.widgets.Button(
name="Delete selected row(s)", button_type="danger"
)
calculate_btn = pn.widgets.Button(
name="Calculate G_250/PMrel from sub-values", button_type="primary"
)
check_btn = pn.widgets.Button(
name="Check completeness", button_type="default"
)
confirm_msg = pn.pane.Markdown(
"⚠️ You have unsaved edits to the aircraft CSV. Continuing will "
"discard them."
)
confirm_yes = pn.widgets.Button(name="Discard and continue", button_type="danger")
confirm_no = pn.widgets.Button(name="Cancel", button_type="default")
confirm_row = pn.Column(confirm_msg, pn.Row(confirm_yes, confirm_no), visible=False)
table = pn.widgets.Tabulator(
_empty_table_df(),
show_index=False,
selectable="checkbox",
editors={
"ac": "input",
# Numeric columns are deliberately absent here
"SAC_eq": {"type": "list", "values": SAC_EQ_OPTIONS},
"source": {"type": "list", "values": SOURCE_OPTIONS},
},
titles={
"ac": "Aircraft ID", "b": "b [m]", "PMrel": "PMrel [-]",
"G_250": "G_250 [Pa/K]", "SAC_eq": "SAC eq.", "Q_h": "Q or Δh",
"eta": "eta [-]", "eta_elec": "eta elec. [-]",
"EIH2O": "EIH2O [kg/kg]", "R": "R", "PM": "PM [1/kg]",
"source": "Source",
},
frozen_columns=["ac"],
sizing_mode="stretch_width",
)
# ── helpers ───────────────────────────────────────────────────────
def _current_csv_path():
config = state.edited_config
if not config:
return None
aircraft = config.get("aircraft", {})
f = aircraft.get("file")
if not f:
return None
d = aircraft.get("dir", "")
return str(Path(state.working_dir) / d / f)
def _load_inventory_ac_values():
"""Unique "ac" values across loaded inventories, cached until the
inventory dir/file list actually changes. Inventories without an
"ac" variable contribute "DEFAULT", matching read_netcdf.py's
split_inventory_by_aircraft."""
config = state.edited_config
if not config:
return set()
inv_cfg = config.get("inventories", {})
inv_dir = inv_cfg.get("dir", "")
inv_files = tuple(inv_cfg.get("files", []))
key = (state.working_dir, inv_dir, inv_files)
if _inv_ac_cache["key"] == key:
return _inv_ac_cache["values"]
ac_values = set()
any_no_ac = False
if inv_files and state.working_dir:
for f in inv_files:
try:
ds = load_inventory(state.working_dir, inv_dir, f)
except Exception: # pylint: disable=broad-exception-caught
continue
if "ac" in ds.data_vars:
ac_values.update(str(v) for v in ds["ac"].values.tolist())
else:
any_no_ac = True
if any_no_ac:
ac_values.add("DEFAULT")
_inv_ac_cache["key"] = key
_inv_ac_cache["values"] = ac_values
return ac_values
def _run_check():
"""Update check_status with, in order:
1. Ambiguous rows - G_250 set directly *and* via sub-values (core
silently keeps the direct value and ignores the sub-values),
same for PMrel/PM. Checked regardless of "cont"/inventories.
2. Aircraft identifiers found in inventories but not defined here.
3. Only if "cont" is in species.out: do those identifiers have
complete G_250/b/PMrel data?
"""
config = state.edited_config
if not config:
check_status.object = ""
return
messages = []
df = table.value
# a direct value *and* its sub-values can both legitimately be
# present at once. Only warn when the two disagree.
conflicting_g250 = [
row["ac"] for _, row in df.iterrows()
if not _is_blank(row.get("ac")) and not _is_blank(row.get("G_250"))
and any(not _is_blank(row.get(c)) for c in G250_SUBCOLS)
and (derived := _compute_g250_preview(row)) is not None
and not math.isclose(
derived, float(row["G_250"]), rel_tol=1e-3, abs_tol=1e-3
)
]
conflicting_pmrel = [
row["ac"] for _, row in df.iterrows()
if not _is_blank(row.get("ac")) and not _is_blank(row.get("PMrel"))
and not _is_blank(row.get("PM"))
and (derived := _compute_pmrel_preview(row)) is not None
and not math.isclose(
derived, float(row["PMrel"]), rel_tol=1e-3, abs_tol=1e-3
)
]
if conflicting_g250:
messages.append(
"⚠️ G_250 is set directly to a value that doesn't match what "
"its sub-values (SAC_eq/Q_h/...) compute for: "
f"{', '.join(conflicting_g250)} - the direct value will be "
"used, the sub-values ignored."
)
if conflicting_pmrel:
messages.append(
"⚠️ PMrel is set directly to a value that doesn't match "
f"PM/1.5e15 for: {', '.join(conflicting_pmrel)} - the direct "
"value will be used, PM ignored."
)
inv_ac = _load_inventory_ac_values()
if inv_ac:
types = set(config.get("aircraft", {}).get("types", []))
missing_types = inv_ac - types
if missing_types:
messages.append(
"⚠️ Aircraft identifier(s) found in emission inventories "
f"but not defined here: {', '.join(sorted(missing_types))}"
)
elif "cont" in config.get("species", {}).get("out", []):
incomplete = []
for ac in sorted(inv_ac):
match = df[df["ac"] == ac]
if match.empty:
incomplete.append(ac)
continue
row = match.iloc[0]
has_b = not _is_blank(row.get("b"))
has_g250 = (
not _is_blank(row.get("G_250")) or _compute_g250_preview(row) is not None
)
has_pmrel = (
not _is_blank(row.get("PMrel")) or _compute_pmrel_preview(row) is not None
)
if not (has_b and has_g250 and has_pmrel):
incomplete.append(ac)
if incomplete:
messages.append(
"⚠️ Contrails are enabled, but G_250/b/PMrel are "
f"incomplete for: {', '.join(incomplete)}"
)
if not messages and inv_ac:
messages.append(
"✅ All aircraft identifiers used in the inventories are "
"fully defined."
)
check_status.object = "\n\n".join(messages)
def _sync_types():
config = state.edited_config
if not config:
return
acs = [a for a in table.value["ac"].tolist() if not _is_blank(a)]
config.setdefault("aircraft", {})["types"] = sorted(dict.fromkeys(acs))
def _clean_value(col, value):
"""Coerce a raw cell value to what should be stored for `col`."""
if _is_blank(value):
return None
return str(value) if col == "SAC_eq" else float(value)
def _sync_row(row, source, old_ac=None):
"""Write one row's data into whichever store matches `source`,
removing it from the other store (and from `old_ac` if renamed).
Args:
row (pandas.Series): The row's current data (TABLE_COLUMNS).
source (str): "config" or "csv".
old_ac (str, optional): Previous aircraft ID, if renamed.
"""
config = state.edited_config
if not config:
return
ac = row["ac"]
aircraft = config.setdefault("aircraft", {})
data_cols = _DATA_FIELDS
for name in {ac, old_ac} - {None}:
aircraft.pop(name, None)
_csv["df"] = _csv["df"][_csv["df"]["ac"] != name]
if source == "config":
data = {
c: _clean_value(c, row.get(c))
for c in data_cols if not _is_blank(row.get(c))
}
aircraft[ac] = data
state.dirty = True
else:
new_row = pd.DataFrame([{
"ac": ac,
**{c: _clean_value(c, row.get(c)) for c in data_cols},
}]).astype(_csv["df"].dtypes.to_dict())
_csv["df"] = pd.concat([_csv["df"], new_row], ignore_index=True)
state.aircraft_csv_dirty = True
def _rebuild_table():
config = state.edited_config
_rebuilding["flag"] = True
try:
table.value = _build_table_df(config, _csv["df"]) if config else _empty_table_df()
finally:
_rebuilding["flag"] = False
check_status.object = ""
# ── table edits ──────────────────────────────────────────────────
def _on_table_edit(event):
if _rebuilding["flag"]:
return
row = table.value.loc[event.row]
ac = row["ac"]
if _is_blank(ac):
return # nothing to sync yet - aircraft not named
old_ac = event.old if event.column == "ac" and not _is_blank(event.old) else None
_sync_row(row, row["source"], old_ac=old_ac)
_sync_types()
state.param.trigger("edited_config")
table.on_edit(_on_table_edit)
def _on_add_row(_event=None):
blank = {
c: ("" if c == "ac" else ("config" if c == "source" else None))
for c in TABLE_COLUMNS
}
new_row = pd.DataFrame([blank]).astype(
{c: dt for c, dt in table.value.dtypes.items() if c in blank}
)
table.value = pd.concat([table.value, new_row], ignore_index=True)
def _on_delete_rows(_event=None):
if not table.selection:
return
config = state.edited_config
rows = table.value.loc[table.selection]
for _, row in rows.iterrows():
ac = row["ac"]
if _is_blank(ac):
continue
if row["source"] == "config" and config:
config.get("aircraft", {}).pop(ac, None)
state.dirty = True
else:
_csv["df"] = _csv["df"][_csv["df"]["ac"] != ac]
state.aircraft_csv_dirty = True
table.value = table.value.drop(index=table.selection).reset_index(drop=True)
table.selection = []
_sync_types()
if config:
state.param.trigger("edited_config")
add_row_btn.on_click(_on_add_row)
delete_row_btn.on_click(_on_delete_rows)
def _on_calculate_click(_event=None):
"""Fill blank G_250/PMrel cells from their sub-values, using the
same core calculation as the completeness check, then sync only
the rows that actually changed."""
df = table.value.copy()
changed_idx = []
for idx, row in df.iterrows():
if _is_blank(row.get("ac")):
continue
row_changed = False
if _is_blank(row.get("G_250")):
g250 = _compute_g250_preview(row)
if g250 is not None:
df.at[idx, "G_250"] = g250
row_changed = True
if _is_blank(row.get("PMrel")):
pmrel = _compute_pmrel_preview(row)
if pmrel is not None:
df.at[idx, "PMrel"] = pmrel
row_changed = True
if row_changed:
changed_idx.append(idx)
if not changed_idx:
check_status.object = "ℹ️ Nothing to calculate."
return
_rebuilding["flag"] = True
try:
table.value = df
finally:
_rebuilding["flag"] = False
config = state.edited_config
for idx in changed_idx:
row = df.loc[idx]
_sync_row(row, row["source"])
_sync_types()
if config:
state.param.trigger("edited_config")
_run_check()
calculate_btn.on_click(_on_calculate_click)
check_btn.on_click(lambda event=None: _run_check())
# ── CSV open / new / save ───────────────────────────────────────
def _do_open_csv(path):
try:
df = pd.read_csv(path)
except Exception as e: # pylint: disable=broad-exception-caught
csv_status.object = f"❌ Failed to read CSV: {e}"
return
df.columns = df.columns.str.strip()
if "ac" not in df.columns:
csv_status.object = "❌ CSV must have an 'ac' column."
return
for col in CSV_COLUMNS:
if col not in df.columns:
df[col] = None
_csv["df"] = df[CSV_COLUMNS].copy()
config = state.edited_config
if config:
aircraft = config.setdefault("aircraft", {})
aircraft["dir"] = str(Path(path).parent)
aircraft["file"] = Path(path).name
state.dirty = True
state.param.trigger("edited_config")
state.aircraft_csv_dirty = False
csv_status.object = f"ℹ️ Loaded `{Path(path).name}`."
_rebuild_table()
def _on_open_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 aircraft CSV file",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
initialdir=state.working_dir or None,
)
root.destroy()
if selected:
_do_open_csv(selected)
def _on_new_click(_event=None):
_csv["df"] = _empty_csv_df()
config = state.edited_config
if config:
aircraft = config.setdefault("aircraft", {})
aircraft["dir"] = ""
aircraft["file"] = ""
state.dirty = True
state.param.trigger("edited_config")
state.aircraft_csv_dirty = False
csv_status.object = "ℹ️ Started a new blank aircraft CSV."
_rebuild_table()
def _request_open(_event=None):
if state.aircraft_csv_dirty:
pending_action["type"] = "open"
confirm_row.visible = True
else:
_on_open_click()
def _request_new(_event=None):
if state.aircraft_csv_dirty:
pending_action["type"] = "new"
confirm_row.visible = True
else:
_on_new_click()
def _on_confirm_yes(_event=None):
confirm_row.visible = False
action = pending_action["type"]
pending_action["type"] = None
if action == "open":
_on_open_click()
elif action == "new":
_on_new_click()
def _on_confirm_no(_event=None):
confirm_row.visible = False
pending_action["type"] = None
open_btn.on_click(_request_open)
new_btn.on_click(_request_new)
confirm_yes.on_click(_on_confirm_yes)
confirm_no.on_click(_on_confirm_no)
def _prompt_save_path():
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
selected = filedialog.asksaveasfilename(
title="Save aircraft CSV as",
defaultextension=".csv",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
initialdir=state.working_dir or None,
)
root.destroy()
return selected
def _save_csv_to(path):
out_df = _csv["df"][~_csv["df"]["ac"].apply(_is_blank)]
if out_df.empty:
csv_status.object = (
"⚠️ Nothing to save - no aircraft are currently sourced from "
"the CSV. Use \"Unlink CSV from config\" instead if you no "
"longer need a separate file."
)
return
try:
out_df.to_csv(path, index=False, columns=CSV_COLUMNS)
except Exception as e: # pylint: disable=broad-exception-caught
csv_status.object = f"❌ Failed to save: {e}"
return
config = state.edited_config
if config:
aircraft = config.setdefault("aircraft", {})
aircraft["dir"] = str(Path(path).parent)
aircraft["file"] = Path(path).name
state.dirty = True
state.param.trigger("edited_config")
state.aircraft_csv_dirty = False
csv_status.object = f"✅ Saved to `{path}`."
def _on_save_click(_event=None):
path = _current_csv_path()
if not path:
path = _prompt_save_path()
if not path:
return
_save_csv_to(path)
def _on_save_as_click(_event=None):
path = _prompt_save_path()
if path:
_save_csv_to(path)
save_btn.on_click(_on_save_click)
save_as_btn.on_click(_on_save_as_click)
def _on_unlink_click(_event=None):
"""Remove the CSV file reference from the config, leaving any
inline (source="config") aircraft data untouched.
Refuses if any row is still sourced from the CSV - unlinking
would silently orphan that data, since it only lives in the
locally-held CSV DataFrame and isn't written anywhere once the
config no longer points at a file.
"""
config = state.edited_config
if not config:
return
aircraft = config.get("aircraft", {})
if not (aircraft.get("dir") or aircraft.get("file")):
csv_status.object = "ℹ️ No CSV file is currently linked."
return
still_csv = sorted(
row["ac"] for _, row in table.value.iterrows()
if row["source"] == "csv" and not _is_blank(row.get("ac"))
)
if still_csv:
csv_status.object = (
"⚠️ Cannot unlink - still sourced from the CSV: "
f"{', '.join(still_csv)}. Switch their source to "
'"config" first.'
)
return
aircraft["dir"] = ""
aircraft["file"] = ""
_csv["df"] = _empty_csv_df()
state.aircraft_csv_dirty = False
state.dirty = True
state.param.trigger("edited_config")
csv_status.object = "✅ CSV file reference removed from the configuration."
_rebuild_table()
unlink_btn.on_click(_on_unlink_click)
# ── config change watchers ───────────────────────────────────────
def _on_config_generation_changed(_event=None):
"""A fresh config was loaded/created - reset the local CSV state
and rebuild the table from scratch, auto-loading the config's
referenced aircraft CSV file if it already points at one."""
_csv["df"] = _empty_csv_df()
state.aircraft_csv_dirty = False
csv_status.object = ""
path = _current_csv_path()
if path and Path(path).exists():
try:
df = pd.read_csv(path)
df.columns = df.columns.str.strip()
for col in CSV_COLUMNS:
if col not in df.columns:
df[col] = None
if "ac" in df.columns:
_csv["df"] = df[CSV_COLUMNS].copy()
csv_status.object = f"ℹ️ Loaded `{Path(path).name}`."
except Exception as e: # pylint: disable=broad-exception-caught
csv_status.object = f"⚠️ Could not auto-load aircraft CSV: {e}"
if state.edited_config is None:
status_pane.object = "⚠️ Create or load a configuration first."
else:
status_pane.object = ""
_rebuild_table()
state.param.watch(_on_config_generation_changed, "config_generation")
if state.edited_config is None:
status_pane.object = "⚠️ Create or load a configuration first."
else:
_on_config_generation_changed()
# ── layout ────────────────────────────────────────────────────────
card_table = pn.Card(
status_pane,
pn.Row(add_row_btn, delete_row_btn, calculate_btn, check_btn),
table,
check_status,
title="Aircraft data",
collapsible=False,
sizing_mode="stretch_width",
)
return pn.Column(
pn.pane.Markdown(TITLE),
pn.Row(open_btn, new_btn),
pn.Row(save_btn, save_as_btn, unlink_btn),
confirm_row,
csv_status,
card_table,
sizing_mode="stretch_width",
styles={"gap": "10px", "margin-top": "15px"},
)