apmtools 0.2.46__tar.gz → 0.2.48__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 (27) hide show
  1. {apmtools-0.2.46 → apmtools-0.2.48}/PKG-INFO +1 -1
  2. {apmtools-0.2.46 → apmtools-0.2.48}/pyproject.toml +1 -1
  3. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/classes.py +21 -40
  4. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/appearance.py +3 -2
  5. apmtools-0.2.48/src/apmtools/shiny/color_editor.py +64 -0
  6. apmtools-0.2.48/src/apmtools/shiny/elements.py +96 -0
  7. apmtools-0.2.48/src/apmtools/shiny/histogram_multi.py +32 -0
  8. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/matplotlib_renderer.py +147 -19
  9. apmtools-0.2.48/src/apmtools/shiny/multi_axis.py +150 -0
  10. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/plotly_renderer.py +32 -5
  11. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/ui.py +577 -97
  12. apmtools-0.2.48/src/apmtools/units.py +190 -0
  13. apmtools-0.2.46/src/apmtools/units.py +0 -390
  14. {apmtools-0.2.46 → apmtools-0.2.48}/.gitignore +0 -0
  15. {apmtools-0.2.46 → apmtools-0.2.48}/LICENSE +0 -0
  16. {apmtools-0.2.46 → apmtools-0.2.48}/README.md +0 -0
  17. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/__init__.py +0 -0
  18. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/data_processing.py +0 -0
  19. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/functions.py +0 -0
  20. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/legacy.py +0 -0
  21. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/__init__.py +0 -0
  22. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/columns.py +0 -0
  23. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/config.py +0 -0
  24. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/data.py +0 -0
  25. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/export.py +0 -0
  26. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/multi_y.py +0 -0
  27. {apmtools-0.2.46 → apmtools-0.2.48}/src/apmtools/shiny/summary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: apmtools
3
- Version: 0.2.46
3
+ Version: 0.2.48
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.46"
7
+ version = "0.2.48"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -574,7 +574,7 @@ class Dataset(DictionaryPlus):
574
574
  self.scan_folder(directory=f"{directory}{j}/", levels=levels,
575
575
  level=level+1, monitor=monitor, levels_dict=levels_dict, utc_timezone_shift=utc_timezone_shift, interpolate=interpolate, add_m=add_m)
576
576
 
577
- def save_summary(self, save_csv=True, filename="./", columns={}, merged=True):
577
+ def save_summary(self, save_csv=True, filename="./", all_variables=False, merged=True):
578
578
  types_in = list(set(type(v) for v in self.values()))
579
579
 
580
580
  def match_class(x):
@@ -619,13 +619,9 @@ class Dataset(DictionaryPlus):
619
619
  "start", "end", "length", "datetime","timedelta"]
620
620
  columns2 = list(set(d.meta()).difference(
621
621
  set(["filter", "header", "parameters"])))
622
- if "upas" not in columns.keys():
623
- columns3 = ["pm25"]
624
- else:
625
- columns3 = ["pm25"]+columns["upas"] if columns["upas"] != "all" else ["pm25"]+list(
626
- d.set_attrib("columns"))
627
- columns4 = ["pm25correctedrh",
628
- "pm25correctedrhandgrav", "filterid", "grav_not", "grav"]
622
+ columns3 = ["upas_pm25"] if not all_variables else ["upas_"+i for i in list(d.set_attrib("columns"))]+["upas_pm25"]
623
+ columns4 = ["upas_pm25_RHcorrected",
624
+ "upas_pm25_RHgravcorrected", "upas_filterid", "upas_gravimetric", "upas_gravimetric_blankcorrected"]
629
625
  buffer = StringIO()
630
626
  with buffer as file:
631
627
  df = writer(file, delimiter=",")
@@ -658,15 +654,13 @@ class Dataset(DictionaryPlus):
658
654
  app.append(v.m[m] if v.m[m] != None else "")
659
655
  else:
660
656
  app.append("")
661
- if "upas" not in columns.keys():
662
- app.append(v["PM2_5MC"].iloc[i])
663
- else:
664
- app.append(v["PM2_5MC"].iloc[i])
665
- for col in columns3[1:]:
666
- if col in v.columns:
667
- app.append(v[col].iloc[i])
668
- else:
669
- app.append(None)
657
+
658
+ for col in columns3[:-1]:
659
+ if col[5:] in v.columns:
660
+ app.append(v[col[5:]].iloc[i])
661
+ else:
662
+ app.append(None)
663
+ app.append(v["PM2_5MC"].iloc[i])
670
664
  xx = (a*v["PM2_5MC"].iloc[i])+(b*v["AtmoRH"].iloc[i])+c
671
665
  app.append(xx)
672
666
  if grav != "":
@@ -695,7 +689,7 @@ class Dataset(DictionaryPlus):
695
689
  columns1 = ["identifier", "monitor",
696
690
  "start", "end", "length", "datetime", "timedelta"]
697
691
  columns2 = list(set(d.meta()))
698
- columns3 = ["CO(ppm)"]
692
+ columns3 = ["lascar_CO(ppm)"]
699
693
 
700
694
  buffer = StringIO()
701
695
  with buffer as file:
@@ -743,9 +737,9 @@ class Dataset(DictionaryPlus):
743
737
  columns1 = ["identifier", "monitor",
744
738
  "start", "end", "length", "datetime", "timedelta"]
745
739
  columns2 = list(set(d.meta()))
746
- columns3 = ["sensorName", "deviceId", "resolution",
747
- "PM2.5", "PM10", "CO2", "NO2", "Temperature", "Humidity"]
748
- columns4 = ["pm25correctedrh"]
740
+ columns3 = ["lcm_sensorName", "lcm_deviceId", "lcm_resolution",
741
+ "lcm_PM2.5", "lcm_PM10", "lcm_CO2", "lcm_NO2", "lcm_Temperature", "lcm_Humidity"] if all_variables else ["lcm_PM2.5"]
742
+ columns4 = ["lcm_PM2.5correctedrh"]
749
743
 
750
744
  buffer = StringIO()
751
745
  with buffer as file:
@@ -774,7 +768,7 @@ class Dataset(DictionaryPlus):
774
768
  else:
775
769
  app.append("")
776
770
  for m in columns3:
777
- app.append(v[m].iloc[i])
771
+ app.append(v[m[4:]].iloc[i])
778
772
  xx = (a*v["PM2.5"].iloc[i])+(b*v["Humidity"].iloc[i])+c
779
773
  app.append(xx)
780
774
  df.writerow(list(map(str, app)))
@@ -793,13 +787,8 @@ class Dataset(DictionaryPlus):
793
787
  columns1 = ["identifier", "monitor",
794
788
  "start", "end", "length", "datetime", "timedelta"]
795
789
  columns2 = list(set(d.meta()))
796
- columns3 = ["pm2_5", "pm2_5_adj"]
797
- columns4 = []
798
- if "purple" not in columns.keys():
799
- columns4 = []
800
- else:
801
- columns4 = columns["purple"] if columns["purple"] != "all" else list(
802
- d.set_attrib("columns"))
790
+ columns3 = ["purple_pm25", "purple_pm25_RHcorrected"]
791
+ columns4 = [] if not all_variables else ["purple_"+i for i in list(d.set_attrib("columns"))]
803
792
  buffer = StringIO()
804
793
  with buffer as file:
805
794
  df = writer(file, delimiter=",")
@@ -829,17 +818,9 @@ class Dataset(DictionaryPlus):
829
818
  app.append(
830
819
  ((v["pm2_5_cf_1"]+v["pm2_5_cf_1_b"])/2).iloc[i])
831
820
  app.append(v["pm_adj"].iloc[i])
832
- if "purple" in columns.keys():
833
- if columns["purple"] == "all":
834
- for col in list(d.set_attrib("columns")):
835
- if col in v.columns:
836
- app.append(v[col].iloc[i])
837
- else:
838
- app.append(None)
839
- else:
840
- for col in columns["purple"]:
841
- if col in v.columns:
842
- app.append(v[col].iloc[i])
821
+ for col in columns4:
822
+ if col[6:] in v.columns:
823
+ app.append(v[col[6:]].iloc[i])
843
824
  else:
844
825
  app.append(None)
845
826
  df.writerow(list(map(str, app)))
@@ -1,5 +1,6 @@
1
1
  """Figure labels and appearance controls."""
2
2
  from __future__ import annotations
3
+ import re
3
4
  import threading
4
5
  from datetime import date, datetime, time, timedelta
5
6
  from collections.abc import Sequence
@@ -38,7 +39,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
38
39
  elif plot_type in {"box", "bar"}:
39
40
  categories = category_columns or [x_column]
40
41
  fig.update_xaxes(title_text=" | ".join(_axis_label(c) for c in categories))
41
- fig.update_yaxes(title_text=_axis_label(y_columns[0]))
42
+ fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
42
43
  elif plot_type == "line" and len(y_columns) > 1:
43
44
  fig.update_xaxes(title_text=_axis_label(x_column))
44
45
  fig.update_yaxes(title_text="<br>".join(_axis_label(y) for y in y_columns))
@@ -59,7 +60,7 @@ def _label_plot_axes(fig, plot_type, x_column, y_columns, *,
59
60
  def _parse_label_map(value):
60
61
  """Parse semicolon-separated original=replacement entries."""
61
62
  result = {}
62
- for entry in (value or "").split(";"):
63
+ for entry in re.split(r"[;\r\n]+", value or ""):
63
64
  if "=" in entry:
64
65
  key, replacement = entry.split("=", 1)
65
66
  if key.strip() and replacement.strip():
@@ -0,0 +1,64 @@
1
+ """Stable colour-editor keys for the actual prepared plotting data."""
2
+ from __future__ import annotations
3
+ import re
4
+ import pandas as pd
5
+
6
+ _HEX = re.compile(r"#[0-9a-fA-F]{6}\Z")
7
+
8
+ def valid_hex_color(value):
9
+ return isinstance(value, str) and _HEX.fullmatch(value.strip()) is not None
10
+
11
+
12
+ def active_color_keys(spec, *, mode="group", panel_by=()):
13
+ """Return colour dimensions with nonempty plotted observations.
14
+
15
+ Faceting removes its grouping columns. For multi-Y plots, 'both' uses
16
+ combinations, while group/variable/none use the corresponding colour
17
+ dimension (the same keys accepted by multi_y_styles).
18
+ """
19
+ if "error" in spec or spec["data"].empty:
20
+ return ()
21
+ if spec.get("hist_variables"):
22
+ return tuple(dict.fromkeys(spec["data"].loc[
23
+ spec["data"][spec["x"]].notna(), "__hist_series"].astype(str)))
24
+ from .matplotlib_renderer import _panel_partitions
25
+ from .multi_axis import records
26
+ parts = (_panel_partitions(spec, panel_by) if panel_by
27
+ else [(None, spec["data"], spec["groups"])])
28
+ keys = []
29
+ for _, frame, remaining in parts:
30
+ if frame.empty:
31
+ continue
32
+ sub = {**spec, "data": frame, "groups": remaining,
33
+ "_original_groups": spec["groups"]} if panel_by else {**spec, "data": frame, "groups": remaining}
34
+ if spec.get("multi_axis"):
35
+ pairs = [(str(y), str(g) if remaining else None)
36
+ for _, g, y, _, _, _ in records(sub)]
37
+ else:
38
+ ys = list(sub["ys"])
39
+ groups = (list(pd.unique(frame["__plot_group"].astype(str)))
40
+ if remaining and "__plot_group" in frame else [None])
41
+ pairs = []
42
+ for g in groups:
43
+ rows = (frame.loc[frame["__plot_group"].astype(str) == g]
44
+ if g is not None else frame)
45
+ for y in ys:
46
+ if y in rows and rows[y].notna().any():
47
+ pairs.append((str(y), g))
48
+ multi = bool(spec.get("multi_axis") or len(sub["ys"]) > 1)
49
+ for y, g in pairs:
50
+ if not multi or mode in ("group", "none"):
51
+ key = g if g is not None else y
52
+ elif mode == "variable":
53
+ key = y
54
+ else:
55
+ key = f"{g}, {y}" if g is not None else y
56
+ if key not in keys and key != "All observations":
57
+ keys.append(key)
58
+ return tuple(keys)
59
+
60
+
61
+ def color_defaults(spec, keys, palette="Default"):
62
+ from .config import _MPL_PALETTES
63
+ values = _MPL_PALETTES.get(palette, _MPL_PALETTES["Default"])
64
+ return {key: values[i % len(values)] for i, key in enumerate(keys)}
@@ -0,0 +1,96 @@
1
+ """Selectable plotted elements, independent of the rendering backend."""
2
+ import pandas as pd
3
+
4
+
5
+ def _group_labels(spec):
6
+ frame = spec['data']
7
+ if spec.get('groups') and '__plot_group' in frame:
8
+ return frame['__plot_group'].astype(str)
9
+ return pd.Series('All observations', index=frame.index)
10
+
11
+
12
+ def element_keys(spec):
13
+ """Selectable element identifiers with at least one valid plotted value.
14
+
15
+ Elements with exclusively missing observations are not selectable, rather
16
+ than merely being unchecked by default.
17
+ """
18
+ return default_visible_keys(spec)
19
+
20
+
21
+ def filter_elements(spec, selected):
22
+ """Remove hidden elements without changing the underlying Summary or filters.
23
+
24
+ For multi-Y lines, individual group/variable combinations are removed by
25
+ masking only their Y values. Panels with no remaining values disappear.
26
+ """
27
+ if 'error' in spec or selected is None:
28
+ return spec
29
+ allowed = set(selected)
30
+ if spec.get('hist_variables'):
31
+ frame = spec['data'].loc[spec['data']['__hist_series'].isin(allowed)].copy()
32
+ return {**spec, 'data': frame, '_visible_elements': allowed}
33
+ if spec.get('multi_axis'):
34
+ from .multi_axis import records
35
+ filtered = {**spec, '_visible_elements': allowed}
36
+ keep = pd.Index([])
37
+ for _, _, _, _, _, rows in records(filtered):
38
+ keep = keep.union(rows.index, sort=False)
39
+ return {**filtered, 'data': spec['data'].loc[spec['data'].index.isin(keep)].copy()}
40
+ frame = spec['data'].copy()
41
+ kind = spec['kind']
42
+ groups = _group_labels(spec)
43
+ if kind == 'line':
44
+ keep_any = pd.Series(False, index=frame.index)
45
+ for y in spec['ys']:
46
+ keys = groups.map(lambda g: f'{g} | {y}' if len(spec['ys']) > 1 else g)
47
+ permitted = keys.isin(allowed)
48
+ frame.loc[~permitted, y] = float('nan')
49
+ keep_any |= permitted & frame[y].notna()
50
+ frame = frame.loc[keep_any].copy()
51
+ elif kind in ('box', 'bar'):
52
+ keys = [f'{c} | {g}' if spec['groups'] else c
53
+ for c, g in zip(frame[spec['x']].astype(str), groups)]
54
+ frame = frame.loc[pd.Series(keys, index=frame.index).isin(allowed)].copy()
55
+ else:
56
+ frame = frame.loc[groups.isin(allowed)].copy()
57
+ return {**spec, 'data': frame, '_visible_elements': allowed}
58
+
59
+
60
+ def default_visible_keys(spec):
61
+ """Elements with at least one nonmissing plotted value (not merely rows)."""
62
+ if 'error' in spec:
63
+ return []
64
+ if spec.get('multi_axis'):
65
+ from .multi_axis import all_keys
66
+ return all_keys(spec, nonmissing=True)
67
+ frame, kind, x = spec['data'], spec['kind'], spec['x']
68
+ if spec.get('hist_variables'):
69
+ return list(dict.fromkeys(frame.loc[frame[x].notna(), '__hist_series'].astype(str)))
70
+ groups = _group_labels(spec)
71
+ if kind == 'line':
72
+ return list(dict.fromkeys(
73
+ f'{group} | {y}' if len(spec['ys']) > 1 else group
74
+ for y in spec['ys'] if y in frame
75
+ for group in groups[frame[y].notna()]
76
+ ))
77
+ if kind in ('box', 'bar'):
78
+ y = next((y for y in spec['ys'] if y in frame), None)
79
+ if y is None:
80
+ return []
81
+ valid = frame[y].notna()
82
+ return list(dict.fromkeys(
83
+ f'{category} | {group}' if spec['groups'] else str(category)
84
+ for category, group in zip(frame.loc[valid, x].astype(str), groups.loc[valid])
85
+ ))
86
+ # Scatter requires both coordinates; histogram requires its numeric x.
87
+ if kind == 'scatter':
88
+ valid = frame[x].notna()
89
+ for y in spec['ys']:
90
+ if y in frame:
91
+ valid &= frame[y].notna()
92
+ else:
93
+ valid = frame[x].notna()
94
+ return list(dict.fromkeys(groups.loc[valid]))
95
+
96
+
@@ -0,0 +1,32 @@
1
+ """Long-format data preparation for overlaid multi-variable histograms."""
2
+ import pandas as pd
3
+ from .data import _composite_labels
4
+
5
+
6
+ def histogram_long_data(frame, variables, *, group_columns=()):
7
+ """Create one histogram series per variable/group using a shared numeric axis.
8
+
9
+ Non-histogram columns are retained so faceting, export, and filtering work.
10
+ """
11
+ variables = list(dict.fromkeys(variables))
12
+ if not variables:
13
+ raise ValueError("At least one histogram variable is required")
14
+ missing = [name for name in variables if name not in frame]
15
+ if missing:
16
+ raise KeyError(f"Missing histogram variables: {missing}")
17
+ groups = list(group_columns)
18
+ for col in groups:
19
+ if col not in frame:
20
+ raise KeyError(col)
21
+ chunks = []
22
+ for name in variables:
23
+ part = frame.copy()
24
+ part["__hist_value"] = pd.to_numeric(part[name], errors="coerce")
25
+ part["__hist_variable"] = name
26
+ part["__hist_series"] = (name + " | " + _composite_labels(part, groups)
27
+ if groups else name)
28
+ part = part.loc[part["__hist_value"].notna()]
29
+ chunks.append(part)
30
+ if not chunks:
31
+ return frame.iloc[0:0].copy()
32
+ return pd.concat(chunks, ignore_index=True)