1+ import csv
12from pathlib import Path
23
34import pandas as pd
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+
40125def 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