Source code for pysotope.standards_manager.editor

from pathlib import Path

import numpy as np
import pandas as pd
import ipywidgets as widgets
from IPython.display import clear_output, display


HERE = Path(__file__).resolve().parent
DATA_DIR = HERE / "data"
DATA_DIR.mkdir(exist_ok=True, parents=True)

ISO_FILENAMES = {"rs_dc.csv", "rs_dd.csv"}

DEFAULT_VSMOW = {
    "dD": {
        "ID": ["C18", "C20", "C28", "C24"],
        "type": ["drift", "linearity", "linearity", "drift"],
        "chain length": ["C18", "C20", "C28", "C24"],
        "isotope value": [-206.2, -166.7, -89.28, -179.3],
        "std": [1.7, 0.3, 1.0627, 1.7],
        "n": [5, 3, 924, 5],
        "RS accuracy check": ["False", "False", "False", "True"],
        "Use as Standard": ["True", "True", "True", "True"],
    },
    "dC": {
        "ID": ["C18", "C20", "C24", "C28"],
        "type": ["drift", "linearity", "drift", "linearity"],
        "chain length": ["C18", "C20", "C24", "C28"],
        "isotope value": [-23.24, -30.68, -26.57, -27.35793034],
        "std": [0.01, 0.02, 0.02, 0.26315684],
        "n": [5, 3, 5, 89],
        "RS accuracy check": ["False", "False", "False", "True"],
        "Use as Standard": ["True", "True", "True", "True"],
    },
}

BOOL_COLS = {
    "RS accuracy check",
    "Use as Standard",
    "Linearity Standard",
}

CELL_LAYOUT = widgets.Layout(width="140px")
TABLE_LAYOUT = widgets.Layout(overflow_x="auto")

def _get_bool_cols_for_df(df: pd.DataFrame) -> set[str]:
    return BOOL_COLS.intersection(df.columns)

def list_standard_files():
    return sorted(path.name for path in DATA_DIR.glob("*.csv"))

def get_standard_path(filename: str) -> Path:
    return DATA_DIR / filename

def _is_iso_file(path: Path) -> bool:
    return path.name.lower() in ISO_FILENAMES

def _ensure_default_iso_file(path: Path):
    key = "dD" if path.name.lower() == "rs_dd.csv" else "dC"
    if not path.exists():
        pd.DataFrame(DEFAULT_VSMOW[key]).to_csv(path, index=False)

def _coerce_bool_series(series: pd.Series) -> pd.Series:
    return (
        series.astype(str)
        .str.strip()
        .str.lower()
        .isin(["true", "1", "yes", "y", "t"])
    )

# def _make_widget_for_value(val, is_bool=False):
#     if is_bool:
#         if isinstance(val, str):
#             checked = val.strip().lower() == "true"
#         else:
#             checked = bool(val) if not pd.isna(val) else False
#         return widgets.Checkbox(value=checked, layout=CELL_LAYOUT)
#
#     if pd.isna(val):
#         return widgets.Text(value="", layout=CELL_LAYOUT)
#     if isinstance(val, (int, np.integer)):
#         return widgets.IntText(value=int(val), layout=CELL_LAYOUT)
#     if isinstance(val, (float, np.floating)):
#         return widgets.FloatText(value=float(val), layout=CELL_LAYOUT)
#     return widgets.Text(value=str(val), layout=CELL_LAYOUT)

def _make_widget_for_value(val, is_bool=False):
    if is_bool:
        checked = bool(val) if not pd.isna(val) else False
        return widgets.Checkbox(value=checked, layout=CELL_LAYOUT)
    if pd.isna(val):
        return widgets.Text(value="", layout=CELL_LAYOUT)
    if isinstance(val, (int, np.integer)):
        return widgets.IntText(value=int(val), layout=CELL_LAYOUT)
    if isinstance(val, (float, np.floating)):
        return widgets.FloatText(value=float(val), layout=CELL_LAYOUT)
    return widgets.Text(value=str(val), layout=CELL_LAYOUT)

def _make_blank_widget_for_column(df: pd.DataFrame, col: str, bool_cols):
    if col in bool_cols:
        return widgets.Checkbox(value=False, layout=CELL_LAYOUT)

    ser_nonnull = df[col].dropna()
    if len(ser_nonnull) > 0 and pd.api.types.is_numeric_dtype(ser_nonnull):
        if pd.api.types.is_integer_dtype(ser_nonnull):
            return widgets.IntText(value=0, layout=CELL_LAYOUT)
        return widgets.FloatText(value=float("nan"), layout=CELL_LAYOUT)
    return widgets.Text(value="", layout=CELL_LAYOUT)

# def _build_editor(path: Path, df: pd.DataFrame, bool_cols=None):
#     bool_cols = set(bool_cols or [])
#     cell_widgets = []
#     row_boxes = []
#     table_box = widgets.VBox(layout=TABLE_LAYOUT)
#     message = widgets.Output()
#
#     def rebuild_table():
#         headers = [widgets.Label(str(c), layout=CELL_LAYOUT) for c in df.columns]
#         table_box.children = [widgets.HBox(headers)] + row_boxes
#
#     def add_row_widgets(row_widgets):
#         cell_widgets.append(row_widgets)
#         row_boxes.append(widgets.HBox(row_widgets))
#
#     for _, row in df.iterrows():
#         add_row_widgets([
#             _make_widget_for_value(row[col], is_bool=col in bool_cols)
#             for col in df.columns
#         ])
#
#     rebuild_table()
#
#     add_btn = widgets.Button(description="Add row")
#     save_btn = widgets.Button(description="Save", button_style="success")
#
#     def on_add_row(_):
#         add_row_widgets([
#             _make_blank_widget_for_column(df, col, bool_cols)
#             for col in df.columns
#         ])
#         rebuild_table()
#
#     def on_save(_):
#         data = {col: [] for col in df.columns}
#         for row_widget in cell_widgets:
#             for col, widget in zip(df.columns, row_widget):
#                 value = widget.value
#                 if isinstance(value, str) and value.strip() == "":
#                     value = np.nan
#                 data[col].append(value)
#
#         new_df = pd.DataFrame(data, columns=df.columns)
#         for col in bool_cols.intersection(new_df.columns):
#             new_df[col] = _coerce_bool_series(new_df[col])
#
#         new_df.to_csv(path, index=False)
#         with message:
#             clear_output()
#             print(f"Saved {path.name}")
#
#     add_btn.on_click(on_add_row)
#     save_btn.on_click(on_save)
#
#     return widgets.VBox([
#         widgets.HBox([add_btn, save_btn]),
#         table_box,
#         message,
#     ])

def _build_editor(path: Path, df: pd.DataFrame, bool_cols=None):
    bool_cols = set(bool_cols or [])
    cell_widgets = []
    row_boxes = []
    table_box = widgets.VBox(layout=TABLE_LAYOUT)
    message = widgets.Output()
    DELETE_BUTTON_LAYOUT = widgets.Layout(width="32px")
    def rebuild_table():
        headers = [widgets.Label(str(c), layout=CELL_LAYOUT) for c in df.columns]
        spacer = widgets.Label("", layout=DELETE_BUTTON_LAYOUT)
        table_box.children = [widgets.HBox(headers + [spacer])] + row_boxes
    def delete_row(row_widgets, row_box):
        if row_widgets in cell_widgets:
            cell_widgets.remove(row_widgets)
        if row_box in row_boxes:
            row_boxes.remove(row_box)
        rebuild_table()
    def add_row_widgets(row_widgets):
        delete_btn = widgets.Button(
            description="×",
            tooltip="Delete row",
            button_style="danger",
            layout=DELETE_BUTTON_LAYOUT,
        )
        row_box = widgets.HBox(row_widgets + [delete_btn])
        delete_btn.on_click(lambda _, rw=row_widgets, rb=row_box: delete_row(rw, rb))
        cell_widgets.append(row_widgets)
        row_boxes.append(row_box)
    for _, row in df.iterrows():
        add_row_widgets([
            _make_widget_for_value(row[col], is_bool=col in bool_cols)
            for col in df.columns])
    rebuild_table()
    add_btn = widgets.Button(description="Add row")
    save_btn = widgets.Button(description="Save", button_style="success")
    def on_add_row(_):
        add_row_widgets([
            _make_blank_widget_for_column(df, col, bool_cols)
            for col in df.columns])
        rebuild_table()
    def on_save(_):
        data = {col: [] for col in df.columns}
        for row_widget in cell_widgets:
            for col, widget in zip(df.columns, row_widget):
                value = widget.value
                if isinstance(value, str) and value.strip() == "":
                    value = np.nan
                data[col].append(value)
        new_df = pd.DataFrame(data, columns=df.columns)
        for col in bool_cols.intersection(new_df.columns):
            new_df[col] = _coerce_bool_series(new_df[col])
        new_df.to_csv(path, index=False)
        with message:
            clear_output()
            print(f"Saved {path.name}")
    add_btn.on_click(on_add_row)
    save_btn.on_click(on_save)
    return widgets.VBox([
        widgets.HBox([add_btn, save_btn]),
        table_box,
        message,])

def _render_iso_editor(path: Path) -> widgets.Widget:
    _ensure_default_iso_file(path)
    df = pd.read_csv(path, dtype={"type": str, "chain length": str, "ID": str})
    # bool_cols = {"Use as Standard", "RS accuracy check", "Linearity Standard"}
    bool_cols = _get_bool_cols_for_df(df)
    for col in bool_cols:
        df[col] = _coerce_bool_series(df[col])
    return _build_editor(path, df, bool_cols=bool_cols)

def _render_general_editor(path: Path) -> widgets.Widget:
    df = pd.read_csv(path)
    bool_cols = _get_bool_cols_for_df(df)
    for col in bool_cols:
        df[col] = _coerce_bool_series(df[col])
    return _build_editor(path, df, bool_cols=bool_cols)

# def standard_editor() -> pd.DataFrame:
#     files = list_standard_files()
#     if not files:
#         raise FileNotFoundError(f"No standards CSV files found in {DATA_DIR}")
#
#     selector = widgets.Dropdown(options=files, description="File:", layout=widgets.Layout(width="360px"))
#     output = widgets.Output()
#
#     def render_selected(filename):
#         path = get_standard_path(filename)
#         editor = _render_iso_editor(path) if _is_iso_file(path) else _render_general_editor(path)
#         with output:
#             clear_output()
#             display(editor)
#
#     selector.observe(
#         lambda change: render_selected(change["new"]) if change["name"] == "value" else None,
#         names="value",
#     )
#     display(widgets.VBox([selector, output]))
#     render_selected(selector.value)
#     return pd.read_csv(get_standard_path(selector.value))
[docs] def standard_editor() -> None: files = list_standard_files() if not files: raise FileNotFoundError(f"No standards CSV files found in {DATA_DIR}") selector = widgets.Dropdown( options=files, description="File:", layout=widgets.Layout(width="360px"), ) output = widgets.Output() def render_selected(filename): path = get_standard_path(filename) editor = _render_iso_editor(path) if _is_iso_file(path) else _render_general_editor(path) with output: clear_output() display(editor) selector.observe( lambda change: render_selected(change["new"]) if change["name"] == "value" else None, names="value", ) display(widgets.VBox([selector, output])) render_selected(selector.value)