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 ADDED
@@ -0,0 +1,20 @@
1
+ """SampleKit — Scientific sample documentation framework."""
2
+
3
+ from .property import Property
4
+ from .table import Table, Column
5
+ from .sample import Sample
6
+ from .sample_list import SampleList
7
+ from . import report
8
+ from . import converters
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "Property",
14
+ "Table",
15
+ "Column",
16
+ "Sample",
17
+ "SampleList",
18
+ "report",
19
+ "converters",
20
+ ]
@@ -0,0 +1,117 @@
1
+ """Converters — transform Samples to/from dicts, DataFrames, and other formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .sample import Sample
9
+ from .sample_list import SampleList
10
+
11
+
12
+ # ── Sample converters ───────────────────────────────────
13
+
14
+
15
+ def sample_to_dict(sample: Sample) -> dict[str, Any]:
16
+ """Export all sample data as a nested dict.
17
+
18
+ Parameters
19
+ ----------
20
+ sample : Sample
21
+ The sample to export.
22
+
23
+ Returns
24
+ -------
25
+ dict
26
+ ``{"name": ..., "prop_name": {"value": ..., "uncertainty": ..., ...}, ...}``
27
+ """
28
+ result: dict[str, Any] = {"name": sample.name}
29
+
30
+ for key, prop in sample.props.items():
31
+ entry: dict[str, Any] = {}
32
+ if prop.value is not None:
33
+ entry["value"] = prop.value
34
+ if prop.uncertainty is not None:
35
+ entry["uncertainty"] = prop.uncertainty
36
+ if prop.unit:
37
+ entry["unit"] = prop.unit
38
+ if prop.data is not None:
39
+ entry["data"] = prop.data
40
+ if entry:
41
+ result[key] = entry
42
+
43
+ for key, table in sample.tables.items():
44
+ result[key] = table.to_yaml()
45
+
46
+ return result
47
+
48
+
49
+ def sample_to_dataframe(sample: Sample):
50
+ """Export scalar properties as a single-row pandas DataFrame.
51
+
52
+ Parameters
53
+ ----------
54
+ sample : Sample
55
+ The sample to export.
56
+
57
+ Returns
58
+ -------
59
+ pandas.DataFrame
60
+ One row indexed by sample name, columns are numeric properties.
61
+ """
62
+ import pandas as pd
63
+ data: dict[str, Any] = {}
64
+ for name, prop in sample.props.items():
65
+ v = prop.value
66
+ if v is not None and not isinstance(v, str):
67
+ data[name] = v
68
+ if prop.uncertainty is not None:
69
+ data[f"{name}_unc"] = prop.uncertainty
70
+ return pd.DataFrame(data, index=[sample.name])
71
+
72
+
73
+ # ── SampleList converters ───────────────────────────────
74
+
75
+
76
+ def samplelist_to_dataframe(sample_list: SampleList):
77
+ """Concatenate all samples into a single DataFrame (samples as rows).
78
+
79
+ Parameters
80
+ ----------
81
+ sample_list : SampleList
82
+ The collection to export.
83
+
84
+ Returns
85
+ -------
86
+ pandas.DataFrame
87
+ """
88
+ import pandas as pd
89
+ frames = [sample_to_dataframe(s) for s in sample_list]
90
+ return pd.concat(frames) if frames else pd.DataFrame()
91
+
92
+
93
+ def samplelist_stats(sample_list: SampleList, prop_name: str):
94
+ """Descriptive statistics for a property across all samples.
95
+
96
+ Parameters
97
+ ----------
98
+ sample_list : SampleList
99
+ The collection to analyze.
100
+ prop_name : str
101
+ Name of the property to gather statistics on.
102
+
103
+ Returns
104
+ -------
105
+ pandas.Series
106
+ Output of ``pandas.Series.describe()``.
107
+ """
108
+ import pandas as pd
109
+ values = []
110
+ for s in sample_list:
111
+ try:
112
+ v = s[prop_name].value
113
+ if v is not None and not isinstance(v, str):
114
+ values.append(float(v))
115
+ except (KeyError, TypeError, ValueError):
116
+ pass
117
+ return pd.Series(values, name=prop_name).describe()
samplekit/property.py ADDED
@@ -0,0 +1,332 @@
1
+ """Property — a scalar scientific quantity: value ± uncertainty [unit]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import statistics
6
+ from typing import Any, Callable, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .sample import Sample
10
+
11
+
12
+ class Property:
13
+ """
14
+ Scientific property with value, uncertainty, unit, and optional computation.
15
+
16
+ Modes
17
+ -----
18
+ - Static: Property(value=25.0)
19
+ - Measured: Property(value=[25.1, 24.9, 25.0]) → auto mean ± std
20
+ - Computed: Property(compute=self._calc_rho) → lazy, cached, invalidated
21
+
22
+ Parameters
23
+ ----------
24
+ value : float, str, list[float], or None
25
+ Static value or list of measurements (auto mean ± std).
26
+ uncertainty : float or None
27
+ Static uncertainty (overrides auto-std when value is a list).
28
+ unit : str
29
+ Plain-text unit (e.g. "°C", "kPa").
30
+ unit_math : str, optional
31
+ Math-mode unit for MathJax/LaTeX (defaults to unit).
32
+ symbol : str, optional
33
+ Text/unicode symbol for CLI/TUI display (defaults to name).
34
+ symbol_math : str, optional
35
+ Math-mode symbol for MathJax/LaTeX (defaults to symbol).
36
+ precision : str
37
+ Format spec for value (default "").
38
+ precision_unc : str, optional
39
+ Format spec for uncertainty (defaults to precision).
40
+ compute : callable, optional
41
+ Function() → float. Called lazily when value is accessed.
42
+ compute_unc : callable, optional
43
+ Function() → float. Called lazily when uncertainty is accessed.
44
+ depends_on : list[Property], optional
45
+ Properties that this one depends on. When they change,
46
+ this property's cache is invalidated.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ value: float | str | list[float] | None = None,
52
+ uncertainty: float | None = None,
53
+ unit: str = "",
54
+ unit_math: str | None = None,
55
+ symbol: str | None = None,
56
+ symbol_math: str | None = None,
57
+ precision: str = "",
58
+ precision_unc: str | None = None,
59
+ compute: Callable[[], Any] | None = None,
60
+ compute_unc: Callable[[], float] | None = None,
61
+ depends_on: list[Property] | None = None,
62
+ ):
63
+ # Identity (set by Sample.__setattr__)
64
+ self._name: str | None = None
65
+ self._parent: Sample | None = None
66
+
67
+ # Display
68
+ self.symbol = symbol
69
+ self.symbol_math = symbol_math
70
+ self.unit = unit
71
+ self.unit_math = unit_math or unit
72
+ self.precision = precision
73
+ self.precision_unc = precision_unc or precision
74
+
75
+ # Dependencies
76
+ self._depends_on_refs: list[Property] = depends_on or []
77
+ self._dependents: list[Property] = []
78
+
79
+ # Value backends
80
+ self._compute = compute
81
+ self._compute_unc = compute_unc
82
+ self._data: list[float] | None = None
83
+ self._static_value: float | str | None = None
84
+ self._static_uncertainty: float | None = None
85
+
86
+ # Cache (for computed properties)
87
+ self._value_cached: Any = None
88
+ self._unc_cached: float | None = None
89
+ self._value_cache_valid = False
90
+ self._unc_cache_valid = False
91
+
92
+ # Initialize from constructor args
93
+ if compute is None:
94
+ if isinstance(value, list):
95
+ self._data = list(value)
96
+ else:
97
+ self._static_value = value
98
+
99
+ if compute_unc is None:
100
+ self._static_uncertainty = uncertainty
101
+
102
+ # ── Name ─────────────────────────────────────────────
103
+
104
+ @property
105
+ def name(self) -> str | None:
106
+ return self._name
107
+
108
+ # ── Value ────────────────────────────────────────────
109
+
110
+ @property
111
+ def value(self) -> float | str | None:
112
+ if self._compute is not None:
113
+ if not self._value_cache_valid:
114
+ self._value_cached = self._compute()
115
+ self._value_cache_valid = True
116
+ return self._value_cached
117
+ if self._data is not None:
118
+ return statistics.mean(self._data) if self._data else None
119
+ return self._static_value
120
+
121
+ @value.setter
122
+ def value(self, val):
123
+ """Set value directly (clears compute if any)."""
124
+ self._compute = None
125
+ if isinstance(val, list):
126
+ self._data = list(val)
127
+ self._static_value = None
128
+ else:
129
+ self._data = None
130
+ self._static_value = val
131
+ self._value_cache_valid = False
132
+ self._invalidate_dependents()
133
+
134
+ # ── Uncertainty ──────────────────────────────────────
135
+
136
+ @property
137
+ def uncertainty(self) -> float | None:
138
+ if self._compute_unc is not None:
139
+ if not self._unc_cache_valid:
140
+ self._unc_cached = self._compute_unc()
141
+ self._unc_cache_valid = True
142
+ return self._unc_cached
143
+ if self._static_uncertainty is not None:
144
+ return self._static_uncertainty
145
+ # Auto std from measurement list
146
+ if self._data is not None and len(self._data) > 1:
147
+ return statistics.stdev(self._data)
148
+ return None
149
+
150
+ @uncertainty.setter
151
+ def uncertainty(self, val):
152
+ self._compute_unc = None
153
+ self._static_uncertainty = val
154
+ self._unc_cache_valid = False
155
+ self._invalidate_dependents()
156
+
157
+ # ── Data (raw measurements) ──────────────────────────
158
+
159
+ @property
160
+ def data(self) -> list[float] | None:
161
+ return list(self._data) if self._data is not None else None
162
+
163
+ @property
164
+ def is_computed(self) -> bool:
165
+ return self._compute is not None
166
+
167
+ # ── Cache management ────────────────────────────────
168
+
169
+ def invalidate(self):
170
+ """Manually invalidate this property's cache and propagate."""
171
+ self._value_cache_valid = False
172
+ self._unc_cache_valid = False
173
+ self._invalidate_dependents()
174
+
175
+ def _invalidate_dependents(self, _seen: set[int] | None = None):
176
+ if _seen is None:
177
+ _seen = set()
178
+ for dep in self._dependents:
179
+ dep_id = id(dep)
180
+ if dep_id not in _seen:
181
+ _seen.add(dep_id)
182
+ dep._value_cache_valid = False
183
+ dep._unc_cache_valid = False
184
+ dep._invalidate_dependents(_seen)
185
+
186
+ def _wire_dependencies(self):
187
+ """Register self as a dependent of each dependency."""
188
+ for dep in self._depends_on_refs:
189
+ if isinstance(dep, Property) and self not in dep._dependents:
190
+ dep._dependents.append(self)
191
+
192
+ def _seed_cache(self, value=None, uncertainty=None):
193
+ """Seed the cache for a computed property (used during hydration)."""
194
+ if value is not None:
195
+ self._value_cached = value
196
+ self._value_cache_valid = True
197
+ if uncertainty is not None:
198
+ self._unc_cached = uncertainty
199
+ self._unc_cache_valid = True
200
+
201
+ # ── Display ─────────────────────────────────────────
202
+
203
+ @property
204
+ def text(self) -> str:
205
+ return self.format()
206
+
207
+ def format(self, unit: bool = True) -> str:
208
+ """Format as plain text.
209
+
210
+ Parameters
211
+ ----------
212
+ unit : bool
213
+ Include unit in output.
214
+ """
215
+ v = self.value
216
+ if v is None:
217
+ return "N/A"
218
+
219
+ # String values (e.g. material name)
220
+ if isinstance(v, str):
221
+ if unit and self.unit and self.unit != "-":
222
+ return f"{v} {self.unit}"
223
+ return v
224
+
225
+ u = self.uncertainty
226
+ v_str = f"{v:{self.precision}}"
227
+
228
+ if u is not None and u != 0:
229
+ u_str = f"{u:{self.precision_unc}}"
230
+ val_part = f"{v_str} ± {u_str}"
231
+ else:
232
+ val_part = v_str
233
+ if unit and self.unit and self.unit != "-":
234
+ return f"{val_part} {self.unit}"
235
+ return val_part
236
+
237
+ def __repr__(self):
238
+ return f"Property({self._name}: {self.text})"
239
+
240
+ def __str__(self):
241
+ return self.text
242
+
243
+ # ── Serialization ───────────────────────────────────
244
+
245
+ def to_yaml(self) -> Any:
246
+ """Convert to a YAML-friendly value (scalar, dict, or None)."""
247
+ v = self.value
248
+ u = self.uncertainty
249
+ unit = self.unit
250
+ data = self.data
251
+
252
+ # String with no extra metadata → bare string
253
+ if isinstance(v, str) and u is None and not unit:
254
+ return v
255
+
256
+ d: dict[str, Any] = {}
257
+ if v is not None:
258
+ d["value"] = v
259
+ if data is not None:
260
+ d["data"] = data
261
+ if u is not None:
262
+ d["uncertainty"] = u
263
+ if unit:
264
+ d["unit"] = unit
265
+ if self.unit_math and self.unit_math != unit:
266
+ d["unit_math"] = self.unit_math
267
+ sym = self.symbol
268
+ if sym and sym != self._name:
269
+ d["symbol"] = sym
270
+ if self.symbol_math and self.symbol_math != (sym or self._name):
271
+ d["symbol_math"] = self.symbol_math
272
+ if self.precision:
273
+ d["precision"] = self.precision
274
+ if self.precision_unc and self.precision_unc != self.precision:
275
+ d["precision_unc"] = self.precision_unc
276
+
277
+ if not d:
278
+ return None
279
+
280
+ # Only metadata and no actual data → skip
281
+ data_keys = {"value", "data", "uncertainty"}
282
+ if not (set(d.keys()) & data_keys):
283
+ return None
284
+
285
+ # Only value and it's numeric → bare scalar
286
+ if list(d.keys()) == ["value"] and isinstance(v, (int, float)):
287
+ return v
288
+
289
+ return d
290
+
291
+ def from_yaml(self, raw: Any):
292
+ """Populate this Property from a YAML value (scalar or dict)."""
293
+ if isinstance(raw, dict):
294
+ data = {
295
+ "value": raw.get("value"),
296
+ "uncertainty": raw.get("uncertainty"),
297
+ "unit": raw.get("unit"),
298
+ "data": raw.get("data"),
299
+ }
300
+ # Metadata fields
301
+ if raw.get("unit_math") is not None:
302
+ self.unit_math = raw["unit_math"]
303
+ if raw.get("symbol") is not None:
304
+ self.symbol = raw["symbol"]
305
+ if raw.get("symbol_math") is not None:
306
+ self.symbol_math = raw["symbol_math"]
307
+ if raw.get("precision") is not None:
308
+ self.precision = raw["precision"]
309
+ if raw.get("precision_unc") is not None:
310
+ self.precision_unc = raw["precision_unc"]
311
+ else:
312
+ data = {"value": raw}
313
+
314
+ # Data list takes priority (value/uncertainty will be computed from it)
315
+ if data.get("data") is not None:
316
+ self.value = data["data"]
317
+ elif data.get("value") is not None:
318
+ if self.is_computed:
319
+ self._seed_cache(value=data["value"])
320
+ else:
321
+ self.value = data["value"]
322
+
323
+ if data.get("uncertainty") is not None:
324
+ if self._compute_unc is not None:
325
+ self._seed_cache(uncertainty=data["uncertainty"])
326
+ else:
327
+ self.uncertainty = data["uncertainty"]
328
+
329
+ if data.get("unit") is not None:
330
+ self.unit = data["unit"]
331
+ if self.unit_math == self.unit or not self.unit_math:
332
+ self.unit_math = data["unit"]
samplekit/report.py ADDED
@@ -0,0 +1,214 @@
1
+ """Rendering utilities — markdown tables, property tables, Table rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .property import Property
9
+ from .sample import Sample
10
+ from .table import Table
11
+
12
+ # Characters that need escaping inside $...$ (LaTeX math mode)
13
+ _LATEX_SPECIAL = str.maketrans({"#": r"\#", "%": r"\%", "&": r"\&", "_": r"\_"})
14
+
15
+ import re
16
+ _SCI_RE = re.compile(r'^([+-]?\d+(?:\.\d+)?)[eE]([+-]?\d+)$')
17
+
18
+ def _math_sci(s: str) -> str:
19
+ """Convert '9e-07' → '9 \\times 10^{-7}' for math mode."""
20
+ m = _SCI_RE.match(s.strip())
21
+ if not m:
22
+ return s
23
+ mantissa, exp = m.group(1), int(m.group(2))
24
+ return f"{mantissa} \\times 10^{{{exp}}}"
25
+
26
+
27
+ # ════════════════════════════════════════════════════════════
28
+ # Property formatting
29
+ # ════════════════════════════════════════════════════════════
30
+
31
+ def format_property(prop: Property, style: str = "text", unit: bool = True) -> str:
32
+ """Format a Property for display.
33
+
34
+ Parameters
35
+ ----------
36
+ prop : Property
37
+ style : "text" or "math"
38
+ "text" → plain text (e.g. ``25.0 ± 0.5 °C``)
39
+ "math" → inline math (e.g. ``$25.0 \\pm 0.5$ $°C$``)
40
+ unit : bool
41
+ Include unit in output.
42
+ """
43
+ if style == "math":
44
+ v = prop.value
45
+ if v is None:
46
+ return "N/A"
47
+ if isinstance(v, str):
48
+ if unit and prop.unit and prop.unit != "-":
49
+ return f"{v} {prop.unit}"
50
+ return v
51
+ u = prop.uncertainty
52
+ v_str = _math_sci(f"{v:{prop.precision}}")
53
+ if u is not None and u != 0:
54
+ u_str = _math_sci(f"{u:{prop.precision_unc}}")
55
+ val_part = f"${v_str} \\pm {u_str}$"
56
+ else:
57
+ val_part = f"${v_str}$"
58
+ if unit and prop.unit and prop.unit != "-":
59
+ return f"{val_part} ${prop.unit_math.translate(_LATEX_SPECIAL)}$"
60
+ return val_part
61
+ return prop.format(unit=unit)
62
+
63
+
64
+ # ════════════════════════════════════════════════════════════
65
+ # Generic markdown table
66
+ # ════════════════════════════════════════════════════════════
67
+
68
+ def markdown_table(
69
+ rows: list[list[str]],
70
+ headers: list[str],
71
+ align: list[str] | None = None,
72
+ ) -> str:
73
+ """Generate a markdown table.
74
+
75
+ Parameters
76
+ ----------
77
+ rows : list of list of str
78
+ Table data.
79
+ headers : list of str
80
+ Column headers.
81
+ align : list of str, optional
82
+ Per-column alignment: "l", "c", "r". Defaults to center.
83
+ """
84
+ ncols = len(headers)
85
+ align = align or ["c"] * ncols
86
+
87
+ header_row = "| " + " | ".join(headers) + " |"
88
+ sep_map = {"l": ":---", "r": "---:", "c": ":---:"}
89
+ sep_row = "| " + " | ".join(sep_map.get(a, ":---:") for a in align) + " |"
90
+ data_rows = "\n".join("| " + " | ".join(row) + " |" for row in rows)
91
+
92
+ return f"{header_row}\n{sep_row}\n{data_rows}"
93
+
94
+
95
+ # ════════════════════════════════════════════════════════════
96
+ # Property table
97
+ # ════════════════════════════════════════════════════════════
98
+
99
+ def properties_table(
100
+ sample: Sample,
101
+ names: list[str],
102
+ headers: list[str] | None = None,
103
+ style: str = "math",
104
+ align: list[str] | None = None,
105
+ ) -> str:
106
+ """Render selected scalar properties as a markdown table.
107
+
108
+ Parameters
109
+ ----------
110
+ sample : Sample
111
+ names : list of property names to include
112
+ headers : column headers (default: ["Property", "Value", "Unit"])
113
+ style : "math" or "text"
114
+ """
115
+ headers = headers or ["Property", "Value", "Unit"]
116
+ math = style == "math"
117
+ props = sample.props
118
+
119
+ rows = []
120
+ for name in names:
121
+ prop = props.get(name)
122
+ if prop is None or prop.value is None:
123
+ continue
124
+ if math:
125
+ sym = prop.symbol_math or prop.symbol or name
126
+ sym_cell = f"${sym}$"
127
+ else:
128
+ sym = prop.symbol or name
129
+ sym_cell = sym
130
+ val_cell = format_property(prop, style, unit=False)
131
+ unit_cell = f"${prop.unit_math.translate(_LATEX_SPECIAL)}$" if math and prop.unit else prop.unit
132
+ rows.append([sym_cell, val_cell, unit_cell])
133
+
134
+ return markdown_table(rows, headers, align or ["c"] * len(headers))
135
+
136
+
137
+ # ════════════════════════════════════════════════════════════
138
+ # Table rendering
139
+ # ════════════════════════════════════════════════════════════
140
+
141
+ def _col_header(col_name: str, col, style: str) -> str:
142
+ """Build a column header string (symbol + unit) for a given style."""
143
+ math = style == "math"
144
+ if col:
145
+ if math:
146
+ sym = col.symbol_math or col.symbol or col_name
147
+ else:
148
+ sym = col.symbol or col_name
149
+ else:
150
+ sym = col_name
151
+
152
+ h = f"${sym}$" if math else sym
153
+
154
+ if col and col.unit and col.unit != "-":
155
+ unit_str = f"${col.unit_math.translate(_LATEX_SPECIAL)}$" if math else col.unit
156
+ h += f" ({unit_str})"
157
+ return h
158
+
159
+
160
+ def table_to_markdown(
161
+ table: Table,
162
+ style: str = "math",
163
+ columns: list[str] | None = None,
164
+ index_label: str | None = None,
165
+ align: list[str] | None = None,
166
+ ) -> str:
167
+ """Render a Table as a markdown table.
168
+
169
+ Parameters
170
+ ----------
171
+ table : Table
172
+ style : "math" or "text"
173
+ columns : list of column names to include (default: all)
174
+ index_label : override for the index column header
175
+ """
176
+ math = style == "math"
177
+ cols = columns or table.data_columns
178
+
179
+ # Index header
180
+ if index_label:
181
+ idx_header = index_label
182
+ else:
183
+ idx_col = table.columns.get(table.index)
184
+ idx_header = _col_header(table.index, idx_col, style)
185
+
186
+ # Column headers
187
+ headers = [idx_header]
188
+ for col_name in cols:
189
+ col = table.columns.get(col_name)
190
+ headers.append(_col_header(col_name, col, style))
191
+
192
+ # Data rows
193
+ rows = []
194
+ for idx in table.index_values:
195
+ row_cells = [f"${idx}$" if math else str(idx)]
196
+ row = table(idx)
197
+ for col_name in cols:
198
+ try:
199
+ cell = row[col_name]
200
+ row_cells.append(format_property(cell, style, unit=False))
201
+ except (KeyError, AttributeError):
202
+ row_cells.append("N/A")
203
+ rows.append(row_cells)
204
+
205
+ return markdown_table(rows, headers, align or ["c"] * len(headers))
206
+
207
+
208
+ # ════════════════════════════════════════════════════════════
209
+ # Heading helper
210
+ # ════════════════════════════════════════════════════════════
211
+
212
+ def heading(text: str, level: int = 2) -> str:
213
+ """Generate a markdown heading."""
214
+ return f"{'#' * level} {text}\n"