-
Notifications
You must be signed in to change notification settings - Fork 0
feat(elt-pipelines): Port electricity_sharepoint pipeline to elt #403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
WHTaylor
wants to merge
7
commits into
main
Choose a base branch
from
321-port-electricity-sharepoint-pipeline
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8b2f747
Copy existing electrcity_sharepoint pipeline
WHTaylor 3a3985f
feat(elt-pipelines): Port electricity_sharepoint to elt
WHTaylor b6371cc
Combine optional dependencies for sharepoint pipelines
WHTaylor 6735ed3
Handle cases where no data is extracted
WHTaylor 9ebaee4
Don't use assert for runtime checks
WHTaylor f5d171e
Test that empty table causes no write at all
WHTaylor c0696c3
Fix merge conflicts
WHTaylor File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
141 changes: 141 additions & 0 deletions
141
elt-pipelines/facility_ops/ingest/estates/electricity_sharepoint/electricity_sharepoint.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| """Extracts measurements of ISIS's electricity usage from the ISIS Sustainability Sharepoint site""" | ||
|
|
||
| import concurrent.futures | ||
| import datetime as dt | ||
| import io | ||
| import os | ||
| import logging | ||
| import pathlib | ||
|
|
||
| import pandas as pd | ||
| import pyarrow as pa | ||
|
|
||
| from parsing import read_power_consumption_csv, read_power_consumption_excel | ||
|
|
||
| from elt_common.extract import ( | ||
| BaseExtract, | ||
| ResourceProperties, | ||
| Watermark, | ||
| ResourceWriteProperties, | ||
| ) | ||
| from elt_common.sources.m365.client import SPListClient, M365File | ||
| from elt_common.sources.m365.credentials import M365Credentials | ||
|
|
||
| SITE_URL = "https://stfc365.sharepoint.com/sites/ISISSustainability" | ||
| MAX_WORKERS = min(8, (os.cpu_count() or 1) + 4) | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
|
|
||
| _root_path = "/General/RDM Data" | ||
| _default_backfill_globs = [ | ||
| "**/*.xlsx", | ||
| "**/*-daily.csv", | ||
| "**/*-manual-export.csv", | ||
| ] | ||
|
|
||
|
|
||
| class Configuration(M365Credentials): | ||
| backfill: bool = False | ||
| backfill_globs: list[str] = [] | ||
|
|
||
| @property | ||
| def glob_patterns(self): | ||
| if not self.backfill: | ||
| return ["*-ISIS.csv"] | ||
| return self.backfill_globs if self.backfill_globs else _default_backfill_globs | ||
|
|
||
|
|
||
| class Extract(BaseExtract): | ||
| config_cls = Configuration | ||
|
|
||
| def __init__(self, cfg: Configuration): | ||
| super().__init__(cfg) | ||
| self._client = SPListClient(SITE_URL, cfg) | ||
| self._backfilling = cfg.backfill | ||
| self._glob_patterns = cfg.glob_patterns | ||
|
|
||
| LOGGER.debug(f"Searching for files matching: {self._glob_patterns}") | ||
|
|
||
| def extract_resource_properties(self): | ||
| yield ( | ||
| "rdm_data", | ||
| ResourceProperties( | ||
| extractor=self._extract_electricity_usage, | ||
| write_properties=ResourceWriteProperties( | ||
| merge_on=["date_time"], write_mode="merge" | ||
| ), | ||
| watermark_column="date_time", | ||
| ), | ||
| ) | ||
|
|
||
| def _extract_electricity_usage(self, w: Watermark | None): | ||
| watermark_value: dt.datetime | None = None | ||
| if w and self._backfilling: | ||
| LOGGER.debug("Ignoring watermark because this is a backfill") | ||
| elif w: | ||
| if not isinstance(w.value, dt.datetime): | ||
| LOGGER.warning( | ||
| f"Ignoring watermark for electricity SP because it was '{w.value}', not a datetime" | ||
| ) | ||
| else: | ||
| watermark_value = w.value | ||
| LOGGER.debug(f"Only fetching files modified after {watermark_value}") | ||
|
|
||
| files = [] | ||
| for pattern in self._glob_patterns: | ||
| files.extend( | ||
| self._client.glob( | ||
| _root_path, pattern=pattern, modified_after=watermark_value | ||
| ) | ||
| ) | ||
|
|
||
| LOGGER.debug(f"Matched {len(files)} files") | ||
|
|
||
| resolved = self._read_files(files) | ||
| df = read_contents_to_dataframe(resolved) | ||
| if df is not None and df.size > 0: | ||
| yield pa.Table.from_pandas(df, preserve_index=False) | ||
| else: | ||
| yield pa.Table.from_pylist([]) | ||
|
|
||
| def _read_files(self, files: list[M365File]) -> list[tuple[str, bytes]]: | ||
| results = [] | ||
| with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: | ||
| future_to_file_item = { | ||
| executor.submit(self._client.read_file, f.path): f for f in files | ||
| } | ||
|
|
||
| for future in concurrent.futures.as_completed(future_to_file_item): | ||
| file = future_to_file_item[future] | ||
| results.append((file.name, future.result())) | ||
| return results | ||
|
|
||
|
|
||
| def read_contents_to_dataframe(files: list[tuple[str, bytes]]): | ||
| """Extracts data from files into a single, combined dataframe | ||
|
|
||
| :param files: (name, content) pairs for the files to extract data from | ||
| """ | ||
|
|
||
| def read_as_dataframe(file_name, file_bytes) -> pd.DataFrame | None: | ||
| file_content = io.BytesIO(file_bytes) | ||
| LOGGER.debug(f"File '{file_name}' has size {len(file_bytes)} bytes.") | ||
| match pathlib.Path(file_name).suffix: | ||
| case ".csv": | ||
| df = read_power_consumption_csv(file_name, file_content) | ||
| case ".xlsx": | ||
| df = read_power_consumption_excel(file_content) | ||
| case _: | ||
| raise RuntimeError(f"Unsupported file extension in '{file_name}'") | ||
|
|
||
| if df is not None: | ||
| df["file_name"] = file_name | ||
| return df | ||
|
|
||
| df_batch: pd.DataFrame | None = None | ||
| for name, file_contents in files: | ||
| df = read_as_dataframe(name, file_contents) | ||
| if df is not None: | ||
| df_batch = df if df_batch is None else pd.concat((df_batch, df)) | ||
|
|
||
| return df_batch |
150 changes: 150 additions & 0 deletions
150
elt-pipelines/facility_ops/ingest/estates/electricity_sharepoint/parsing.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,150 @@ | ||
| """Functions for parsing data from the electricity files | ||
|
|
||
| There are 3 known formats for the data. Each contains a preamble of the form: | ||
|
|
||
| Site Information: | ||
| RAL ISIS RDM | ||
| RAL ISIS RDM | ||
| Controller: ISIS | ||
| Controller description: ISIS Energy Totals | ||
| Status: Online | ||
|
|
||
| This preamble is discarded from all formats. | ||
|
|
||
| 1. Excel (old, manual export format) | ||
| ------------------------------------ | ||
|
|
||
| These were produced by manual export from the original system. | ||
|
|
||
| There are two columns: "Time,Total Power (MW)". Time is in the format "YYYY-mm-DDTHH:MM:SS". | ||
|
|
||
| 2. Automated CSV export | ||
| ----------------------- | ||
|
|
||
| An automation deposits .csv files at regular intervals. | ||
|
|
||
| There are three columns: "Time,Date,ISIS Elec Total Power", where Time is in format HH:MM:SS and Date is in format DD/mm/YY. | ||
| Several of these files can be concatenated together to form larger timespan records - they are concatenated as is and repeated | ||
| preamble sections are not discarded. The variable CSV_PREAMBLE_ANCHOR defines the line at which a new section occurs. | ||
|
|
||
| 3. Manual CSV export | ||
| -------------------- | ||
|
|
||
| These were produced more recently by manual export from the original system. | ||
| There are three columns: "Time,ISIS Elec Total Energy,ISIS Elec Total Power", where Time is in format "DD/mm/YY HH:MM:SS". | ||
| The second column is discarded. | ||
| """ | ||
|
|
||
| import io | ||
| import logging | ||
| import pandas as pd | ||
|
|
||
| from typing import Sequence | ||
|
|
||
| CSV_PREAMBLE_ANCHOR = "time" | ||
| COL_DATE_TIME = "date_time" | ||
| COL_TOTAL_POWER = "isis_elec_total_power_mw" | ||
| EXCEL_SKIP_ROWS = 7 | ||
| RDM_TIMEZONE = "Europe/London" | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def read_power_consumption_excel(file_content: io.BytesIO) -> pd.DataFrame: | ||
| # See module comment for description of the format | ||
| df_raw = pd.read_excel(file_content, skiprows=EXCEL_SKIP_ROWS) | ||
| df_raw = df_raw.rename( | ||
| columns={"Time": COL_DATE_TIME, "ISIS Elec Total Power {MW}": COL_TOTAL_POWER} | ||
| ) | ||
| df_raw[COL_DATE_TIME] = _to_utc(df_raw[COL_DATE_TIME]) # type: ignore | ||
| return df_raw | ||
|
|
||
|
|
||
| def read_power_consumption_csv( | ||
| file_name: str, file_content: io.BytesIO | ||
| ) -> pd.DataFrame | None: | ||
| # See module comment for description of the format | ||
|
|
||
| def _append_if_not_none(seq, df): | ||
| if df is not None: | ||
| seq.append(df) | ||
|
|
||
| metadata_anchor = "site information" | ||
| sections = [] | ||
| current_lines: list[str] = [] | ||
| in_data = False | ||
| for line in file_content.getvalue().decode().splitlines(): | ||
| line = line.strip() | ||
| line_lower = line.lower() | ||
|
|
||
| if line_lower.startswith(CSV_PREAMBLE_ANCHOR): | ||
| # Save any previous section | ||
| if current_lines: | ||
| _append_if_not_none( | ||
| sections, _csv_section_to_df(file_name, current_lines) | ||
| ) | ||
| # start new section with header | ||
| current_lines = [line] | ||
| in_data = True | ||
|
|
||
| elif in_data: | ||
| if line_lower.startswith(metadata_anchor): | ||
| # Metadata block | ||
| in_data = False | ||
| continue | ||
| else: | ||
| current_lines.append(line) | ||
|
|
||
| # the last section | ||
| if current_lines: | ||
| _append_if_not_none(sections, _csv_section_to_df(file_name, current_lines)) | ||
|
|
||
| # concatenate | ||
| if sections: | ||
| return pd.concat(sections) | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| def _csv_section_to_df(file_name: str, lines: Sequence[str]) -> pd.DataFrame | None: | ||
| """Parse csv and return a DataFrame if the times are valid. None if not""" | ||
| buff = io.StringIO("\n".join(lines)) | ||
| df_raw: pd.DataFrame = pd.read_csv(buff) # type: ignore | ||
|
|
||
| # clean up column names (strip any whitespace) | ||
| df_raw.columns = df_raw.columns.str.strip() | ||
| cols = [c for c in df_raw.columns] | ||
| if len(cols) != 3 or "power" not in cols[2].lower(): | ||
| LOGGER.warning(f"Columns in {file_name} are an unexpected format: {cols}") | ||
| return None | ||
|
|
||
| try: | ||
| if cols[1].strip() == "Date": | ||
| # Automated CSV format | ||
| raw_datetime = df_raw["Date"] + " " + df_raw["Time"] | ||
| else: | ||
| # Manual CSV format | ||
| raw_datetime = df_raw["Time"] | ||
|
|
||
| df_datetime: pd.Series = pd.to_datetime( # type: ignore | ||
| raw_datetime, format="%d/%m/%y %H:%M:%S" | ||
| ) | ||
| df = _to_utc(df_datetime).to_frame(name=COL_DATE_TIME) | ||
| except ValueError as exc: | ||
| # Pandas 3 uses ValueError for conversions that produce ambiguous/non-existent times | ||
| msg = str(exc) | ||
| if "ambiguous" in msg or "nonexistent" in msg: | ||
| LOGGER.warning( | ||
| f"'Error loading section of {file_name}'. DST issues detected: {str(exc)}" | ||
| ) | ||
| return None | ||
| else: | ||
| raise | ||
|
|
||
| df[COL_TOTAL_POWER] = df_raw[cols[2]] | ||
| return df | ||
|
|
||
|
|
||
| def _to_utc(ts: pd.Series) -> pd.Series: | ||
| """Assumes timezone unaware data and converts to UTC""" | ||
| return ts.dt.tz_localize(RDM_TIMEZONE).dt.tz_convert("UTC") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.