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.
- trendify/__about__.py +14 -0
- trendify/__init__.py +250 -0
- trendify/base/__init__.py +36 -0
- trendify/base/helpers.py +76 -0
- trendify/base/pen.py +161 -0
- trendify/base/record.py +148 -0
- trendify/cli.py +403 -0
- trendify/color.py +260 -0
- trendify/examples.py +474 -0
- trendify/formats/__init__.py +28 -0
- trendify/formats/format2d.py +128 -0
- trendify/formats/table.py +55 -0
- trendify/generator/__init__.py +38 -0
- trendify/generator/generate.py +167 -0
- trendify/generator/histogrammer.py +62 -0
- trendify/generator/render.py +195 -0
- trendify/generator/table_builder.py +194 -0
- trendify/generator/xy_data_plotter.py +195 -0
- trendify/log.py +195 -0
- trendify/pipeline.py +136 -0
- trendify/plotting/__init__.py +48 -0
- trendify/plotting/axline.py +122 -0
- trendify/plotting/figure.py +389 -0
- trendify/plotting/histogram.py +282 -0
- trendify/plotting/point.py +89 -0
- trendify/plotting/scatter.py +100 -0
- trendify/plotting/trace.py +129 -0
- trendify/progress.py +43 -0
- trendify/store/__init__.py +30 -0
- trendify/store/db.py +119 -0
- trendify/store/record_store.py +448 -0
- trendify/store/tags.py +65 -0
- trendify/styling/__init__.py +30 -0
- trendify/styling/grid.py +125 -0
- trendify/styling/legend.py +217 -0
- trendify/styling/marker.py +168 -0
- trendify/typing.py +26 -0
- trendify/viewer/__init__.py +41 -0
- trendify/viewer/app.py +208 -0
- trendify/viewer/hydration.py +181 -0
- trendify/viewer/plot_config.py +102 -0
- trendify/viewer/routes/__init__.py +6 -0
- trendify/viewer/routes/api.py +360 -0
- trendify/viewer/routes/pages.py +39 -0
- trendify/viewer/tag_tree.py +148 -0
- trendify/viewer/templates/base.html +63 -0
- trendify/viewer/templates/index.html +131 -0
- trendify/viewer/templates/partials/__init__.py +1 -0
- trendify/viewer/templates/partials/footer.html +15 -0
- trendify/viewer/templates/partials/nav.html +181 -0
- trendify/viewer/templates/partials/plot_view.html +221 -0
- trendify/viewer/templates/partials/sidebar.html +163 -0
- trendify/viewer/templates/partials/table_view.html +43 -0
- trendify/viewer/templates/static/__init__.py +1 -0
- trendify/viewer/templates/static/assets/__init__.py +1 -0
- trendify/viewer/templates/static/assets/main-logo-white-bg.svg +75 -0
- trendify/viewer/templates/static/assets/main-logo.svg +66 -0
- trendify/viewer/templates/static/css/app.css +75 -0
- trendify/viewer/templates/static/js/main.js +2 -0
- trendify/viewer/templates/static/vendored/__init__.py +1 -0
- trendify/viewer/templates/static/vendored/alpine-3.15.12.min.js +5 -0
- trendify/viewer/templates/static/vendored/alpine-LICENSE.md +21 -0
- trendify/viewer/templates/static/vendored/dataTables-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/dataTables.dataTables.min.css +1 -0
- trendify/viewer/templates/static/vendored/dataTables.min.js +4 -0
- trendify/viewer/templates/static/vendored/jquery-3.7.1.min.js +2 -0
- trendify/viewer/templates/static/vendored/jquery-LICENSE.txt +3 -0
- trendify/viewer/templates/static/vendored/plotly-3.6.0.min.js +3882 -0
- trendify/viewer/templates/static/vendored/plotly-LICENSE.txt +21 -0
- trendify/viewer/templates/static/vendored/tailwind-4.3.2.global.js +947 -0
- trendify/viewer/templates/static/vendored/tailwind-LICENSE +21 -0
- trendify2-2.0.4.dist-info/METADATA +107 -0
- trendify2-2.0.4.dist-info/RECORD +76 -0
- trendify2-2.0.4.dist-info/WHEEL +4 -0
- trendify2-2.0.4.dist-info/entry_points.txt +2 -0
- 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)
|