apmtools 0.2.53__tar.gz → 0.2.54__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.
- {apmtools-0.2.53 → apmtools-0.2.54}/PKG-INFO +1 -1
- {apmtools-0.2.53 → apmtools-0.2.54}/pyproject.toml +2 -2
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/plot_configs.py +9 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/ui.py +131 -16
- {apmtools-0.2.53 → apmtools-0.2.54}/.gitignore +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/LICENSE +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/README.md +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/__init__.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/classes.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/data_processing.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/functions.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/legacy.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/plot_app.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/__init__.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/appearance.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/color_editor.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/columns.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/config.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/data.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/elements.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/export.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/filter_config.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/histogram_multi.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/matplotlib_renderer.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/multi_axis.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/multi_y.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/plotly_renderer.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/shiny/summary.py +0 -0
- {apmtools-0.2.53 → apmtools-0.2.54}/src/apmtools/units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: apmtools
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.54
|
|
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.
|
|
7
|
+
version = "0.2.54"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
|
|
10
10
|
]
|
|
@@ -26,4 +26,4 @@ shiny = [
|
|
|
26
26
|
"shinywidgets>=0.6",
|
|
27
27
|
"shinyswatch>=0.12",
|
|
28
28
|
"plotly>=6.0",
|
|
29
|
-
]
|
|
29
|
+
]
|
|
@@ -130,6 +130,15 @@ class PlotConfigManager:
|
|
|
130
130
|
self.revision += 1
|
|
131
131
|
return preset_id
|
|
132
132
|
|
|
133
|
+
def delete_saved(self, preset_id):
|
|
134
|
+
"""Delete one explicitly saved plot by stable ID, not its open draft."""
|
|
135
|
+
items = copy.deepcopy(self.summary.plots or [])
|
|
136
|
+
remaining = [p for p in items if not (p.get('id') == preset_id and p.get('type') == 'plot')]
|
|
137
|
+
if len(remaining) == len(items):
|
|
138
|
+
raise KeyError(f'Unknown saved plot: {preset_id}')
|
|
139
|
+
self.summary.plots = remaining
|
|
140
|
+
self.revision += 1
|
|
141
|
+
|
|
133
142
|
def saved(self):
|
|
134
143
|
return copy.deepcopy(self.summary.plots or [])
|
|
135
144
|
|
|
@@ -607,16 +607,14 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
607
607
|
_label_keys[field].set(keys)
|
|
608
608
|
|
|
609
609
|
def _label_string(field):
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return "; ".join(f"{key}={values.get(key, key)}" for key in active
|
|
619
|
-
if values.get(key, key) != key)
|
|
610
|
+
# Pass the complete persisted mapping to the renderer, even
|
|
611
|
+
# before legend keys have been rediscovered on a loaded tab.
|
|
612
|
+
# The editor's key list controls visible inputs only; it must
|
|
613
|
+
# not decide which saved overrides reach Matplotlib.
|
|
614
|
+
# Watch appearance changes so edits invalidate the figure.
|
|
615
|
+
values = _editor_map(field, watch=(field == "legend_labels"))
|
|
616
|
+
return "; ".join(f"{key}={value}" for key, value in values.items()
|
|
617
|
+
if value != key)
|
|
620
618
|
|
|
621
619
|
for _field in _label_fields:
|
|
622
620
|
def _make_editor(field):
|
|
@@ -679,9 +677,11 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
679
677
|
return _editor_map("trace_colors", watch=watch)
|
|
680
678
|
|
|
681
679
|
def _color_string():
|
|
682
|
-
|
|
680
|
+
# The colour-key discovery effect can run after the first
|
|
681
|
+
# restored render. Do not discard persisted overrides while
|
|
682
|
+
# the key list is empty (or temporarily incomplete).
|
|
683
683
|
values = _color_map(watch=True)
|
|
684
|
-
return "; ".join(f"{key}={
|
|
684
|
+
return "; ".join(f"{key}={value}" for key, value in values.items())
|
|
685
685
|
|
|
686
686
|
# Each rendered colour key has a stable swatch button. The popup
|
|
687
687
|
# itself is mounted only on demand, avoiding text-input remounts
|
|
@@ -1420,6 +1420,11 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1420
1420
|
# appearance inputs, so editing a label or colour cannot reset it.
|
|
1421
1421
|
_last_line_style_keys = [None]
|
|
1422
1422
|
_last_scatter_style_keys = [None]
|
|
1423
|
+
# Arm automatic axis-label regeneration only after the restored
|
|
1424
|
+
# X/Y selectors have actually reached their saved values. This
|
|
1425
|
+
# avoids treating intermediate selector initialization as an edit.
|
|
1426
|
+
_restored_axis_selection = [None]
|
|
1427
|
+
_axis_selection_armed = [not bool(initial_filters)]
|
|
1423
1428
|
|
|
1424
1429
|
@reactive.effect
|
|
1425
1430
|
@reactive.event(prepared_plot, lambda: _optional_input("multi_panel", False) if input.group_by() else False,
|
|
@@ -1458,9 +1463,31 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
1458
1463
|
)
|
|
1459
1464
|
if not same_restored_data or "title" not in initial_filters:
|
|
1460
1465
|
ui.update_text("title", value="Summary plot")
|
|
1461
|
-
|
|
1466
|
+
# Preserve restored labels during initialization, then reset
|
|
1467
|
+
# them when the user changes either axis variable selection.
|
|
1468
|
+
# Changes to grouping, panels or appearance do not reset labels.
|
|
1469
|
+
current_axis_selection = (
|
|
1470
|
+
tuple(_optional_input("x", []) or []),
|
|
1471
|
+
tuple(_optional_input("y", []) or []),
|
|
1472
|
+
)
|
|
1473
|
+
saved_axis_selection = (
|
|
1474
|
+
tuple(initial_filters.get("x", []) or []),
|
|
1475
|
+
tuple(initial_filters.get("y", []) or []),
|
|
1476
|
+
)
|
|
1477
|
+
axis_selection_changed = False
|
|
1478
|
+
if not _axis_selection_armed[0]:
|
|
1479
|
+
if current_axis_selection == saved_axis_selection:
|
|
1480
|
+
_axis_selection_armed[0] = True
|
|
1481
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1482
|
+
elif _restored_axis_selection[0] is None:
|
|
1483
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1484
|
+
elif current_axis_selection != _restored_axis_selection[0]:
|
|
1485
|
+
axis_selection_changed = True
|
|
1486
|
+
_restored_axis_selection[0] = current_axis_selection
|
|
1487
|
+
|
|
1488
|
+
if axis_selection_changed or "x_axis_label" not in initial_filters:
|
|
1462
1489
|
ui.update_text("x_axis_label", value=defaults["x_label"])
|
|
1463
|
-
if
|
|
1490
|
+
if axis_selection_changed or "y_axis_label" not in initial_filters:
|
|
1464
1491
|
ui.update_text_area("y_axis_label", value=defaults["y_label"])
|
|
1465
1492
|
limits = _axis_bounds_from_spec(
|
|
1466
1493
|
spec,
|
|
@@ -2032,12 +2059,18 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2032
2059
|
for field in _label_fields:
|
|
2033
2060
|
with reactive.isolate():
|
|
2034
2061
|
values = dict(_editor_map(field))
|
|
2062
|
+
# A restored tab may snapshot before its dynamic editors
|
|
2063
|
+
# have mounted. Preserve the saved mapping in that case.
|
|
2064
|
+
if not values and initial_filters.get(field):
|
|
2065
|
+
values = dict(_parse_label_map(initial_filters[field]))
|
|
2035
2066
|
result[field] = "; ".join(f"{key}={value}" for key, value in values.items()
|
|
2036
2067
|
if value != key)
|
|
2037
2068
|
with reactive.isolate():
|
|
2038
2069
|
colors = dict(_color_map())
|
|
2039
|
-
|
|
2040
|
-
|
|
2070
|
+
if not colors and initial_filters.get("trace_colors"):
|
|
2071
|
+
colors = dict(_parse_label_map(initial_filters["trace_colors"]))
|
|
2072
|
+
chosen = configured_visibility()
|
|
2073
|
+
visible_keys = tuple(available_elements())
|
|
2041
2074
|
result["trace_colors"] = "; ".join(f"{key}={value}" for key, value in colors.items())
|
|
2042
2075
|
# No available components must not erase visibility in a
|
|
2043
2076
|
# retained configuration (e.g. threshold above maximum).
|
|
@@ -2395,6 +2428,8 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2395
2428
|
ui.input_action_button("close_tab", "Close selected tab", class_="btn-outline-danger"),
|
|
2396
2429
|
ui.input_action_button("retain_plot", "Save selected plot", class_="btn-outline-primary"),
|
|
2397
2430
|
ui.input_action_button("restore_plot", "Load saved plot", class_="btn-outline-primary"),
|
|
2431
|
+
ui.input_action_button("delete_saved_plot", "Delete plot", class_="btn-outline-danger"),
|
|
2432
|
+
ui.input_action_button("save_dataset_pickle", "Save dataset", class_="btn-outline-primary"),
|
|
2398
2433
|
class_="summary-toolbar-actions",
|
|
2399
2434
|
),
|
|
2400
2435
|
# Exactly one app-wide theme picker, outside dynamically added
|
|
@@ -2473,6 +2508,86 @@ if (!window.__apmColorPickerInstalled) {
|
|
|
2473
2508
|
except Exception as exc:
|
|
2474
2509
|
ui.notification_show(f"Could not save configuration: {exc}", type="error")
|
|
2475
2510
|
|
|
2511
|
+
@reactive.effect
|
|
2512
|
+
@reactive.event(input.delete_saved_plot)
|
|
2513
|
+
def choose_plot_to_delete():
|
|
2514
|
+
presets = [p for p in manager.saved() if p.get("type") == "plot"]
|
|
2515
|
+
if not presets:
|
|
2516
|
+
ui.notification_show("No saved plots to delete", type="warning")
|
|
2517
|
+
return
|
|
2518
|
+
ui.modal_show(ui.modal(
|
|
2519
|
+
ui.input_select("delete_saved_plot_choice", "Saved plot to delete",
|
|
2520
|
+
choices={p["id"]: p.get("name", p["id"]) for p in presets}),
|
|
2521
|
+
ui.p("This deletes the saved configuration from Summary.plots. Open tabs are not closed."),
|
|
2522
|
+
title="Delete saved plot",
|
|
2523
|
+
footer=ui.div(ui.modal_button("Cancel"),
|
|
2524
|
+
ui.input_action_button("confirm_delete_saved_plot", "Delete", class_="btn-danger")),
|
|
2525
|
+
))
|
|
2526
|
+
|
|
2527
|
+
@reactive.effect
|
|
2528
|
+
@reactive.event(input.confirm_delete_saved_plot)
|
|
2529
|
+
def confirm_delete_saved_plot():
|
|
2530
|
+
try:
|
|
2531
|
+
with reactive.isolate():
|
|
2532
|
+
manager.delete_saved(input.delete_saved_plot_choice())
|
|
2533
|
+
saved_revision.set(saved_revision.get() + 1)
|
|
2534
|
+
ui.modal_remove()
|
|
2535
|
+
ui.notification_show("Saved plot deleted. Save the dataset to persist this change to disk.", type="message")
|
|
2536
|
+
except Exception as exc:
|
|
2537
|
+
ui.notification_show(f"Could not delete saved plot: {exc}", type="error")
|
|
2538
|
+
|
|
2539
|
+
@reactive.effect
|
|
2540
|
+
@reactive.event(input.save_dataset_pickle)
|
|
2541
|
+
def show_save_dataset_dialog():
|
|
2542
|
+
ui.modal_show(ui.modal(
|
|
2543
|
+
ui.p("Save the current Summary, including saved plot configurations, as a pickle file."),
|
|
2544
|
+
ui.input_text("dataset_pickle_path", "Destination path on the computer running Shiny",
|
|
2545
|
+
value="summary.pkl", placeholder="/path/to/drive/summary.pkl"),
|
|
2546
|
+
ui.p("For a mounted drive, enter its full directory path. Existing files will not be overwritten."),
|
|
2547
|
+
title="Save dataset",
|
|
2548
|
+
footer=ui.div(ui.modal_button("Cancel"),
|
|
2549
|
+
ui.input_action_button("confirm_save_dataset_pickle", "Save pickle", class_="btn-primary")),
|
|
2550
|
+
))
|
|
2551
|
+
|
|
2552
|
+
@reactive.effect
|
|
2553
|
+
@reactive.event(input.confirm_save_dataset_pickle)
|
|
2554
|
+
def confirm_save_dataset_pickle():
|
|
2555
|
+
import os
|
|
2556
|
+
import pickle
|
|
2557
|
+
import tempfile
|
|
2558
|
+
from pathlib import Path
|
|
2559
|
+
try:
|
|
2560
|
+
with reactive.isolate():
|
|
2561
|
+
raw = (input.dataset_pickle_path() or "").strip()
|
|
2562
|
+
if not raw:
|
|
2563
|
+
raise ValueError("Provide a destination path")
|
|
2564
|
+
target = Path(raw).expanduser().resolve()
|
|
2565
|
+
if target.suffix.lower() != ".pkl":
|
|
2566
|
+
raise ValueError("The filename must end in .pkl")
|
|
2567
|
+
if not target.parent.is_dir():
|
|
2568
|
+
raise FileNotFoundError(f"Directory does not exist: {target.parent}")
|
|
2569
|
+
if target.exists():
|
|
2570
|
+
raise FileExistsError(f"File already exists: {target}")
|
|
2571
|
+
# Serialize to a temporary file, then publish atomically without
|
|
2572
|
+
# overwriting an existing file. Preserve the original on errors.
|
|
2573
|
+
temp_path = None
|
|
2574
|
+
try:
|
|
2575
|
+
with tempfile.NamedTemporaryFile(mode="wb", dir=target.parent,
|
|
2576
|
+
prefix=".apmtools-", suffix=".tmp",
|
|
2577
|
+
delete=False) as stream:
|
|
2578
|
+
temp_path = Path(stream.name)
|
|
2579
|
+
pickle.dump(manager.summary, stream, protocol=pickle.HIGHEST_PROTOCOL)
|
|
2580
|
+
stream.flush()
|
|
2581
|
+
os.fsync(stream.fileno())
|
|
2582
|
+
os.link(temp_path, target) # fails if target already exists
|
|
2583
|
+
finally:
|
|
2584
|
+
if temp_path is not None:
|
|
2585
|
+
temp_path.unlink(missing_ok=True)
|
|
2586
|
+
ui.modal_remove()
|
|
2587
|
+
ui.notification_show(f"Dataset saved to {target}", type="message", duration=8)
|
|
2588
|
+
except Exception as exc:
|
|
2589
|
+
ui.notification_show(f"Could not save dataset: {exc}", type="error", duration=10)
|
|
2590
|
+
|
|
2476
2591
|
@reactive.effect
|
|
2477
2592
|
@reactive.event(input.restore_plot)
|
|
2478
2593
|
def choose_saved():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|