saviialib 1.0.1__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of saviialib might be problematic. Click here for more details.

@@ -21,3 +21,7 @@ class OsClient(DirectoryClientContract):
21
21
  @staticmethod
22
22
  async def isdir(path: str) -> list:
23
23
  return await asyncio.to_thread(os.path.isdir, path)
24
+
25
+ @staticmethod
26
+ async def makedirs(path: str) -> None:
27
+ return await asyncio.to_thread(os.makedirs, path, exist_ok=True)
@@ -26,3 +26,6 @@ class DirectoryClient(DirectoryClientContract):
26
26
 
27
27
  async def isdir(self, path: str) -> bool:
28
28
  return await self.client_obj.isdir(path)
29
+
30
+ async def makedirs(self, path: str) -> None:
31
+ return await self.client_obj.makedirs(path)
@@ -17,3 +17,7 @@ class DirectoryClientContract(ABC):
17
17
  @abstractmethod
18
18
  async def isdir(self, path) -> bool:
19
19
  pass
20
+
21
+ @abstractmethod
22
+ async def makedirs(self, path: str) -> None:
23
+ pass
@@ -0,0 +1,42 @@
1
+ import csv
2
+ from asyncio import to_thread
3
+
4
+ from saviialib.libs.directory_client.directory_client import (
5
+ DirectoryClient,
6
+ DirectoryClientArgs,
7
+ )
8
+ from saviialib.libs.files_client.files_client_contract import FilesClientContract
9
+ from saviialib.libs.files_client.types.files_client_types import (
10
+ FilesClientInitArgs,
11
+ ReadArgs,
12
+ WriteArgs,
13
+ )
14
+
15
+
16
+ class CsvClient(FilesClientContract):
17
+ def __init__(self, args: FilesClientInitArgs):
18
+ self.dir_client = DirectoryClient(DirectoryClientArgs(client_name="os_client"))
19
+
20
+ async def read(self, args: ReadArgs) -> str | bytes:
21
+ raise OSError("This method is not implemented yet.")
22
+
23
+ async def write(self, args: WriteArgs) -> None:
24
+ file_type = args.file_name.split(".")[-1]
25
+ file_content = args.file_content
26
+ header = file_content[0].keys()
27
+ if file_type == "tsv":
28
+ delimiter = "\t"
29
+ else: # Default CSV.
30
+ delimiter = ","
31
+
32
+ if args.destination_path == "":
33
+ dest_path = self.dir_client.join_paths(args.file_name)
34
+ else:
35
+ dest_path = self.dir_client.join_paths(
36
+ args.destination_path, args.file_name
37
+ )
38
+
39
+ with open(dest_path, "w", newline="") as file:
40
+ writer = csv.DictWriter(file, fieldnames=header, delimiter=delimiter) # type: ignore
41
+ await to_thread(writer.writeheader)
42
+ await to_thread(writer.writerows, file_content) # type: ignore
@@ -1,10 +1,11 @@
1
1
  from .clients.aiofiles_client import AioFilesClient
2
+ from .clients.csv_client import CsvClient
2
3
  from .files_client_contract import FilesClientContract
3
4
  from .types.files_client_types import FilesClientInitArgs, ReadArgs, WriteArgs
4
5
 
5
6
 
6
7
  class FilesClient(FilesClientContract):
7
- CLIENTS = {"aiofiles_client"}
8
+ CLIENTS = {"aiofiles_client", "csv_client"}
8
9
 
9
10
  def __init__(self, args: FilesClientInitArgs) -> None:
10
11
  if args.client_name not in FilesClient.CLIENTS:
@@ -13,15 +14,12 @@ class FilesClient(FilesClientContract):
13
14
 
14
15
  if args.client_name == "aiofiles_client":
15
16
  self.client_obj = AioFilesClient(args)
17
+ elif args.client_name == "csv_client":
18
+ self.client_obj = CsvClient(args)
19
+
16
20
  self.client_name = args.client_name
17
21
 
18
22
  async def read(self, args: ReadArgs):
19
- """Reads data from a specified source using the provided arguments.
20
-
21
- :param args (ReadArgs): An object containing the parameters required for the read operation.
22
-
23
- :return file: The result of the read operation, as returned by the client object.
24
- """
25
23
  return await self.client_obj.read(args)
26
24
 
27
25
  async def write(self, args: WriteArgs):
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass
2
- from typing import Literal
2
+ from typing import Literal, Union, List, Dict
3
3
 
4
4
 
5
5
  @dataclass
@@ -27,6 +27,6 @@ class ReadArgs:
27
27
  @dataclass
28
28
  class WriteArgs:
29
29
  file_name: str
30
- file_content: str | bytes
30
+ file_content: Union[str, bytes, List[Dict]]
31
31
  mode: Literal["w", "wb", "a"]
32
32
  destination_path: str = ""
@@ -0,0 +1,160 @@
1
+ from .thies_bp import THIESDayData
2
+ from typing import List
3
+ from logging import Logger
4
+ from asyncio import to_thread
5
+ from saviialib.libs.directory_client import DirectoryClient
6
+ from saviialib.libs.zero_dependency.utils.datetime_utils import datetime_to_str, today
7
+ from saviialib.libs.files_client import FilesClient, FilesClientInitArgs, WriteArgs
8
+
9
+
10
+ AVG_COLUMNS = {
11
+ "Date": "date",
12
+ "Time": "time",
13
+ "AirTemperature": "air_temperature",
14
+ "Radiation": "radiation",
15
+ "CO2": "carbon_dioxide",
16
+ "Precipitation": "precipitation",
17
+ "WS": "wind_velocity",
18
+ "WD": "wind_direction",
19
+ "Humidity": "humidity",
20
+ }
21
+
22
+ EXT_COLUMNS = {
23
+ "Date": "date",
24
+ "Time": "time",
25
+ "AirTemperature MIN": "air_temperature",
26
+ "AirTemperature MAX": "air_temperature",
27
+ "Radiation MIN": "radiation",
28
+ "Radiation MAX": "radiation",
29
+ "CO2 MIN": "carbon_dioxide",
30
+ "CO2 MAX": "carbon_dioxide",
31
+ "WS MIN": "wind_velocity",
32
+ "WS MAX gust": "wind_velocity",
33
+ "WD MIN": "wind_direction",
34
+ "WD MAX gust": "wind_direction",
35
+ "Humidity MIN": "humidity",
36
+ "Humidity MAX": "humidity",
37
+ }
38
+
39
+ AGG_DICT = {
40
+ "AirTemperature": "mean",
41
+ "AirTemperature MIN": "mean",
42
+ "AirTemperature MAX": "mean",
43
+ "Precipitation": "sum",
44
+ "Humidity": "mean",
45
+ "Humidity MIN": "mean",
46
+ "Humidity MAX": "mean",
47
+ "Radiation": "sum",
48
+ "Radiation MIN": "sum",
49
+ "Radiation MAX": "sum",
50
+ "CO2": "sum",
51
+ "CO2 MIN": "sum",
52
+ "CO2 MAX": "sum",
53
+ "WS": "mean",
54
+ "WS MIN": "mean",
55
+ "WS MAX gust": "mean",
56
+ "WD": "mean",
57
+ "WD MIN": "mean",
58
+ "WD MAX gust": "mean",
59
+ }
60
+
61
+ UNITS = {
62
+ "AirTemperature": "°C",
63
+ "Precipitation": "mm",
64
+ "Humidity": "%",
65
+ "Radiation": "W/m2",
66
+ "CO2": "ppm",
67
+ "WS": "m/s",
68
+ "WD": "°",
69
+ }
70
+
71
+
72
+ async def create_thies_daily_statistics_file(
73
+ os_client: DirectoryClient, logger: Logger, daily_files: List[str]
74
+ ) -> None:
75
+ logger.debug("[thies_synchronization_lib] Creating Daily Statistics ...")
76
+ csv_client = FilesClient(FilesClientInitArgs(client_name="csv_client"))
77
+ filename = datetime_to_str(today(), date_format="%Y%m%d") + ".BIN"
78
+ path_bin_av = os_client.join_paths("thies-daily-files", "ARCH_AV1", filename)
79
+ path_ini_av = os_client.join_paths("thies-daily-files", "ARCH_AV1", "DESCFILE.INI")
80
+ path_bin_ex = os_client.join_paths("thies-daily-files", "ARCH_EX1", filename)
81
+ path_ini_ex = os_client.join_paths("thies-daily-files", "ARCH_EX1", "DESCFILE.INI")
82
+
83
+ ext_df = THIESDayData("ex")
84
+ await to_thread(ext_df.read_binfile, path_bin_ex, path_ini_ex)
85
+
86
+ avg_df = THIESDayData("av")
87
+ await to_thread(avg_df.read_binfile, path_bin_av, path_ini_av)
88
+
89
+ ext_df = ext_df.dataDF[EXT_COLUMNS.keys()]
90
+ avg_df = avg_df.dataDF[AVG_COLUMNS.keys()]
91
+
92
+ # Merge both dataframes
93
+ df = avg_df.merge(ext_df, on=["Date", "Time"], how="outer")
94
+ # Set the date as dd.mm.yyyy format.
95
+ df["Date"] = df["Date"].str.replace(
96
+ r"(\d{4})/(\d{2})/(\d{2})", r"\3.\2.\1", regex=True
97
+ )
98
+ df["Hour"] = df["Time"].str[:2]
99
+
100
+ # Group by hour.
101
+ hourly_agg = df.groupby(["Date", "Hour"]).agg(AGG_DICT).reset_index()
102
+
103
+ rows = []
104
+ # For each attribute in avg_columns (except Date, Time)
105
+ for col, col_id in AVG_COLUMNS.items():
106
+ if col in ["Date", "Time"]:
107
+ continue
108
+ # Determine the corresponding min/max columns if they exist
109
+ min_col = f"{col} MIN"
110
+ max_col = f"{col} MAX"
111
+ mean_col = col
112
+ if col in ["WS", "WD"]:
113
+ max_col += " gust"
114
+
115
+ unit = UNITS.get(col, "")
116
+
117
+ for idx, row in hourly_agg.iterrows():
118
+ statistic_id = f"sensor.saviia_epii_{col_id}"
119
+ start = f"{row['Date']} {row['Hour']}:00"
120
+ mean = row[mean_col] if mean_col in row else 0
121
+ min_val = row[min_col] if min_col in row else 0
122
+ max_val = row[max_col] if max_col in row else 0
123
+
124
+ # If no min/max for this attribute is 0
125
+ if min_col not in row:
126
+ min_val = 0
127
+ if max_col not in row:
128
+ max_val = 0
129
+
130
+ if col in ["WD"]: # Avoid error
131
+ rows.append(
132
+ {
133
+ "statistic_id": statistic_id,
134
+ "unit": unit,
135
+ "start": start,
136
+ "min": mean,
137
+ "max": mean,
138
+ "mean": mean,
139
+ }
140
+ )
141
+ else:
142
+ rows.append(
143
+ {
144
+ "statistic_id": statistic_id,
145
+ "unit": unit,
146
+ "start": start,
147
+ "min": min_val,
148
+ "max": max_val,
149
+ "mean": mean,
150
+ }
151
+ )
152
+
153
+ logger.debug("[thies_synchronization_lib] Saving file in /config folder ...")
154
+ logger.debug(rows[0].keys())
155
+ await csv_client.write(
156
+ WriteArgs(file_name="thies_daily_statistics.tsv", file_content=rows, mode="w")
157
+ )
158
+ logger.debug(
159
+ "[thies_synchronization_lib] thies_daily_statistics.tsv created successfully!"
160
+ )
@@ -0,0 +1,442 @@
1
+ import configparser
2
+ import os
3
+ import struct
4
+ from datetime import datetime, timedelta
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from bitarray import bitarray
9
+
10
+ TIME_LIST = []
11
+
12
+ start_time = datetime.strptime("00:00", "%H:%M")
13
+ for i in range(0, 24 * 60, 10):
14
+ TIME_LIST.append((start_time + timedelta(minutes=i)).strftime("%H:%M"))
15
+
16
+ ROWS = len(TIME_LIST)
17
+
18
+
19
+ def date_range(start_date: str, end_date: str) -> list:
20
+ start = datetime.strptime(start_date, "%Y/%m/%d") + timedelta(days=1)
21
+ end = datetime.strptime(end_date, "%Y/%m/%d") - timedelta(days=1)
22
+ return [
23
+ (start + timedelta(days=i)).strftime("%Y/%m/%d")
24
+ for i in range((end - start).days + 1)
25
+ if start <= end
26
+ ]
27
+
28
+
29
+ def add_date_sep(date: str) -> str:
30
+ """
31
+ Input: date as YYYYMMDD.BIN
32
+ Returns: date as YYYY/MM/DD
33
+ """
34
+ return date[:4] + "/" + date[4:6] + "/" + date[6:8]
35
+
36
+
37
+ def verify_datestr(filename: str) -> bool:
38
+ """
39
+ Returns True if filename has the YYYYMMDD.BIN format
40
+ """
41
+ try:
42
+ datetime.strptime(filename[:8], "%Y%m%d")
43
+ return filename.endswith(".BIN")
44
+ except ValueError:
45
+ return False
46
+
47
+
48
+ def read_descfile(path) -> dict:
49
+ """
50
+ Input: path DESCFILE.INI
51
+ Returns: dict
52
+ key is index [i]
53
+ value is dict with parameters from .ini
54
+ """
55
+ if type(path) == dict: # noqa: E721
56
+ return path
57
+ config = configparser.ConfigParser()
58
+ config.read(path)
59
+ data_dict = {}
60
+ for section in config.sections():
61
+ section_dict = dict(config.items(section))
62
+ for v in section_dict:
63
+ if v == "name":
64
+ continue
65
+ section_dict[v] = int(section_dict[v])
66
+ data_dict[int(section)] = section_dict
67
+ return data_dict
68
+
69
+
70
+ class THIESDayData:
71
+ # Bytes per parameter
72
+ BPP = {"av": 5, "ex": 9}
73
+ # Timestamp Offset
74
+ OFFSET = 4
75
+
76
+ def __init__(self, datatype: str) -> None:
77
+ d = datatype.lower().strip()
78
+ if d not in ["av", "ex"]:
79
+ raise ValueError(
80
+ "Invalid datatype. Expected 'av' (average values) or 'ex' (minmax values)."
81
+ )
82
+
83
+ self._bpr = -1 # Bytes per row
84
+ self._bpp = THIESDayData.BPP[d] # Bytes per parameter
85
+ self._datatype = d
86
+ self._binfile = None
87
+ self.descfile = {}
88
+ self.nparameters = -1
89
+ self._parameters = []
90
+ self.nbytes = -1
91
+ self.nrows = -1
92
+ self._date = ""
93
+ self.statusDF = pd.DataFrame()
94
+ self.dataDF = pd.DataFrame()
95
+ self.datesDF = pd.DataFrame()
96
+
97
+ @staticmethod
98
+ def _bytes2datetime(b: bytes, only_time: bool = False) -> str:
99
+ """
100
+ Input: bytes (size 4)
101
+ Output: str (YYYY/MM/DD hh:mm:ss)
102
+ """
103
+ bits = bitarray()
104
+ bits.frombytes(b[::-1]) # Invert 4 bytes
105
+ hr = int(bits[15:20].to01(), 2)
106
+ min = int(bits[20:26].to01(), 2)
107
+ sec = int(bits[26:].to01(), 2)
108
+ time = f"{str(hr).zfill(2)}:{str(min).zfill(2)}"
109
+ if only_time:
110
+ return time
111
+ yr = int(bits[0:6].to01(), 2)
112
+ mon = int(bits[6:10].to01(), 2)
113
+ day = int(bits[10:15].to01(), 2)
114
+ date = f"20{yr}/{str(mon).zfill(2)}/{str(day).zfill(2)}"
115
+ return date + " " + time + f":{str(sec).zfill(2)}"
116
+
117
+ def _set_descfile(self, inipath: str) -> None:
118
+ self.descfile = read_descfile(inipath)
119
+ self.nparameters = len(self.descfile)
120
+ row_size = sum([self.descfile[num]["size"] for num in self.descfile])
121
+ self._bpr = row_size + THIESDayData.OFFSET
122
+
123
+ def read_binfile(self, binpath: str, inipath: str) -> None:
124
+ self._set_descfile(inipath)
125
+ with open(binpath, "rb") as bin_file:
126
+ binfile = bin_file.read()
127
+ self._binfile = binfile
128
+ self.nbytes = len(self._binfile)
129
+ self.nrows = int(self.nbytes / self._bpr)
130
+ self._make_dataframes()
131
+
132
+ def make_empty(self, inipath: str, date: str) -> None:
133
+ self._set_descfile(inipath)
134
+ dataDF = pd.DataFrame(
135
+ None, index=range(ROWS), columns=range(self.nparameters + 2)
136
+ )
137
+ col_names = {0: "Date", 1: "Time"}
138
+ par_names = {key + 1: self.descfile[key]["name"] for key in self.descfile}
139
+ col_names.update(par_names)
140
+ dataDF = dataDF.rename(columns=col_names)
141
+ dataDF["Time"] = TIME_LIST
142
+ dataDF["Date"] = [date] * ROWS
143
+
144
+ self.dataDF = dataDF
145
+ self.statusDF = dataDF
146
+ self.datesDF = dataDF
147
+
148
+ def _make_dataframes(self) -> None:
149
+ """
150
+ Builds data DF, status DF and, if datatype=ex, dates DF.
151
+ """
152
+ byterows = [
153
+ self._binfile[i * self._bpr + THIESDayData.OFFSET : (i + 1) * self._bpr]
154
+ for i in range(0, self.nrows)
155
+ ]
156
+ data_arr = np.zeros((self.nrows, self.nparameters))
157
+ status_arr = np.zeros((self.nrows, self.nparameters))
158
+ time_idx = np.empty(self.nrows, dtype=object)
159
+ date_idx = np.empty(self.nrows, dtype=object)
160
+ dates_arr = np.empty((self.nrows, self.nparameters), dtype=object)
161
+
162
+ for i, row in enumerate(byterows):
163
+ # Timestamp
164
+ ts_bytes = self._binfile[i * self._bpr : i * self._bpr + 4]
165
+ ts = THIESDayData._bytes2datetime(ts_bytes)
166
+ date_idx[i], time_idx[i] = ts[:-3].split()
167
+
168
+ for j in range(self.nparameters):
169
+ # Status = byte 1
170
+ status = row[j * self._bpp]
171
+ status_arr[i, j] = status
172
+
173
+ # Value = bytes 2-5, float
174
+ value = struct.unpack("<f", row[j * self._bpp + 1 : j * self._bpp + 5])[
175
+ 0
176
+ ]
177
+ data_arr[i, j] = round(value, 1)
178
+
179
+ if self._datatype == "ex":
180
+ # Datetime = bytes 6-9
181
+ dt = THIESDayData._bytes2datetime(
182
+ row[j * self._bpp + 5 : j * self._bpp + 9], only_time=True
183
+ )
184
+ dates_arr[i, j] = dt
185
+
186
+ self.dataDF = pd.DataFrame(data_arr).rename(
187
+ columns={i: self.descfile[i + 1]["name"] for i in range(self.nparameters)}
188
+ )
189
+ self.statusDF = pd.DataFrame(status_arr).rename(
190
+ columns={i: self.descfile[i + 1]["name"] for i in range(self.nparameters)}
191
+ )
192
+ self.dataDF = self.dataDF.where(self.statusDF == 0.0, other=None)
193
+
194
+ if self._datatype == "ex":
195
+ self.datesDF = pd.DataFrame(dates_arr).rename(
196
+ columns={
197
+ i: self.descfile[i + 1]["name"] for i in range(self.nparameters)
198
+ }
199
+ )
200
+ self.datesDF = self.datesDF.where(self.statusDF == 0.0, other=None)
201
+ self.datesDF.insert(0, "Time", time_idx)
202
+ self.datesDF.insert(0, "Date", date_idx)
203
+
204
+ self.dataDF.insert(0, "Time", time_idx)
205
+ self.dataDF.insert(0, "Date", date_idx)
206
+ self.statusDF.insert(0, "Time", time_idx)
207
+ self.statusDF.insert(0, "Date", date_idx)
208
+
209
+ def _generate_blank_rows(self) -> pd.DataFrame:
210
+ if len(self) == ROWS:
211
+ # Nothing to fill (already full rows)
212
+ return []
213
+
214
+ new = []
215
+ none_row = {col: None for col in self.dataDF.columns}
216
+ none_row["Date"] = self.date
217
+ current_times = self.dataDF["Time"]
218
+ for time in TIME_LIST:
219
+ if time not in current_times.values:
220
+ row = none_row.copy()
221
+ # 'time' was not measured in the original data
222
+ # fill it with None row
223
+ row["Time"] = time
224
+ new.append(row)
225
+ return pd.DataFrame(new)
226
+
227
+ def complete_empty(self):
228
+ """
229
+ Completes DataFrames with all the timestamps of missing data
230
+ Fills all columns with 'None' except Date and Time cols
231
+ """
232
+ if len(self) == ROWS:
233
+ return
234
+ new_rows = self._generate_blank_rows()
235
+ # self.dataDF = self.dataDF.append(new_rows, ignore_index=True)
236
+ self.dataDF = pd.concat([self.dataDF, new_rows], ignore_index=True)
237
+ self.dataDF = self.dataDF.sort_values(by="Time").reset_index(drop=True)
238
+ # self.statusDF = self.statusDF.append(new_rows, ignore_index=True)
239
+ self.statusDF = pd.concat([self.statusDF, new_rows], ignore_index=True)
240
+ self.statusDF = self.statusDF.sort_values(by="Time").reset_index(drop=True)
241
+
242
+ if self._datatype == "ex":
243
+ # self.datesDF = self.datesDF.append(new_rows, ignore_index=True)
244
+ self.datesDF = pd.concat([self.datesDF, new_rows], ignore_index=True)
245
+ self.datesDF = self.datesDF.sort_values(by="Time").reset_index(drop=True)
246
+
247
+ def sort_by(self, cols: list):
248
+ self.dataDF = self.dataDF.sort_values(
249
+ by=cols, ascending=[True, True]
250
+ ).reset_index(drop=True)
251
+ self.statusDF = self.statusDF.sort_values(
252
+ by=cols, ascending=[True, True]
253
+ ).reset_index(drop=True)
254
+ if len(self.datesDF):
255
+ self.datesDF = self.datesDF.sort_values(
256
+ by=cols, ascending=[True, True]
257
+ ).reset_index(drop=True)
258
+
259
+ @property
260
+ def date(self) -> str:
261
+ """
262
+ Returns str of date of measurement
263
+ """
264
+ if len(self.dataDF) and self._date == "":
265
+ self._date = self.dataDF["Date"][0]
266
+ return self._date
267
+
268
+ @property
269
+ def shape(self):
270
+ return self.dataDF.shape
271
+
272
+ @property
273
+ def info(self) -> None:
274
+ bf = self._binfile
275
+ if bf:
276
+ bf = bf[:8]
277
+ print(f"""=== THIES Day Data Instance ===\n
278
+ Bytes per row (BPR): {self._bpr}
279
+ Bytes per parameter (BPP): {self._bpp}
280
+ Datatype: {self._datatype}
281
+ Binfile: {bf}...
282
+ Descfile: {self.descfile}
283
+ N parameters: {self.nparameters}
284
+ N Bytes: {self.nbytes}
285
+ Rows: {self.nrows}
286
+ Date: {self.date}
287
+ """)
288
+
289
+ @property
290
+ def parameters(self) -> list:
291
+ if self._parameters == []:
292
+ self._parameters = [self.descfile[i]["name"] for i in self.descfile]
293
+ return self._parameters
294
+
295
+ def write_csv(self, filename: str) -> None:
296
+ with open(filename + ".csv", "w") as outfile:
297
+ outfile.write(self.dataDF.to_csv())
298
+
299
+ def __repr__(self) -> str:
300
+ return str(self.dataDF)
301
+
302
+ def _repr_html_(self):
303
+ return self.dataDF._repr_html_()
304
+
305
+ def __len__(self):
306
+ return len(self.dataDF)
307
+
308
+ def __add__(self, other):
309
+ if isinstance(other, THIESDayData):
310
+ new = THIESDayData(datatype=self._datatype)
311
+ new.descfile = other.descfile
312
+ new.nparameters = other.nparameters
313
+ new._parameters = other.parameters
314
+ new.nrows = self.nrows + other.nrows
315
+ new.nbytes = self.nbytes + other.nbytes
316
+ new.statusDF = pd.concat([self.statusDF, other.statusDF]).reset_index(
317
+ drop=True
318
+ )
319
+ new.dataDF = pd.concat([self.dataDF, other.dataDF]).reset_index(drop=True)
320
+ if self._datatype == "ex":
321
+ new.datesDF = pd.concat([self.datesDF, other.datesDF]).reset_index(
322
+ drop=True
323
+ )
324
+ return new
325
+ raise TypeError(
326
+ f"unsupported operand type(s) for +: 'THIESDayData' and '{type(other)}'"
327
+ )
328
+
329
+
330
+ class THIESData:
331
+ def __init__(self, datatype: str, dirpath: str) -> None:
332
+ d = datatype.lower().strip()
333
+ if d not in ["av", "ex"]:
334
+ raise ValueError(
335
+ "Invalid datatype. Expected 'av' (average values) or 'ex' (minmax values)."
336
+ )
337
+
338
+ self._path = dirpath
339
+ self._datatype = d
340
+ self.filelist = []
341
+
342
+ self._verify_path(dirpath)
343
+ descpath = self._path + "/DESCFILE.INI"
344
+ self.descfile = read_descfile(descpath)
345
+
346
+ self.daylist = []
347
+ self.fullData = pd.DataFrame()
348
+
349
+ self.completed = False
350
+
351
+ def reset(self):
352
+ self.daylist = []
353
+ self.fullData = pd.DataFrame()
354
+ self.completed = False
355
+
356
+ def _verify_path(self, path: str) -> None:
357
+ fl = sorted(os.listdir(path))
358
+ if "DESCFILE.INI" not in fl:
359
+ raise FileNotFoundError("No DESCFILE.INI found in this directory.")
360
+ self.filelist = [file for file in fl if verify_datestr(file)]
361
+
362
+ def load_df(self, complete_rows=False) -> pd.DataFrame:
363
+ """Reads folder given in DIRPATH and
364
+ transforms data into DF. Saves it in self.fullData
365
+ - complete_rows (bool): if True, completes DFs with Empty Rows by calling
366
+ THIESDayData.complete_empty()
367
+ """
368
+ self.reset()
369
+ for f in self.filelist:
370
+ filepath = f"{self._path}/{f}"
371
+ daydata = THIESDayData(datatype=self._datatype)
372
+ daydata.read_binfile(binpath=filepath, inipath=self.descfile)
373
+ if complete_rows:
374
+ daydata.complete_empty()
375
+ self.daylist.append(daydata)
376
+
377
+ self.fullData = sum(self.daylist, start=THIESDayData(self._datatype))
378
+
379
+ return self.fullData
380
+
381
+ def complete_empty_dates(self):
382
+ if self.completed:
383
+ return
384
+ date_s = add_date_sep(self.filelist[0])
385
+ date_e = add_date_sep(self.filelist[-1])
386
+ d_range = date_range(date_s, date_e)
387
+ for date in d_range:
388
+ if date not in self.fullData.dataDF["Date"].values:
389
+ # Missing day
390
+ new = THIESDayData(self._datatype)
391
+ new.make_empty(self.descfile, date=date)
392
+ self.fullData += new
393
+
394
+ self.fullData.sort_by(["Date", "Time"])
395
+ self.completed = True
396
+
397
+ def df2csv(self, outpath: str) -> None:
398
+ # if self._datatype == 'av':
399
+ # FORMAT FOR EX FILES ???
400
+ self.fullData.write_csv(outpath)
401
+ print(f"Data written in: {outpath}.csv")
402
+
403
+ def read_write(self, outpath: str):
404
+ """Quick version of the read-write process.
405
+ Reads the path given and writes all BIN file data in same CSV
406
+ Does NOT save as DF the data.
407
+ Does NOT complete missing timestamps with empty rows.
408
+ """
409
+ write_header = True
410
+ bcount = 0
411
+ with open(outpath + ".csv", "w") as outfile:
412
+ for i, f in enumerate(self.filelist):
413
+ filepath = f"{self._path}/{f}"
414
+ daydata = THIESDayData(datatype=self._datatype)
415
+ daydata.read_binfile(binpath=filepath, inipath=self.descfile)
416
+ outfile.write(daydata.dataDF.to_csv(header=write_header))
417
+ bcount += daydata.nbytes
418
+ if i == 0:
419
+ write_header = False
420
+ print(f"Data written in: {outpath}.csv")
421
+
422
+ @property
423
+ def dataDF(self):
424
+ return self.fullData.dataDF
425
+
426
+ @property
427
+ def shape(self):
428
+ return self.fullData.shape
429
+
430
+ @property
431
+ def size(self):
432
+ return len(self.filelist)
433
+
434
+ @property
435
+ def parameters(self):
436
+ return self.fullData.parameters
437
+
438
+ def __repr__(self) -> str:
439
+ return str(self.fullData)
440
+
441
+ def _repr_html_(self):
442
+ return self.fullData
@@ -23,6 +23,9 @@ from saviialib.libs.sharepoint_client import (
23
23
  SpListFoldersArgs,
24
24
  SpUploadFileArgs,
25
25
  )
26
+ from saviialib.libs.directory_client import DirectoryClient, DirectoryClientArgs
27
+
28
+ from saviialib.libs.files_client import FilesClient, FilesClientInitArgs, WriteArgs
26
29
  from saviialib.services.epii.use_cases.types import (
27
30
  FtpClientConfig,
28
31
  SharepointConfig,
@@ -31,6 +34,8 @@ from saviialib.services.epii.use_cases.types import (
31
34
  from saviialib.services.epii.utils import (
32
35
  parse_execute_response,
33
36
  )
37
+ from saviialib.libs.zero_dependency.utils.datetime_utils import today, datetime_to_str
38
+ from .components.create_thies_statistics_file import create_thies_daily_statistics_file
34
39
 
35
40
 
36
41
  class UpdateThiesDataUseCase:
@@ -44,6 +49,8 @@ class UpdateThiesDataUseCase:
44
49
  self.ftp_server_folders_path = input.ftp_server_folders_path
45
50
  self.sharepoint_base_url = f"/sites/{self.sharepoint_client.site_name}"
46
51
  self.uploading = set()
52
+ self.os_client = self._initialize_os_client()
53
+ self.files_client = self._initialize_files_client()
47
54
 
48
55
  def _initialize_sharepoint_client(
49
56
  self, config: SharepointConfig
@@ -63,6 +70,12 @@ class UpdateThiesDataUseCase:
63
70
  except RuntimeError as error:
64
71
  raise FtpClientError(error)
65
72
 
73
+ def _initialize_os_client(self) -> DirectoryClient:
74
+ return DirectoryClient(DirectoryClientArgs(client_name="os_client"))
75
+
76
+ def _initialize_files_client(self) -> FilesClient:
77
+ return FilesClient(FilesClientInitArgs(client_name="aiofiles_client"))
78
+
66
79
  async def _validate_sharepoint_current_folders(self):
67
80
  async with self.sharepoint_client:
68
81
  folder_base_path = "/".join(
@@ -204,6 +217,98 @@ class UpdateThiesDataUseCase:
204
217
 
205
218
  return upload_results
206
219
 
220
+ async def _sync_pending_files(self, thies_files: set, cloud_files: set) -> set:
221
+ uploading = thies_files - cloud_files
222
+
223
+ # Update content of the daily files
224
+ daily_files = {
225
+ prefix + datetime_to_str(today(), date_format="%Y%m%d") + ".BIN"
226
+ for prefix in ["EXT_", "AVG_"]
227
+ }
228
+ for file in daily_files:
229
+ if file in thies_files:
230
+ uploading.add(file)
231
+
232
+ return uploading
233
+
234
+ async def _extract_thies_daily_statistics(self) -> None:
235
+ # Create the folder thies-daily-files if doesnt exists
236
+ self.logger.info("[thies_synchronization_lib] Creating Daily files directory")
237
+ base_folder = "thies-daily-files"
238
+ if not await self.os_client.isdir(base_folder):
239
+ for dest_folder in {"ARCH_AV1", "ARCH_EX1"}:
240
+ await self.os_client.makedirs(
241
+ self.os_client.join_paths(base_folder, dest_folder)
242
+ )
243
+ else:
244
+ self.logger.info(
245
+ "[thies_synchronization_lib] Thies daily files already exists"
246
+ )
247
+
248
+ # Read the daily files and save each data in the folder
249
+ daily_files = [
250
+ prefix + datetime_to_str(today(), date_format="%Y%m%d") + ".BIN"
251
+ for prefix in ["AVG_", "EXT_"]
252
+ ]
253
+ # Receive from FTP server and write the file in thies-daily-files
254
+ for file in daily_files:
255
+ prefix, filename = file.split("_", 1)
256
+ # The first path is for AVG files. The second file is for EXT files
257
+ folder_path = next(
258
+ (
259
+ path
260
+ for path in self.ftp_server_folders_path
261
+ if prefix == ("AVG" if "AV" in path else "EXT")
262
+ ),
263
+ self.ftp_server_folders_path[0], # Default to the first path
264
+ )
265
+ # Retrieve the AVG or EXT file
266
+ file_path = f"{folder_path}/{filename}"
267
+ try:
268
+ content = await self.thies_ftp_client.read_file(
269
+ FtpReadFileArgs(file_path)
270
+ )
271
+ except FileNotFoundError as error:
272
+ raise ThiesFetchingError(reason=str(error))
273
+ # Destination local folder
274
+ self.logger.debug(file_path)
275
+
276
+ dest_folder = "ARCH_AV1" if prefix == "AVG" else "ARCH_EX1"
277
+ await self.files_client.write(
278
+ WriteArgs(
279
+ file_name=filename,
280
+ file_content=content,
281
+ mode="wb",
282
+ destination_path=f"{base_folder}/{dest_folder}",
283
+ )
284
+ )
285
+ # Retrieve the DESCFILE and save if is not in the base folder
286
+ descfile_name = "DESCFILE.INI"
287
+ if not await self.os_client.path_exists(
288
+ self.os_client.join_paths(base_folder, dest_folder, descfile_name)
289
+ ):
290
+ descfile_path = f"{folder_path}/{descfile_name}"
291
+ descfile_content = await self.thies_ftp_client.read_file(
292
+ FtpReadFileArgs(descfile_path)
293
+ )
294
+ await self.files_client.write(
295
+ WriteArgs(
296
+ file_name=descfile_name,
297
+ file_content=descfile_content,
298
+ mode="wb",
299
+ destination_path=f"{base_folder}/{dest_folder}",
300
+ )
301
+ )
302
+ else:
303
+ self.logger.debug(
304
+ "[thies_synchronization_lib] DESCFILE.INI already exists in %s",
305
+ dest_folder,
306
+ )
307
+ # Read the files with ThiesDayData class
308
+ await create_thies_daily_statistics_file(
309
+ self.os_client, self.logger, daily_files
310
+ )
311
+
207
312
  async def execute(self):
208
313
  """Synchronize data from the THIES Center to the cloud."""
209
314
  self.logger.debug("[thies_synchronization_lib] Starting ...")
@@ -223,7 +328,10 @@ class UpdateThiesDataUseCase:
223
328
  "[thies_synchronization_lib] Total files fetched from Sharepoint: %s",
224
329
  str(len(cloud_files)),
225
330
  )
226
- self.uploading = thies_files - cloud_files
331
+ self.uploading = await self._sync_pending_files(thies_files, cloud_files)
332
+ # Extract thies statistics for SAVIIA Sensors
333
+ await self._extract_thies_daily_statistics()
334
+
227
335
  if not self.uploading:
228
336
  raise EmptyDataError(reason="No files to upload.")
229
337
  # Fetch the content of the files to be uploaded from THIES FTP Server
@@ -233,7 +341,9 @@ class UpdateThiesDataUseCase:
233
341
  upload_statistics = await self.upload_thies_files_to_sharepoint(
234
342
  thies_fetched_files
235
343
  )
344
+ self.logger.info(upload_statistics)
236
345
  self.logger.debug(
237
346
  "[thies_synchronization_lib] All the files were uploaded successfully 🎉"
238
347
  )
348
+
239
349
  return parse_execute_response(thies_fetched_files, upload_statistics) # type: ignore
@@ -1,21 +1,23 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: saviialib
3
- Version: 1.0.1
3
+ Version: 1.2.0
4
4
  Summary: A client library for IoT projects in the RCER initiative
5
5
  License: MIT
6
6
  Author: pedropablozavalat
7
- Requires-Python: >=3.10,<4.0
7
+ Requires-Python: >=3.11,<4.0
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.10
11
10
  Classifier: Programming Language :: Python :: 3.11
12
11
  Classifier: Programming Language :: Python :: 3.12
13
12
  Classifier: Programming Language :: Python :: 3.13
14
13
  Requires-Dist: aiofiles
15
14
  Requires-Dist: aioftp
16
15
  Requires-Dist: aiohttp
16
+ Requires-Dist: bitarray (>=1.9.2,<2.0.0)
17
17
  Requires-Dist: build
18
18
  Requires-Dist: dotenv (>=0.9.9,<0.10.0)
19
+ Requires-Dist: numpy (>=2.3.0,<3.0.0)
20
+ Requires-Dist: pandas (>=2.3.0,<3.0.0)
19
21
  Requires-Dist: pytest-cov (>=6.1.1,<7.0.0)
20
22
  Description-Content-Type: text/markdown
21
23
 
@@ -8,15 +8,16 @@ saviialib/general_types/error_types/api/epii_api_error_types.py,sha256=iprpNvVoN
8
8
  saviialib/general_types/error_types/common/__init__.py,sha256=yOBLZbt64Ki9Q0IJ0tMAubgq7PtrQ7XQ3RgtAzyOjiE,170
9
9
  saviialib/general_types/error_types/common/common_types.py,sha256=n5yuw-gVtkrtNfmaZ83ZkYxYHGl4jynOLUB9C8Tr32w,474
10
10
  saviialib/libs/directory_client/__init__.py,sha256=ys07nzp74fHew2mUkbGpntp5w4t_PnhZIS6D4_mJw2A,162
11
- saviialib/libs/directory_client/client/os_client.py,sha256=cL1ocxfLu6csBg7RKhP7sbpgCUr6_Me5cMOs4wrXmhw,628
12
- saviialib/libs/directory_client/directory_client.py,sha256=mcYA2AY0f44ae39ImsetVRU8YW7xI25xTFTY5HmWivY,984
13
- saviialib/libs/directory_client/directory_client_contract.py,sha256=eRTKPkpArMSi7gEDoowJ15hupLB_gr4ztO1Zc1BoXpc,396
11
+ saviialib/libs/directory_client/client/os_client.py,sha256=TgDnIQvgMP7sIVjn5kAKEJ5BYET7pN-FIQP0lvFMl4Q,763
12
+ saviialib/libs/directory_client/directory_client.py,sha256=5HmB1Pt9M8Tk18WKzfh3Fwr8gKa_RZCdAqvbU0rg7mw,1086
13
+ saviialib/libs/directory_client/directory_client_contract.py,sha256=wtNaUak1a7r6t9OI1L2ou7XMMJFXWpQFb7WyT6X7fCQ,479
14
14
  saviialib/libs/directory_client/types/directory_client_types.py,sha256=ncMwVs_o6EYMuypXXmVInsjVDKJsdxVkmwj1M-LEInA,109
15
15
  saviialib/libs/files_client/__init__.py,sha256=sIi9ne7Z3EfxnqGTaSmH-cZ8QsKyu0hoOz61GyA3njs,192
16
16
  saviialib/libs/files_client/clients/aiofiles_client.py,sha256=Mu5pSnnEa3dT3GONmt1O-lCQstuQNXHtJHM3D2L6TU8,1107
17
- saviialib/libs/files_client/files_client.py,sha256=eoByr7ZzOJ3b4NWlrEH0jKO1i-Rv3hSJV2s2JLhaOJ0,1081
17
+ saviialib/libs/files_client/clients/csv_client.py,sha256=Rk4QbiKlVKrYxYtxQt-Pmkp9QoB_dNgs5r36JB9hU0o,1478
18
+ saviialib/libs/files_client/files_client.py,sha256=oWfaMLA_Lw91H1_pPirFtFbdJh4abSyZp91qBsAiePs,950
18
19
  saviialib/libs/files_client/files_client_contract.py,sha256=fYvd68IMpc1OFkxbzNSmRUoTWVctf3hkNVQ7QZV0m6I,304
19
- saviialib/libs/files_client/types/files_client_types.py,sha256=8OHm4nvZTW9K3ryeIUvthE_Cj7wF4zVcqG0KCi_vQIU,718
20
+ saviialib/libs/files_client/types/files_client_types.py,sha256=GOTM5fBY304-EX6IiUnWmt2JpsyQ6Nmb_Tp0wingt0M,755
20
21
  saviialib/libs/ftp_client/__init__.py,sha256=dW2Yutgc7mJJJzgKLhWKXMgQ6KIWJYfFa1sGpjHH5xU,191
21
22
  saviialib/libs/ftp_client/clients/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
23
  saviialib/libs/ftp_client/clients/aioftp_client.py,sha256=5sgr3PMETgaBRlKeu_avxHIh6tr1gb7t2mkxmpRas_k,1925
@@ -39,16 +40,18 @@ saviialib/services/epii/controllers/types/update_thies_data_types.py,sha256=kmt1
39
40
  saviialib/services/epii/controllers/types/upload_backup_to_sharepoint_types.py,sha256=5mDMLy3J5grACl1ezBGYZPOyhIkWYeIWWBOklaP2IlQ,471
40
41
  saviialib/services/epii/controllers/update_thies_data.py,sha256=8TV420d4VWTvCW18M6sAguHZrX5VODXscgrUfhwO1fs,4884
41
42
  saviialib/services/epii/controllers/upload_backup_to_sharepoint.py,sha256=7-ABo89yXhrWv-iOFjCkBGX-y_I62eFaOi1LtO5Gb4k,3817
43
+ saviialib/services/epii/use_cases/components/create_thies_statistics_file.py,sha256=G9K5UpsUYPM_w2sbusiyRWnZwbumSh5rYgil-rt8EB8,5171
44
+ saviialib/services/epii/use_cases/components/thies_bp.py,sha256=1Iq5Wz3kqzJfQNawV_v8ORr_Pl-PoamFp_2Xo8DjvmI,15042
42
45
  saviialib/services/epii/use_cases/constants/upload_backup_to_sharepoint_constants.py,sha256=erkn-3E8YwBMFs25o7exXoK7s73NdgP9IYDXeWzALcI,98
43
46
  saviialib/services/epii/use_cases/types/__init__.py,sha256=u6fyodOEJE2j6FMqJux40Xf9ccYAi-UUYxqT-Kzc0kE,199
44
47
  saviialib/services/epii/use_cases/types/update_thies_data_types.py,sha256=3lJzG1nuZoP1mqFlvQ0-aFJp80SECaeiROlvucVhaSY,539
45
48
  saviialib/services/epii/use_cases/types/upload_backup_to_sharepoint_types.py,sha256=J_sGhqSaPoMZA0GIrzCx6pmgSKgIkGi0uyrsltU_H8w,320
46
- saviialib/services/epii/use_cases/update_thies_data.py,sha256=0dkk45qPkvPpxS8uERPDrPN4woWz4vze3FR0gA-P6wI,9866
49
+ saviialib/services/epii/use_cases/update_thies_data.py,sha256=moB4fz_aOiKBqQixjm5uS0hyvpF9ufLIMWbVxwXqICc,14591
47
50
  saviialib/services/epii/use_cases/upload_backup_to_sharepoint.py,sha256=-0MvCzkFM7T2y7XLgFevWAXmqAsthzuY68ReiiL2zDU,11886
48
51
  saviialib/services/epii/utils/__init__.py,sha256=cYt2tvq65_OMjFaqb8-CCC7IGCQgFd4ziEUWJV7s1iY,98
49
52
  saviialib/services/epii/utils/update_thies_data_utils.py,sha256=EpjYWXqyHxJ-dO3MHhdXp-rGV7WyUckeFko-nnfnNac,555
50
53
  saviialib/services/epii/utils/upload_backup_to_sharepoint_utils.py,sha256=hEeV4_kcG8YL6t_3V8AlhgDHtHiUNsdYpilfgTLaQMc,3528
51
- saviialib-1.0.1.dist-info/LICENSE,sha256=NWpf6b38xgBWPBo5HZsCbdfp9hZSliEbRqWQgm0fkOo,1076
52
- saviialib-1.0.1.dist-info/METADATA,sha256=qxj1sUazmjA38ue9RFtljIuW4xt9IJGyPSkBpS_LXqQ,4063
53
- saviialib-1.0.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
54
- saviialib-1.0.1.dist-info/RECORD,,
54
+ saviialib-1.2.0.dist-info/LICENSE,sha256=NWpf6b38xgBWPBo5HZsCbdfp9hZSliEbRqWQgm0fkOo,1076
55
+ saviialib-1.2.0.dist-info/METADATA,sha256=RP2WzDmV8mw8Y9IBsqE3-tXVoBchB2ugGlNjrlnH2oY,4130
56
+ saviialib-1.2.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
57
+ saviialib-1.2.0.dist-info/RECORD,,