|
1 | | -# Copyright 2024 Zeppelin Bend Pty Ltd |
| 1 | +# Copyright 2025 Zeppelin Bend Pty Ltd |
2 | 2 | # This Source Code Form is subject to the terms of the Mozilla Public |
3 | 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
4 | 4 | # file, You can obtain one at https://mozilla.org/MPL/2.0/. |
5 | 5 |
|
6 | 6 | __all__ = ['EwbDataFilePaths'] |
7 | 7 |
|
| 8 | +from abc import ABC, abstractmethod |
8 | 9 | from datetime import date, timedelta |
9 | 10 | from pathlib import Path |
10 | | -from typing import Callable, Iterator, Optional, List |
| 11 | +from typing import Optional, List, Generator |
11 | 12 |
|
12 | 13 | from zepben.ewb import require |
13 | 14 | from zepben.ewb.database.paths.database_type import DatabaseType |
14 | 15 |
|
15 | 16 |
|
16 | | -class EwbDataFilePaths: |
| 17 | +class EwbDataFilePaths(ABC): |
17 | 18 | """Provides paths to all the various data files / folders used by EWB.""" |
18 | 19 |
|
19 | | - def __init__(self, base_dir: Path, |
20 | | - create_path: bool = False, |
21 | | - create_directories_func: Callable[[Path], None] = lambda it: it.mkdir(parents=True), |
22 | | - is_directory: Callable[[Path], bool] = Path.is_dir, |
23 | | - exists: Callable[[Path], bool] = Path.exists, |
24 | | - list_files: Callable[[Path], Iterator[Path]] = Path.iterdir): |
25 | | - """ |
26 | | - :param base_dir: The root directory of the EWB data structure. |
27 | | - :param create_path: Create the root directory (and any missing parent folders) if it does not exist. |
28 | | - """ |
29 | | - self.create_directories_func = create_directories_func |
30 | | - self.is_directory = is_directory |
31 | | - self.exists = exists |
32 | | - self.list_files = list_files |
33 | | - self._base_dir = base_dir |
34 | | - |
35 | | - if create_path: |
36 | | - self.create_directories_func(base_dir) |
37 | | - |
38 | | - require(self.is_directory(base_dir), lambda: f"base_dir must be a directory") |
39 | | - |
40 | | - @property |
41 | | - def base_dir(self): |
42 | | - """The root directory of the EWB data structure.""" |
43 | | - return self._base_dir |
44 | | - |
45 | | - def customer(self, database_date: date) -> Path: |
46 | | - """ |
47 | | - Determine the path to the "customers" database for the specified date. |
48 | | -
|
49 | | - :param database_date: The :class:`date` to use for the "customers" database. |
50 | | - :return: The :class:`path` to the "customers" database for the specified date. |
51 | | - """ |
52 | | - return self._to_dated_path(database_date, DatabaseType.CUSTOMER.file_descriptor) |
53 | | - |
54 | | - def diagram(self, database_date: date) -> Path: |
55 | | - """ |
56 | | - Determine the path to the "diagrams" database for the specified date. |
57 | | -
|
58 | | - :param database_date: The :class:`date` to use for the "diagrams" database. |
59 | | - :return: The :class:`path` to the "diagrams" database for the specified date. |
60 | | - """ |
61 | | - return self._to_dated_path(database_date, DatabaseType.DIAGRAM.file_descriptor) |
62 | | - |
63 | | - def measurement(self, database_date: date) -> Path: |
64 | | - """ |
65 | | - Determine the path to the "measurements" database for the specified date. |
66 | | -
|
67 | | - :param database_date: The :class:`date` to use for the "measurements" database. |
68 | | - :return: The :class:`path` to the "measurements" database for the specified date. |
69 | | - """ |
70 | | - return self._to_dated_path(database_date, DatabaseType.MEASUREMENT.file_descriptor) |
71 | | - |
72 | | - def network_model(self, database_date: date) -> Path: |
73 | | - """ |
74 | | - Determine the path to the "network model" database for the specified date. |
75 | | -
|
76 | | - :param database_date: The :class:`date` to use for the "network model" database. |
77 | | - :return: The :class:`path` to the "network model" database for the specified date. |
78 | | - """ |
79 | | - return self._to_dated_path(database_date, DatabaseType.NETWORK_MODEL.file_descriptor) |
80 | | - |
81 | | - def tile_cache(self, database_date: date) -> Path: |
82 | | - """ |
83 | | - Determine the path to the "tile cache" database for the specified date. |
84 | | -
|
85 | | - :param database_date: The :class:`date` to use for the "tile cache" database. |
86 | | - :return: The :class:`path` to the "tile cache" database for the specified date. |
87 | | - """ |
88 | | - return self._to_dated_path(database_date, DatabaseType.TILE_CACHE.file_descriptor) |
89 | | - |
90 | | - def energy_reading(self, database_date: date) -> Path: |
91 | | - """ |
92 | | - Determine the path to the "energy readings" database for the specified date. |
93 | | -
|
94 | | - :param database_date: The :class:`date` to use for the "energy readings" database. |
95 | | - :return: The :class:`path` to the "energy readings" database for the specified date. |
96 | | - """ |
97 | | - return self._to_dated_path(database_date, DatabaseType.ENERGY_READING.file_descriptor) |
98 | | - |
99 | | - def energy_readings_index(self) -> Path: |
100 | | - """ |
101 | | - Determine the path to the "energy readings index" database. |
102 | | -
|
103 | | - :return: The :class:`path` to the "energy readings index" database. |
104 | | - """ |
105 | | - return self._base_dir.joinpath(f"{DatabaseType.ENERGY_READINGS_INDEX.file_descriptor}.sqlite") |
| 20 | + VARIANTS_PATH: str = "variants" |
| 21 | + """ |
| 22 | + The folder containing the variants. Will be placed under the dated folder alongside the network model database. |
| 23 | + """ |
106 | 24 |
|
107 | | - def load_aggregator_meters_by_date(self) -> Path: |
| 25 | + def resolve(self, database_type: DatabaseType, database_date: Optional[date] = None, variant: Optional[str] = None) -> Path: |
108 | 26 | """ |
109 | | - Determine the path to the "load aggregator meters-by-date" database. |
| 27 | + Resolves the :class:`Path` to the database file for the specified :class:`DatabaseType`, within the specified `database_date` |
| 28 | + and optional `variant` when `DatabaseType.per_date` is set to true. |
110 | 29 |
|
111 | | - :return: The :class:`path` to the "load aggregator meters-by-date" database. |
112 | | - """ |
113 | | - return self._base_dir.joinpath(f"{DatabaseType.LOAD_AGGREGATOR_METERS_BY_DATE.file_descriptor}.sqlite") |
114 | | - |
115 | | - def weather_reading(self) -> Path: |
116 | | - """ |
117 | | - Determine the path to the "weather readings" database. |
| 30 | + :param database_type: The :class:`DatabaseType` to use for the database :class:`Path`. |
| 31 | + :param database_date: The :class:`date` to use for the database :class:`Path`. Required when `database_type.per_date` is true, otherwise must be `None`. |
| 32 | + :param variant: The optional name of the variant containing the database. |
118 | 33 |
|
119 | | - :return: The :class:`path` to the "weather readings" database. |
| 34 | + :return: The :class:`Path` to the :class:`DatabaseType` database file. |
120 | 35 | """ |
121 | | - return self._base_dir.joinpath(f"{DatabaseType.WEATHER_READING.file_descriptor}.sqlite") |
122 | | - |
123 | | - def results_cache(self) -> Path: |
124 | | - """ |
125 | | - Determine the path to the "results cache" database. |
126 | | -
|
127 | | - :return: The :class:`path` to the "results cache" database. |
128 | | - """ |
129 | | - return self._base_dir.joinpath(f"{DatabaseType.RESULTS_CACHE.file_descriptor}.sqlite") |
| 36 | + if database_date is not None: |
| 37 | + require(database_type.per_date, lambda: "database_type must have its per_date set to True to use this method with a database_date.") |
| 38 | + if variant is not None: |
| 39 | + return self.resolve_database(self._to_dated_variant_path(database_type, database_date, variant)) |
| 40 | + else: |
| 41 | + return self.resolve_database(self._to_dated_path(database_type, database_date)) |
| 42 | + else: |
| 43 | + require(not database_type.per_date, lambda: "database_type must have its per_date set to False to use this method without a database_date.") |
| 44 | + return self.resolve_database(Path(self._database_name(database_type))) |
130 | 45 |
|
| 46 | + @abstractmethod |
131 | 47 | def create_directories(self, database_date: date) -> Path: |
132 | 48 | """ |
133 | 49 | Create the directories required to have a valid path for the specified date. |
134 | 50 |
|
135 | 51 | :param database_date: The :class:`date` required in the path. |
136 | | - :return: The :class:`path` to the directory for the `database_date`. |
137 | | - """ |
138 | | - date_path = self._base_dir.joinpath(str(database_date)) |
139 | | - if self.exists(date_path): |
140 | | - return date_path |
141 | | - else: |
142 | | - self.create_directories_func(date_path) |
143 | | - return date_path |
144 | | - |
145 | | - def _to_dated_path(self, database_date: date, file: str) -> Path: |
146 | | - return self._base_dir.joinpath(str(database_date), f"{database_date}-{file}.sqlite") |
147 | | - |
148 | | - def _check_exists(self, database_type: DatabaseType, database_date: date) -> bool: |
| 52 | + :return: The :class:`Path` to the directory for the `database_date`. |
149 | 53 | """ |
150 | | - Check if a database of the specified type and date exists. |
| 54 | + raise NotImplemented |
151 | 55 |
|
152 | | - :param database_type: The type of database to search for. |
153 | | - :param database_date: The date to check. |
154 | | - :return: `True` if a database of the specified `database_type` and `database_date` exists in the date path. |
155 | | - """ |
156 | | - if not database_type.per_date: |
157 | | - raise ValueError("INTERNAL ERROR: Should only be calling `checkExists` for `perDate` files.") |
158 | | - |
159 | | - if database_type == DatabaseType.CUSTOMER: |
160 | | - model_path = self.customer(database_date) |
161 | | - elif database_type == DatabaseType.DIAGRAM: |
162 | | - model_path = self.diagram(database_date) |
163 | | - elif database_type == DatabaseType.MEASUREMENT: |
164 | | - model_path = self.measurement(database_date) |
165 | | - elif database_type == DatabaseType.NETWORK_MODEL: |
166 | | - model_path = self.network_model(database_date) |
167 | | - elif database_type == DatabaseType.TILE_CACHE: |
168 | | - model_path = self.tile_cache(database_date) |
169 | | - elif database_type == DatabaseType.ENERGY_READING: |
170 | | - model_path = self.energy_reading(database_date) |
171 | | - else: |
172 | | - raise ValueError( |
173 | | - "INTERNAL ERROR: Should only be calling `check_exists` for `per_date` files, which should all be covered above, so go ahead and add it.") |
174 | | - return self.exists(model_path) |
175 | | - |
176 | | - def find_closest(self, database_type: DatabaseType, max_days_to_search: int = 999, target_date: date = date.today(), search_forwards: bool = False) -> \ |
177 | | - Optional[date]: |
| 56 | + def find_closest( |
| 57 | + self, |
| 58 | + database_type: DatabaseType, |
| 59 | + max_days_to_search: int = 999999, |
| 60 | + target_date: date = date.today(), |
| 61 | + search_forwards: bool = False |
| 62 | + ) -> Optional[date]: |
178 | 63 | """ |
179 | 64 | Find the closest date with a usable database of the specified type. |
180 | 65 |
|
181 | 66 | :param database_type: The type of database to search for. |
182 | 67 | :param max_days_to_search: The maximum number of days to search for a valid database. |
183 | | - :param target_date: The target :class:`date`. Defaults to today. |
184 | | - :param search_forwards: Indicates the search should also look forwards in time from `start_date` for a valid file. Defaults to reverse search only. |
185 | | - :return: The closest :class:`date` to `database_date` with a valid database of `database_type` within the search parameters, or `None` if no valid database was found. |
| 68 | + :param target_date: The target date. Defaults to today. |
| 69 | + :param search_forwards: Indicates the search should also look forwards in time from `target_date` for a valid file. Defaults to reverse search only. |
| 70 | +
|
| 71 | + :return: The closest :class:`date` to `target_date` with a valid database of `database_type` within the search parameters, or null if no valid database |
| 72 | + was found. |
186 | 73 | """ |
187 | 74 | if not database_type.per_date: |
188 | 75 | return None |
189 | 76 |
|
190 | | - if self._check_exists(database_type, target_date): |
| 77 | + descendants = list(self.enumerate_descendants()) |
| 78 | + if self._check_exists(descendants, database_type, target_date): |
191 | 79 | return target_date |
192 | 80 |
|
193 | 81 | offset = 1 |
194 | | - |
195 | 82 | while offset <= max_days_to_search: |
196 | 83 | offset_days = timedelta(offset) |
197 | 84 | try: |
198 | 85 | previous_date = target_date - offset_days |
199 | | - if self._check_exists(database_type, previous_date): |
| 86 | + if self._check_exists(descendants, database_type, previous_date): |
200 | 87 | return previous_date |
201 | 88 | except OverflowError: |
202 | 89 | pass |
203 | 90 |
|
204 | 91 | if search_forwards: |
205 | 92 | try: |
206 | 93 | forward_date = target_date + offset_days |
207 | | - if self._check_exists(database_type, forward_date): |
| 94 | + if self._check_exists(descendants, database_type, forward_date): |
208 | 95 | return forward_date |
209 | 96 | except OverflowError: |
210 | 97 | pass |
| 98 | + |
211 | 99 | offset += 1 |
| 100 | + |
212 | 101 | return None |
213 | 102 |
|
214 | | - def _get_available_dates_for(self, database_type: DatabaseType) -> List[date]: |
| 103 | + def get_available_dates_for(self, database_type: DatabaseType) -> List[date]: |
| 104 | + """ |
| 105 | + Find available databases specified by :class:`DatabaseType` in data path. |
| 106 | +
|
| 107 | + :param database_type: The type of database to search for. |
| 108 | +
|
| 109 | + :return: list of :class:`date`'s for which this specified :class:`DatabaseType` databases exist in the data path. |
| 110 | + """ |
215 | 111 | if not database_type.per_date: |
216 | 112 | raise ValueError( |
217 | | - "INTERNAL ERROR: Should only be calling `_get_available_dates_for` for `per_date` files.") |
| 113 | + "INTERNAL ERROR: Should only be calling `get_available_dates_for` for `per_date` files, " |
| 114 | + "which should all be covered above, so go ahead and add it." |
| 115 | + ) |
218 | 116 |
|
219 | 117 | to_return = list() |
220 | 118 |
|
221 | | - for file in self.list_files(self._base_dir): |
222 | | - if self.is_directory(file): |
| 119 | + for it in self.enumerate_descendants(): |
| 120 | + if it.name.endswith(self._database_name(database_type)): |
223 | 121 | try: |
224 | | - database_date = date.fromisoformat(file.name) |
225 | | - if self.exists(self._to_dated_path(database_date, database_type.file_descriptor)): |
226 | | - to_return.append(database_date) |
| 122 | + to_return.append(date.fromisoformat(it.parent.name)) |
227 | 123 | except ValueError: |
228 | 124 | pass |
| 125 | + |
| 126 | + return sorted(to_return) |
| 127 | + |
| 128 | + def get_available_variants_for(self, target_date: date = date.today()) -> List[str]: |
| 129 | + """ |
| 130 | + Find available variants for the specified `target_date` in data path. |
| 131 | +
|
| 132 | + :param target_date: The target date. Defaults to today. |
| 133 | +
|
| 134 | + :return: list of variant names that exist in the data path for the specified `target_date`. |
| 135 | + """ |
| 136 | + to_return = list() |
| 137 | + |
| 138 | + for it in self.enumerate_descendants(): |
| 139 | + try: |
| 140 | + if (str(it.parent.name).lower() == self.VARIANTS_PATH) and (str(it.parent.parent.name) == str(target_date)): |
| 141 | + to_return.append(str(it.name)) |
| 142 | + except ValueError: |
| 143 | + pass |
| 144 | + |
229 | 145 | return sorted(to_return) |
230 | 146 |
|
231 | | - def get_network_model_databases(self) -> List[date]: |
| 147 | + @abstractmethod |
| 148 | + def enumerate_descendants(self) -> Generator[Path, None, None]: |
| 149 | + """ |
| 150 | + Lists the child items of source location. |
| 151 | +
|
| 152 | + :return: generator of child items. |
| 153 | + """ |
| 154 | + raise NotImplemented |
| 155 | + |
| 156 | + @abstractmethod |
| 157 | + def resolve_database(self, path: Path) -> Path: |
232 | 158 | """ |
233 | | - Find available network-model databases in data path. |
| 159 | + Resolves the database in the specified source :class:`Path`. |
234 | 160 |
|
235 | | - :return: A list of :class:`date`'s for which network-model databases exist in the data path. |
| 161 | + :param path: :class:`Path` to the source database file. |
| 162 | + :return: :class:`Path` to the local database file. |
236 | 163 | """ |
237 | | - return self._get_available_dates_for(DatabaseType.NETWORK_MODEL) |
| 164 | + raise NotImplemented |
| 165 | + |
| 166 | + def _check_exists(self, descendants: List[Path], database_type: DatabaseType, database_date: date) -> bool: |
| 167 | + """ |
| 168 | + Check if a database :class:`Path` of the specified :class:`DatabaseType` and :class:`date` exists. |
| 169 | +
|
| 170 | + :param descendants: A list of :class:`Path` representing the descendant paths. |
| 171 | + :param database_type: The type of database to search for. |
| 172 | + :param database_date: The date to check. |
| 173 | +
|
| 174 | + :return: True if a database of the specified `database_type` and `database_date` exits in the date path. |
| 175 | + """ |
| 176 | + for cp in descendants: |
| 177 | + if cp.is_relative_to(self._to_dated_path(database_type, database_date)): |
| 178 | + return True |
| 179 | + |
| 180 | + return False |
| 181 | + |
| 182 | + def _to_dated_path(self, database_type: DatabaseType, database_date: date) -> Path: |
| 183 | + date_str = str(database_date) |
| 184 | + return Path(date_str).joinpath(f"{date_str}-{self._database_name(database_type)}") |
| 185 | + |
| 186 | + def _to_dated_variant_path(self, database_type: DatabaseType, database_date: date, variant: str) -> Path: |
| 187 | + date_str = str(database_date) |
| 188 | + return Path(date_str).joinpath(self.VARIANTS_PATH, variant, f"{date_str}-{self._database_name(database_type)}") |
| 189 | + |
| 190 | + @staticmethod |
| 191 | + def _database_name(database_type: DatabaseType) -> str: |
| 192 | + return f"{database_type.file_descriptor}.sqlite" |
0 commit comments