scistackplot 0.1.26__tar.gz → 0.1.28__tar.gz

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 (25) hide show
  1. {scistackplot-0.1.26 → scistackplot-0.1.28}/.gitignore +4 -0
  2. {scistackplot-0.1.26 → scistackplot-0.1.28}/PKG-INFO +2 -1
  3. {scistackplot-0.1.26 → scistackplot-0.1.28}/pyproject.toml +1 -0
  4. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/__init__.py +2 -0
  5. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/capability.py +5 -2
  6. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/codegen.py +57 -0
  7. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/reduce.py +57 -2
  8. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/spec.py +90 -0
  9. {scistackplot-0.1.26 → scistackplot-0.1.28}/README.md +0 -0
  10. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/groups.py +0 -0
  11. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/render/__init__.py +0 -0
  12. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/render/base.py +0 -0
  13. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/render/mpl.py +0 -0
  14. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/render/plotly_.py +0 -0
  15. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/resolved.py +0 -0
  16. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/roles.py +0 -0
  17. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/shape.py +0 -0
  18. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/sources/__init__.py +0 -0
  19. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/sources/base.py +0 -0
  20. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/sources/csv.py +0 -0
  21. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/sources/frame.py +0 -0
  22. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/table.py +0 -0
  23. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/variants.py +0 -0
  24. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/xaxis.py +0 -0
  25. {scistackplot-0.1.26 → scistackplot-0.1.28}/src/scistackplot/ylimits.py +0 -0
@@ -20,6 +20,10 @@ __pycache__/
20
20
  *.duckdb.wal
21
21
  *.wal
22
22
  scistack-gui/frontend/node_modules/
23
+ # Compiled output of the frontend's React-free unit tests (npm test in
24
+ # scistack-gui/frontend). Regenerated by `tsc -p tsconfig.test.json`; unlike
25
+ # extension/dist/, nothing loads it at runtime.
26
+ scistack-gui/frontend/dist/
23
27
  # mkdocs build output (regenerated by `mkdocs build`)
24
28
  /site/
25
29
  # Runtime output of code_export_service (pipeline-to-code export) — timestamped
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scistackplot
3
- Version: 0.1.26
3
+ Version: 0.1.28
4
4
  Summary: Spec-driven plotting for long-format scientific data — standalone, GUI-friendly
5
5
  Author: SciStack Contributors
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Programming Language :: Python :: 3.10
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
16
17
  Classifier: Topic :: Scientific/Engineering :: Visualization
17
18
  Classifier: Typing :: Typed
18
19
  Requires-Python: >=3.10
@@ -32,6 +32,7 @@ classifiers = [
32
32
  "Programming Language :: Python :: 3.10",
33
33
  "Programming Language :: Python :: 3.11",
34
34
  "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
35
36
  "Topic :: Scientific/Engineering :: Visualization",
36
37
  "Typing :: Typed",
37
38
  ]
@@ -68,6 +68,7 @@ from .spec import (
68
68
  FacetOptions,
69
69
  Filter,
70
70
  LevelGroup,
71
+ LocationFilter,
71
72
  MatchOp,
72
73
  Matcher,
73
74
  PlotKind,
@@ -122,6 +123,7 @@ __all__ = [
122
123
  "StyleOptions",
123
124
  "Filter",
124
125
  "LevelGroup",
126
+ "LocationFilter",
125
127
  "VariantSet",
126
128
  # data
127
129
  "LongTable",
@@ -402,9 +402,12 @@ def factor_summary(spec: PlotSpec, derived: LongTable) -> list[dict]:
402
402
  entry["x_available"] = on_x["available"]
403
403
  entry["x_reason"] = on_x["reason"]
404
404
 
405
- if not spec.filters:
405
+ if not spec.filters and spec.location_filter.is_empty():
406
406
  # Nothing filtered: everything is selected, and no frame scan is needed
407
- # on the common path.
407
+ # on the common path. The location filter has to be checked here too —
408
+ # it narrows rows exactly as a Filter does, and a fast path that only
409
+ # knew about one of them would report "all 12 selected" beside a figure
410
+ # drawing 3, which is the precise failure this readout exists to avoid.
408
411
  for entry in factors:
409
412
  entry["selected"] = list(entry["levels"])
410
413
  return factors
@@ -352,6 +352,14 @@ def _preamble(spec, table, roles, shape) -> list[str]:
352
352
  ]
353
353
  )
354
354
 
355
+ # Schema locations. Emitted as one vectorised comparison per (prefix, key),
356
+ # which is the SAME rule reduce._location_mask applies — a ragged selection
357
+ # cannot be expressed as `for_each(subject=[...], trial=[...])`, whose keys
358
+ # cross-product, so it has to be a mask inside the function body. The
359
+ # `if _k in df.columns` guard is not defensive clutter: it is how a key the
360
+ # frame lacks goes unconstrained, matching the interactive path exactly.
361
+ lines.extend(_location_lines(spec))
362
+
355
363
  filter_lines: list[str] = []
356
364
  for flt in spec.filters:
357
365
  if flt.include is not None:
@@ -467,6 +475,32 @@ def _preamble(spec, table, roles, shape) -> list[str]:
467
475
  return lines
468
476
 
469
477
 
478
+ def _location_lines(spec: PlotSpec) -> list[str]:
479
+ """Generated pandas for ``spec.location_filter`` — empty when it is inert.
480
+
481
+ Kept beside the other preamble emitters rather than inlined so the one test
482
+ that matters can compare its output against
483
+ :func:`scistackplot.reduce._location_mask` on the same frame.
484
+ """
485
+ prefixes = spec.location_filter.prefixes()
486
+ if not prefixes:
487
+ return []
488
+ plural = "" if len(prefixes) == 1 else "s"
489
+ return [
490
+ f"# schema locations: {len(prefixes)} selection{plural}",
491
+ f"_loc_prefixes = {[[list(pair) for pair in p] for p in prefixes]!r}",
492
+ "_loc_mask = pd.Series(False, index=df.index)",
493
+ "for _p in _loc_prefixes:",
494
+ " _m = pd.Series(True, index=df.index)",
495
+ " for _k, _v in _p:",
496
+ " if _k in df.columns:",
497
+ " _m &= df[_k].astype(str) == _v",
498
+ " _loc_mask |= _m",
499
+ "df = df[_loc_mask]",
500
+ "",
501
+ ]
502
+
503
+
470
504
  def _facet_layout_args(spec, table: LongTable, facets: list[str]) -> list[str]:
471
505
  """
472
506
  seaborn arguments that reproduce the interactive facet arrangement.
@@ -754,6 +788,7 @@ def _color_level_count(spec: PlotSpec, table: LongTable, color: str) -> int:
754
788
  levels = [str(level) for level in table.factor(color).levels]
755
789
  except KeyError:
756
790
  return 2
791
+ levels = _levels_after_location(spec, color, levels)
757
792
  for flt in spec.filters:
758
793
  if flt.column != color:
759
794
  continue
@@ -766,6 +801,28 @@ def _color_level_count(spec: PlotSpec, table: LongTable, color: str) -> int:
766
801
  return len(levels)
767
802
 
768
803
 
804
+ def _levels_after_location(spec: PlotSpec, column: str, levels: list[str]) -> list[str]:
805
+ """``levels`` narrowed by ``spec.location_filter``, for one schema key.
806
+
807
+ The rule follows from prefixes constraining only the keys they name: a
808
+ prefix that never mentions this column says nothing about it, so if any
809
+ such prefix is selected the column keeps every level. Only when *every*
810
+ prefix names it do the values they give become the surviving set.
811
+
812
+ Concretely, selecting all of subject 01 plus subject 02's trial 3 leaves
813
+ ``trial`` with all its levels — because "all of subject 01" did not name a
814
+ trial — which is what the figure will actually draw.
815
+ """
816
+ prefixes = spec.location_filter.prefixes()
817
+ if not prefixes:
818
+ return levels
819
+ named = [dict(prefix) for prefix in prefixes]
820
+ if any(column not in prefix for prefix in named):
821
+ return levels
822
+ keep = {prefix[column] for prefix in named}
823
+ return [level for level in levels if level in keep]
824
+
825
+
769
826
  #: Column the generated code builds for a nested x axis.
770
827
  _X_NESTED = "_x"
771
828
 
@@ -504,8 +504,53 @@ def _fanout_notes(spec: PlotSpec, table: LongTable) -> list[str]:
504
504
  # ---------------------------------------------------------------------------
505
505
 
506
506
 
507
+ def _location_mask(frame: pd.DataFrame, spec: PlotSpec) -> "pd.Series | None":
508
+ """Rows matching any prefix in ``spec.location_filter``, or None if inert.
509
+
510
+ One prefix constrains only the keys it names **that the frame actually
511
+ has**. A key the frame lacks is simply not constrained — the graceful answer
512
+ for a shallower variable (a subject-level Mass has one value for "subject 02
513
+ trial 3", and that is the value that contributes).
514
+
515
+ Each prefix carries its own keys, so a non-contiguous location
516
+ (``subject`` + ``speed``, ``timepoint`` NULL) matches the keys it names
517
+ rather than whatever sits at that position in the schema — see
518
+ :class:`~scistackplot.spec.LocationFilter`.
519
+
520
+ Written as one vectorised comparison per (prefix, key) rather than a row-wise
521
+ tuple match, because :func:`scistackplot.codegen` has to emit the *same*
522
+ rule as readable pandas and the two must not be able to disagree. That makes
523
+ the cost O(prefixes x depth) column comparisons; a single-select is one
524
+ prefix, and a ticked subtree collapses to its own short prefix, so the
525
+ common cases are small by construction.
526
+ """
527
+ prefixes = spec.location_filter.prefixes()
528
+ if not prefixes:
529
+ return None # inert: clicking into a picker is not a statement
530
+
531
+ named = {key for prefix in prefixes for key, _value in prefix}
532
+ known = [key for key in named if key in frame.columns]
533
+ if not known:
534
+ Log.warn(
535
+ "location filter names none of this table's columns (%s) — ignored",
536
+ ", ".join(sorted(named)),
537
+ layer=LAYER,
538
+ )
539
+ return None
540
+
541
+ as_text = {key: frame[key].astype(str) for key in known}
542
+ mask = pd.Series(False, index=frame.index)
543
+ for prefix in prefixes:
544
+ matched = pd.Series(True, index=frame.index)
545
+ for key, value in prefix:
546
+ if key in as_text:
547
+ matched &= as_text[key] == str(value)
548
+ mask |= matched
549
+ return mask
550
+
551
+
507
552
  def apply_filters(frame: pd.DataFrame, spec: PlotSpec) -> pd.DataFrame:
508
- """Rows surviving ``spec.filters``.
553
+ """Rows surviving ``spec.filters`` and ``spec.location_filter``.
509
554
 
510
555
  Public because the GUI's pickers report "3 of 12 selected" and that readout
511
556
  has to be measured with exactly the rule the figure uses — the same reason
@@ -517,9 +562,19 @@ def apply_filters(frame: pd.DataFrame, spec: PlotSpec) -> pd.DataFrame:
517
562
  or ``1`` (int) depending on the source, and a silently empty figure is the
518
563
  worst possible answer to a picker the user just clicked.
519
564
  """
520
- if not spec.filters:
565
+ location = _location_mask(frame, spec)
566
+ if not spec.filters and location is None:
521
567
  return frame
522
568
  mask = pd.Series(True, index=frame.index)
569
+ if location is not None:
570
+ mask &= location
571
+ Log.debug(
572
+ "location filter: %d -> %d row(s) over %d prefix(es)",
573
+ len(frame),
574
+ int(mask.sum()),
575
+ len(spec.location_filter.prefixes()),
576
+ layer=LAYER,
577
+ )
523
578
  for flt in spec.filters:
524
579
  if flt.column not in frame.columns:
525
580
  # A spec outlives the table it was written against — a filter naming
@@ -187,6 +187,90 @@ class Filter:
187
187
  maximum: float | None = None
188
188
 
189
189
 
190
+ @dataclass(frozen=True)
191
+ class LocationFilter:
192
+ """Which schema locations to draw, as a set of hierarchy **prefixes**.
193
+
194
+ The difference from :class:`Filter` is the whole reason this exists.
195
+ ``Filter`` holds one include-list per column, so a set of them can only ever
196
+ express a **Cartesian product**: ``subject ∈ {01,02} × trial ∈ {3,7}``. That
197
+ is the right shape for "narrow this factor", and the wrong shape for a
198
+ hierarchical location picker, where the user ticks boxes on a tree and means
199
+ something ragged — *all* of subject 01, plus only trials 1–3 of subject 02.
200
+ Real datasets are ragged (subject 01 ran eight trials, subject 02 ran five),
201
+ so the Cartesian form is not merely less expressive, it names combinations
202
+ that never existed.
203
+
204
+ Worse, in a tree UI the per-column form is actively misleading: unticking
205
+ ``S01 / trial 3`` would silently drop trial 3 from *every* subject, because
206
+ there is only one ``trial`` include-list to edit.
207
+
208
+ ``include`` is a list of prefixes, each a list of ``[key, value]`` pairs
209
+ outermost first: ``[["subject", "01"]]`` is all of subject 01,
210
+ ``[["subject", "02"], ["trial", "3"]]`` is one trial. Storing the **minimal
211
+ covering set** (collapse a fully-ticked parent to its own short prefix) is
212
+ what keeps a later-added trial inside an already-selected subject instead of
213
+ silently outside it.
214
+
215
+ **A prefix names its keys rather than relying on position**, and that is not
216
+ verbosity. A dataset schema is a linear hierarchy but a *saved location* need
217
+ not fill it: a cross-cutting result saved at ``subject`` + ``speed`` with
218
+ ``timepoint`` NULL is a supported, documented shape
219
+ (docs/claude/schema-hierarchy-contiguity.md), and its location is
220
+ ``[["subject","01"], ["speed","SSV"]]``. Read positionally against the
221
+ schema, that second value would be matched against ``timepoint`` — selecting
222
+ nothing, in silence. Naming the key makes the hole impossible to
223
+ misinterpret, and it means the picker sends a node's own path back verbatim
224
+ with no translation step to get wrong.
225
+
226
+ An empty ``include`` is **inert**: it constrains nothing, matching the rule
227
+ an unfilled variant row follows (docs/claude/plot-variant-rows.md §3).
228
+ Clicking into a picker is not a statement about the data.
229
+
230
+ Values are compared as text, like every other selection that crosses JSON.
231
+ """
232
+
233
+ #: Schema keys, outermost first. Display order only — matching reads the
234
+ #: keys named inside each prefix, never this list's positions.
235
+ keys: list[str] = field(default_factory=list)
236
+ #: Prefixes to keep. Lists rather than tuples so JSON and TOML round-trip
237
+ #: with no conversion; :meth:`prefixes` is the tuple view for matching.
238
+ include: list[list[list[str]]] = field(default_factory=list)
239
+
240
+ def prefixes(self) -> list[tuple[tuple[str, str], ...]]:
241
+ """``include`` as tuples of ``(key, value)`` text pairs, empties dropped."""
242
+ out = []
243
+ for entry in self.include:
244
+ prefix = tuple(
245
+ (str(pair[0]), str(pair[1])) for pair in entry if len(pair) == 2
246
+ )
247
+ if prefix:
248
+ out.append(prefix)
249
+ return out
250
+
251
+ def is_empty(self) -> bool:
252
+ return not self.prefixes()
253
+
254
+ def to_dict(self) -> dict:
255
+ return {
256
+ "keys": list(self.keys),
257
+ "include": [
258
+ [[str(pair[0]), str(pair[1])] for pair in entry if len(pair) == 2]
259
+ for entry in self.include
260
+ ],
261
+ }
262
+
263
+ @classmethod
264
+ def from_dict(cls, raw: dict) -> "LocationFilter":
265
+ return cls(
266
+ keys=list(raw.get("keys") or []),
267
+ include=[
268
+ [[str(pair[0]), str(pair[1])] for pair in entry if len(pair) == 2]
269
+ for entry in (raw.get("include") or [])
270
+ ],
271
+ )
272
+
273
+
190
274
  @dataclass(frozen=True)
191
275
  class LevelGroup:
192
276
  """A factor derived by bucketing another factor's levels.
@@ -468,6 +552,10 @@ class PlotSpec:
468
552
  y_axis: YAxis = field(default_factory=YAxis)
469
553
  style: StyleOptions = field(default_factory=StyleOptions)
470
554
  filters: list[Filter] = field(default_factory=list)
555
+ #: Which schema locations to draw. Written by the schema location picker,
556
+ #: which REPLACED the flat per-key pickers — see :class:`LocationFilter` for
557
+ #: why a set of ``Filter``s cannot express the same selection.
558
+ location_filter: LocationFilter = field(default_factory=LocationFilter)
471
559
  #: Variables joined in as FACTORS rather than plotted — a subject-level
472
560
  #: ``Condition`` holding stim/sham, say. They classify as CATEGORICAL and so
473
561
  #: are rightly refused as measures; as factors they take a role like any
@@ -569,6 +657,7 @@ class PlotSpec:
569
657
  "cols": [_matcher_to_dict(m) for m in self.facet.cols],
570
658
  }
571
659
  raw["y_axis"] = self.y_axis.to_dict()
660
+ raw["location_filter"] = self.location_filter.to_dict()
572
661
  raw["variant_sets"] = [s.to_dict() for s in self.variant_sets]
573
662
  raw["level_groups"] = [g.to_dict() for g in self.level_groups]
574
663
  raw["x_layers"] = list(self.x_layers)
@@ -593,6 +682,7 @@ class PlotSpec:
593
682
  y_axis=YAxis.from_dict(raw.get("y_axis") or {}),
594
683
  style=StyleOptions(**(raw.get("style") or {})),
595
684
  filters=[Filter(**f) for f in (raw.get("filters") or [])],
685
+ location_filter=LocationFilter.from_dict(raw.get("location_filter") or {}),
596
686
  factor_variables=list(raw.get("factor_variables") or []),
597
687
  level_groups=[
598
688
  LevelGroup.from_dict(g) for g in (raw.get("level_groups") or [])
File without changes