apmtools 0.2.44__tar.gz → 0.2.45__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.44
3
+ Version: 0.2.45
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.44"
7
+ version = "0.2.45"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -861,7 +861,9 @@ class Dataset(DictionaryPlus):
861
861
  z[1].to_csv(output, index=False)
862
862
  out[z[0]] = z[1]
863
863
  if merged:
864
- return pd.concat([out[k] for k in out.keys()]).reset_index()
864
+ out_merged = pd.concat([out[k] for k in out.keys()]).reset_index()
865
+ out_merged.description = self.description
866
+ return out_merged
865
867
  return out
866
868
 
867
869
  def save_data(self, directory="./saved/", levels=[]):
@@ -23,8 +23,7 @@ def _composite_labels(data: pd.DataFrame, columns: list[str]) -> pd.Series:
23
23
  return "None" if pd.isna(value) else str(value)
24
24
  if len(columns) == 1:
25
25
  return data[columns[0]].map(label)
26
- return pd.Series(
27
- (" | ".join(label(value) for value in row)
26
+ return pd.Series((" | ".join(label(value) for value in row)
28
27
  for row in data[columns].itertuples(index=False, name=None)),
29
28
  index=data.index,
30
29
  dtype="str",
@@ -327,7 +327,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
327
327
  scatter_size="", scatter_marker="", scatter_alpha="", hist_alpha="",
328
328
  box_showfliers=True, box_orientation="vertical",
329
329
  title_font_size=14, axis_font_size=12, tick_font_size=10,
330
- legend_font_size=12, panel_font_size=14, bins=30, density=False,
330
+ legend_font_size=12, panel_font_size=14, plot_dpi=100, bins=30, density=False,
331
331
  stacked=False, histtype="bar", cumulative=0,
332
332
  panel_by=None, independent_panel_limits=True, panel_titles="", _axes=None,
333
333
  x_major_ticks=6, y_major_ticks=6, x_minor_ticks=False, y_minor_ticks=False):
@@ -339,7 +339,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
339
339
  from matplotlib.patches import Patch
340
340
 
341
341
  if _axes is None:
342
- fig, ax = plt.subplots(figsize=(10, 5), dpi=100)
342
+ fig, ax = plt.subplots(figsize=(10, 5), dpi=plot_dpi)
343
343
  else:
344
344
  ax = _axes
345
345
  fig = ax.figure
@@ -364,7 +364,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
364
364
  plt.close(fig)
365
365
  ncols = (2 if len(partitions) == 4 else min(3, len(partitions)))
366
366
  nrows = math.ceil(len(partitions) / ncols)
367
- fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 3.8*nrows), dpi=100,
367
+ fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 3.8*nrows), dpi=plot_dpi,
368
368
  squeeze=False, sharex=False, sharey=False)
369
369
  for idx, (panel_title, panel_df, other_groups) in enumerate(partitions):
370
370
  panel_spec = {**spec, "data": panel_df, "groups": other_groups,
@@ -382,7 +382,7 @@ def _matplotlib_direct_figure(spec, *, title="", x_label="", y_label="",
382
382
  hist_alpha=hist_alpha, box_showfliers=box_showfliers, box_orientation=box_orientation,
383
383
  title_font_size=panel_font_size, axis_font_size=axis_font_size,
384
384
  tick_font_size=tick_font_size, legend_font_size=legend_font_size,
385
- panel_font_size=panel_font_size,
385
+ panel_font_size=panel_font_size, plot_dpi=plot_dpi,
386
386
  bins=bins, density=density,
387
387
  stacked=stacked, histtype=histtype, cumulative=cumulative, _axes=panel_ax,
388
388
  x_major_ticks=x_major_ticks, y_major_ticks=y_major_ticks,
@@ -671,7 +671,7 @@ def _matplotlib_figure(plotly_figure):
671
671
  import matplotlib.colors as mcolors
672
672
  from matplotlib.patches import Patch
673
673
 
674
- fig, ax = plt.subplots(figsize=(10, 5), dpi=100)
674
+ fig, ax = plt.subplots(figsize=(10, 5), dpi=plot_dpi)
675
675
  source = plotly_figure
676
676
  category_labels = list(getattr(source.layout.xaxis, "categoryarray", None) or [])
677
677
  categorical = (getattr(source.layout.xaxis, "type", None) == "category")
@@ -41,6 +41,7 @@ def create_summary_app(
41
41
  from shinywidgets import output_widget, render_plotly
42
42
  # Keep the Summary object intact: subset(), dtfilter() and group_stat()
43
43
  # are methods on Summary and are intentionally used below.
44
+ summary_description = str(getattr(data, "description", "") or "")
44
45
  summary = data.copy(deep=False)
45
46
  if not isinstance(summary, pd.DataFrame):
46
47
  raise TypeError("data must be a pandas DataFrame/Summary")
@@ -91,6 +92,57 @@ def create_summary_app(
91
92
  subset_choice_cache[name] = _subset_choices(summary, column_map[name])
92
93
  return subset_choice_cache[name]
93
94
 
95
+ # The Info tab is the only tab at startup. Plot modules are created lazily
96
+ # when the user presses New plot, so no plotting inputs need to initialize
97
+ # until a plot actually exists.
98
+ info_tab = ui.nav_panel(
99
+ "Info",
100
+ ui.div(
101
+ ui.h2("Summary information"),
102
+ ui.p("Description of the Summary object used to launch this application."),
103
+ ui.tags.pre(summary_description, class_="summary-info-description"),
104
+ ui.h3("Using the plotting application"),
105
+ ui.p("Select + New plot to create Plot 1. Each additional plot opens "
106
+ "in its own tab. When a plot is selected, New plot copies its "
107
+ "current settings, which you can then edit independently."),
108
+ ui.h4("Left sidebar — data and plot settings"),
109
+ ui.tags.ul(
110
+ ui.tags.li(ui.strong("Plot settings: "), "Choose the plot type and the X and Y variables."),
111
+ ui.tags.li(ui.strong("Plot filters: "), "Select categorical subsets, numerical "
112
+ "greater-than and less-than bounds, and start/end datetime limits."),
113
+ ui.tags.li(ui.strong("Group by: "), "Choose one or more grouping variables "
114
+ "to distinguish data series."),
115
+ ui.tags.li(ui.strong("Frequency / statistical aggregation: "),
116
+ "Select a time frequency and statistical function for aggregated plots."),
117
+ ),
118
+ ui.h4("Right sidebar — appearance and layout"),
119
+ ui.tags.ul(
120
+ ui.tags.li(ui.strong("Plot appearance: "), "Set the palette, plot theme, "
121
+ "title and title font size."),
122
+ ui.tags.li(ui.strong("Legend: "), "Control legend position, inside/outside "
123
+ "placement, labels, columns and font size."),
124
+ ui.tags.li(ui.strong("Axes: "), "Set axis labels, minimum and maximum "
125
+ "values, grid, tick controls and fonts."),
126
+ ui.tags.li(ui.strong("Plot components: "), "Customize trace colours, "
127
+ "lines, markers, histograms and boxplot options."),
128
+ ui.tags.li(ui.strong("Panel appearance: "), "Configure multi-panel "
129
+ "plots and panel titles (Matplotlib backend)."),
130
+ ),
131
+ ui.h4("Plot tabs, export and application theme"),
132
+ ui.p("Use + New plot to add a plot tab, or Close selected plot to "
133
+ "remove the current plot. The Info tab remains available."),
134
+ ui.p("Export plotted data as CSV saves the data displayed in the plot. "
135
+ "Export plot saves the chart. With the Matplotlib backend, "
136
+ "Export as a Python file and associated data creates a "
137
+ "reproducible script and its data."),
138
+ ui.p("Use the theme selector at the top right to change the Shiny "
139
+ "application's appearance. This is separate from the plot theme "
140
+ "in the Plot appearance tab."),
141
+ class_="summary-info-content",
142
+ ),
143
+ value="info",
144
+ )
145
+
94
146
  @module.ui
95
147
  def plot_tab_ui(initial_filters=None):
96
148
  initial_filters = initial_filters or {}
@@ -148,7 +200,18 @@ def create_summary_app(
148
200
  ui.input_select("palette", "Color palette", choices=["Default", "Plotly", "D3", "G10", "T10", "Set1", "Set2", "Pastel", "Dark24"], selected=initial("palette", "Default")),
149
201
  ui.input_select("template", "Theme", choices={"plotly":"Light", "plotly_white":"White", "plotly_dark":"Dark", "simple_white":"Minimal", "ggplot2":"GGPlot"}, selected=initial("template", "plotly")),
150
202
  ui.input_text("title", "Plot title", value=initial("title", "")),
151
- ui.input_numeric("title_font_size", "Plot title font size", value=initial("title_font_size", 14), min=6, max=72)),
203
+ ui.input_numeric("title_font_size", "Plot title font size", value=initial("title_font_size", 14), min=6, max=72),
204
+ (ui.div(
205
+ ui.help_text("Adjust all plot font sizes together (1 point per click)."),
206
+ ui.div(ui.input_action_button("decrease_plot_fonts", "− Decrease fonts"),
207
+ ui.input_action_button("increase_plot_fonts", "+ Increase fonts"),
208
+ class_="d-flex flex-wrap gap-2 mb-3"),
209
+ ui.input_numeric("plot_dpi", "Plot DPI", value=initial("plot_dpi", 100), min=30, max=600, step=25),
210
+ ui.div(ui.input_action_button("decrease_plot_dpi", "− DPI"),
211
+ ui.input_action_button("increase_plot_dpi", "+ DPI"),
212
+ class_="d-flex flex-wrap gap-2"),
213
+ ui.help_text("DPI affects Matplotlib plot rendering. Export DPI is set separately when exporting."),
214
+ ) if backend == "matplotlib" else ui.div())),
152
215
  ui.nav_panel("Legend",
153
216
  ui.input_select("legend_position", "Legend position", choices={k:k.title() for k in ("right", "left", "top", "bottom")}, selected=initial("legend_position", "right")),
154
217
  ui.input_checkbox("legend_inside", "Place legend inside plot", value=initial("legend_inside", False)),
@@ -843,6 +906,8 @@ def create_summary_app(
843
906
  ("tick_font_size", 10), ("legend_font_size", 12),
844
907
  ("panel_font_size", 14)):
845
908
  ui.update_numeric(field, value=value)
909
+ if backend == "matplotlib":
910
+ ui.update_numeric("plot_dpi", value=100)
846
911
  ui.update_numeric("line_width", value=2)
847
912
  ui.update_checkbox("box_showfliers", value=True)
848
913
  ui.update_select("box_orientation", selected="vertical")
@@ -857,6 +922,49 @@ def create_summary_app(
857
922
  ui.update_checkbox("only_complete", value=True)
858
923
  ui.update_checkbox("flooring_timestamp", value=True)
859
924
 
925
+ if backend == "matplotlib":
926
+ _font_fields = ("title_font_size", "axis_font_size", "tick_font_size",
927
+ "legend_font_size", "panel_font_size")
928
+
929
+ def _change_fonts(delta):
930
+ # Use current values, not the defaults, to retain individual
931
+ # adjustments made in the other appearance tabs.
932
+ with reactive.isolate():
933
+ for field in _font_fields:
934
+ current = _optional_input(field, None)
935
+ if current is None:
936
+ current = initial_filters.get(field, {
937
+ "title_font_size": 14, "axis_font_size": 12,
938
+ "tick_font_size": 10, "legend_font_size": 12,
939
+ "panel_font_size": 14,
940
+ }[field])
941
+ ui.update_numeric(field, value=max(6, min(72, float(current) + delta)))
942
+
943
+ @reactive.effect
944
+ @reactive.event(input.increase_plot_fonts)
945
+ def _increase_plot_fonts():
946
+ _change_fonts(1)
947
+
948
+ @reactive.effect
949
+ @reactive.event(input.decrease_plot_fonts)
950
+ def _decrease_plot_fonts():
951
+ _change_fonts(-1)
952
+
953
+ def _change_dpi(delta):
954
+ with reactive.isolate():
955
+ current = _optional_input("plot_dpi", initial_filters.get("plot_dpi", 100))
956
+ ui.update_numeric("plot_dpi", value=max(30, min(600, int(current or 100) + delta)))
957
+
958
+ @reactive.effect
959
+ @reactive.event(input.increase_plot_dpi)
960
+ def _increase_plot_dpi():
961
+ _change_dpi(25)
962
+
963
+ @reactive.effect
964
+ @reactive.event(input.decrease_plot_dpi)
965
+ def _decrease_plot_dpi():
966
+ _change_dpi(-25)
967
+
860
968
  if backend == "plotly":
861
969
  @reactive.calc
862
970
  def styled_plotly_figure():
@@ -917,7 +1025,7 @@ def create_summary_app(
917
1025
  x_minor_ticks=_optional_input("x_minor_ticks", False), y_minor_ticks=_optional_input("y_minor_ticks", False),
918
1026
  title_font_size=_optional_input("title_font_size", 14), axis_font_size=_optional_input("axis_font_size", 12),
919
1027
  tick_font_size=_optional_input("tick_font_size", 10), legend_font_size=_optional_input("legend_font_size", 12),
920
- panel_font_size=_optional_input("panel_font_size", 14), hist_alpha=_optional_input("hist_alpha", ""),
1028
+ panel_font_size=_optional_input("panel_font_size", 14), plot_dpi=int(_optional_input("plot_dpi", 100) or 100), hist_alpha=_optional_input("hist_alpha", ""),
921
1029
  box_showfliers=_optional_input("box_showfliers", True), box_orientation=_optional_input("box_orientation", "vertical"),
922
1030
  line_width=(_optional_input("line_width", 2) if input.plot_type() == "line" else 2), line_alpha=_optional_input("line_alpha", ""),
923
1031
  line_style=_optional_input("line_style", ""), scatter_size=_optional_input("scatter_size", ""),
@@ -958,7 +1066,7 @@ def create_summary_app(
958
1066
  def snapshot():
959
1067
  keys = ("plot_type", "x", "y", "subset_columns", "numeric_subset_columns", "datetime_start", "datetime_end",
960
1068
  "group_by", "frequency", "aggregate", "only_complete", "flooring_timestamp",
961
- "palette", "template", "title", "title_font_size", "legend_position",
1069
+ "palette", "template", "title", "title_font_size", "plot_dpi", "legend_position",
962
1070
  "legend_inside", "legend_labels", "legend_columns", "legend_font_size",
963
1071
  "x_axis_label", "y_axis_label", "x_min", "x_max", "y_min", "y_max",
964
1072
  "show_grid", "x_major_ticks", "x_minor_ticks", "y_major_ticks",
@@ -1069,7 +1177,17 @@ def create_summary_app(
1069
1177
  ui.div(shinyswatch.theme_picker_ui(), class_="summary-app-theme"),
1070
1178
  class_="summary-top-toolbar",
1071
1179
  ),
1072
- ui.navset_tab(ui.nav_panel("Plot 1", plot_tab_ui("plot_1", {}), value="plot_1"), id="plot_tabs"),
1180
+ ui.tags.style("""
1181
+ .summary-info-content {max-width: 980px; margin: 1.2rem auto 3rem;
1182
+ padding: 0 1.25rem; line-height: 1.6;}
1183
+ .summary-info-content h3 {margin-top: 2rem;}
1184
+ .summary-info-content h4 {margin-top: 1.4rem;}
1185
+ .summary-info-content li {margin-bottom: .45rem;}
1186
+ .summary-info-description {white-space: pre-wrap; overflow-wrap: anywhere;
1187
+ font: inherit; padding: 1rem 1.25rem; border: 1px solid var(--bs-border-color);
1188
+ border-radius: .5rem; background: var(--bs-tertiary-bg); min-height: 3rem;}
1189
+ """),
1190
+ ui.navset_tab(info_tab, id="plot_tabs"),
1073
1191
  ),
1074
1192
  title="Summary plots",
1075
1193
  )
@@ -1079,9 +1197,9 @@ def create_summary_app(
1079
1197
  # Its picker dynamically switches the app's Bootstrap theme without
1080
1198
  # touching plot-specific options or the plot cloning logic.
1081
1199
  shinyswatch.theme_picker_server()
1082
- next_tab = reactive.value(1)
1083
- active_tabs = reactive.value(("plot_1",))
1084
- tab_filters = {"plot_1": plot_tab_server("plot_1", {})}
1200
+ next_tab = reactive.value(0)
1201
+ active_tabs = reactive.value(())
1202
+ tab_filters = {}
1085
1203
 
1086
1204
  @reactive.effect
1087
1205
  @reactive.event(input.add_tab)
@@ -1110,7 +1228,7 @@ def create_summary_app(
1110
1228
  def close_plot():
1111
1229
  selected = input.plot_tabs()
1112
1230
  tabs = active_tabs.get()
1113
- if selected not in tabs or len(tabs) <= 1:
1231
+ if selected not in tabs:
1114
1232
  return
1115
1233
  ui.remove_nav_panel("plot_tabs", target=selected)
1116
1234
  active_tabs.set(tuple(t for t in tabs if t != selected))
File without changes
File without changes
File without changes