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/__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
|
+
]
|
samplekit/converters.py
ADDED
|
@@ -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"
|