trendify2 2.0.4__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.
Files changed (76) hide show
  1. trendify/__about__.py +14 -0
  2. trendify/__init__.py +250 -0
  3. trendify/base/__init__.py +36 -0
  4. trendify/base/helpers.py +76 -0
  5. trendify/base/pen.py +161 -0
  6. trendify/base/record.py +148 -0
  7. trendify/cli.py +403 -0
  8. trendify/color.py +260 -0
  9. trendify/examples.py +474 -0
  10. trendify/formats/__init__.py +28 -0
  11. trendify/formats/format2d.py +128 -0
  12. trendify/formats/table.py +55 -0
  13. trendify/generator/__init__.py +38 -0
  14. trendify/generator/generate.py +167 -0
  15. trendify/generator/histogrammer.py +62 -0
  16. trendify/generator/render.py +195 -0
  17. trendify/generator/table_builder.py +194 -0
  18. trendify/generator/xy_data_plotter.py +195 -0
  19. trendify/log.py +195 -0
  20. trendify/pipeline.py +136 -0
  21. trendify/plotting/__init__.py +48 -0
  22. trendify/plotting/axline.py +122 -0
  23. trendify/plotting/figure.py +389 -0
  24. trendify/plotting/histogram.py +282 -0
  25. trendify/plotting/point.py +89 -0
  26. trendify/plotting/scatter.py +100 -0
  27. trendify/plotting/trace.py +129 -0
  28. trendify/progress.py +43 -0
  29. trendify/store/__init__.py +30 -0
  30. trendify/store/db.py +119 -0
  31. trendify/store/record_store.py +448 -0
  32. trendify/store/tags.py +65 -0
  33. trendify/styling/__init__.py +30 -0
  34. trendify/styling/grid.py +125 -0
  35. trendify/styling/legend.py +217 -0
  36. trendify/styling/marker.py +168 -0
  37. trendify/typing.py +26 -0
  38. trendify/viewer/__init__.py +41 -0
  39. trendify/viewer/app.py +208 -0
  40. trendify/viewer/hydration.py +181 -0
  41. trendify/viewer/plot_config.py +102 -0
  42. trendify/viewer/routes/__init__.py +6 -0
  43. trendify/viewer/routes/api.py +360 -0
  44. trendify/viewer/routes/pages.py +39 -0
  45. trendify/viewer/tag_tree.py +148 -0
  46. trendify/viewer/templates/base.html +63 -0
  47. trendify/viewer/templates/index.html +131 -0
  48. trendify/viewer/templates/partials/__init__.py +1 -0
  49. trendify/viewer/templates/partials/footer.html +15 -0
  50. trendify/viewer/templates/partials/nav.html +181 -0
  51. trendify/viewer/templates/partials/plot_view.html +221 -0
  52. trendify/viewer/templates/partials/sidebar.html +163 -0
  53. trendify/viewer/templates/partials/table_view.html +43 -0
  54. trendify/viewer/templates/static/__init__.py +1 -0
  55. trendify/viewer/templates/static/assets/__init__.py +1 -0
  56. trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
  57. trendify/viewer/templates/static/assets/main-logo.svg +66 -0
  58. trendify/viewer/templates/static/css/app.css +75 -0
  59. trendify/viewer/templates/static/js/main.js +2 -0
  60. trendify/viewer/templates/static/vendored/__init__.py +1 -0
  61. trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
  62. trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
  63. trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
  64. trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
  65. trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
  66. trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
  67. trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
  68. trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
  69. trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
  70. trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
  71. trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
  72. trendify2-2.0.4.dist-info/METADATA +107 -0
  73. trendify2-2.0.4.dist-info/RECORD +76 -0
  74. trendify2-2.0.4.dist-info/WHEEL +4 -0
  75. trendify2-2.0.4.dist-info/entry_points.txt +2 -0
  76. trendify2-2.0.4.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,38 @@
1
+ """Pipeline stages: generating records from run directories, then rendering plots and tables."""
2
+
3
+ from trendify.generator import generate
4
+ from trendify.generator import histogrammer
5
+ from trendify.generator import render
6
+ from trendify.generator import table_builder
7
+ from trendify.generator import xy_data_plotter
8
+
9
+ from trendify.generator.generate import (
10
+ generate_records,
11
+ get_sorted_dirs,
12
+ )
13
+ from trendify.generator.histogrammer import (
14
+ Histogrammer,
15
+ )
16
+ from trendify.generator.render import (
17
+ render_assets,
18
+ )
19
+ from trendify.generator.table_builder import (
20
+ TableBuilder,
21
+ )
22
+ from trendify.generator.xy_data_plotter import (
23
+ XYDataPlotter,
24
+ )
25
+
26
+ __all__ = [
27
+ "Histogrammer",
28
+ "TableBuilder",
29
+ "XYDataPlotter",
30
+ "generate",
31
+ "generate_records",
32
+ "get_sorted_dirs",
33
+ "histogrammer",
34
+ "render",
35
+ "render_assets",
36
+ "table_builder",
37
+ "xy_data_plotter",
38
+ ]
@@ -0,0 +1,167 @@
1
+ """
2
+ Generate pipeline: maps a user-supplied `RecordGenerator` over raw-data run directories and
3
+ writes the resulting records straight to a `RecordStore`-backed `.db` file.
4
+
5
+ There is no separate "sort" step: tags are indexed at write time, so retrieving records by
6
+ tag is an indexed query against `RecordStore`, not a physical grouping step that has to run
7
+ before rendering can start.
8
+
9
+ Worker processes only ever *compute* records; exactly one connection (in this process) ever
10
+ writes. SQLite allows a single writer at a time no matter how many connections are open, so
11
+ having every worker process open its own writing connection (the earlier design) doesn't buy
12
+ any write throughput; it only adds lock contention, which gets worse, not better, as
13
+ `n_procs` goes up. Splitting the pool into N compute workers feeding one writer lets
14
+ `n_procs` control the part that actually parallelizes (running `record_generator`) without
15
+ touching the part that can't (writing to the shared `.db` file).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from concurrent.futures import ProcessPoolExecutor, as_completed
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from trendify.base.record import RecordGenerator, RecordList
26
+ from trendify.log import create_queue_listener
27
+ from trendify.log import worker_init as _init_worker_logging
28
+ from trendify.progress import ProgressCallback, ProgressEvent
29
+ from trendify.store.record_store import RecordStore
30
+
31
+ __all__ = ["generate_records", "get_sorted_dirs"]
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Per-process global set by `_init_worker`: the one thing a compute worker needs. Workers
36
+ # never open a `RecordStore`; only the caller of `generate_records` writes.
37
+ _worker_generator: RecordGenerator | None = None
38
+
39
+
40
+ def get_sorted_dirs(dirs: list[Path]) -> list[Path]:
41
+ """
42
+ Sorts dirs numerically if possible, else alphabetically.
43
+
44
+ Args:
45
+ dirs (list[Path]): Directories to sort
46
+
47
+ Returns:
48
+ (list[Path]): Sorted list of directories
49
+
50
+ """
51
+ dirs = list(dirs)
52
+ try:
53
+ dirs.sort(key=lambda p: int(p.name))
54
+ except ValueError:
55
+ dirs.sort()
56
+ return dirs
57
+
58
+
59
+ def _init_worker(record_generator: RecordGenerator) -> None:
60
+ global _worker_generator
61
+ _worker_generator = record_generator
62
+
63
+
64
+ def _init_worker_with_logging(
65
+ record_generator: RecordGenerator,
66
+ log_queue: Any,
67
+ log_level: int,
68
+ ) -> None:
69
+ # Route this worker's logging through the parent's QueueListener before anything else
70
+ # runs, so no worker ever opens a file/console handler directly. See trendify.log for why
71
+ # that matters (Windows spawn semantics, and fork-inherited-handler races on POSIX).
72
+ _init_worker_logging(log_queue, log_level)
73
+ _init_worker(record_generator)
74
+
75
+
76
+ def _compute(run_dir: Path) -> tuple[Path, RecordList]:
77
+ assert _worker_generator is not None
78
+ logger.info(f"Processing run_dir = '{run_dir}'")
79
+ return run_dir, _worker_generator(run_dir)
80
+
81
+
82
+ def generate_records(
83
+ record_generator: RecordGenerator,
84
+ data_dirs: list[Path],
85
+ db_path: Path,
86
+ n_procs: int = 1,
87
+ on_progress: ProgressCallback | None = None,
88
+ ) -> int:
89
+ """
90
+ Maps `record_generator` over `data_dirs`, writing each directory's records to the
91
+ `RecordStore` at `db_path` (one `write_run` transaction per directory). There is no
92
+ intermediate file and no separate sort step, since tag-based retrieval from `RecordStore`
93
+ is an indexed query.
94
+
95
+ Args:
96
+ record_generator (RecordGenerator): A callable that returns a list of records
97
+ given a working directory. Must be picklable (e.g. a module-level function) when
98
+ `n_procs > 1`, since it is sent to worker processes.
99
+ data_dirs (list[Path]): Directories over which to map `record_generator`.
100
+ db_path (Path): Path to the trendify output directory's `.db` file. Created if it
101
+ doesn't already exist.
102
+ n_procs (int): Number of worker processes computing records in parallel.
103
+ `n_procs == 1` runs sequentially in this process (easier to debug with full
104
+ tracebacks). `n_procs > 1` uses a `ProcessPoolExecutor` for computing records
105
+ only; writing always happens through the single connection this function opens,
106
+ regardless of `n_procs`, since SQLite only ever allows one writer at a time.
107
+ on_progress (ProgressCallback | None): called once per run directory finished
108
+ (after its records are written), always from this process -- never a worker, so
109
+ it never needs to be picklable. Left to raise: a failing callback aborts
110
+ generation rather than being silently swallowed.
111
+
112
+ Returns:
113
+ (int): total number of records written across all directories.
114
+
115
+ """
116
+ sorted_dirs = get_sorted_dirs(data_dirs)
117
+ db_path = Path(db_path)
118
+ total_dirs = len(sorted_dirs)
119
+
120
+ def _report(run_dir: Path, completed: int) -> None:
121
+ if on_progress is not None:
122
+ on_progress(
123
+ ProgressEvent(
124
+ stage="generate",
125
+ completed=completed,
126
+ total=total_dirs,
127
+ detail=str(run_dir),
128
+ )
129
+ )
130
+
131
+ logger.info(f"Generating and writing Records for {total_dirs} run(s)...")
132
+ total = 0
133
+ with RecordStore.open(db_path) as store:
134
+ if n_procs > 1:
135
+ root_logger = logging.getLogger()
136
+ log_queue, listener = create_queue_listener(*root_logger.handlers)
137
+ listener.start()
138
+ try:
139
+ with ProcessPoolExecutor(
140
+ max_workers=n_procs,
141
+ initializer=_init_worker_with_logging,
142
+ initargs=(record_generator, log_queue, root_logger.level),
143
+ ) as executor:
144
+ futures = [
145
+ executor.submit(_compute, run_dir) for run_dir in sorted_dirs
146
+ ]
147
+ for completed, future in enumerate(as_completed(futures), start=1):
148
+ run_dir, records = future.result()
149
+ total += store.write_run(run_dir, records)
150
+ logger.info(f"Wrote records for run_dir = '{run_dir}'")
151
+ _report(run_dir, completed)
152
+ finally:
153
+ # Blocks until every already-queued log record has been dispatched, so worker
154
+ # log output isn't lost or interleaved with what follows.
155
+ listener.stop()
156
+ else:
157
+ _init_worker(record_generator)
158
+ for completed, run_dir in enumerate(sorted_dirs, start=1):
159
+ _, records = _compute(run_dir)
160
+ total += store.write_run(run_dir, records)
161
+ logger.info(f"Wrote records for run_dir = '{run_dir}'")
162
+ _report(run_dir, completed)
163
+
164
+ logger.info(
165
+ f"Finished generating Records: {total} records written across {total_dirs} run(s)"
166
+ )
167
+ return total
@@ -0,0 +1,62 @@
1
+ """
2
+ Draws `HistogramEntry` records onto a matplotlib figure. Fed directly by `RecordStore` query
3
+ results, so there's no directory-loading state to manage.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from trendify.base.helpers import Tag
13
+ from trendify.plotting.figure import SingleAxisFigure
14
+ from trendify.plotting.histogram import HistogramEntry
15
+
16
+ __all__ = ["Histogrammer"]
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Histogrammer(BaseModel):
22
+ """
23
+ Draws `HistogramEntry` records sharing a tag onto a matplotlib axes, grouped by style.
24
+ """
25
+
26
+ @classmethod
27
+ def handle_histogram_entries(
28
+ cls,
29
+ tag: Tag,
30
+ histogram_entries: list[HistogramEntry],
31
+ saf: SingleAxisFigure | None = None,
32
+ ) -> SingleAxisFigure:
33
+ """
34
+ Histograms the provided entries onto `saf` (creating a new figure if not given).
35
+
36
+ Args:
37
+ tag (Tag): tag these entries belong to (used only if a new figure is created)
38
+ histogram_entries (list[HistogramEntry]): entries to histogram, grouped by
39
+ `HistogramStyle` (each distinct style becomes one `ax.hist` call/series)
40
+ saf (SingleAxisFigure | None): figure to draw onto; a new one is created if `None`
41
+
42
+ Returns:
43
+ (SingleAxisFigure): the figure drawn onto
44
+
45
+ """
46
+ if saf is None:
47
+ saf = SingleAxisFigure.new(tag=tag)
48
+
49
+ histogram_styles = set(h.style for h in histogram_entries)
50
+ logger.debug(
51
+ f"Histogramming {len(histogram_entries)} entries into {len(histogram_styles)} "
52
+ f"style(s) for {tag = }"
53
+ )
54
+ for style in histogram_styles:
55
+ matching_entries = [e for e in histogram_entries if e.style == style]
56
+ values = [e.value for e in matching_entries]
57
+ if style is not None:
58
+ saf.ax.hist(values, **style.as_plot_kwargs())
59
+ else:
60
+ saf.ax.hist(values)
61
+
62
+ return saf
@@ -0,0 +1,195 @@
1
+ """
2
+ Static rendering pipeline: renders CSV tables and matplotlib figures directly from a
3
+ `RecordStore`.
4
+
5
+ Design note: every record type sharing a tag (`Point2D`/`Scatter2D`/`Trace2D`/`AxLine`/
6
+ `HistogramEntry`) is drawn onto one shared `SingleAxisFigure` and saved once to `<tag>.jpg`,
7
+ since histogram data isn't special-cased onto its own figure/file; a tag mixing record types
8
+ renders the same way any other tag does.
9
+
10
+ Rendering only ever reads, so worker processes each open their own read-only `RecordStore`
11
+ connection and render disjoint tags in parallel with zero write-lock contention, since SQLite's
12
+ WAL mode allows any number of concurrent readers, unlike `generate_records`, where only one
13
+ process can ever write at a time.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from concurrent.futures import ProcessPoolExecutor, as_completed
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import matplotlib.pyplot as plt
24
+
25
+ from trendify.base.helpers import Tag
26
+ from trendify.formats.format2d import Format2D, Rastered
27
+ from trendify.generator.histogrammer import Histogrammer
28
+ from trendify.generator.table_builder import TableBuilder
29
+ from trendify.generator.xy_data_plotter import XYDataPlotter
30
+ from trendify.log import create_queue_listener
31
+ from trendify.log import worker_init as _init_worker_logging
32
+ from trendify.plotting.axline import AxLine
33
+ from trendify.plotting.histogram import HistogramEntry
34
+ from trendify.plotting.point import Point2D
35
+ from trendify.plotting.scatter import Scatter2D
36
+ from trendify.plotting.trace import Trace2D
37
+ from trendify.progress import ProgressCallback, ProgressEvent
38
+ from trendify.store.record_store import RecordStore
39
+ from trendify.store.tags import tag_to_path_parts
40
+
41
+ __all__ = ["render_assets"]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Per-process global set by `_init_worker`: the one read-only connection a render worker needs.
46
+ _worker_store: RecordStore | None = None
47
+
48
+
49
+ def _init_worker(db_path: str) -> None:
50
+ global _worker_store
51
+ _worker_store = RecordStore.open(Path(db_path), readonly=True)
52
+
53
+
54
+ def _init_worker_with_logging(
55
+ db_path: str,
56
+ log_queue: Any,
57
+ log_level: int,
58
+ ) -> None:
59
+ _init_worker_logging(log_queue, log_level)
60
+ _init_worker(db_path)
61
+
62
+
63
+ def _render_tag(
64
+ tag: Tag,
65
+ output_dir: str,
66
+ ) -> Tag:
67
+ # Returns `tag` (not just None) so the parent process's as_completed loop knows which tag
68
+ # a given future was for, to report progress -- `future.result()` is the only thing that
69
+ # survives the process boundary back to the parent.
70
+ assert _worker_store is not None
71
+ _render_tag_assets(
72
+ _worker_store,
73
+ tag,
74
+ Path(output_dir),
75
+ )
76
+ return tag
77
+
78
+
79
+ def _render_tag_assets(
80
+ store: RecordStore,
81
+ tag: Tag,
82
+ output_dir: Path,
83
+ ) -> None:
84
+ melted = store.get_table_entries(tag)
85
+ if melted.height > 0:
86
+ logger.info(f"Making tables for {tag = }")
87
+ TableBuilder.process_table_entries(tag=tag, melted=melted, out_dir=output_dir)
88
+ logger.info(f"Finished tables for {tag = }")
89
+
90
+ format2d_records = store.get_records_of_type(Format2D, tag=tag)
91
+ format2d = format2d_records[0] if format2d_records else None
92
+
93
+ points = store.get_records_of_type(Point2D, tag=tag)
94
+ traces = store.get_records_of_type(Trace2D, tag=tag)
95
+ scatters = store.get_records_of_type(Scatter2D, tag=tag)
96
+ axlines = store.get_records_of_type(AxLine, tag=tag)
97
+ histogram_entries = store.get_records_of_type(HistogramEntry, tag=tag)
98
+
99
+ if not (points or traces or scatters or axlines or histogram_entries):
100
+ return
101
+
102
+ # Points/traces/scatters/axlines and histogram entries all draw onto the same
103
+ # `SingleAxisFigure`/axes and get saved once, rather than forcing histogram data onto a
104
+ # separate figure: a tag mixing record types is treated the same as any other tag.
105
+ logger.info(f"Making plot for {tag = }")
106
+ saf = XYDataPlotter.handle_points_and_traces(
107
+ tag=tag,
108
+ points=points,
109
+ traces=traces,
110
+ axlines=axlines,
111
+ scatters=scatters,
112
+ )
113
+ if histogram_entries:
114
+ Histogrammer.handle_histogram_entries(
115
+ tag=tag, histogram_entries=histogram_entries, saf=saf
116
+ )
117
+ if format2d is not None:
118
+ saf.apply_format(format2d)
119
+
120
+ renderer = format2d.renderer if format2d is not None else Rastered()
121
+ save_path = output_dir.joinpath(*tag_to_path_parts(tag)).with_suffix(
122
+ renderer.filetype
123
+ )
124
+ save_path.parent.mkdir(parents=True, exist_ok=True)
125
+ logger.info(f"Saving to '{save_path}'")
126
+ saf.savefig(save_path, dpi=renderer.dpi if isinstance(renderer, Rastered) else None)
127
+ plt.close(saf.fig)
128
+ logger.info(f"Finished plot for {tag = }")
129
+
130
+
131
+ def render_assets(
132
+ db_path: Path,
133
+ output_dir: Path,
134
+ n_procs: int = 1,
135
+ on_progress: ProgressCallback | None = None,
136
+ ) -> None:
137
+ """
138
+ Renders CSV tables and matplotlib figures for every tag in the `RecordStore` at
139
+ `db_path`, writing them under `output_dir` (nested per tag).
140
+
141
+ Args:
142
+ db_path (Path): path to the trendify output directory's `.db` file
143
+ output_dir (Path): directory tables/figures are written under
144
+ n_procs (int): number of worker processes rendering tags in parallel. `n_procs == 1` runs sequentially in this process (easier to debug with full tracebacks). `n_procs > 1` uses a `ProcessPoolExecutor`, with one read-only `RecordStore` connection opened per worker, which is safe and contention-free since rendering never writes.
145
+ on_progress (ProgressCallback | None): called once per tag finished, always from this
146
+ process -- never a worker, so it never needs to be picklable. Left to raise: a
147
+ failing callback aborts rendering rather than being silently swallowed.
148
+
149
+ """
150
+ db_path = Path(db_path)
151
+ output_dir = Path(output_dir)
152
+
153
+ with RecordStore.open(db_path, readonly=True) as store:
154
+ tags = store.tag_tree()
155
+ total_tags = len(tags)
156
+
157
+ def _report(tag: Tag, completed: int) -> None:
158
+ if on_progress is not None:
159
+ on_progress(
160
+ ProgressEvent(
161
+ stage="render",
162
+ completed=completed,
163
+ total=total_tags,
164
+ detail=str(tag),
165
+ )
166
+ )
167
+
168
+ if n_procs > 1:
169
+ root_logger = logging.getLogger()
170
+ log_queue, listener = create_queue_listener(*root_logger.handlers)
171
+ listener.start()
172
+ try:
173
+ with ProcessPoolExecutor(
174
+ max_workers=n_procs,
175
+ initializer=_init_worker_with_logging,
176
+ initargs=(str(db_path), log_queue, root_logger.level),
177
+ ) as executor:
178
+ futures = [
179
+ executor.submit(
180
+ _render_tag,
181
+ tag,
182
+ str(output_dir),
183
+ )
184
+ for tag in tags
185
+ ]
186
+ for completed, future in enumerate(as_completed(futures), start=1):
187
+ finished_tag = future.result()
188
+ _report(finished_tag, completed)
189
+ finally:
190
+ listener.stop()
191
+ else:
192
+ with RecordStore.open(db_path, readonly=True) as store:
193
+ for completed, tag in enumerate(tags, start=1):
194
+ _render_tag_assets(store, tag, output_dir)
195
+ _report(tag, completed)
@@ -0,0 +1,194 @@
1
+ """
2
+ Builds melted/pivot/stats CSV tables for `TableEntry` records, from `RecordStore` query
3
+ results, using Polars. `RecordStore.get_table_entries` already hands back the melted
4
+ row/col/value/unit shape as a `pl.DataFrame` via one indexed SQL query, so this module only
5
+ needs to pivot and compute statistics, not reparse or reshape raw records.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import polars as pl
15
+ from pydantic import BaseModel
16
+
17
+ from trendify.base.helpers import Tag
18
+ from trendify.store.tags import tag_to_path_parts
19
+
20
+ __all__ = ["TableBuilder"]
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _DIGIT_RUN = re.compile(r"\d+")
25
+
26
+
27
+ def _natural_sort_key(value: str) -> str:
28
+ """
29
+ Zero-pads every run of digits in `value` so a plain lexicographic sort orders numeric
30
+ substrings numerically (e.g. "row_2" before "row_10") instead of by leading digit
31
+ ("row_10" before "row_2"). Row labels that aren't numeric at all still compare as
32
+ ordinary strings.
33
+ """
34
+ return _DIGIT_RUN.sub(lambda m: m.group().zfill(20), value)
35
+
36
+
37
+ class TableBuilder(BaseModel):
38
+ """
39
+ Builds tables (melted, pivot, and stats) from `TableEntry` records for a given tag.
40
+ """
41
+
42
+ @classmethod
43
+ def process_table_entries(
44
+ cls, tag: Tag, melted: pl.DataFrame, out_dir: Path
45
+ ) -> None:
46
+ """
47
+ Saves CSV files for the melted data frame, pivot dataframe, and pivot dataframe stats.
48
+
49
+ File names all use the tag with different suffixes: `<tag>_melted.csv`,
50
+ `<tag>_pivot.csv`, `<tag>_stats.csv`.
51
+
52
+ Args:
53
+ tag (Tag): record tag these entries belong to
54
+ melted (pl.DataFrame): row/col/value/unit table, as returned by
55
+ `RecordStore.get_table_entries`
56
+ out_dir (Path): directory under which the CSVs are saved (nested per tag)
57
+
58
+ """
59
+ if melted.height == 0:
60
+ return
61
+
62
+ melted = (
63
+ melted.with_columns(
64
+ _sort_key=pl.Series([_natural_sort_key(r) for r in melted["row"]])
65
+ )
66
+ .sort("_sort_key")
67
+ .drop("_sort_key")
68
+ )
69
+
70
+ *parents, stem = tag_to_path_parts(tag)
71
+ save_dir = out_dir.joinpath(*parents)
72
+ save_dir.mkdir(parents=True, exist_ok=True)
73
+ logger.info(f"Saving tables for {tag = } to '{save_dir}/{stem}_*.csv'")
74
+
75
+ _write_csv(melted, save_dir / f"{stem}_melted.csv")
76
+
77
+ pivot = cls.pivot_table(melted)
78
+ if pivot is not None:
79
+ _write_csv(pivot, save_dir / f"{stem}_pivot.csv")
80
+
81
+ try:
82
+ stats = cls.get_stats_table(pivot)
83
+ if stats is not None:
84
+ stats.write_csv(save_dir / f"{stem}_stats.csv")
85
+ else:
86
+ logger.debug(
87
+ f"No stats table for {tag = }: no numeric pivoted columns"
88
+ )
89
+ except Exception as e:
90
+ logger.error(
91
+ f"Could not generate stats table for {tag = }. Error: {e!s}"
92
+ )
93
+ else:
94
+ logger.debug(
95
+ f"No pivot table for {tag = }: a (row, col) pair repeats in the melted table"
96
+ )
97
+
98
+ @classmethod
99
+ def pivot_table(cls, melted: pl.DataFrame) -> pl.DataFrame | None:
100
+ """
101
+ Attempts to pivot a melted row/col/value DataFrame into wide form.
102
+
103
+ Args:
104
+ melted (pl.DataFrame): DataFrame with `row`/`col`/`value` columns
105
+
106
+ Returns:
107
+ (pl.DataFrame | None): pivoted DataFrame, or `None` if the pivot fails because a
108
+ `(row, col)` pair repeats.
109
+
110
+ """
111
+ try:
112
+ return melted.pivot(on="col", index="row", values="value")
113
+ except pl.exceptions.ComputeError:
114
+ return None
115
+
116
+ @classmethod
117
+ def get_stats_table(cls, pivot: pl.DataFrame) -> pl.DataFrame | None:
118
+ """
119
+ Computes min/mean/max/3-sigma statistics for each pivoted column.
120
+
121
+ Non-numeric values are coerced to null rather than causing the whole column to be
122
+ skipped.
123
+
124
+ Args:
125
+ pivot (pl.DataFrame): pivoted table (as returned by `pivot_table`), with a `"row"`
126
+ index column plus one column per pivoted `col` value.
127
+
128
+ Returns:
129
+ (pl.DataFrame | None): stats table with a `"Name"` column (one row per pivoted
130
+ column) plus `min`/`mean`/`max`/`sigma3` columns, or `None` if there are no
131
+ value columns or every computed statistic is null.
132
+
133
+ """
134
+ value_columns = [c for c in pivot.columns if c != "row"]
135
+ if not value_columns:
136
+ return None
137
+
138
+ rows = []
139
+ any_stat = False
140
+ for col in value_columns:
141
+ numeric = _coerce_numeric(pivot[col])
142
+ mn, mean, mx, std = (
143
+ numeric.min(),
144
+ numeric.mean(),
145
+ numeric.max(),
146
+ numeric.std(),
147
+ )
148
+ if any(v is not None for v in (mn, mean, mx, std)):
149
+ any_stat = True
150
+ rows.append(
151
+ {
152
+ "Name": col,
153
+ "min": mn,
154
+ "mean": mean,
155
+ "max": mx,
156
+ "sigma3": None if std is None else std * 3,
157
+ }
158
+ )
159
+ if not any_stat:
160
+ return None
161
+ return pl.DataFrame(rows)
162
+
163
+
164
+ def _coerce_numeric(series: pl.Series) -> pl.Series:
165
+ """Best-effort element-wise cast to float, `None` for values that don't convert."""
166
+ if series.dtype != pl.Object:
167
+ return series.cast(pl.Float64, strict=False)
168
+
169
+ def _to_float(v) -> float | None:
170
+ if v is None:
171
+ return None
172
+ try:
173
+ return float(v)
174
+ except (TypeError, ValueError):
175
+ return None
176
+
177
+ return pl.Series(
178
+ series.name, [_to_float(v) for v in series.to_list()], dtype=pl.Float64
179
+ )
180
+
181
+
182
+ def _write_csv(df: pl.DataFrame, path: Path) -> None:
183
+ """
184
+ Writes `df` to CSV, stringifying any `Object`-dtype columns first. Polars' CSV writer
185
+ doesn't support `Object` directly, and `TableEntry.value`'s heterogeneous float/str/bool
186
+ union is stored as `Object` by `RecordStore.get_table_entries`.
187
+ """
188
+ object_cols = [c for c, dtype in df.schema.items() if dtype == pl.Object]
189
+ if object_cols:
190
+ df = df.with_columns(
191
+ pl.Series(c, [None if v is None else str(v) for v in df[c].to_list()])
192
+ for c in object_cols
193
+ )
194
+ df.write_csv(path)