apmtools 0.2.52__tar.gz → 0.2.53__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 (29) hide show
  1. {apmtools-0.2.52 → apmtools-0.2.53}/PKG-INFO +1 -1
  2. {apmtools-0.2.52 → apmtools-0.2.53}/pyproject.toml +1 -1
  3. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/matplotlib_renderer.py +56 -14
  4. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/ui.py +73 -8
  5. {apmtools-0.2.52 → apmtools-0.2.53}/.gitignore +0 -0
  6. {apmtools-0.2.52 → apmtools-0.2.53}/LICENSE +0 -0
  7. {apmtools-0.2.52 → apmtools-0.2.53}/README.md +0 -0
  8. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/__init__.py +0 -0
  9. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/classes.py +0 -0
  10. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/data_processing.py +0 -0
  11. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/functions.py +0 -0
  12. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/legacy.py +0 -0
  13. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/plot_app.py +0 -0
  14. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/plot_configs.py +0 -0
  15. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/__init__.py +0 -0
  16. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/appearance.py +0 -0
  17. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/color_editor.py +0 -0
  18. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/columns.py +0 -0
  19. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/config.py +0 -0
  20. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/data.py +0 -0
  21. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/elements.py +0 -0
  22. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/export.py +0 -0
  23. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/filter_config.py +0 -0
  24. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/histogram_multi.py +0 -0
  25. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/multi_axis.py +0 -0
  26. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/plotly_renderer.py +0 -0
  28. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/shiny/summary.py +0 -0
  29. {apmtools-0.2.52 → apmtools-0.2.53}/src/apmtools/units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.52
3
+ Version: 0.2.53
4
4
  Summary: A collection of tools for processing air pollution monitoring data
5
5
  Project-URL: Homepage, https://github.com/federlorenz/apmtools
6
6
  Author-email: Federico Lorenzetti <lorenzetti.federico@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "apmtools"
7
- version = "0.2.52"
7
+ version = "0.2.53"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -47,6 +47,17 @@ def _axis_bounds_from_spec(spec, *, bins=30, density=False, stacked=False, cumul
47
47
  if "error" in spec:
48
48
  return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
49
49
  df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
50
+ # A long-form Y grouping must render its value column, not a source
51
+ # column removed by melt(). Normalize the local spec before faceting.
52
+ if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
53
+ ys = ["__axis_y_value"]
54
+ spec = {**spec, "ys": ys}
55
+
56
+ # Reactive selection changes can briefly expose a specification whose
57
+ # original Y names are no longer present after a melt/reshape.
58
+ ys = [y for y in ys if y in df.columns]
59
+ if x not in df.columns or (kind != "histogram" and not ys):
60
+ return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
50
61
  if df.empty:
51
62
  return {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
52
63
  bounds = {key: "" for key in ("x_min", "x_max", "y_min", "y_max")}
@@ -364,6 +375,12 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
364
375
  return fig
365
376
 
366
377
  df, kind, x, ys = spec["data"], spec["kind"], spec["x"], spec["ys"]
378
+ # A long-form Y grouping must render its value column, not a source
379
+ # column removed by melt(). Normalize the local spec before faceting.
380
+ if kind == "scatter" and "__axis_y_value" in df.columns and any(y not in df.columns for y in ys):
381
+ ys = ["__axis_y_value"]
382
+ spec = {**spec, "ys": ys}
383
+
367
384
  # Facet only the native Matplotlib renderer. Each panel receives the
368
385
  # same filtered/aggregated data subset and the same appearance settings.
369
386
  if panel_by and _axes is None:
@@ -404,19 +421,23 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
404
421
  stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
405
422
  x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
406
423
  x_minor_ticks=x_minor_ticks, y_minor_ticks=y_minor_ticks)
407
- for extra in list(axes.flat)[len(partitions):]:
408
- extra.set_visible(False)
424
+ # Resolve the axes once; reuse the same ordered panel collection for
425
+ # legend layout rather than repeatedly materializing axes.flat.
426
+ flat_axes = axes.flat
427
+ active_axes = [flat_axes[i] for i in range(len(partitions))]
428
+ for i in range(len(partitions), axes.size):
429
+ flat_axes[i].set_visible(False)
409
430
  if legend_inside:
410
- for panel_ax in list(axes.flat)[:len(partitions)]:
431
+ for panel_ax in active_axes:
411
432
  if panel_ax.get_legend() is not None:
412
433
  panel_ax.get_legend().set_in_layout(False)
413
434
  fig.tight_layout()
414
435
  # V77 used tight_layout for facets; reserve generous gutters
415
436
  # without changing V83's legend behaviour or column control.
416
437
  fig.subplots_adjust(wspace=panel_wspace, hspace=panel_hspace)
417
- _fit_inside_legends(fig, list(axes.flat)[:len(partitions)], legend_font_size, legend_columns)
438
+ _fit_inside_legends(fig, active_axes, legend_font_size, legend_columns)
418
439
  else:
419
- _place_outside_legend(fig, list(axes.flat)[:len(partitions)],
440
+ _place_outside_legend(fig, active_axes,
420
441
  legend_position, legend_font_size, legend_columns,
421
442
  panel_hspace=panel_hspace, panel_wspace=panel_wspace)
422
443
  return fig
@@ -457,27 +478,45 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
457
478
  variable_names = list(ys)
458
479
  x_names = list(spec.get("x_variables", [x]))
459
480
  group_names = style_groups if group_col else []
481
+ # Reuse stable positions for per-variable markers and hatches.
482
+ x_position = {name: i for i, name in enumerate(x_names)}
483
+ y_position = {name: i for i, name in enumerate(ys)}
484
+ group_position = {name: i for i, name in enumerate(group_names)}
460
485
  active = set()
461
486
  if kind == "scatter":
462
487
  scatter_markers = ['o', 's', 'D', 'x', '^', 'v', 'P', '*']
463
488
  for key, group, y, xv, category, rows in entries:
464
489
  g = group if group_col else None
465
490
  active.add((y, g))
491
+ # Use the same editable series keys as the appearance controls.
492
+ # Previously this multi-axis branch ignored all three overrides.
493
+ candidates = (key, f"{g}, {y}" if g is not None else str(y),
494
+ str(g) if g is not None else str(y), str(y))
495
+ def override(mapping, fallback):
496
+ return next((mapping[name] for name in candidates if name in mapping), fallback)
497
+ size = _bounded_float({"value": override(sizes, marker_size)}, "value", marker_size, 0.1, 100)
498
+ alpha = _bounded_float({"value": override(marker_alphas, 1)}, "value", 1, 0, 1)
499
+ default_marker = scatter_markers[x_position[xv] % len(scatter_markers)]
500
+ marker = (_scatter_marker({"value": override(markers, "dot")}, "value")
501
+ if any(name in markers for name in candidates) else default_marker)
466
502
  ax.scatter(rows[xv], rows[y], color=color_map[(y, g)],
467
- marker=scatter_markers[x_names.index(xv) % len(scatter_markers)],
468
- s=marker_size**2, alpha=.8)
503
+ marker=marker, s=size**2, alpha=alpha)
469
504
  else:
470
505
  cats = list(pd.unique(df[x].astype(str)))
471
506
  combinations = [(g, y) for g in (group_names or ['All observations']) for y in ys]
472
507
  n = max(1, len(combinations))
473
508
  width = .82 / n
509
+ # Resolve categorical positions once rather than scanning both
510
+ # lists for every multi-axis trace. Keep the original ordering.
511
+ combination_index = {item: i for i, item in enumerate(combinations)}
512
+ category_index = {item: i for i, item in enumerate(cats)}
474
513
  from collections import defaultdict
475
514
  buckets = defaultdict(list)
476
515
  for key, group, y, xv, category, rows in entries:
477
516
  g = group if group_col else None
478
517
  active.add((y, g))
479
- idx = combinations.index((group, y))
480
- pos = cats.index(category) + (idx - (n - 1)/2)*width
518
+ idx = combination_index[(group, y)]
519
+ pos = category_index[category] + (idx - (n - 1)/2)*width
481
520
  values = pd.to_numeric(rows[y], errors='coerce').dropna().to_numpy(dtype=float)
482
521
  buckets[(pos, y, g)].extend(values)
483
522
  for (pos, y, g), values in buckets.items():
@@ -485,7 +524,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
485
524
  continue
486
525
  color = color_map[(y, g)]
487
526
  hatch_options = ['', '/', '\\', 'x', '.', '+', '-']
488
- hatch_index = ys.index(y) if multi_y_color_by == 'group' else (group_names.index(g) if g in group_names else 0)
527
+ hatch_index = y_position[y] if multi_y_color_by == 'group' else group_position.get(g, 0)
489
528
  hatch = '' if multi_y_color_by in ('none', 'both') else hatch_options[hatch_index % len(hatch_options)]
490
529
  if kind == 'bar':
491
530
  ax.bar(pos, sum(values), width=width*.92*bar_box_width, color=color, hatch=hatch)
@@ -584,11 +623,14 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
584
623
  elif spec["frequency"] is not None and id_col is None:
585
624
  line_df = _add_gap_sections(df, x_column=x, group_columns=spec["groups"], frequency=spec["frequency"])
586
625
  section_col = "__line_section"
626
+ # Partition the line data once, not once per Y variable. This
627
+ # preserves group order and the existing equality semantics while
628
+ # avoiding repeated full-column masks for multi-Y plots.
629
+ grouped_line_rows = ([(n, line_df.loc[line_df[group_col] == n]) for n in labels]
630
+ if group_col else None)
587
631
  for y in ys:
588
- if group_col:
589
- sets = [(n, line_df.loc[line_df[group_col] == n]) for n in labels]
590
- else:
591
- sets = [(str(y) if len(ys) > 1 else "All observations", line_df)]
632
+ sets = (grouped_line_rows if grouped_line_rows is not None else
633
+ [(str(y) if len(ys) > 1 else "All observations", line_df)])
592
634
  for name, rows in sets:
593
635
  rows = rows.loc[rows[y].notna()]
594
636
  if rows.empty:
@@ -963,6 +963,18 @@ if (!window.__apmColorPickerInstalled) {
963
963
  return
964
964
  _last_axis_group_choices.set(signature)
965
965
  selected = [g for g in current if g in choices]
966
+ # The virtual axis groups are not part of the static selectize
967
+ # choices at mount time. On restoring a saved plot, Shiny may
968
+ # discard them before this effect populates the choices.
969
+ # Restore only eligible saved virtual groups; do not override
970
+ # physical groups or subsequent user changes.
971
+ if previous is None:
972
+ restored = initial_filters.get("group_by", []) or []
973
+ if isinstance(restored, str):
974
+ restored = [restored]
975
+ for name in restored:
976
+ if name in ("variables_x", "variables_y") and name in choices and name not in selected:
977
+ selected.append(name)
966
978
  ui.update_selectize("group_by", choices={c: c for c in choices}, selected=selected)
967
979
 
968
980
  @reactive.calc
@@ -1136,9 +1148,24 @@ if (!window.__apmColorPickerInstalled) {
1136
1148
 
1137
1149
  if (selected_plot == "scatter" and "variables_x" in selected_groups
1138
1150
  and len(x_variables) > 1):
1139
- id_columns = [c for c in plot_df.columns if c not in x_variables]
1140
- plot_df = plot_df.melt(id_vars=id_columns, value_vars=x_variables,
1151
+ # Melt copies rather than the original X columns. A source
1152
+ # variable can simultaneously be an X and a Y selection;
1153
+ # removing it here leaves the subsequent Y melt and the
1154
+ # renderer with a stale reference to a missing column.
1155
+ plot_df = plot_df.copy()
1156
+ x_copies = []
1157
+ x_names = {}
1158
+ for index, column in enumerate(x_variables):
1159
+ temporary = f"__plot_x_melt_{index}"
1160
+ while temporary in plot_df.columns:
1161
+ temporary += "_"
1162
+ plot_df[temporary] = plot_df[column]
1163
+ x_copies.append(temporary)
1164
+ x_names[temporary] = column
1165
+ id_columns = [c for c in plot_df.columns if c not in x_copies]
1166
+ plot_df = plot_df.melt(id_vars=id_columns, value_vars=x_copies,
1141
1167
  var_name="variables_x", value_name="__axis_x_value")
1168
+ plot_df["variables_x"] = plot_df["variables_x"].map(x_names)
1142
1169
  x_column = "__axis_x_value"
1143
1170
  x_variables = [x_column]
1144
1171
 
@@ -1150,9 +1177,30 @@ if (!window.__apmColorPickerInstalled) {
1150
1177
  if "variables_y" in selected_groups and len(y_columns) > 1:
1151
1178
  # Make the selected Y variable an actual categorical dimension,
1152
1179
  # so it works with ordinary grouping and panel partitioning.
1153
- id_columns = [c for c in plot_df.columns if c not in y_columns]
1154
- plot_df = plot_df.melt(id_vars=id_columns, value_vars=y_columns,
1155
- var_name="variables_y", value_name="__axis_y_value")
1180
+ # A variable may be selected as both scatter X and Y.
1181
+ # Melting the original Y columns would remove it from the
1182
+ # frame, leaving the X axis pointing at a missing column.
1183
+ # Melt temporary Y copies instead, retaining X columns.
1184
+ overlapping = set(y_columns).intersection(x_variables)
1185
+ if overlapping:
1186
+ plot_df = plot_df.copy()
1187
+ copies = []
1188
+ original_names = {}
1189
+ for index, column in enumerate(y_columns):
1190
+ temporary = f"__plot_y_melt_{index}"
1191
+ while temporary in plot_df.columns:
1192
+ temporary += "_"
1193
+ plot_df[temporary] = plot_df[column]
1194
+ copies.append(temporary)
1195
+ original_names[temporary] = column
1196
+ id_columns = [c for c in plot_df.columns if c not in copies]
1197
+ plot_df = plot_df.melt(id_vars=id_columns, value_vars=copies,
1198
+ var_name="variables_y", value_name="__axis_y_value")
1199
+ plot_df["variables_y"] = plot_df["variables_y"].map(original_names)
1200
+ else:
1201
+ id_columns = [c for c in plot_df.columns if c not in y_columns]
1202
+ plot_df = plot_df.melt(id_vars=id_columns, value_vars=y_columns,
1203
+ var_name="variables_y", value_name="__axis_y_value")
1156
1204
  y_columns = ["__axis_y_value"]
1157
1205
  # The categorical X labels have already been computed above.
1158
1206
  # Scatter and bar/box now use the standard single-Y renderer.
@@ -1185,6 +1233,12 @@ if (!window.__apmColorPickerInstalled) {
1185
1233
  _composite_labels(plot_df, group_columns)
1186
1234
  )
1187
1235
 
1236
+ # The scatter Y-variable grouping uses a long-form value column.
1237
+ # Keep the spec synchronized with that column even during
1238
+ # reactive transitions between single- and multi-Y selections.
1239
+ if selected_plot == "scatter" and "__axis_y_value" in plot_df.columns:
1240
+ y_columns = ["__axis_y_value"]
1241
+
1188
1242
  return {"data": plot_df, "kind": selected_plot, "x": x_column,
1189
1243
  "ys": y_columns, "groups": group_columns,
1190
1244
  "categories": x_columns if selected_plot in ("box", "bar") else None,
@@ -1365,6 +1419,7 @@ if (!window.__apmColorPickerInstalled) {
1365
1419
  # prepared. The effect depends on the plot specification, not on
1366
1420
  # appearance inputs, so editing a label or colour cannot reset it.
1367
1421
  _last_line_style_keys = [None]
1422
+ _last_scatter_style_keys = [None]
1368
1423
 
1369
1424
  @reactive.effect
1370
1425
  @reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
@@ -1480,9 +1535,19 @@ if (!window.__apmColorPickerInstalled) {
1480
1535
  elif kind == "histogram":
1481
1536
  ui.update_text_area("hist_alpha", value="\n".join(f"{name}=0.65" for name in names))
1482
1537
  elif kind == "scatter":
1483
- ui.update_text_area("scatter_size", value="\n".join(f"{name}=7" for name in names))
1484
- ui.update_text_area("scatter_marker", value="\n".join(f"{name}=dot" for name in names))
1485
- ui.update_text_area("scatter_alpha", value="\n".join(f"{name}=1" for name in names))
1538
+ # Preserve user edits across preparation updates; only
1539
+ # synchronize when the actual trace identities change.
1540
+ current_keys = tuple(names)
1541
+ previous_keys = _last_scatter_style_keys[0]
1542
+ _last_scatter_style_keys[0] = current_keys
1543
+ if previous_keys != current_keys:
1544
+ for field, default in (("scatter_size", "7"), ("scatter_marker", "dot"), ("scatter_alpha", "1")):
1545
+ if previous_keys is None and same_restored_data and field in initial_filters:
1546
+ continue
1547
+ with reactive.isolate():
1548
+ existing = _parse_label_map(_optional_input(field, ""))
1549
+ ui.update_text_area(field, value="\n".join(
1550
+ f"{name}={existing.get(name, default)}" for name in names))
1486
1551
  if backend == "matplotlib":
1487
1552
  panel_variables = ((list(_optional_input("panel_by", []) or []) or selected_groups) if len(selected_groups) > 1
1488
1553
  else selected_groups)
File without changes
File without changes
File without changes