scistackplot 0.1.26__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.
scistackplot/shape.py ADDED
@@ -0,0 +1,106 @@
1
+ """
2
+ Measure shape classification.
3
+
4
+ The *shape* of a measure — scalar, 1-D series, 2-D matrix — is the single
5
+ input that decides which plot kinds are even meaningful for it (see
6
+ ``capability.available_plots``). Classification is deliberately based on
7
+ **observed values**, not on declared dtypes or SQL type-name strings: a pandas
8
+ object column holding numpy arrays and a DuckDB LIST column both arrive here
9
+ as "a cell that contains a sequence", and the value is the only reliable
10
+ common ground. (The GUI's pre-existing ``_numeric_plot_kind`` in
11
+ ``scistack_gui/api/variables.py`` made the same call for the same reason.)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+
23
+ class Shape(str, Enum):
24
+ """What one cell of a measure column holds."""
25
+
26
+ SCALAR = "scalar"
27
+ SERIES_1D = "1d"
28
+ MATRIX_2D = "2d"
29
+ CATEGORICAL = "categorical"
30
+ UNKNOWN = "unknown"
31
+
32
+ def __str__(self) -> str: # keeps f-strings and log lines readable
33
+ return self.value
34
+
35
+
36
+ def _is_number(value: Any) -> bool:
37
+ # bool is an int subclass; a column of True/False is categorical, not scalar.
38
+ if isinstance(value, bool) or isinstance(value, np.bool_):
39
+ return False
40
+ return isinstance(value, (int, float, np.integer, np.floating))
41
+
42
+
43
+ def classify_value(value: Any) -> Shape:
44
+ """Classify a single cell value."""
45
+ if value is None or (isinstance(value, float) and np.isnan(value)):
46
+ return Shape.UNKNOWN
47
+ # bool is an int subclass, so this must precede the numeric check: a
48
+ # True/False column groups data, it is not something to plot on an axis.
49
+ if isinstance(value, (bool, np.bool_)):
50
+ return Shape.CATEGORICAL
51
+ if _is_number(value):
52
+ return Shape.SCALAR
53
+ if isinstance(value, str):
54
+ return Shape.CATEGORICAL
55
+ if isinstance(value, np.ndarray):
56
+ if value.ndim == 1:
57
+ return Shape.SERIES_1D if value.size else Shape.UNKNOWN
58
+ if value.ndim == 2:
59
+ return Shape.MATRIX_2D
60
+ return Shape.UNKNOWN
61
+ if isinstance(value, (list, tuple)):
62
+ if not value:
63
+ return Shape.UNKNOWN
64
+ first = value[0]
65
+ if _is_number(first):
66
+ return Shape.SERIES_1D
67
+ if isinstance(first, (list, tuple, np.ndarray)):
68
+ return Shape.MATRIX_2D
69
+ return Shape.UNKNOWN
70
+ return Shape.UNKNOWN
71
+
72
+
73
+ def classify_column(series: pd.Series) -> Shape:
74
+ """
75
+ Classify a whole measure column.
76
+
77
+ A column is uniformly typed in every source we support (a DuckDB column,
78
+ or a DataFrame column built from one), so the first non-null value decides.
79
+ We still scan a few values rather than exactly one: a column can lead with
80
+ nulls, and an all-null column must classify as UNKNOWN rather than crash.
81
+ """
82
+ if series is None or len(series) == 0:
83
+ return Shape.UNKNOWN
84
+
85
+ if pd.api.types.is_bool_dtype(series):
86
+ return Shape.CATEGORICAL
87
+ if pd.api.types.is_numeric_dtype(series):
88
+ return Shape.SCALAR
89
+ if isinstance(series.dtype, pd.CategoricalDtype):
90
+ return Shape.CATEGORICAL
91
+
92
+ for value in series.head(_SCAN_LIMIT):
93
+ shape = classify_value(value)
94
+ if shape is not Shape.UNKNOWN:
95
+ return shape
96
+ return Shape.UNKNOWN
97
+
98
+
99
+ #: How many leading values to inspect before giving up on a column. Small on
100
+ #: purpose — this runs on every measure of every table the GUI describes.
101
+ _SCAN_LIMIT = 20
102
+
103
+
104
+ def is_plottable(shape: Shape) -> bool:
105
+ """Whether any plot kind can render this shape."""
106
+ return shape in (Shape.SCALAR, Shape.SERIES_1D, Shape.MATRIX_2D)
@@ -0,0 +1,7 @@
1
+ """Data sources: one protocol, many backends."""
2
+
3
+ from .base import BaseSource, DataSource
4
+ from .csv import CsvSource
5
+ from .frame import DataFrameSource
6
+
7
+ __all__ = ["DataSource", "BaseSource", "CsvSource", "DataFrameSource"]
@@ -0,0 +1,256 @@
1
+ """
2
+ The ``DataSource`` protocol — the one seam that makes this package both
3
+ standalone and scistack-compatible.
4
+
5
+ ``scistackplot`` ships CSV and DataFrame implementations; ``scistackplotdb``
6
+ ships the scidb one. The GUI talks only to this protocol and never learns which
7
+ implementation it has, so the same application serves a lone CSV file and a
8
+ full scidb project. Everything above this line is pure long-table logic.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from ..table import LongTable
16
+
17
+
18
+ @runtime_checkable
19
+ class DataSource(Protocol):
20
+ """Supplies long-format tables and the metadata needed to build controls."""
21
+
22
+ def describe(self) -> dict:
23
+ """
24
+ Factors, measures and shapes — everything the GUI needs to render its
25
+ controls before any data is fetched.
26
+ """
27
+ ...
28
+
29
+ def get_table(
30
+ self,
31
+ measures: list[str],
32
+ *,
33
+ x_measure: str | None = None,
34
+ factor_variables: list[str] | None = None,
35
+ ) -> LongTable:
36
+ """The long-format table for a plot's measures.
37
+
38
+ Several ``measures`` **stack** into one value column plus a
39
+ ``Variable`` column; ``x_measure`` **joins** as an x axis instead.
40
+ """
41
+ ...
42
+
43
+ def joinable_with(self, measure: str) -> list[str]:
44
+ """
45
+ Measures that can supply an x axis for ``measure``.
46
+
47
+ Trivially "all the others" for a flat CSV. For scidb it is a real
48
+ question — two variables can only share a plot if their schema levels
49
+ can be joined — and answering it honestly keeps the GUI from offering
50
+ combinations that cannot be built.
51
+ """
52
+ ...
53
+
54
+ def stackable_with(self, measure: str) -> list[str]:
55
+ """Measures that can be plotted as another series alongside ``measure``."""
56
+ ...
57
+
58
+ def groupable_with(self, measure: str) -> list[str]:
59
+ """Variables usable as a grouping FACTOR for ``measure`` — recorded at
60
+ or above its schema level, so each row gets exactly one value."""
61
+ ...
62
+
63
+
64
+ #: How many built tables a source keeps. Small on purpose: the entries are whole
65
+ #: frames (millions of rows for a 1-D measure), and the access pattern they serve
66
+ #: is narrow — the panel asks for the SAME table over and over while the user
67
+ #: moves controls, and only changes which one when a variant row names another
68
+ #: variable. Four covers that with room to spare; a larger cache would mostly
69
+ #: hold frames nobody is going to ask for again.
70
+ TABLE_CACHE_ENTRIES = 4
71
+
72
+ #: Placeholder the stacked value column carries during the melt, before it is
73
+ #: renamed to the primary measure. See :meth:`BaseSource._stacked` for why the
74
+ #: rename is unavoidable. Deliberately not a plausible column name: it only has
75
+ #: to survive one call, and it must not collide with a real one.
76
+ _STACK_VALUE = "__stacked_value__"
77
+
78
+
79
+ class BaseSource:
80
+ """Small shared implementation for sources backed by a single table.
81
+
82
+ Also owns the built-table cache for *every* source, ``ScidbSource``
83
+ included — see :meth:`get_table`.
84
+ """
85
+
86
+ name: str = "table"
87
+
88
+ def _table(self) -> LongTable: # pragma: no cover - overridden
89
+ raise NotImplementedError
90
+
91
+ def describe(self) -> dict:
92
+ return self._table().describe()
93
+
94
+ def get_table(
95
+ self,
96
+ measures: list[str],
97
+ *,
98
+ x_measure: str | None = None,
99
+ factor_variables: list[str] | None = None,
100
+ ) -> LongTable:
101
+ """The long table for these measures, built once per distinct request.
102
+
103
+ **Why this is memoized.** Building the table is melt + stack + join, and
104
+ the panel asks for the same one repeatedly: ``plot_resolve`` and
105
+ ``plot_capabilities`` each build it on every control change, so a single
106
+ click paid for it twice, and four concurrent resolves paid for it eight
107
+ times. The 2026-09-11 log shows exactly that — ``melted 'FilteredEMG'``
108
+ and ``stacked [...]`` repeating in pairs on every action while
109
+ ``load_variable`` (the layer below, already cached) ran once.
110
+
111
+ Keyed on everything that changes the result. Note ``measures`` is
112
+ order-sensitive and stays a tuple rather than a set: the order decides
113
+ which measure is primary and what the ``Variable`` column's level order
114
+ is.
115
+
116
+ **Sharing a table between callers is safe** because nothing downstream
117
+ mutates it — ``apply_variant_sets`` copies before writing its factor,
118
+ and every other step (filters, explode, aggregate) builds a new frame.
119
+ There is no in-place write anywhere in ``scistackplot`` or
120
+ ``scistackplotdb``, and ``test_a_cached_table_is_not_mutated_by_use``
121
+ exists to keep it that way.
122
+
123
+ Staleness is the caller's business, as it already was for the frames
124
+ underneath: a run that writes records drops the whole source
125
+ (``plot_service.invalidate``), and this cache goes with it.
126
+ """
127
+ memo = self._table_cache()
128
+ key = (
129
+ tuple(measures),
130
+ x_measure,
131
+ tuple(factor_variables or ()),
132
+ )
133
+ if key in memo:
134
+ return memo[key]
135
+
136
+ table = self._build_table(
137
+ measures, x_measure=x_measure, factor_variables=factor_variables
138
+ )
139
+ memo[key] = table
140
+ while len(memo) > TABLE_CACHE_ENTRIES:
141
+ # Insertion-ordered dict: the oldest key is the first one.
142
+ memo.pop(next(iter(memo)))
143
+ return table
144
+
145
+ def _table_cache(self) -> dict:
146
+ """The memo, created on first use.
147
+
148
+ Lazy rather than set in ``__init__`` so every source inherits the cache
149
+ without having to remember to call up — including the ones that define
150
+ no ``__init__`` at all.
151
+ """
152
+ memo = getattr(self, "_built_tables", None)
153
+ if memo is None:
154
+ memo = {}
155
+ self._built_tables = memo
156
+ return memo
157
+
158
+ def invalidate_tables(self) -> None:
159
+ """Drop built tables. Call when the rows underneath may have changed."""
160
+ self._table_cache().clear()
161
+
162
+ def _build_table(
163
+ self,
164
+ measures: list[str],
165
+ *,
166
+ x_measure: str | None = None,
167
+ factor_variables: list[str] | None = None,
168
+ ) -> LongTable:
169
+ # `factor_variables` is accepted and ignored: a flat table's factors are
170
+ # already columns of every row, so there is nothing to join in
171
+ # (`groupable_with` returns none). Accepting it keeps one call shape for
172
+ # every source rather than making callers branch on which they hold.
173
+ table = self._table()
174
+ requested = [*measures, *([x_measure] if x_measure else [])]
175
+ unknown = [m for m in requested if m not in table.measure_names]
176
+ if unknown:
177
+ raise KeyError(
178
+ f"Unknown measure(s) {unknown}. Available: {table.measure_names}"
179
+ )
180
+ # A flat table already carries every column, so an x measure needs no
181
+ # join here — only stacking changes the frame's shape.
182
+ return self._stacked(table, measures) if len(measures) > 1 else table
183
+
184
+ def _stacked(self, table: LongTable, measures: list[str]) -> LongTable:
185
+ """Melt several measure columns into one, plus a ``Variable`` column.
186
+
187
+ The CSV equivalent of ``ScidbSource._stacked_table``: two columns of a
188
+ gait CSV are as much "Raw vs Filtered" as two scidb variables are, and
189
+ keeping the standalone path capable of it is what keeps the DataSource
190
+ protocol honest rather than scidb-shaped.
191
+
192
+ The melt goes via :data:`_STACK_VALUE` and is renamed afterwards. It has
193
+ to: the stacked column takes the PRIMARY measure's name, the primary is
194
+ always one of the columns being melted, and ``DataFrame.melt`` refuses a
195
+ ``value_name`` that matches any column it is melting. Passing the name
196
+ directly raised ``ValueError`` for every possible input, so stacking on
197
+ a CSV or DataFrame source never worked at all — found 2026-09-11 by the
198
+ first test to ask for it.
199
+ """
200
+ from ..variants import VARIABLE_COLUMN
201
+
202
+ primary = measures[0]
203
+ frame = table.frame
204
+ id_vars = [c for c in frame.columns if c not in table.measure_names]
205
+ melted = frame.melt(
206
+ id_vars=id_vars,
207
+ value_vars=list(measures),
208
+ var_name=VARIABLE_COLUMN,
209
+ value_name=_STACK_VALUE,
210
+ ).rename(columns={_STACK_VALUE: primary})
211
+ level_order = {f.name: list(f.levels) for f in table.factors}
212
+ # Declared order, not observed: the user listed the measures.
213
+ level_order[VARIABLE_COLUMN] = list(measures)
214
+ return LongTable.from_frame(
215
+ melted,
216
+ factors=[*table.factor_names, VARIABLE_COLUMN],
217
+ measures=[primary],
218
+ level_order=level_order,
219
+ index_column=table.index_column,
220
+ name=primary,
221
+ schema_levels=table.schema_levels,
222
+ measure_labels={primary: " / ".join(measures)},
223
+ )
224
+
225
+ def joinable_with(self, measure: str) -> list[str]:
226
+ return [m for m in self._table().measure_names if m != measure]
227
+
228
+ def groupable_with(self, measure: str) -> list[str]:
229
+ """A flat table's factors are already columns of every row.
230
+
231
+ Grouping variables exist because scidb records a subject-level fact
232
+ separately from trial-level data; a CSV has no such split, so there is
233
+ nothing to join in.
234
+ """
235
+ return []
236
+
237
+ def stackable_with(self, measure: str) -> list[str]:
238
+ """Same shape, so the two share an axis. A flat table has no levels to
239
+ reconcile, which is the only other thing scidb has to check."""
240
+ table = self._table()
241
+ try:
242
+ shape = table.shape_of(measure)
243
+ except KeyError:
244
+ return []
245
+ return [
246
+ name
247
+ for name in table.measure_names
248
+ if name != measure and table.shape_of(name) is shape
249
+ ]
250
+
251
+ def default_measure(self) -> str | None:
252
+ measures = self._table().measure_names
253
+ return measures[0] if measures else None
254
+
255
+ def metadata(self) -> dict[str, Any]:
256
+ return {"source": type(self).__name__, "name": self.name}
@@ -0,0 +1,67 @@
1
+ """
2
+ CSV source — the standalone entry point, and the direct descendant of the
3
+ R/Shiny proof of concept.
4
+
5
+ Its factor/measure split follows the same rule the proof of concept used
6
+ (``getColNames.R``: factor columns are the non-numeric ones), but derived from
7
+ observed value shapes rather than R's ``is.factor``, so a column of integer
8
+ subject IDs read from CSV can still be declared a factor explicitly.
9
+
10
+ This path is kept first-class — the VS Code extension routes "Plot" on a .csv
11
+ file here, with no project and no database — so the standalone claim stays
12
+ exercised rather than aspirational.
13
+
14
+ One inference limit worth knowing: a column of purely numeric IDs (``1, 2, 3``
15
+ for subject) is indistinguishable from a measurement by value alone, so it is
16
+ classified as a measure. Pass ``factors=["subject", ...]`` — or read it as text
17
+ with ``dtype={"subject": str}`` — when that is not what you want. A scidb
18
+ source never hits this: it knows which columns are schema keys.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Any, Iterable
25
+
26
+ import pandas as pd
27
+ from scistacklog import Log
28
+
29
+ from ..table import LongTable
30
+ from .base import BaseSource
31
+
32
+ LAYER = "scistackplot"
33
+
34
+
35
+ class CsvSource(BaseSource):
36
+ def __init__(
37
+ self,
38
+ path: str | Path,
39
+ *,
40
+ factors: Iterable[str] | None = None,
41
+ measures: Iterable[str] | None = None,
42
+ level_order: dict[str, list[Any]] | None = None,
43
+ index_column: str | None = None,
44
+ **read_csv_kwargs: Any,
45
+ ) -> None:
46
+ self.path = Path(path)
47
+ self.name = self.path.name
48
+ frame = pd.read_csv(self.path, **read_csv_kwargs)
49
+ self._long = LongTable.from_frame(
50
+ frame,
51
+ factors=factors,
52
+ measures=measures,
53
+ level_order=level_order,
54
+ index_column=index_column,
55
+ name=self.name,
56
+ )
57
+ Log.info(
58
+ "loaded %s: %d row(s), %d factor(s), %d measure(s)",
59
+ self.path.name,
60
+ len(frame),
61
+ len(self._long.factors),
62
+ len(self._long.measures),
63
+ layer=LAYER,
64
+ )
65
+
66
+ def _table(self) -> LongTable:
67
+ return self._long
@@ -0,0 +1,45 @@
1
+ """In-memory DataFrame source — the scifor-equivalent entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable
6
+
7
+ import pandas as pd
8
+
9
+ from ..table import LongTable
10
+ from .base import BaseSource
11
+
12
+
13
+ class DataFrameSource(BaseSource):
14
+ """
15
+ Wrap a DataFrame you already have.
16
+
17
+ This is the direct analogue of ``scifor.for_each`` taking a plain table:
18
+ no database, no configuration, no file IO. Column roles are inferred from
19
+ observed value shapes when not given (see ``LongTable.from_frame``).
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ frame: pd.DataFrame,
25
+ *,
26
+ factors: Iterable[str] | None = None,
27
+ measures: Iterable[str] | None = None,
28
+ level_order: dict[str, list[Any]] | None = None,
29
+ variant_factors: Iterable[str] = (),
30
+ index_column: str | None = None,
31
+ name: str = "dataframe",
32
+ ) -> None:
33
+ self.name = name
34
+ self._long = LongTable.from_frame(
35
+ frame,
36
+ factors=factors,
37
+ measures=measures,
38
+ level_order=level_order,
39
+ variant_factors=variant_factors,
40
+ index_column=index_column,
41
+ name=name,
42
+ )
43
+
44
+ def _table(self) -> LongTable:
45
+ return self._long