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/__init__.py +53 -0
- ipyrowtable/columns.py +265 -0
- ipyrowtable/examples/__init__.py +4 -0
- ipyrowtable/examples/conduction.py +340 -0
- ipyrowtable/figure.py +64 -0
- ipyrowtable/formatting.py +40 -0
- ipyrowtable/layout.py +16 -0
- ipyrowtable/persistence.py +160 -0
- ipyrowtable/table.py +785 -0
- ipyrowtable/units.py +71 -0
- ipyrowtable-0.1.1.dist-info/METADATA +303 -0
- ipyrowtable-0.1.1.dist-info/RECORD +14 -0
- ipyrowtable-0.1.1.dist-info/WHEEL +4 -0
- ipyrowtable-0.1.1.dist-info/licenses/LICENSE +21 -0
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}")
|