3333LOGGER = logging .getLogger (__name__ )
3434
3535
36+ MAX_WORKERS_DEFAULT = min (8 , (os .cpu_count () or 1 ) + 4 )
3637EXCEL_ENGINE = "calamine"
38+ ONE_DAY_SECS = 24 * 60 * 60
3739PIPELINE_NAME = "electricity_sharepoint"
3840RDM_TIMEZONE = "Europe/London"
3941SITE_URL = "https://stfc365.sharepoint.com/sites/ISISSustainability"
40- LAG_WINDOW_SECONDS = 24 * 60 * 60
41- # It was observed that trying to load too many files concurrently from a sharepoint drive
42- # randomly resulted in empty content. Lower the maximum number of threads that can be used
43- # to extract the data
44- MAX_WORKERS_DEFAULT = min (10 , (os .cpu_count () or 1 ) + 4 )
42+
43+
44+ def effective_max_workers (max_workers : int | None ) -> int :
45+ # For high-cpu-count machines the ThreadPoolExecutor default ends up being too high.
46+ # It has been observed that too many concurrent threads cause issues with partial file reads from
47+ # OneDrive. 8 threads seems to work correctly so clamp this as them maximum...
48+
49+ return (
50+ min (MAX_WORKERS_DEFAULT , max_workers )
51+ if max_workers is not None
52+ else MAX_WORKERS_DEFAULT
53+ )
4554
4655
4756def to_utc (ts : pd .Series ) -> pd .Series :
@@ -59,7 +68,7 @@ def read_power_consumption_csv(
5968 """
6069 df = pd .read_csv (file_content , skiprows = skip_rows )
6170 df ["DateTime" ] = to_utc (
62- pd .to_datetime (df ["Date" ] + " " + df ["Time" ], format = "%d/%m/%y %H:%M:%S" )
71+ pd .to_datetime (df ["Date" ] + " " + df ["Time" ], format = "%d/%m/%y %H:%M:%S" ) # type: ignore
6372 )
6473 return df .drop (["Date" , "Time" ], axis = 1 )
6574
@@ -92,34 +101,33 @@ def extract_content_and_read(
92101 :param items: An iterator of dicts describing the file content
93102 :param skip_rows: Number of rows in the csv/xlsx files to skip
94103 :param max_workers (optional): How many threads to use to process the files.
95- Defaults to a maximum of MAX_WORKERS_DEFAULT.
104+ Defaults to a maximum defined by concurrent.futures.ThreadPoolExecutor
96105 """
97106
98107 # The files are all independent. Process them in parallel and combine for a single yield
99108 def read_as_dataframe (file_obj : M365DriveItem ) -> pd .DataFrame | None :
100- file_content = io .BytesIO (file_obj .read_bytes ())
109+ file_name = file_obj ["file_name" ]
110+ file_bytes = file_obj .read_bytes ()
111+ LOGGER .debug (f"Filename '{ file_name } ' has size { len (file_bytes )} bytes." )
101112 try :
102- match pathlib .Path (file_obj [ " file_name" ] ).suffix :
113+ match pathlib .Path (file_name ).suffix :
103114 case ".csv" :
104- df = read_power_consumption_csv (file_content , skip_rows )
115+ df = read_power_consumption_csv (io . BytesIO ( file_bytes ) , skip_rows )
105116 case ".xlsx" :
106- df = read_power_consumption_excel (file_content , skip_rows )
117+ df = read_power_consumption_excel (io . BytesIO ( file_bytes ) , skip_rows )
107118 case _:
108- raise RuntimeError (
109- f"Unsupported file extension in '{ file_obj ['file_name' ]} '"
110- )
111- except ValueError as exc :
112- LOGGER .warning (
113- f"Error reading '{ file_obj ['file_name' ]} ': { str (exc )} . Skipping"
114- )
115- df = None
119+ raise RuntimeError (f"Unsupported file extension in '{ file_name } '" )
120+ except pd .errors .EmptyDataError as exc :
121+ raise RuntimeError (
122+ f"'{ file_name } ({ len (file_bytes )} bytes)': { str (exc )} "
123+ ) from exc
116124
125+ df ["file_name" ] = file_obj ["file_name" ]
117126 return df
118127
119128 df_batch = None
120- effective_max_workers = min (MAX_WORKERS_DEFAULT , max_workers or MAX_WORKERS_DEFAULT )
121129 with concurrent .futures .ThreadPoolExecutor (
122- max_workers = effective_max_workers
130+ max_workers = effective_max_workers ( max_workers )
123131 ) as executor :
124132 future_to_file_item = {
125133 executor .submit (read_as_dataframe , file_obj ): file_obj for file_obj in items
@@ -132,24 +140,18 @@ def read_as_dataframe(file_obj: M365DriveItem) -> pd.DataFrame | None:
132140 yield df_batch
133141
134142
135- # Occasionally pandas raises a EmptyDataError when parsing the CSV content indicating
136- # there is no data. This is likely an issue with a file being listed but no content yet available.
137- # We skip these files but want to make sure we grab them next time so we use the lag functionality
138- # to look back LAG_WINDOW_SECONDS when the next load is run.
139- @dlt .resource (merge_key = "DateTime" )
143+ @dlt .resource (merge_key = "file_name" )
140144def rdm_data (
141145 datetime_cur = dlt .sources .incremental (
142146 "DateTime" ,
143147 initial_value = pendulum .DateTime .EPOCH ,
144- lag = LAG_WINDOW_SECONDS ,
145- last_value_func = max ,
146148 ),
147149) -> Iterator [TDataItems ]:
148150 files = sharepoint (
149151 site_url = SITE_URL ,
150- file_glob = "/General/RDM Data/**/*.*" ,
151152 extract_content = False ,
152- modified_after = datetime_cur .start_value ,
153+ modified_after = datetime_cur .start_value
154+ - pendulum .Duration (seconds = ONE_DAY_SECS ),
153155 )
154156 reader = files | extract_content_and_read ()
155157 yield from reader
0 commit comments