Skip to content

Commit f3fa95a

Browse files
authored
Merge pull request #48
Update AutoGrowth upload pipeline for high-frequency OD pre-aggregation and flexible calibration inputs
2 parents 1dca58e + 889f6b0 commit f3fa95a

3 files changed

Lines changed: 280 additions & 9 deletions

File tree

AutoGrowth/0_upload_data.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
REQUIRED_COLUMNS_NAME_MAP,
77
process_chibio_data,
88
process_od_pioreactor,
9+
read_od_adjustment_table,
910
)
1011
from ui_components import page_header_with_help
1112

@@ -210,7 +211,7 @@ def apply_linear_adjustments(
210211
with st.popover("See an Example", width="stretch"):
211212
st.markdown("**OD Calibration Table**")
212213
st.markdown(
213-
"- CSV file with columns `reactor` and `od`.\n"
214+
"- CSV/TXT (`,` or `;`) or Excel file with columns `reactor` and `od`.\n"
214215
"- Used to adjust OD readings by reactor based on calibration data."
215216
)
216217
st.divider()
@@ -236,7 +237,7 @@ def apply_linear_adjustments(
236237
st.info(f"File previously uploaded: {_file_name}")
237238
od_adjustment_upload = st.file_uploader(
238239
"OD adjustment table",
239-
type=["csv", "txt"],
240+
type=["csv", "txt", "xlsx", "xls"],
240241
key="upload_page_od_adjustment_table",
241242
)
242243
with optional_upload_cols[1]:
@@ -422,26 +423,29 @@ def apply_linear_adjustments(
422423
iqr_range_value = st.slider(
423424
"IQR factor for outlier removal",
424425
1.0,
425-
3.0,
426+
5.0,
426427
st.session_state.get("iqr_range_value", 1.5),
427428
step=0.1,
428429
help="Used when outlier method is IQR. Multiplier of the IQR.",
429430
)
430431
rolling_window = st.slider(
431432
"Rolling window (of timepoints) for IQR outlier removal",
432433
11,
433-
61,
434+
141,
434435
st.session_state.get("rolling_window", 21),
435436
step=2,
436437
help="Used when outlier method is IQR.",
437438
)
438439
ecod_factor = st.slider(
439440
"ECOD factor for outlier removal",
440441
0.5,
441-
8.0,
442+
12.0,
442443
st.session_state.get("ecod_factor", 4.0),
443444
step=0.1,
444-
help="Used when outlier method is ECOD. Anomaly detection sensitivity.",
445+
help=(
446+
"Used when outlier method is ECOD. Lower values are more "
447+
"sensitive; higher values are less sensitive."
448+
),
445449
)
446450

447451
st.divider()
@@ -460,10 +464,10 @@ def apply_linear_adjustments(
460464
"Round time to nearest second (defining timesteps). "
461465
"Used to align timeseries "
462466
"with slight time offsets.",
463-
1,
467+
5,
464468
300,
465469
st.session_state.get("round_time", 5),
466-
step=1,
470+
step=5,
467471
help=(
468472
"Rounding helps pivot the data to wide format from the "
469473
"long format. If you have multiple measurements for the same "
@@ -504,6 +508,15 @@ def apply_linear_adjustments(
504508
"outliers and therefore the default."
505509
),
506510
)
511+
aggregate_high_frequency_raw_data = st.checkbox(
512+
"Aggregate raw OD data when sampled below every 15 seconds (PioReactor)",
513+
value=st.session_state.get("aggregate_high_frequency_raw_data", False),
514+
disabled=reactor_type != "PioReactor",
515+
help=(
516+
"If enabled, PioReactor raw OD data sampled faster than every 15 "
517+
"seconds is aggregated to 15-second time bins before processing."
518+
),
519+
)
507520
st.divider()
508521
button_pressed = st.form_submit_button(
509522
"Apply options to uploaded data", type="primary", width="stretch"
@@ -531,6 +544,9 @@ def apply_linear_adjustments(
531544
st.session_state["aggregate_duplicated_rounded_timepoint_method"] = (
532545
aggregate_duplicated_rounded_timepoint_method
533546
)
547+
st.session_state["aggregate_high_frequency_raw_data"] = (
548+
aggregate_high_frequency_raw_data
549+
)
534550

535551
# region: Process files
536552
########################################################################################
@@ -626,6 +642,7 @@ def apply_linear_adjustments(
626642
keep_core_data=keep_core_data,
627643
aggregate_duplicated_rounded_timepoint=aggregate_duplicated_rounded_timepoint,
628644
aggregate_duplicated_rounded_timepoint_method=aggregate_duplicated_rounded_timepoint_method,
645+
aggregate_high_frequency_raw_data=aggregate_high_frequency_raw_data,
629646
)
630647

631648
rerun = st.session_state.get("df_raw_od_data") is None
@@ -806,7 +823,7 @@ def apply_linear_adjustments(
806823
"OD adjustments have already been applied. "
807824
"Re-applying will overwrite previous adjustments."
808825
)
809-
df_adjustments = pd.read_csv(od_adjustment_upload).convert_dtypes()
826+
df_adjustments = read_od_adjustment_table(od_adjustment_upload)
810827
try:
811828
df_rolling, adjustment_warnings = apply_linear_adjustments(
812829
df_rolling, df_adjustments

AutoGrowth/process_data.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import csv
12
from pathlib import Path
23

34
import pandas as pd
@@ -37,6 +38,90 @@
3738
}
3839

3940

41+
def maybe_aggregate_high_frequency_raw_data(
42+
df_raw_od_data: pd.DataFrame,
43+
min_interval_seconds: int = 15,
44+
aggregation_method: str = "median",
45+
) -> tuple[pd.DataFrame, bool, float | None]:
46+
"""Aggregate OD data if sampling is faster than the minimum interval.
47+
48+
Returns
49+
-------
50+
tuple[pd.DataFrame, bool, float | None]
51+
(possibly aggregated data, whether aggregation was applied,
52+
median sampling interval in seconds).
53+
"""
54+
sampling_interval = (
55+
df_raw_od_data.sort_values(["pioreactor_unit", "timestamp_localtime"])
56+
.groupby("pioreactor_unit")["timestamp_localtime"]
57+
.diff()
58+
.dt.total_seconds()
59+
.dropna()
60+
)
61+
median_interval_seconds = (
62+
float(sampling_interval.median()) if not sampling_interval.empty else None
63+
)
64+
if (
65+
median_interval_seconds is None
66+
or median_interval_seconds >= min_interval_seconds
67+
):
68+
return df_raw_od_data, False, median_interval_seconds
69+
70+
df_aggregated = df_raw_od_data.copy()
71+
# flooring to 00, 15, 30, 45 seconds for example, to aggregate to 15s intervals
72+
# ? Can it cause trouble with metadata?
73+
df_aggregated["timestamp_localtime"] = df_aggregated[
74+
"timestamp_localtime"
75+
].dt.floor(f"{min_interval_seconds}s")
76+
group_columns = ["timestamp_localtime", "pioreactor_unit"]
77+
agg_map = {
78+
col: (aggregation_method if col == "od_reading" else "first")
79+
for col in df_aggregated.columns
80+
if col not in group_columns
81+
}
82+
df_aggregated = df_aggregated.groupby(
83+
group_columns,
84+
sort=False,
85+
dropna=False,
86+
as_index=False,
87+
).agg(agg_map)
88+
return df_aggregated.convert_dtypes(), True, median_interval_seconds
89+
90+
91+
def read_od_adjustment_table(file) -> pd.DataFrame:
92+
"""Read OD adjustment table from CSV/TXT or Excel files.
93+
94+
Parameters
95+
----------
96+
file
97+
Uploaded file-like object with a ``name`` attribute.
98+
99+
Returns
100+
-------
101+
pd.DataFrame
102+
Adjustment table expected to include ``reactor`` and ``od`` columns.
103+
"""
104+
suffix = Path(getattr(file, "name", "")).suffix.lower()
105+
if hasattr(file, "seek"):
106+
file.seek(0)
107+
if suffix in {".xlsx", ".xls"}:
108+
return pd.read_excel(file).convert_dtypes()
109+
if hasattr(file, "read"):
110+
preview = file.read(4096)
111+
if hasattr(file, "seek"):
112+
file.seek(0)
113+
else:
114+
preview = ""
115+
if isinstance(preview, bytes):
116+
preview = preview.decode("utf-8", errors="ignore")
117+
header = next((line for line in preview.splitlines() if line.strip()), "")
118+
try:
119+
delimiter = csv.Sniffer().sniff(header, delimiters=",;").delimiter
120+
except csv.Error:
121+
delimiter = ","
122+
return pd.read_csv(file, sep=delimiter).convert_dtypes()
123+
124+
40125
def read_pioreactor_csv(file: str, round_time: int = 60):
41126
"""Read raw OD data from a PioReactor export CSV file and round timestamps."""
42127
df_raw_od_data = pd.read_csv(file, converters=COLUMN_TYPES_PIO).convert_dtypes()
@@ -117,6 +202,8 @@ def process_od_pioreactor(
117202
keep_core_data: bool = True,
118203
aggregate_duplicated_rounded_timepoint: bool = True,
119204
aggregate_duplicated_rounded_timepoint_method: str = "mean",
205+
aggregate_high_frequency_raw_data: bool = False,
206+
min_raw_sampling_interval_seconds: int = 15,
120207
):
121208
"""Process raw OD data from a PioReactor export CSV file and return both the
122209
raw and wide formats of the data, along with a summary message and a boolean
@@ -142,6 +229,11 @@ def process_od_pioreactor(
142229
Method to use for aggregating duplicated rounded timepoints, by default "mean".
143230
Options are what pandas groupby.agg accepts, e.g. "mean", "median",
144231
"min", "max", etc.
232+
aggregate_high_frequency_raw_data : bool, optional
233+
Whether to aggregate raw OD data before processing when sampling is faster
234+
than the minimum sampling interval.
235+
min_raw_sampling_interval_seconds : int, optional
236+
Minimum expected sampling interval in seconds, default 15.
145237
146238
Returns
147239
-------
@@ -150,6 +242,29 @@ def process_od_pioreactor(
150242
the processing steps.
151243
"""
152244
df_raw_od_data, msg = read_pioreactor_csv(file, round_time)
245+
if aggregate_high_frequency_raw_data:
246+
n_before = df_raw_od_data.shape[0]
247+
df_raw_od_data, was_aggregated, median_interval_seconds = (
248+
maybe_aggregate_high_frequency_raw_data(
249+
df_raw_od_data=df_raw_od_data,
250+
min_interval_seconds=min_raw_sampling_interval_seconds,
251+
aggregation_method=aggregate_duplicated_rounded_timepoint_method,
252+
)
253+
)
254+
if was_aggregated:
255+
n_after = df_raw_od_data.shape[0]
256+
msg += (
257+
"- Aggregated high-frequency raw OD data sampled every "
258+
f"{median_interval_seconds:.1f}s to {min_raw_sampling_interval_seconds}s "
259+
f"(rows: {n_before:,d} -> {n_after:,d}).\n"
260+
)
261+
elif median_interval_seconds is not None:
262+
msg += (
263+
"- Raw OD data sampling interval is "
264+
f"{median_interval_seconds:.1f}s (>= "
265+
f"{min_raw_sampling_interval_seconds}s), so no pre-aggregation was "
266+
"applied.\n"
267+
)
153268
# use starttime to compute elapsed time
154269
start_time = df_raw_od_data["timestamp_rounded"].min()
155270
st.session_state["start_time"] = start_time

0 commit comments

Comments
 (0)