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/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)