ipyrowtable 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipyrowtable/table.py ADDED
@@ -0,0 +1,785 @@
1
+ """RowTable: an editable table of elements with live computed outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import html
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import ipywidgets as widgets
11
+ import numpy as np
12
+
13
+ from .columns import Column, InputColumn, NodeColumn, OutputColumn
14
+ from .formatting import right_aligned
15
+ from .persistence import StateFile, StateFileError, default_state_file, persistence_enabled
16
+ from .units import UnitSystem
17
+
18
+ __all__ = ["RowTable", "TableInputs", "TableResults"]
19
+
20
+ _ENDS = ("first", "last")
21
+
22
+
23
+ @dataclass
24
+ class TableInputs:
25
+ """What your compute function receives. All values are in base units.
26
+
27
+ rows list of dicts, one per row (top to bottom): {input column key: value}
28
+ edges {NodeColumn key: (first, last)}; an end that isn't an input is None
29
+ params {parameter key: value}
30
+ """
31
+
32
+ rows: list
33
+ edges: dict
34
+ params: dict
35
+
36
+ def column(self, key) -> np.ndarray:
37
+ """One input column as an array over rows, top to bottom."""
38
+ return np.asarray([row[key] for row in self.rows])
39
+
40
+
41
+ @dataclass
42
+ class TableResults:
43
+ """The inputs and compute outputs of one recompute, with unit-aware accessors.
44
+
45
+ results[key] / results.base(key) any output, parameter, NodeColumn boundary pair or
46
+ input column, in base units
47
+ results.display(key) the same in the display units in use at the time
48
+ results.records() one dict per row, mirroring the table
49
+ """
50
+
51
+ inputs: TableInputs
52
+ outputs: dict
53
+ units: UnitSystem
54
+ quantities: dict
55
+ columns: tuple = field(default=())
56
+
57
+ def base(self, key):
58
+ for source in (self.outputs, self.inputs.params, self.inputs.edges):
59
+ if key in source:
60
+ return source[key]
61
+ if self.inputs.rows and key in self.inputs.rows[0]:
62
+ return self.inputs.column(key)
63
+ raise KeyError(key)
64
+
65
+ def __getitem__(self, key):
66
+ return self.base(key)
67
+
68
+ def __contains__(self, key):
69
+ try:
70
+ self.base(key)
71
+ except KeyError:
72
+ return False
73
+ return True
74
+
75
+ def display(self, key):
76
+ """The value of `key` converted to display units."""
77
+ return self.units.to_display(self.quantities.get(key), self.base(key))
78
+
79
+ def records(self, display: bool = True) -> list[dict]:
80
+ """One dict per row with every column's value, as the table shows it.
81
+
82
+ NodeColumns give the value at each row's bottom/outlet face. Handy for
83
+ ``pandas.DataFrame(results.records())``.
84
+ """
85
+ get = self.display if display else self.base
86
+ records = [{} for _ in self.inputs.rows]
87
+ for col in self.columns:
88
+ if isinstance(col, InputColumn):
89
+ values = get(col.key)
90
+ elif col.key in self.outputs:
91
+ values = get(col.key)
92
+ if isinstance(col, NodeColumn):
93
+ values = values[1:]
94
+ else:
95
+ values = [None] * len(records)
96
+ for record, value in zip(records, values, strict=True):
97
+ record[col.key] = value.item() if hasattr(value, "item") else value
98
+ return records
99
+
100
+
101
+ class RowTable(widgets.VBox):
102
+ """An editable table of rows with live computed outputs.
103
+
104
+ Parameters
105
+ ----------
106
+ columns : list of Column
107
+ Left to right. Input columns (ChoiceColumn, NumberColumn, TextColumn, or your own
108
+ InputColumn subclass) are edited by the user; OutputColumn and NodeColumn are filled
109
+ in by `compute`.
110
+ compute : callable(TableInputs) -> dict
111
+ Returns {column key: values} in base units: n values for each OutputColumn and n + 1
112
+ for each NodeColumn (including both ends). Extra keys are kept in the results. Raise
113
+ ValueError("message") to show a message instead of results.
114
+ rows : list of dict, optional
115
+ Initial rows as {input column key: value in the starting display units}; missing keys
116
+ use the column default. Defaults to `min_rows` rows of defaults (at least one).
117
+ edges : dict, optional
118
+ Initial NodeColumn boundary values {key: (first, last)} in display units; None keeps
119
+ the column default.
120
+ parameters : list of InputColumn, optional
121
+ Table-wide inputs shown above the table; `params` gives their initial display values.
122
+ unit_systems : dict or list of UnitSystem, optional
123
+ With more than one, a toggle is shown. Values are stored in base units, so switching
124
+ only changes what is displayed.
125
+ units : str, optional
126
+ Name of the starting unit system (default: the first).
127
+ leading_label : str
128
+ Text in the leading row above the first element (shown when there is a NodeColumn).
129
+ item_name : str
130
+ What a row is called, for "Add <item>" and "Remove <item>".
131
+ min_rows : int
132
+ Rows that can't be removed (at least 1 when there is a NodeColumn).
133
+ removable : bool
134
+ Show the ✕ column.
135
+ summary : callable(TableResults) -> str, optional
136
+ HTML shown under the table after each recompute.
137
+ output_quantities : dict, optional
138
+ {extra output key: quantity name}, so ``results.display(key)`` converts extra outputs.
139
+ persist : str, optional
140
+ Save the inputs (rows, boundary values, parameters and unit system) whenever they
141
+ change, under this key, and restore them the next time a table with the same key is
142
+ created. `rows`, `edges`, `params` and `units` are then the *initial* values, used
143
+ only when nothing usable has been saved, and by the Reset button. None (the default)
144
+ always starts from the initial values. The environment variable
145
+ IPYROWTABLE_PERSIST=off turns saving off for every table.
146
+ persist_file : str or Path, optional
147
+ Where to save. Default: `<notebook name>.ipyrowtable.json` beside the notebook, or
148
+ `ipyrowtable.json` in the working directory if the notebook can't be identified.
149
+
150
+ Attributes
151
+ ----------
152
+ results : TableResults or None
153
+ The latest results; None while the inputs are invalid.
154
+ error : Exception or None
155
+ Why the latest recompute failed, if it did.
156
+ grid, add_button, message, units_toggle : widgets
157
+ Sub-widgets, e.g. for styling. `units_toggle` is None with a single unit system.
158
+ reset_button, persist_note : widgets
159
+ Shown when saving is on; None otherwise.
160
+ persist_file : Path or None
161
+ The file inputs are saved to, when saving is on.
162
+ """
163
+
164
+ REMOVE_WIDTH = "34px"
165
+
166
+ def __init__(
167
+ self,
168
+ columns,
169
+ compute,
170
+ *,
171
+ rows=None,
172
+ edges=None,
173
+ parameters=(),
174
+ params=None,
175
+ unit_systems=None,
176
+ units=None,
177
+ leading_label="",
178
+ item_name="row",
179
+ min_rows=1,
180
+ removable=True,
181
+ summary=None,
182
+ output_quantities=None,
183
+ persist=None,
184
+ persist_file=None,
185
+ ):
186
+ super().__init__()
187
+ self._loading = True # no saving while the table is being built
188
+ self.add_class("ipyrowtable")
189
+ self.columns = list(columns)
190
+ _check_columns(self.columns)
191
+ self.compute = compute
192
+ self.removable = removable
193
+ self.results = None
194
+ self.error = None
195
+ self.callback_error = None
196
+ self._summary_fn = summary
197
+ self._callbacks = []
198
+ self._syncing = False # True while a unit change rewrites the inputs
199
+ self._rows = []
200
+
201
+ if unit_systems is None:
202
+ unit_systems = {"default": UnitSystem("default")}
203
+ elif not isinstance(unit_systems, dict):
204
+ unit_systems = {system.name: system for system in unit_systems}
205
+ self.unit_systems = dict(unit_systems)
206
+ u = self._units = self.unit_systems[units or next(iter(self.unit_systems))]
207
+
208
+ self._inputs = [c for c in self.columns if isinstance(c, InputColumn)]
209
+ self._outputs = [c for c in self.columns if isinstance(c, OutputColumn)]
210
+ self._nodes = [c for c in self.columns if isinstance(c, NodeColumn)]
211
+ self._params = list(parameters)
212
+ self.min_rows = max(min_rows, 1 if self._nodes else 0)
213
+ self._quantities = {c.key: c.quantity for c in self.columns + self._params if c.quantity}
214
+ self._quantities.update(output_quantities or {})
215
+ self._item_name = item_name
216
+
217
+ children = []
218
+
219
+ # ---- unit toggle
220
+ self.units_toggle = None
221
+ if len(self.unit_systems) > 1:
222
+ self.units_toggle = widgets.ToggleButtons(
223
+ options=list(self.unit_systems), value=u.name, description="Units:",
224
+ style={"button_width": "90px"},
225
+ )
226
+ self.units_toggle.add_class("ipyrowtable-units")
227
+ self.units_toggle.observe(self._on_units_toggle, names="value")
228
+ children.append(self.units_toggle)
229
+
230
+ # ---- parameters
231
+ params = params or {}
232
+ unknown = set(params) - {c.key for c in self._params}
233
+ if unknown:
234
+ raise KeyError(f"Unknown parameter(s): {sorted(unknown)}")
235
+ self._param_values, self._param_widgets, self._param_labels = {}, {}, {}
236
+ boxes = []
237
+ for col in self._params:
238
+ value = col.to_base(params[col.key], u) if col.key in params else col.default_value(u)
239
+ self._param_values[col.key] = value
240
+ w = col.create_widget(value, u)
241
+ w.add_class(f"ipyrowtable-param-{col.key}")
242
+ w.observe(lambda ch, c=col: self._on_param(c, ch.new), names="value")
243
+ label = widgets.HTML()
244
+ self._param_widgets[col.key], self._param_labels[col.key] = w, label
245
+ boxes.append(widgets.HBox(
246
+ [label, widgets.Box([w], layout=widgets.Layout(width=col.width))],
247
+ layout=widgets.Layout(margin="0 24px 4px 0", align_items="center"),
248
+ ))
249
+ if boxes:
250
+ children.append(widgets.HBox(boxes, layout=widgets.Layout(flex_flow="row wrap")))
251
+
252
+ # ---- boundary values of node columns
253
+ edges = edges or {}
254
+ unknown = set(edges) - {c.key for c in self._nodes}
255
+ if unknown:
256
+ raise KeyError(f"Unknown NodeColumn(s) in edges: {sorted(unknown)}")
257
+ self._edge_values, self._edge_widgets, self._leading_outputs = {}, {}, {}
258
+ for col in self._nodes:
259
+ given = (tuple(edges.get(col.key) or ()) + (None, None))[:2]
260
+ values, ends = [], {}
261
+ for end, spec in zip(_ENDS, given, strict=True):
262
+ value = widget = None
263
+ if end in col.inputs:
264
+ number = col.ends[end]
265
+ value = number.default_value(u) if spec is None else number.to_base(spec, u)
266
+ widget = number.create_widget(value, u)
267
+ widget.add_class(f"ipyrowtable-{col.key}-{end}")
268
+ widget.observe(
269
+ lambda ch, c=col, e=end: self._on_edge(c, e, ch.new), names="value"
270
+ )
271
+ values.append(value)
272
+ ends[end] = widget
273
+ self._edge_values[col.key], self._edge_widgets[col.key] = values, ends
274
+ if ends["first"] is None:
275
+ self._leading_outputs[col.key] = widgets.HTML()
276
+ self._leading_outputs[col.key].add_class(f"ipyrowtable-{col.key}")
277
+
278
+ # ---- header and leading row
279
+ self._headers = {c.key: widgets.HTML() for c in self.columns}
280
+ for header in self._headers.values():
281
+ header.add_class("ipyrowtable-header")
282
+ self._corner = [widgets.HTML(), widgets.HTML()] if removable else []
283
+ self._leading = []
284
+ if self._nodes:
285
+ label_col = next((c for c in self.columns if not isinstance(c, NodeColumn)), None)
286
+ for col in self.columns:
287
+ if isinstance(col, NodeColumn):
288
+ cell = self._edge_widgets[col.key]["first"] or self._leading_outputs[col.key]
289
+ elif col is label_col and leading_label:
290
+ cell = widgets.HTML(f"<i style='color:gray'>{html.escape(leading_label)}</i>")
291
+ else:
292
+ cell = widgets.HTML()
293
+ self._leading.append(cell)
294
+
295
+ # ---- grid, add button, message line
296
+ widths = ([self.REMOVE_WIDTH] if removable else []) + [c.width for c in self.columns]
297
+ self.grid = widgets.GridBox(layout=widgets.Layout(
298
+ grid_template_columns=" ".join(widths), grid_gap="4px 10px", align_items="center",
299
+ ))
300
+ self.grid.add_class("ipyrowtable-grid")
301
+ self.add_button = widgets.Button(description=f"Add {item_name}", icon="plus")
302
+ self.add_button.add_class("ipyrowtable-add")
303
+ self.add_button.on_click(lambda _: self.add_row())
304
+ self.message = widgets.HTML()
305
+ self.message.add_class("ipyrowtable-message")
306
+
307
+ self.persist_key = persist if persist and persistence_enabled() else None
308
+ self._store = None
309
+ self.reset_button = self.persist_note = None
310
+ buttons = [self.add_button]
311
+ if self.persist_key:
312
+ self._store = StateFile(persist_file if persist_file else default_state_file())
313
+ self.reset_button = widgets.Button(
314
+ description="Reset", icon="undo", tooltip="Go back to the initial values"
315
+ )
316
+ self.reset_button.add_class("ipyrowtable-reset")
317
+ self.reset_button.on_click(self._on_reset_click)
318
+ self._reset_armed = False
319
+ buttons.append(self.reset_button)
320
+ self.persist_note = widgets.HTML()
321
+ self.persist_note.add_class("ipyrowtable-persist")
322
+ self._backup_pending = False
323
+ self.children = children + [
324
+ self.grid,
325
+ widgets.HBox(buttons) if len(buttons) > 1 else self.add_button,
326
+ self.message,
327
+ *([self.persist_note] if self.persist_note is not None else []),
328
+ ]
329
+
330
+ if rows is None:
331
+ rows = [{}] * max(self.min_rows, 1)
332
+ elif len(rows) < self.min_rows:
333
+ raise ValueError(f"Need at least {self.min_rows} row(s); got {len(rows)}.")
334
+ for values in rows:
335
+ self._append_row(values)
336
+ self._update_labels()
337
+ self._rebuild()
338
+
339
+ self._initial_state = copy.deepcopy(self.get_inputs())
340
+ if self._store is not None:
341
+ self._restore()
342
+ self._loading = False
343
+
344
+ # ----------------------------------------------------------------- public API
345
+
346
+ @property
347
+ def units(self) -> UnitSystem:
348
+ """The current UnitSystem. Assign a unit-system name to switch (same as the toggle)."""
349
+ return self._units
350
+
351
+ @units.setter
352
+ def units(self, name):
353
+ name = getattr(name, "name", name)
354
+ if name not in self.unit_systems:
355
+ raise KeyError(f"Unknown unit system {name!r}; have {list(self.unit_systems)}")
356
+ if self.units_toggle is not None:
357
+ self.units_toggle.value = name
358
+ else:
359
+ self._set_units(name)
360
+
361
+ def __len__(self):
362
+ return len(self._rows)
363
+
364
+ def add_row(self, values=None) -> int:
365
+ """Append a row and return its index.
366
+
367
+ `values` maps input column keys to values in the current display units; missing
368
+ keys use the column defaults.
369
+ """
370
+ self._append_row(values)
371
+ self._rebuild()
372
+ return len(self._rows) - 1
373
+
374
+ def add_rows(self, rows) -> None:
375
+ """Append several rows (each a dict like `add_row` takes), recomputing once."""
376
+ for values in rows:
377
+ self._append_row(values)
378
+ self._rebuild()
379
+
380
+ def _append_row(self, values):
381
+ """Append a row given display-unit values (missing keys use defaults)."""
382
+ u = self._units
383
+ values = values or {}
384
+ unknown = set(values) - {c.key for c in self._inputs}
385
+ if unknown:
386
+ raise KeyError(f"Unknown input column(s): {sorted(unknown)}")
387
+ self._append_row_base({
388
+ c.key: c.to_base(values[c.key], u) if c.key in values else c.default_value(u)
389
+ for c in self._inputs
390
+ })
391
+
392
+ def _append_row_base(self, base_values):
393
+ """Append a row given a complete dict of base-unit values."""
394
+ u = self._units
395
+ row = {"values": {}, "widgets": {}, "outputs": {}}
396
+ for col in self._inputs:
397
+ value = base_values[col.key]
398
+ widget = col.create_widget(value, u)
399
+ widget.add_class(f"ipyrowtable-{col.key}")
400
+ widget.observe(lambda ch, r=row, c=col: self._on_row_input(r, c, ch.new), names="value")
401
+ row["values"][col.key], row["widgets"][col.key] = value, widget
402
+ for col in self._outputs:
403
+ row["outputs"][col.key] = widgets.HTML()
404
+ row["outputs"][col.key].add_class(f"ipyrowtable-{col.key}")
405
+ row["remove"] = widgets.Button(
406
+ description="✕",
407
+ tooltip=f"Remove {self._item_name}",
408
+ layout=widgets.Layout(width="32px"),
409
+ )
410
+ row["remove"].add_class("ipyrowtable-remove")
411
+ row["remove"].on_click(lambda _, r=row: self.remove_row(r))
412
+ self._rows.append(row)
413
+
414
+ def remove_row(self, row) -> bool:
415
+ """Remove a row by index (0 = top). Returns False if only `min_rows` rows remain."""
416
+ if isinstance(row, int):
417
+ row = self._rows[row]
418
+ if len(self._rows) <= self.min_rows:
419
+ return False
420
+ self._rows.remove(row)
421
+ _close_row(row)
422
+ self._rebuild()
423
+ return True
424
+
425
+ @property
426
+ def rows(self) -> list[dict]:
427
+ """The input rows as dicts of display-unit values, top to bottom."""
428
+ return [
429
+ {c.key: c.to_display(r["values"][c.key], self._units) for c in self._inputs}
430
+ for r in self._rows
431
+ ]
432
+
433
+ @property
434
+ def params(self) -> dict:
435
+ """Parameter values in display units."""
436
+ return {c.key: c.to_display(self._param_values[c.key], self._units) for c in self._params}
437
+
438
+ def cell(self, row: int, key: str):
439
+ """The widget shown for column `key` in row `row` (an input box or an output HTML).
440
+
441
+ For a NodeColumn with an editable last end, the last row's cell is that input.
442
+ """
443
+ r = self._rows[row]
444
+ if key in r["widgets"]:
445
+ return r["widgets"][key]
446
+ last = self._edge_widgets.get(key, {}).get("last")
447
+ if last is not None and r is self._rows[-1]:
448
+ return last
449
+ return r["outputs"][key]
450
+
451
+ def param_widget(self, key: str):
452
+ """The input widget for a parameter."""
453
+ return self._param_widgets[key]
454
+
455
+ def edge_widget(self, key: str, end: str = "first"):
456
+ """The input widget for one end ("first" or "last") of a NodeColumn, or None if that
457
+ end is an output."""
458
+ return self._edge_widgets[key][end]
459
+
460
+ def on_update(self, callback):
461
+ """Call callback(results) after every change; results is None while inputs are invalid.
462
+
463
+ The callback is also called once right away.
464
+ """
465
+ self._callbacks.append(callback)
466
+ callback(self.results)
467
+
468
+ def recompute(self):
469
+ """Run compute again, e.g. after changing data your compute function reads."""
470
+ self._recompute()
471
+
472
+ # ----------------------------------------------------------------- state
473
+
474
+ def get_inputs(self) -> dict:
475
+ """A JSON-ready snapshot of the inputs, in base units.
476
+
477
+ {"units": name, "rows": [{key: value}], "edges": {key: [first, last]},
478
+ "params": {key: value}}; an end of a NodeColumn that isn't an input is None.
479
+ """
480
+ return {
481
+ "units": self._units.name,
482
+ "rows": [{k: _plain(v) for k, v in r["values"].items()} for r in self._rows],
483
+ "edges": {k: [_plain(v) for v in values] for k, values in self._edge_values.items()},
484
+ "params": {k: _plain(v) for k, v in self._param_values.items()},
485
+ }
486
+
487
+ def set_inputs(self, state: dict) -> None:
488
+ """Load a snapshot from `get_inputs` (or a saved file).
489
+
490
+ Keys for columns that no longer exist are ignored, and missing ones get defaults.
491
+ Raises ValueError, leaving the table unchanged, if a value can't be used.
492
+ """
493
+ self._apply_state(self._validate_state(state))
494
+
495
+ def reset(self) -> None:
496
+ """Go back to the initial rows, boundary values, parameters and unit system."""
497
+ self._apply_state(copy.deepcopy(self._initial_state))
498
+ if self.persist_note is not None:
499
+ self._note("Reset to the initial values.")
500
+
501
+ @property
502
+ def persist_file(self) -> Path | None:
503
+ return self._store.path if self._store is not None else None
504
+
505
+ # ----------------------------------------------------------------- event handlers
506
+
507
+ def _on_row_input(self, row, col, value):
508
+ if not self._syncing:
509
+ row["values"][col.key] = col.to_base(value, self._units)
510
+ self._recompute()
511
+
512
+ def _on_param(self, col, value):
513
+ if not self._syncing:
514
+ self._param_values[col.key] = col.to_base(value, self._units)
515
+ self._recompute()
516
+
517
+ def _on_edge(self, col, end, value):
518
+ if not self._syncing:
519
+ self._edge_values[col.key][_ENDS.index(end)] = col.ends[end].to_base(value, self._units)
520
+ self._recompute()
521
+
522
+ def _on_units_toggle(self, change):
523
+ if not self._syncing:
524
+ self._set_units(change.new)
525
+
526
+ def _set_units(self, name):
527
+ self._units = self.unit_systems[name]
528
+ self._refresh_widgets()
529
+ self._update_labels()
530
+ self._recompute()
531
+
532
+ def _refresh_widgets(self):
533
+ """Show every stored (base) value in its widget, in the current units."""
534
+ u = self._units
535
+ self._syncing = True
536
+ try:
537
+ if self.units_toggle is not None:
538
+ self.units_toggle.value = u.name
539
+ for row in self._rows:
540
+ for col in self._inputs:
541
+ col.refresh(row["widgets"][col.key], row["values"][col.key], u)
542
+ for col in self._params:
543
+ col.refresh(self._param_widgets[col.key], self._param_values[col.key], u)
544
+ for col in self._nodes:
545
+ for index, end in enumerate(_ENDS):
546
+ widget = self._edge_widgets[col.key][end]
547
+ if widget is not None:
548
+ col.ends[end].refresh(widget, self._edge_values[col.key][index], u)
549
+ finally:
550
+ self._syncing = False
551
+
552
+ def _validate_state(self, state) -> dict:
553
+ """Check a snapshot and fill gaps; returns a complete snapshot or raises ValueError."""
554
+ if not isinstance(state, dict):
555
+ raise ValueError("the saved inputs aren't a table")
556
+ name = state.get("units") # an unknown or missing unit system means the initial one
557
+ units = self.unit_systems.get(name) or self.unit_systems[self._initial_state["units"]]
558
+ rows = state.get("rows")
559
+ if not isinstance(rows, list):
560
+ raise ValueError("the saved inputs have no list of rows")
561
+ if len(rows) < max(self.min_rows, 1):
562
+ raise ValueError(f"need at least {max(self.min_rows, 1)} row(s), found {len(rows)}")
563
+ clean_rows = []
564
+ for i, row in enumerate(rows, start=1):
565
+ if not isinstance(row, dict):
566
+ raise ValueError(f"row {i} isn't a set of column values")
567
+ clean = {}
568
+ for col in self._inputs:
569
+ try:
570
+ clean[col.key] = (col.validate(row[col.key]) if col.key in row
571
+ else col.default_value(units))
572
+ except ValueError as exc:
573
+ raise ValueError(f"row {i}, {exc}") from None
574
+ clean_rows.append(clean)
575
+
576
+ saved_edges = state.get("edges") or {}
577
+ if not isinstance(saved_edges, dict):
578
+ raise ValueError("the saved boundary values aren't a mapping")
579
+ clean_edges = {}
580
+ for col in self._nodes:
581
+ given = saved_edges.get(col.key)
582
+ if given is not None and (not isinstance(given, list) or len(given) != 2):
583
+ raise ValueError(f"{col.key}: expected [first, last] boundary values")
584
+ values = []
585
+ for index, end in enumerate(_ENDS):
586
+ if end not in col.inputs:
587
+ values.append(None)
588
+ elif given is None or given[index] is None:
589
+ values.append(col.ends[end].default_value(units))
590
+ else:
591
+ values.append(col.ends[end].validate(given[index]))
592
+ clean_edges[col.key] = values
593
+
594
+ saved_params = state.get("params") or {}
595
+ if not isinstance(saved_params, dict):
596
+ raise ValueError("the saved parameters aren't a mapping")
597
+ clean_params = {
598
+ col.key: (col.validate(saved_params[col.key]) if col.key in saved_params
599
+ else col.default_value(units))
600
+ for col in self._params
601
+ }
602
+ return {"units": units.name, "rows": clean_rows, "edges": clean_edges,
603
+ "params": clean_params}
604
+
605
+ def _apply_state(self, state):
606
+ """Replace everything with a validated snapshot, then lay out and recompute."""
607
+ self._units = self.unit_systems[state["units"]]
608
+ for row in self._rows:
609
+ _close_row(row)
610
+ self._rows = []
611
+ for values in state["rows"]:
612
+ self._append_row_base(values)
613
+ for key, values in state["edges"].items():
614
+ self._edge_values[key] = list(values)
615
+ self._param_values.update(state["params"])
616
+ self._refresh_widgets()
617
+ self._update_labels()
618
+ self._rebuild()
619
+
620
+ # ----------------------------------------------------------------- saving
621
+
622
+ def _restore(self):
623
+ where = self._file_label()
624
+ try:
625
+ saved = self._store.load(self.persist_key)
626
+ if saved is None:
627
+ self._note(f"Inputs will be saved to {where} as “{html.escape(self.persist_key)}”.")
628
+ return
629
+ self._apply_state(self._validate_state(saved))
630
+ except (StateFileError, ValueError) as exc:
631
+ self._backup_pending = True
632
+ self._note(f"Couldn't restore the saved inputs ({html.escape(str(exc))}), so the "
633
+ "table starts from its initial values. The old file will be kept as a "
634
+ ".bak copy when you make a change.", warning=True)
635
+ return
636
+ when = saved.get("saved", "")
637
+ when = f" (saved {html.escape(when[:16].replace('T', ' '))})" if when else ""
638
+ self._note(f"Restored your saved inputs from {where}{when}.")
639
+
640
+ def _autosave(self):
641
+ if self._store is None or self._loading:
642
+ return
643
+ try:
644
+ if self._backup_pending:
645
+ backup = self._store.backup()
646
+ self._backup_pending = False
647
+ kept = f" The old file was kept as {html.escape(backup.name)}." if backup else ""
648
+ else:
649
+ kept = ""
650
+ saved = self._store.save(self.persist_key, self.get_inputs())
651
+ except Exception as exc:
652
+ self._note(f"Couldn't save inputs: {html.escape(f'{type(exc).__name__}: {exc}')}",
653
+ warning=True)
654
+ return
655
+ self._note(f"Saved to {self._file_label()} at {saved[11:19]}.{kept}")
656
+
657
+ def _file_label(self):
658
+ path = self._store.path
659
+ return f"<span title='{html.escape(str(path))}'>{html.escape(path.name)}</span>"
660
+
661
+ def _note(self, text, warning=False):
662
+ color = "#b9770e" if warning else "gray"
663
+ self.persist_note.value = f"<span style='color:{color}; font-size:0.85em'>{text}</span>"
664
+
665
+ def _on_reset_click(self, _):
666
+ if not self._reset_armed:
667
+ self._reset_armed = True
668
+ self.reset_button.description = "Confirm reset"
669
+ self.reset_button.button_style = "warning"
670
+ return
671
+ self.reset()
672
+
673
+ def _disarm_reset(self):
674
+ if self.reset_button is not None and self._reset_armed:
675
+ self._reset_armed = False
676
+ self.reset_button.description = "Reset"
677
+ self.reset_button.button_style = ""
678
+
679
+ # ----------------------------------------------------------------- internals
680
+
681
+ def _update_labels(self):
682
+ for col in self.columns:
683
+ self._headers[col.key].value = f"<b>{col.header_text(self._units)}</b>"
684
+ for col in self._params:
685
+ self._param_labels[col.key].value = col.header_text(self._units)
686
+
687
+ def _rebuild(self):
688
+ """Lay out the grid. For a NodeColumn with an editable last end, the last row shows it."""
689
+ cells = self._corner[:1] + [self._headers[c.key] for c in self.columns]
690
+ if self._nodes:
691
+ cells += self._corner[1:] + self._leading
692
+ last = len(self._rows) - 1
693
+ for i, row in enumerate(self._rows):
694
+ if self.removable:
695
+ row["remove"].disabled = len(self._rows) <= self.min_rows
696
+ cells.append(row["remove"])
697
+ for col in self.columns:
698
+ if isinstance(col, InputColumn):
699
+ cells.append(row["widgets"][col.key])
700
+ elif (isinstance(col, NodeColumn) and i == last
701
+ and self._edge_widgets[col.key]["last"] is not None):
702
+ cells.append(self._edge_widgets[col.key]["last"])
703
+ else:
704
+ cells.append(row["outputs"][col.key])
705
+ self.grid.children = cells
706
+ self._recompute()
707
+
708
+ def _recompute(self):
709
+ u = self._units
710
+ n = len(self._rows)
711
+ inputs = TableInputs(
712
+ rows=[dict(r["values"]) for r in self._rows],
713
+ edges={key: tuple(values) for key, values in self._edge_values.items()},
714
+ params=dict(self._param_values),
715
+ )
716
+ try:
717
+ outputs = dict(self.compute(inputs) or {})
718
+ results = TableResults(inputs, outputs, u, self._quantities, tuple(self.columns))
719
+ for col in self._outputs:
720
+ values = outputs.get(col.key)
721
+ is_node = isinstance(col, NodeColumn)
722
+ if values is not None and len(values) != n + is_node:
723
+ raise ValueError(
724
+ f"compute returned {len(values)} values for {col.key!r}; "
725
+ f"expected {n + is_node}."
726
+ )
727
+ if is_node and col.key in self._leading_outputs:
728
+ self._leading_outputs[col.key].value = (
729
+ "" if values is None else col.render(values[0], u)
730
+ )
731
+ row_values = values[1:] if (is_node and values is not None) else values
732
+ for i, row in enumerate(self._rows):
733
+ row["outputs"][col.key].value = (
734
+ "" if values is None else col.render(row_values[i], u)
735
+ )
736
+ self.results, self.error = results, None
737
+ self.message.value = self._summary_fn(results) if self._summary_fn else ""
738
+ except Exception as exc: # show the problem in the table rather than a hidden traceback
739
+ self._show_error(exc)
740
+ self._notify()
741
+ self._disarm_reset()
742
+ self._autosave()
743
+
744
+ def _show_error(self, exc):
745
+ self.results, self.error = None, exc
746
+ dash = right_aligned("—")
747
+ for row in self._rows:
748
+ for widget in row["outputs"].values():
749
+ widget.value = dash
750
+ for widget in self._leading_outputs.values():
751
+ widget.value = dash
752
+ text = str(exc) if isinstance(exc, ValueError) else f"{type(exc).__name__}: {exc}"
753
+ self.message.value = f"<span style='color:#c0392b'>{html.escape(text)}</span>"
754
+
755
+ def _notify(self):
756
+ self.callback_error = None
757
+ for callback in self._callbacks:
758
+ try:
759
+ callback(self.results)
760
+ except Exception as exc:
761
+ self.callback_error = exc
762
+ self.message.value += (
763
+ "<br><span style='color:#c0392b'>Update callback failed: "
764
+ f"{html.escape(f'{type(exc).__name__}: {exc}')}</span>"
765
+ )
766
+
767
+
768
+ def _close_row(row):
769
+ for widget in [*row["widgets"].values(), *row["outputs"].values(), row["remove"]]:
770
+ widget.close()
771
+
772
+
773
+ def _plain(value):
774
+ """numpy scalars to Python ones, so snapshots are JSON-ready."""
775
+ return value.item() if hasattr(value, "item") else value
776
+
777
+
778
+ def _check_columns(columns):
779
+ for col in columns:
780
+ if not isinstance(col, Column):
781
+ raise TypeError(f"{col!r} is not a Column.")
782
+ keys = [c.key for c in columns]
783
+ duplicates = sorted({k for k in keys if keys.count(k) > 1})
784
+ if duplicates:
785
+ raise ValueError(f"Duplicate column key(s): {duplicates}")