apmtools 0.2.9__tar.gz → 0.2.10__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.009
3
+ Version: 0.2.010
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.009"
7
+ version = "0.2.010"
8
8
  authors = [
9
9
  { name="Federico Lorenzetti", email="lorenzetti.federico@gmail.com" },
10
10
  ]
@@ -142,6 +142,91 @@ def _aggregated_value_columns(data, value_columns, aggregate):
142
142
  return columns
143
143
 
144
144
 
145
+ def _frequency_timedelta(frequency: str) -> pd.Timedelta | None:
146
+ """Convert a timestamp aggregation frequency to its expected spacing."""
147
+ if not frequency or frequency == "weekday":
148
+ return None
149
+
150
+ parts = frequency.split()
151
+ if len(parts) == 1:
152
+ multiplier = 1.0
153
+ unit = parts[0]
154
+ elif len(parts) == 2:
155
+ try:
156
+ multiplier = float(parts[0])
157
+ except ValueError:
158
+ return None
159
+ unit = parts[1]
160
+ else:
161
+ return None
162
+
163
+ units = {
164
+ "second": "s",
165
+ "minute": "min",
166
+ "hour": "h",
167
+ "day": "d",
168
+ "week": "W",
169
+ }
170
+ if unit not in units:
171
+ return None
172
+ return pd.to_timedelta(multiplier, unit=units[unit])
173
+
174
+
175
+ def _add_gap_sections(
176
+ data: pd.DataFrame,
177
+ *,
178
+ x_column: str,
179
+ group_columns: list[str],
180
+ frequency: str,
181
+ section_column: str = "__line_section",
182
+ ) -> pd.DataFrame:
183
+ """Assign separate line sections when aggregated observations have gaps.
184
+
185
+ Rows remain in the same section while adjacent timestamps are no farther
186
+ apart than the selected aggregation frequency. Section numbering restarts
187
+ independently inside every selected plotting group.
188
+
189
+ This is primarily used when ``identifier`` was not included in
190
+ ``group_stat(grouping_by=...)`` and is therefore no longer available after
191
+ aggregation.
192
+ """
193
+ result = data.copy()
194
+ if result.empty:
195
+ result[section_column] = pd.Series(dtype="string")
196
+ return result
197
+
198
+ step = _frequency_timedelta(frequency)
199
+ if step is None or x_column not in result.columns:
200
+ # Frequencies such as weekday do not preserve enough chronological
201
+ # information to infer discontinuities reliably.
202
+ result[section_column] = "0"
203
+ return result
204
+
205
+ result[x_column] = pd.to_datetime(result[x_column], errors="coerce")
206
+ sort_columns = [*group_columns, x_column]
207
+ result = result.sort_values(sort_columns, kind="stable").copy()
208
+
209
+ if group_columns:
210
+ gap = result.groupby(group_columns, dropna=False)[x_column].diff()
211
+ new_section = gap.gt(step) | gap.isna()
212
+ section_number = (
213
+ new_section.astype(int)
214
+ .groupby([result[c] for c in group_columns], dropna=False)
215
+ .cumsum()
216
+ .sub(1)
217
+ )
218
+ group_label = result[group_columns].astype(str).agg(" | ".join, axis=1)
219
+ result[section_column] = group_label + \
220
+ " | section " + section_number.astype(str)
221
+ else:
222
+ gap = result[x_column].diff()
223
+ section_number = (gap.gt(step) | gap.isna()
224
+ ).astype(int).cumsum().sub(1)
225
+ result[section_column] = "section " + section_number.astype(str)
226
+
227
+ return result
228
+
229
+
145
230
  def create_summary_app(
146
231
  data: pd.DataFrame,
147
232
  *,
@@ -459,13 +544,29 @@ def create_summary_app(
459
544
  # being connected by a line merely because they share the
460
545
  # same selected group. The identifier is deliberately NOT
461
546
  # used for colour, so monitors in the same selected group
462
- # retain the same colour.
547
+ # retain the same colour. After frequency aggregation,
548
+ # identifier is only available if it was selected as a
549
+ # grouping variable. If it is absent, discontinuous runs are
550
+ # inferred from gaps larger than the selected frequency.
463
551
  identifier_column = (
464
552
  column_map["identifier"]
465
- if "identifier" in column_map
553
+ if (
554
+ "identifier" in column_map
555
+ and column_map["identifier"] in plot_df.columns
556
+ )
466
557
  else None
467
558
  )
468
559
 
560
+ section_column = None
561
+ if frequency is not None and identifier_column is None:
562
+ plot_df = _add_gap_sections(
563
+ plot_df,
564
+ x_column=x_column,
565
+ group_columns=group_columns,
566
+ frequency=frequency,
567
+ )
568
+ section_column = "__line_section"
569
+
469
570
  if group_columns:
470
571
  group_column = "__plot_group"
471
572
  line_columns = [x_column, group_column, *y_columns]
@@ -486,7 +587,7 @@ def create_summary_app(
486
587
  x=x_column,
487
588
  y="value",
488
589
  color=group_column,
489
- line_group=identifier_column if identifier_column is not None else None,
590
+ line_group=identifier_column or section_column,
490
591
  facet_row="variable" if len(y_columns) > 1 else None,
491
592
  )
492
593
  elif identifier_column is not None:
@@ -516,6 +617,29 @@ def create_summary_app(
516
617
  line_group=identifier_column,
517
618
  facet_row="variable",
518
619
  )
620
+ elif section_column is not None:
621
+ if len(y_columns) == 1:
622
+ fig = px.line(
623
+ plot_df,
624
+ x=x_column,
625
+ y=y_columns[0],
626
+ line_group=section_column,
627
+ )
628
+ else:
629
+ line_columns = [x_column, section_column, *y_columns]
630
+ long_df = plot_df[line_columns].melt(
631
+ id_vars=[x_column, section_column],
632
+ var_name="variable",
633
+ value_name="value",
634
+ )
635
+ fig = px.line(
636
+ long_df,
637
+ x=x_column,
638
+ y="value",
639
+ color="variable",
640
+ line_group=section_column,
641
+ facet_row="variable",
642
+ )
519
643
  else:
520
644
  fig = px.line(plot_df, x=x_column, y=y_columns)
521
645
 
File without changes
File without changes
File without changes