samplekit 0.1.0__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.
- samplekit/__init__.py +20 -0
- samplekit/converters.py +117 -0
- samplekit/property.py +332 -0
- samplekit/report.py +214 -0
- samplekit/sample.py +320 -0
- samplekit/sample_list.py +190 -0
- samplekit/table.py +612 -0
- samplekit-0.1.0.dist-info/METADATA +422 -0
- samplekit-0.1.0.dist-info/RECORD +12 -0
- samplekit-0.1.0.dist-info/WHEEL +5 -0
- samplekit-0.1.0.dist-info/licenses/LICENSE +8 -0
- samplekit-0.1.0.dist-info/top_level.txt +1 -0
samplekit/table.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Table — tabular scientific data indexed by a parameter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .sample import Sample
|
|
9
|
+
|
|
10
|
+
from .property import Property
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Column:
|
|
14
|
+
"""Column metadata for a Table.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
unit : str
|
|
19
|
+
Plain-text unit.
|
|
20
|
+
unit_math : str, optional
|
|
21
|
+
Math-mode unit for MathJax/LaTeX (defaults to unit).
|
|
22
|
+
symbol : str, optional
|
|
23
|
+
Text/unicode symbol for CLI/TUI display (defaults to column name).
|
|
24
|
+
symbol_math : str, optional
|
|
25
|
+
Math-mode symbol for MathJax/LaTeX (defaults to symbol).
|
|
26
|
+
precision : str
|
|
27
|
+
Format spec for values (default "").
|
|
28
|
+
precision_unc : str, optional
|
|
29
|
+
Format spec for uncertainties (defaults to precision).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
unit: str = "",
|
|
35
|
+
unit_math: str | None = None,
|
|
36
|
+
symbol: str | None = None,
|
|
37
|
+
symbol_math: str | None = None,
|
|
38
|
+
precision: str = "",
|
|
39
|
+
precision_unc: str | None = None,
|
|
40
|
+
):
|
|
41
|
+
self.unit = unit
|
|
42
|
+
self.unit_math = unit_math or unit
|
|
43
|
+
self.symbol = symbol
|
|
44
|
+
self.symbol_math = symbol_math
|
|
45
|
+
self.precision = precision
|
|
46
|
+
self.precision_unc = precision_unc or precision
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
parts = []
|
|
50
|
+
if self.symbol:
|
|
51
|
+
parts.append(f"symbol={self.symbol!r}")
|
|
52
|
+
if self.symbol_math and self.symbol_math != self.symbol:
|
|
53
|
+
parts.append(f"symbol_math={self.symbol_math!r}")
|
|
54
|
+
if self.unit:
|
|
55
|
+
parts.append(f"unit={self.unit!r}")
|
|
56
|
+
return f"Column({', '.join(parts)})"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ════════════════════════════════════════════════════════════
|
|
60
|
+
# RowView — lightweight proxy for row-level access
|
|
61
|
+
# ════════════════════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
class RowView:
|
|
64
|
+
"""Read-only view of a single row in a Table.
|
|
65
|
+
|
|
66
|
+
Access cell Properties via attribute: ``row.f``, ``row.Q``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, table: Table, index_val: Any):
|
|
70
|
+
object.__setattr__(self, '_table', table)
|
|
71
|
+
object.__setattr__(self, '_index_val', index_val)
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, name: str) -> Property:
|
|
74
|
+
table = object.__getattribute__(self, '_table')
|
|
75
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
76
|
+
row = table._data.get(idx)
|
|
77
|
+
if row is not None and name in row:
|
|
78
|
+
return row[name]
|
|
79
|
+
raise AttributeError(f"Row has no column {name!r}")
|
|
80
|
+
|
|
81
|
+
def __getitem__(self, name: str) -> Property:
|
|
82
|
+
table = object.__getattribute__(self, '_table')
|
|
83
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
84
|
+
row = table._data.get(idx)
|
|
85
|
+
if row is not None and name in row:
|
|
86
|
+
return row[name]
|
|
87
|
+
raise KeyError(name)
|
|
88
|
+
|
|
89
|
+
def __contains__(self, name: str) -> bool:
|
|
90
|
+
table = object.__getattribute__(self, '_table')
|
|
91
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
92
|
+
row = table._data.get(idx)
|
|
93
|
+
return row is not None and name in row
|
|
94
|
+
|
|
95
|
+
def __iter__(self):
|
|
96
|
+
table = object.__getattribute__(self, '_table')
|
|
97
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
98
|
+
return iter(table._data.get(idx, {}))
|
|
99
|
+
|
|
100
|
+
def keys(self) -> list[str]:
|
|
101
|
+
table = object.__getattribute__(self, '_table')
|
|
102
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
103
|
+
return list(table._data.get(idx, {}))
|
|
104
|
+
|
|
105
|
+
def items(self):
|
|
106
|
+
table = object.__getattribute__(self, '_table')
|
|
107
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
108
|
+
return table._data.get(idx, {}).items()
|
|
109
|
+
|
|
110
|
+
def __repr__(self):
|
|
111
|
+
idx = object.__getattribute__(self, '_index_val')
|
|
112
|
+
table = object.__getattribute__(self, '_table')
|
|
113
|
+
row = table._data.get(idx, {})
|
|
114
|
+
parts = []
|
|
115
|
+
for name, prop in row.items():
|
|
116
|
+
if name == table._index_name:
|
|
117
|
+
continue
|
|
118
|
+
v, u = prop.value, prop.uncertainty
|
|
119
|
+
parts.append(f"{name}=({v}, {u})" if u is not None else f"{name}={v!r}")
|
|
120
|
+
return f"Row({table._index_name}={idx}, {', '.join(parts)})"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ════════════════════════════════════════════════════════════
|
|
124
|
+
# ColumnView — lazy proxy for column-level access
|
|
125
|
+
# ════════════════════════════════════════════════════════════
|
|
126
|
+
|
|
127
|
+
class ColumnView:
|
|
128
|
+
"""Read-only view of a single column across all rows.
|
|
129
|
+
|
|
130
|
+
Access via ``table.f`` where ``f`` is a column name.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(self, table: Table, col_name: str):
|
|
134
|
+
self._table = table
|
|
135
|
+
self._col_name = col_name
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def values(self) -> list:
|
|
139
|
+
"""Values for this column, ordered by index."""
|
|
140
|
+
return [
|
|
141
|
+
self._table._data[idx][self._col_name].value
|
|
142
|
+
if self._col_name in self._table._data[idx]
|
|
143
|
+
else None
|
|
144
|
+
for idx in self._table.index_values
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def uncertainties(self) -> list:
|
|
149
|
+
"""Uncertainties for this column, ordered by index."""
|
|
150
|
+
return [
|
|
151
|
+
self._table._data[idx][self._col_name].uncertainty
|
|
152
|
+
if self._col_name in self._table._data[idx]
|
|
153
|
+
else None
|
|
154
|
+
for idx in self._table.index_values
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
def __getitem__(self, index_val) -> Property:
|
|
158
|
+
"""Get the Property for this column at a given index value."""
|
|
159
|
+
row = self._table._data.get(index_val)
|
|
160
|
+
if row is None:
|
|
161
|
+
raise KeyError(f"No row at index {index_val!r}")
|
|
162
|
+
prop = row.get(self._col_name)
|
|
163
|
+
if prop is None:
|
|
164
|
+
raise KeyError(f"No column {self._col_name!r} at index {index_val!r}")
|
|
165
|
+
return prop
|
|
166
|
+
|
|
167
|
+
def __iter__(self):
|
|
168
|
+
"""Iterate over Property objects for this column, ordered by index."""
|
|
169
|
+
for idx in self._table.index_values:
|
|
170
|
+
prop = self._table._data[idx].get(self._col_name)
|
|
171
|
+
if prop is not None:
|
|
172
|
+
yield prop
|
|
173
|
+
|
|
174
|
+
def __repr__(self):
|
|
175
|
+
return f"ColumnView({self._col_name!r}, {len(self._table)} rows)"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ════════════════════════════════════════════════════════════
|
|
179
|
+
# Table
|
|
180
|
+
# ════════════════════════════════════════════════════════════
|
|
181
|
+
|
|
182
|
+
class Table:
|
|
183
|
+
"""Tabular data indexed by a parameter (e.g. temperature).
|
|
184
|
+
|
|
185
|
+
The **first column** in *columns* is the index. Every cell is a
|
|
186
|
+
:class:`Property` prefilled with Column metadata at grid creation.
|
|
187
|
+
|
|
188
|
+
Parameters
|
|
189
|
+
----------
|
|
190
|
+
columns : dict[str, Column]
|
|
191
|
+
Column definitions. The first key is the index column.
|
|
192
|
+
index : list, optional
|
|
193
|
+
Index values — creates the grid upfront (required for compute).
|
|
194
|
+
data : dict, optional
|
|
195
|
+
``{index_val: {col: value, ...}}`` — static row values.
|
|
196
|
+
Tuples are interpreted as ``(value, uncertainty)``.
|
|
197
|
+
compute : dict[str, callable], optional
|
|
198
|
+
Column-wise compute: ``{col: fn(index_values) → list[value]}``.
|
|
199
|
+
compute_unc : dict[str, callable], optional
|
|
200
|
+
Column-wise uncertainty: ``{col: fn(index_values) → list[unc]}``.
|
|
201
|
+
compute_row : dict[str, tuple], optional
|
|
202
|
+
Row-wise compute: ``{col: (fn, [dep1, dep2, ...])}``.
|
|
203
|
+
``fn`` receives dependency values (same row), returns a scalar.
|
|
204
|
+
|
|
205
|
+
Examples
|
|
206
|
+
--------
|
|
207
|
+
>>> table = Table(
|
|
208
|
+
... columns={"T": Column(unit="°C"), "f": Column(unit="GHz")},
|
|
209
|
+
... index=[20, 30, 40],
|
|
210
|
+
... compute={"f": lambda Ts: [8.878 - 0.001 * (T - 20) for T in Ts]},
|
|
211
|
+
... )
|
|
212
|
+
|
|
213
|
+
>>> table = Table(
|
|
214
|
+
... columns={"T": Column(unit="°C"), "f": Column(unit="GHz"), "Q": Column()},
|
|
215
|
+
... data={20: {"f": (8.878, 9e-7), "Q": 24840},
|
|
216
|
+
... 30: {"f": 8.874, "Q": 24104}},
|
|
217
|
+
... )
|
|
218
|
+
|
|
219
|
+
>>> table.add(T=40, f=8.858, Q=23500)
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
columns: dict[str, Column] | None = None,
|
|
225
|
+
index: list | None = None,
|
|
226
|
+
data: dict | None = None,
|
|
227
|
+
compute: dict[str, Callable] | None = None,
|
|
228
|
+
compute_unc: dict[str, Callable] | None = None,
|
|
229
|
+
compute_row: dict[str, tuple] | None = None,
|
|
230
|
+
title: str | None = None,
|
|
231
|
+
):
|
|
232
|
+
self.columns: dict[str, Column] = columns or {}
|
|
233
|
+
self.title: str | None = title
|
|
234
|
+
col_names = list(self.columns.keys())
|
|
235
|
+
self._index_name: str = col_names[0] if col_names else ""
|
|
236
|
+
|
|
237
|
+
self._compute_fns: dict[str, Callable] = compute or {}
|
|
238
|
+
self._compute_unc_fns: dict[str, Callable] = compute_unc or {}
|
|
239
|
+
self._compute_row_fns: dict[str, tuple] = compute_row or {}
|
|
240
|
+
|
|
241
|
+
# {index_val: {col_name: Property}} — includes index column
|
|
242
|
+
self._data: dict[Any, dict[str, Property]] = {}
|
|
243
|
+
|
|
244
|
+
# Set by Sample.__setattr__
|
|
245
|
+
self._name: str | None = None
|
|
246
|
+
self._parent: Sample | None = None
|
|
247
|
+
|
|
248
|
+
# Collect index values from both sources
|
|
249
|
+
idx_vals: list = list(index) if index else []
|
|
250
|
+
if data:
|
|
251
|
+
for k in data:
|
|
252
|
+
if k not in idx_vals:
|
|
253
|
+
idx_vals.append(k)
|
|
254
|
+
|
|
255
|
+
# Create grid (prefilled Properties for every cell)
|
|
256
|
+
for iv in idx_vals:
|
|
257
|
+
self._ensure_row(iv)
|
|
258
|
+
|
|
259
|
+
# Static data
|
|
260
|
+
if data:
|
|
261
|
+
for iv, row_data in data.items():
|
|
262
|
+
if isinstance(row_data, dict):
|
|
263
|
+
self._fill_row(iv, row_data)
|
|
264
|
+
|
|
265
|
+
# Column-wise compute
|
|
266
|
+
if idx_vals and self._compute_fns:
|
|
267
|
+
self._run_column_compute(idx_vals)
|
|
268
|
+
|
|
269
|
+
# Row-wise compute (after column-wise, so deps are ready)
|
|
270
|
+
if idx_vals and self._compute_row_fns:
|
|
271
|
+
self._run_row_compute(idx_vals)
|
|
272
|
+
|
|
273
|
+
# ── Internal helpers ────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def _new_cell(self, col_name: str) -> Property:
|
|
276
|
+
"""Create a Property prefilled with Column metadata."""
|
|
277
|
+
col = self.columns.get(col_name)
|
|
278
|
+
prop = Property()
|
|
279
|
+
if col:
|
|
280
|
+
self._apply_column_meta(prop, col)
|
|
281
|
+
return prop
|
|
282
|
+
|
|
283
|
+
def _ensure_row(self, index_val):
|
|
284
|
+
"""Create a row with prefilled Properties for all columns."""
|
|
285
|
+
if index_val in self._data:
|
|
286
|
+
return
|
|
287
|
+
row: dict[str, Property] = {}
|
|
288
|
+
for col_name in self.columns:
|
|
289
|
+
prop = self._new_cell(col_name)
|
|
290
|
+
if col_name == self._index_name:
|
|
291
|
+
prop.value = index_val
|
|
292
|
+
row[col_name] = prop
|
|
293
|
+
self._data[index_val] = row
|
|
294
|
+
|
|
295
|
+
def _fill_row(self, index_val, values: dict):
|
|
296
|
+
"""Set values on existing Properties in a row."""
|
|
297
|
+
row = self._data[index_val]
|
|
298
|
+
for col_name, val in values.items():
|
|
299
|
+
if col_name == self._index_name:
|
|
300
|
+
continue
|
|
301
|
+
if isinstance(val, Property):
|
|
302
|
+
self._apply_column_meta(val, self.columns.get(col_name))
|
|
303
|
+
row[col_name] = val
|
|
304
|
+
continue
|
|
305
|
+
prop = row.get(col_name)
|
|
306
|
+
if prop is None:
|
|
307
|
+
prop = self._new_cell(col_name)
|
|
308
|
+
row[col_name] = prop
|
|
309
|
+
if isinstance(val, tuple) and len(val) == 2:
|
|
310
|
+
prop.value = val[0]
|
|
311
|
+
prop.uncertainty = val[1]
|
|
312
|
+
else:
|
|
313
|
+
prop.value = val
|
|
314
|
+
|
|
315
|
+
def _run_column_compute(self, idx_vals: list):
|
|
316
|
+
"""Execute column-wise compute functions."""
|
|
317
|
+
for col_name, fn in self._compute_fns.items():
|
|
318
|
+
values = fn(idx_vals)
|
|
319
|
+
unc_fn = self._compute_unc_fns.get(col_name)
|
|
320
|
+
uncertainties = unc_fn(idx_vals) if unc_fn else [None] * len(idx_vals)
|
|
321
|
+
for idx_val, v, u in zip(idx_vals, values, uncertainties):
|
|
322
|
+
prop = self._data[idx_val].get(col_name)
|
|
323
|
+
if prop is None:
|
|
324
|
+
prop = self._new_cell(col_name)
|
|
325
|
+
self._data[idx_val][col_name] = prop
|
|
326
|
+
prop.value = v
|
|
327
|
+
if u is not None:
|
|
328
|
+
prop.uncertainty = u
|
|
329
|
+
|
|
330
|
+
def _run_row_compute(self, idx_vals: list):
|
|
331
|
+
"""Execute row-wise compute functions (lazy Properties)."""
|
|
332
|
+
for col_name, (fn, deps) in self._compute_row_fns.items():
|
|
333
|
+
col = self.columns.get(col_name)
|
|
334
|
+
for idx_val in idx_vals:
|
|
335
|
+
row = self._data[idx_val]
|
|
336
|
+
dep_props = [row[d] for d in deps]
|
|
337
|
+
|
|
338
|
+
def _make_compute(fn_=fn, deps_=dep_props):
|
|
339
|
+
return lambda: fn_(*(p.value for p in deps_))
|
|
340
|
+
|
|
341
|
+
prop = Property(
|
|
342
|
+
compute=_make_compute(),
|
|
343
|
+
depends_on=dep_props,
|
|
344
|
+
)
|
|
345
|
+
if col:
|
|
346
|
+
self._apply_column_meta(prop, col)
|
|
347
|
+
row[col_name] = prop
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _apply_column_meta(prop: Property, col: Column | None):
|
|
351
|
+
"""Copy Column metadata into a Property if not already set."""
|
|
352
|
+
if col is None:
|
|
353
|
+
return
|
|
354
|
+
if not prop.unit and col.unit:
|
|
355
|
+
prop.unit = col.unit
|
|
356
|
+
prop.unit_math = col.unit_math
|
|
357
|
+
if not prop.precision and col.precision:
|
|
358
|
+
prop.precision = col.precision
|
|
359
|
+
if prop.precision_unc == prop.precision and col.precision_unc != col.precision:
|
|
360
|
+
prop.precision_unc = col.precision_unc
|
|
361
|
+
if prop.symbol is None and col.symbol:
|
|
362
|
+
prop.symbol = col.symbol
|
|
363
|
+
if prop.symbol_math is None and col.symbol_math:
|
|
364
|
+
prop.symbol_math = col.symbol_math
|
|
365
|
+
|
|
366
|
+
# ── Index ───────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def index(self) -> str:
|
|
370
|
+
"""Name of the index column."""
|
|
371
|
+
return self._index_name
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def index_unit(self) -> str:
|
|
375
|
+
"""Unit of the index column."""
|
|
376
|
+
col = self.columns.get(self._index_name)
|
|
377
|
+
return col.unit if col else ""
|
|
378
|
+
|
|
379
|
+
@property
|
|
380
|
+
def index_values(self) -> list:
|
|
381
|
+
"""Sorted list of index values."""
|
|
382
|
+
vals = list(self._data.keys())
|
|
383
|
+
try:
|
|
384
|
+
return sorted(vals)
|
|
385
|
+
except TypeError:
|
|
386
|
+
return vals
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def data_columns(self) -> list[str]:
|
|
390
|
+
"""Non-index column names, in order."""
|
|
391
|
+
return [c for c in self.columns if c != self._index_name]
|
|
392
|
+
|
|
393
|
+
# ── Add rows ────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
def add(self, **kwargs):
|
|
396
|
+
"""Add a row to the table.
|
|
397
|
+
|
|
398
|
+
The first-column keyword is the index value;
|
|
399
|
+
other keywords are column values.
|
|
400
|
+
Tuples are ``(value, uncertainty)``.
|
|
401
|
+
|
|
402
|
+
>>> table.add(T=20, f=8.878, Q=24840)
|
|
403
|
+
>>> table.add(T=30, f=(8.874, 9e-7), Q=24104)
|
|
404
|
+
"""
|
|
405
|
+
if self._index_name not in kwargs:
|
|
406
|
+
raise ValueError(f"Missing index column {self._index_name!r}")
|
|
407
|
+
index_val = kwargs.pop(self._index_name)
|
|
408
|
+
self._ensure_row(index_val)
|
|
409
|
+
self._fill_row(index_val, kwargs)
|
|
410
|
+
|
|
411
|
+
# ── Access by position ──────────────────────────────
|
|
412
|
+
|
|
413
|
+
def __getitem__(self, key) -> RowView | list[RowView]:
|
|
414
|
+
"""Positional access: ``table[0]``, ``table[-1]``, ``table[0:3]``."""
|
|
415
|
+
ordered = self.index_values
|
|
416
|
+
if isinstance(key, int):
|
|
417
|
+
if key < 0:
|
|
418
|
+
key += len(ordered)
|
|
419
|
+
if key < 0 or key >= len(ordered):
|
|
420
|
+
raise IndexError(f"Table index {key} out of range")
|
|
421
|
+
return RowView(self, ordered[key])
|
|
422
|
+
if isinstance(key, slice):
|
|
423
|
+
return [RowView(self, ordered[i]) for i in range(*key.indices(len(ordered)))]
|
|
424
|
+
raise TypeError(
|
|
425
|
+
f"Use table({key!r}) for index-value access, "
|
|
426
|
+
f"table[int] for positional access"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
# ── Access by index value ───────────────────────────
|
|
430
|
+
|
|
431
|
+
def __call__(self, index_val) -> RowView:
|
|
432
|
+
"""Value-based access: ``table(20)`` → RowView for index=20."""
|
|
433
|
+
if index_val not in self._data:
|
|
434
|
+
raise KeyError(f"No row at index {index_val!r}")
|
|
435
|
+
return RowView(self, index_val)
|
|
436
|
+
|
|
437
|
+
def __contains__(self, index_val) -> bool:
|
|
438
|
+
return index_val in self._data
|
|
439
|
+
|
|
440
|
+
def __len__(self) -> int:
|
|
441
|
+
return len(self._data)
|
|
442
|
+
|
|
443
|
+
def __iter__(self):
|
|
444
|
+
"""Iterate over RowView objects, sorted by index."""
|
|
445
|
+
for idx in self.index_values:
|
|
446
|
+
yield RowView(self, idx)
|
|
447
|
+
|
|
448
|
+
def __bool__(self) -> bool:
|
|
449
|
+
return len(self._data) > 0
|
|
450
|
+
|
|
451
|
+
# ── Access by column ────────────────────────────────
|
|
452
|
+
|
|
453
|
+
def __getattr__(self, name: str):
|
|
454
|
+
if name.startswith('_'):
|
|
455
|
+
raise AttributeError(name)
|
|
456
|
+
col_names = set()
|
|
457
|
+
try:
|
|
458
|
+
col_names.update(object.__getattribute__(self, 'columns'))
|
|
459
|
+
except AttributeError:
|
|
460
|
+
pass
|
|
461
|
+
if name in col_names:
|
|
462
|
+
return ColumnView(self, name)
|
|
463
|
+
raise AttributeError(f"'{type(self).__name__}' has no column {name!r}")
|
|
464
|
+
|
|
465
|
+
# ── Serialization ───────────────────────────────────
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def _cell_to_yaml(prop: Property):
|
|
469
|
+
"""Serialize a cell — value only (+ uncertainty if present).
|
|
470
|
+
|
|
471
|
+
Column metadata (unit, precision…) is already in ``columns=``
|
|
472
|
+
so we never duplicate it in the YAML.
|
|
473
|
+
"""
|
|
474
|
+
v = prop.value
|
|
475
|
+
if v is None:
|
|
476
|
+
return None
|
|
477
|
+
u = prop.uncertainty
|
|
478
|
+
if u is not None:
|
|
479
|
+
return {"value": v, "uncertainty": u}
|
|
480
|
+
return v
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def _cell_from_yaml(prop: Property, raw):
|
|
484
|
+
"""Deserialize a cell — scalar or {value, uncertainty}."""
|
|
485
|
+
if isinstance(raw, dict):
|
|
486
|
+
val = raw.get("value")
|
|
487
|
+
unc = raw.get("uncertainty")
|
|
488
|
+
else:
|
|
489
|
+
val, unc = raw, None
|
|
490
|
+
|
|
491
|
+
if val is not None:
|
|
492
|
+
if prop.is_computed:
|
|
493
|
+
prop._seed_cache(value=val)
|
|
494
|
+
else:
|
|
495
|
+
prop.value = val
|
|
496
|
+
if unc is not None:
|
|
497
|
+
if prop._compute_unc is not None:
|
|
498
|
+
prop._seed_cache(uncertainty=unc)
|
|
499
|
+
else:
|
|
500
|
+
prop.uncertainty = unc
|
|
501
|
+
|
|
502
|
+
def _columns_to_yaml(self) -> list:
|
|
503
|
+
"""Serialize column metadata as a list of dicts."""
|
|
504
|
+
cols = []
|
|
505
|
+
for name, col in self.columns.items():
|
|
506
|
+
d: dict[str, Any] = {"name": name}
|
|
507
|
+
if col.unit:
|
|
508
|
+
d["unit"] = col.unit
|
|
509
|
+
if col.unit_math and col.unit_math != col.unit:
|
|
510
|
+
d["unit_math"] = col.unit_math
|
|
511
|
+
sym = col.symbol
|
|
512
|
+
if sym and sym != name:
|
|
513
|
+
d["symbol"] = sym
|
|
514
|
+
if col.symbol_math and col.symbol_math != (sym or name):
|
|
515
|
+
d["symbol_math"] = col.symbol_math
|
|
516
|
+
if col.precision:
|
|
517
|
+
d["precision"] = col.precision
|
|
518
|
+
if col.precision_unc and col.precision_unc != col.precision:
|
|
519
|
+
d["precision_unc"] = col.precision_unc
|
|
520
|
+
cols.append(d)
|
|
521
|
+
return cols
|
|
522
|
+
|
|
523
|
+
def to_yaml(self) -> dict:
|
|
524
|
+
"""Convert to a YAML-friendly nested dict.
|
|
525
|
+
|
|
526
|
+
Includes ``_title``, ``_index``, ``_columns`` metadata,
|
|
527
|
+
then a ``_rows`` list where each row is a dict including the
|
|
528
|
+
index column as a regular value.
|
|
529
|
+
Cells carry only value (+ uncertainty if present).
|
|
530
|
+
"""
|
|
531
|
+
result: dict[str, Any] = {}
|
|
532
|
+
if self.title:
|
|
533
|
+
result["_title"] = self.title
|
|
534
|
+
result["_index"] = self._index_name
|
|
535
|
+
result["_columns"] = self._columns_to_yaml()
|
|
536
|
+
rows: list[dict[str, Any]] = []
|
|
537
|
+
for idx_val in self.index_values:
|
|
538
|
+
row = self._data[idx_val]
|
|
539
|
+
entry: dict[str, Any] = {}
|
|
540
|
+
for name, prop in row.items():
|
|
541
|
+
serialized = self._cell_to_yaml(prop)
|
|
542
|
+
if serialized is not None:
|
|
543
|
+
entry[name] = serialized
|
|
544
|
+
if entry:
|
|
545
|
+
rows.append(entry)
|
|
546
|
+
result["_rows"] = rows
|
|
547
|
+
return result
|
|
548
|
+
|
|
549
|
+
def from_yaml(self, data: dict):
|
|
550
|
+
"""Populate this Table from a YAML nested dict."""
|
|
551
|
+
# ── Metadata ────────────────────────────────────
|
|
552
|
+
if "_title" in data:
|
|
553
|
+
self.title = data["_title"]
|
|
554
|
+
|
|
555
|
+
if "_index" in data:
|
|
556
|
+
self._index_name = data["_index"]
|
|
557
|
+
|
|
558
|
+
# Build columns from YAML if none were defined in code
|
|
559
|
+
if "_columns" in data and not self.columns:
|
|
560
|
+
for col_entry in data["_columns"]:
|
|
561
|
+
if not isinstance(col_entry, dict):
|
|
562
|
+
continue
|
|
563
|
+
col_name = col_entry.get("name", "")
|
|
564
|
+
if not col_name:
|
|
565
|
+
continue
|
|
566
|
+
self.columns[col_name] = Column(
|
|
567
|
+
unit=col_entry.get("unit", ""),
|
|
568
|
+
unit_math=col_entry.get("unit_math"),
|
|
569
|
+
symbol=col_entry.get("symbol"),
|
|
570
|
+
symbol_math=col_entry.get("symbol_math"),
|
|
571
|
+
precision=col_entry.get("precision", ""),
|
|
572
|
+
precision_unc=col_entry.get("precision_unc"),
|
|
573
|
+
)
|
|
574
|
+
if not self._index_name and self.columns:
|
|
575
|
+
self._index_name = next(iter(self.columns))
|
|
576
|
+
|
|
577
|
+
# ── Data rows ───────────────────────────────────
|
|
578
|
+
for row_data in data.get("_rows", []):
|
|
579
|
+
if not isinstance(row_data, dict):
|
|
580
|
+
continue
|
|
581
|
+
idx_val = row_data.get(self._index_name)
|
|
582
|
+
if idx_val is None:
|
|
583
|
+
continue
|
|
584
|
+
self._ensure_row(idx_val)
|
|
585
|
+
row = self._data[idx_val]
|
|
586
|
+
for key, raw in row_data.items():
|
|
587
|
+
if key == self._index_name:
|
|
588
|
+
continue
|
|
589
|
+
prop = row.get(key)
|
|
590
|
+
if prop is None:
|
|
591
|
+
prop = self._new_cell(key)
|
|
592
|
+
row[key] = prop
|
|
593
|
+
self._cell_from_yaml(prop, raw)
|
|
594
|
+
|
|
595
|
+
# ── Display ─────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
def __repr__(self):
|
|
598
|
+
cols = ", ".join(self.data_columns)
|
|
599
|
+
return f"Table({self._name}: {len(self)} rows, [{cols}])"
|
|
600
|
+
|
|
601
|
+
def __str__(self):
|
|
602
|
+
idx_name = self._index_name or "?"
|
|
603
|
+
lines = [f"Table: {self._name or '?'} ({len(self)} rows)"]
|
|
604
|
+
lines.append(f" Index: {idx_name} ({self.index_unit})")
|
|
605
|
+
for idx_val in self.index_values:
|
|
606
|
+
row = self._data[idx_val]
|
|
607
|
+
vals = ", ".join(
|
|
608
|
+
f"{k}={p.value}" for k, p in row.items()
|
|
609
|
+
if k != self._index_name and p.value is not None
|
|
610
|
+
)
|
|
611
|
+
lines.append(f" {idx_val}: {vals}")
|
|
612
|
+
return "\n".join(lines)
|