pthelma 1.0.0__cp310-cp310-win_amd64.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,574 @@
1
+ import csv
2
+ import datetime as dt
3
+ from configparser import ParsingError
4
+ from io import StringIO
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from pandas.tseries.frequencies import to_offset
9
+ from textbisect import text_bisect_left
10
+
11
+ from .timezone_utils import TzinfoFromString
12
+
13
+
14
+ class _BacktrackableFile(object):
15
+ def __init__(self, fp):
16
+ self.fp = fp
17
+ self.line_number = 0
18
+ self.next_line = None
19
+
20
+ def readline(self):
21
+ if self.next_line is None:
22
+ self.line_number += 1
23
+ result = self.fp.readline()
24
+ else:
25
+ result = self.next_line
26
+ self.next_line = None
27
+ return result
28
+
29
+ def backtrack(self, line):
30
+ self.next_line = line
31
+
32
+ def read(self, size=None):
33
+ return self.fp.read() if size is None else self.fp.read(size)
34
+
35
+ def __getattr__(self, name):
36
+ return getattr(self.fp, name)
37
+
38
+
39
+ class _FilePart(object):
40
+ """A wrapper that views only a subset of the wrapped csv filelike object.
41
+
42
+ When it is created, three mandatory parameters are passed: a filelike object,
43
+ start_date and end_date, the latter as stirngs. This wrapper then acts as a filelike
44
+ object, which views the part of the wrapped object between start_date and end_date.
45
+ """
46
+
47
+ def __init__(self, stream, start_date, end_date):
48
+ self.stream = stream
49
+ self.start_date = start_date
50
+ self.end_date = end_date
51
+
52
+ lo = stream.tell()
53
+ key = lambda x: x.split(",")[0] # NOQA
54
+ self.startpos = text_bisect_left(stream, start_date, lo=lo, key=key)
55
+ if self.stream.tell() < self.startpos:
56
+ self.stream.seek(self.startpos)
57
+
58
+ def readline(self, size=-1):
59
+ max_available_size = self.endpos + 1 - self.stream.tell()
60
+ size = min(size, max_available_size)
61
+ return self.stream.readline(size)
62
+
63
+ def __iter__(self):
64
+ return self
65
+
66
+ def __next__(self):
67
+ result = self.stream.__next__()
68
+ if result[:16] > self.end_date:
69
+ raise StopIteration
70
+ return result
71
+
72
+ def __getattr__(self, name):
73
+ return getattr(self.stream, name)
74
+
75
+
76
+ class MetadataWriter:
77
+ def __init__(self, f, htimeseries, version):
78
+ self.version = version
79
+ self.htimeseries = htimeseries
80
+ self.f = f
81
+
82
+ def write_meta(self):
83
+ if self.version == 2:
84
+ self.f.write("Version=2\r\n")
85
+ self.write_simple("unit")
86
+ self.write_count()
87
+ self.write_simple("title")
88
+ self.write_comment()
89
+ self.write_timezone()
90
+ self.write_time_step()
91
+ self.write_simple("interval_type")
92
+ self.write_simple("variable")
93
+ self.write_simple("precision")
94
+ self.write_location()
95
+ self.write_altitude()
96
+
97
+ def write_simple(self, parm):
98
+ value = getattr(self.htimeseries, parm, None)
99
+ if value is not None:
100
+ self.f.write("{}={}\r\n".format(parm.capitalize(), value))
101
+
102
+ def write_count(self):
103
+ self.f.write("Count={}\r\n".format(len(self.htimeseries.data)))
104
+
105
+ def write_comment(self):
106
+ if hasattr(self.htimeseries, "comment"):
107
+ for line in self.htimeseries.comment.splitlines():
108
+ self.f.write("Comment={}\r\n".format(line))
109
+
110
+ def write_timezone(self):
111
+ offset = self.htimeseries.data.index.tz.utcoffset(None)
112
+ sign = "-+"[offset >= dt.timedelta(0)]
113
+ offset = abs(offset)
114
+ hours = offset.seconds // 3600
115
+ minutes = offset.seconds % 3600 // 60
116
+ self.f.write(f"Timezone={sign}{hours:02}{minutes:02}\r\n")
117
+
118
+ def write_location(self):
119
+ if self.version <= 2 or not getattr(self.htimeseries, "location", None):
120
+ return
121
+ self.f.write(
122
+ "Location={:.6f} {:.6f} {}\r\n".format(
123
+ *[
124
+ self.htimeseries.location[x]
125
+ for x in ["abscissa", "ordinate", "srid"]
126
+ ]
127
+ )
128
+ )
129
+
130
+ def write_altitude(self):
131
+ no_altitude = (
132
+ (self.version <= 2)
133
+ or not getattr(self.htimeseries, "location", None)
134
+ or (self.htimeseries.location.get("altitude") is None)
135
+ )
136
+ if no_altitude:
137
+ return
138
+ altitude = self.htimeseries.location["altitude"]
139
+ asrid = (
140
+ self.htimeseries.location["asrid"]
141
+ if "asrid" in self.htimeseries.location
142
+ else None
143
+ )
144
+ fmt = (
145
+ "Altitude={altitude:.2f} {asrid}\r\n"
146
+ if asrid
147
+ else "Altitude={altitude:.2f}\r\n"
148
+ )
149
+ self.f.write(fmt.format(altitude=altitude, asrid=asrid))
150
+
151
+ def write_time_step(self):
152
+ if getattr(self.htimeseries, "time_step", ""):
153
+ self._write_nonempty_time_step()
154
+
155
+ def _write_nonempty_time_step(self):
156
+ if self.version is None or self.version >= 5:
157
+ self.f.write("Time_step={}\r\n".format(self.htimeseries.time_step))
158
+ else:
159
+ self._write_old_time_step()
160
+
161
+ def _write_old_time_step(self):
162
+ try:
163
+ old_time_step = self._get_old_time_step_in_minutes()
164
+ except ValueError:
165
+ old_time_step = self._get_old_time_step_in_months()
166
+ self.f.write("Time_step={}\r\n".format(old_time_step))
167
+
168
+ def _get_old_time_step_in_minutes(self):
169
+ td = pd.to_timedelta(to_offset(self.htimeseries.time_step))
170
+ return str(int(td.total_seconds() / 60)) + ",0"
171
+
172
+ def _get_old_time_step_in_months(self):
173
+ time_step = self.htimeseries.time_step
174
+ try:
175
+ value, unit = self._split_time_step_string(time_step)
176
+ value = value or 1
177
+ if unit in ("M", "ME"):
178
+ return "0," + str(int(value))
179
+ elif unit in ("A", "Y", "YE"):
180
+ return "0," + str(12 * int(value))
181
+ except (IndexError, ValueError):
182
+ pass
183
+ raise ValueError('Cannot format time step "{}"'.format(time_step))
184
+
185
+ def _split_time_step_string(self, time_step_string):
186
+ value = ""
187
+ for i, char in enumerate(time_step_string):
188
+ if not char.isdigit():
189
+ return value, time_step_string[i:]
190
+ value += char
191
+
192
+
193
+ class MetadataReader:
194
+ def __init__(self, f):
195
+ f = _BacktrackableFile(f)
196
+
197
+ # Check if file contains headers
198
+ first_line = f.readline()
199
+ f.backtrack(first_line)
200
+ if isinstance(first_line, bytes):
201
+ first_line = first_line.decode("utf-8-sig")
202
+ has_headers = not first_line[0].isdigit()
203
+
204
+ # Read file, with its headers if needed
205
+ self.meta = {}
206
+ if has_headers:
207
+ self.read_meta(f)
208
+
209
+ def read_meta(self, f):
210
+ """Read the headers of a file in file format and place them in the
211
+ self.meta dictionary.
212
+ """
213
+ if not isinstance(f, _BacktrackableFile):
214
+ f = _BacktrackableFile(f)
215
+
216
+ try:
217
+ (name, value) = self.read_meta_line(f)
218
+ while name:
219
+ method_name = "get_{}".format(name)
220
+ if hasattr(self, method_name):
221
+ method = getattr(self, method_name)
222
+ method(name, value)
223
+ name, value = self.read_meta_line(f)
224
+ if not name and not value:
225
+ break
226
+ except ParsingError as e:
227
+ e.args = e.args + (f.line_number,)
228
+ raise
229
+
230
+ def get_unit(self, name, value):
231
+ self.meta[name] = value
232
+
233
+ get_title = get_unit
234
+ get_variable = get_unit
235
+
236
+ def get_time_step(self, name, value):
237
+ if value and "," in value:
238
+ minutes, months = self.read_minutes_months(value)
239
+ self.meta[name] = self._time_step_from_minutes_months(minutes, months)
240
+ else:
241
+ self.meta[name] = value
242
+
243
+ def get_timezone(self, name, value):
244
+ self.meta["_timezone"] = value
245
+
246
+ def _time_step_from_minutes_months(self, minutes, months):
247
+ if minutes != 0 and months != 0:
248
+ raise ParsingError("Invalid time step")
249
+ elif minutes != 0:
250
+ return str(minutes) + "min"
251
+ else:
252
+ return str(months) + "M"
253
+
254
+ def get_interval_type(self, name, value):
255
+ value = value.lower()
256
+ if value not in ("sum", "average", "maximum", "minimum", "vector_average"):
257
+ raise ParsingError(("Invalid interval type"))
258
+ self.meta[name] = value
259
+
260
+ def get_precision(self, name, value):
261
+ try:
262
+ self.meta[name] = int(value)
263
+ except ValueError as e:
264
+ raise ParsingError(e.args)
265
+
266
+ def get_comment(self, name, value):
267
+ if "comment" in self.meta:
268
+ self.meta["comment"] += "\n"
269
+ else:
270
+ self.meta["comment"] = ""
271
+ self.meta["comment"] += value
272
+
273
+ def get_location(self, name, value):
274
+ self._ensure_location_attribute_exists()
275
+ try:
276
+ items = value.split()
277
+ self.meta["location"]["abscissa"] = float(items[0])
278
+ self.meta["location"]["ordinate"] = float(items[1])
279
+ self.meta["location"]["srid"] = int(items[2])
280
+ except (IndexError, ValueError):
281
+ raise ParsingError("Invalid location")
282
+
283
+ def _ensure_location_attribute_exists(self):
284
+ if "location" not in self.meta:
285
+ self.meta["location"] = {}
286
+
287
+ def get_altitude(self, name, value):
288
+ self._ensure_location_attribute_exists()
289
+ try:
290
+ items = value.split()
291
+ self.meta["location"]["altitude"] = float(items[0])
292
+ self.meta["location"]["asrid"] = int(items[1]) if len(items) > 1 else None
293
+ except (IndexError, ValueError):
294
+ raise ParsingError("Invalid altitude")
295
+
296
+ def read_minutes_months(self, s):
297
+ """Return a (minutes, months) tuple after parsing a "M,N" string."""
298
+ try:
299
+ (minutes, months) = [int(x.strip()) for x in s.split(",")]
300
+ return minutes, months
301
+ except Exception:
302
+ raise ParsingError(('Value should be "minutes, months"'))
303
+
304
+ def read_meta_line(self, f):
305
+ """Read one line from a file format header and return a (name, value)
306
+ tuple, where name is lowercased. Returns ('', '') if the next line is
307
+ blank. Raises ParsingError if next line in f is not a valid header
308
+ line."""
309
+ line = f.readline()
310
+ if isinstance(line, bytes):
311
+ line = line.decode("utf-8-sig")
312
+ name, value = "", ""
313
+ if line.isspace():
314
+ return (name, value)
315
+ if line.find("=") > 0:
316
+ name, value = line.split("=", 1)
317
+ name = name.rstrip().lower()
318
+ value = value.strip()
319
+ name = "" if any([c.isspace() for c in name]) else name
320
+ if not name:
321
+ raise ParsingError("Invalid file header line")
322
+ return (name, value)
323
+
324
+
325
+ class HTimeseries:
326
+ TEXT = "TEXT"
327
+ FILE = "FILE"
328
+ args = {
329
+ "format": None,
330
+ "start_date": None,
331
+ "end_date": None,
332
+ "default_tzinfo": None,
333
+ }
334
+
335
+ def __init__(self, data=None, **kwargs):
336
+ extra_parms = set(kwargs) - set(self.args)
337
+ if extra_parms:
338
+ raise TypeError(
339
+ "HTimeseries.__init__() got an unexpected keyword argument "
340
+ f"'{extra_parms.pop()}'"
341
+ )
342
+ for arg, default_value in self.args.items():
343
+ kwargs.setdefault(arg, default_value)
344
+ if data is None:
345
+ if not kwargs["default_tzinfo"]:
346
+ kwargs["default_tzinfo"] = dt.timezone.utc
347
+ self._read_filelike(StringIO(), **kwargs)
348
+ elif isinstance(data, pd.DataFrame):
349
+ self._check_dataframe(data)
350
+ self.data = data
351
+ else:
352
+ self._read_filelike(data, **kwargs)
353
+
354
+ def _check_dataframe(self, data):
355
+ if data.index.tz is None:
356
+ raise TypeError("data.index.tz must exist")
357
+
358
+ def _read_filelike(self, *args, **kwargs):
359
+ reader = TimeseriesStreamReader(*args, **kwargs)
360
+ self.__dict__.update(reader.get_metadata())
361
+ try:
362
+ tzinfo = TzinfoFromString(self._timezone)
363
+ except AttributeError:
364
+ tzinfo = kwargs["default_tzinfo"]
365
+ self.data = reader.get_data(tzinfo)
366
+ if self.data.size and (tzinfo is None):
367
+ raise TypeError(
368
+ "Cannot read filelike object without timezone or default_tzinfo "
369
+ "specified"
370
+ )
371
+
372
+ def write(self, f, format=TEXT, version=5):
373
+ writer = TimeseriesStreamWriter(self, f, format=format, version=version)
374
+ writer.write()
375
+
376
+
377
+ class TimeseriesStreamReader:
378
+ def __init__(self, f, **kwargs):
379
+ self.f = f
380
+ self.specified_format = kwargs["format"]
381
+ self.start_date = kwargs["start_date"]
382
+ self.end_date = kwargs["end_date"]
383
+ self.default_tzinfo = kwargs["default_tzinfo"]
384
+
385
+ def get_metadata(self):
386
+ if self.format == HTimeseries.FILE:
387
+ return MetadataReader(self.f).meta
388
+ else:
389
+ return {}
390
+
391
+ @property
392
+ def format(self):
393
+ if self.specified_format is None:
394
+ return self.autodetected_format
395
+ else:
396
+ return self.specified_format
397
+
398
+ @property
399
+ def autodetected_format(self):
400
+ if not hasattr(self, "_stored_autodetected_format"):
401
+ self._stored_autodetected_format = FormatAutoDetector(self.f).detect()
402
+ return self._stored_autodetected_format
403
+
404
+ def get_data(self, tzinfo):
405
+ return TimeseriesRecordsReader(
406
+ self.f, self.start_date, self.end_date, tzinfo=tzinfo
407
+ ).read()
408
+
409
+
410
+ def _check_timeseries_index_has_no_duplicates(data, error_message_prefix):
411
+ duplicate_dates = data.index[data.index.duplicated()].tolist()
412
+ if duplicate_dates:
413
+ dates_str = ", ".join([str(x) for x in duplicate_dates])
414
+ raise ValueError(
415
+ f"{error_message_prefix}: the following timestamps appear more than "
416
+ f"once: {dates_str}"
417
+ )
418
+
419
+
420
+ class TimeseriesRecordsReader:
421
+ def __init__(self, f, start_date, end_date, tzinfo):
422
+ self.f = f
423
+ self.start_date = start_date
424
+ self.end_date = end_date
425
+ self.tzinfo = tzinfo
426
+
427
+ def read(self):
428
+ start_date, end_date = self._get_bounding_dates_as_strings()
429
+ f2 = _FilePart(self.f, start_date, end_date)
430
+ data = self._read_data_from_stream(f2)
431
+ self._check_there_are_no_duplicates(data)
432
+ return data
433
+
434
+ def _get_bounding_dates_as_strings(self):
435
+ start_date = "0001-01-01 00:00" if self.start_date is None else self.start_date
436
+ end_date = "9999-12-31 00:00" if self.end_date is None else self.end_date
437
+ if isinstance(start_date, dt.datetime):
438
+ start_date = start_date.strftime("%Y-%m-%d %H:%M")
439
+ if isinstance(end_date, dt.datetime):
440
+ end_date = end_date.strftime("%Y-%m-%d %H:%M")
441
+ return start_date, end_date
442
+
443
+ def _read_data_from_stream(self, f):
444
+ dates, values, flags = self._read_csv(f)
445
+ dates = self._localize_dates(dates)
446
+ result = pd.DataFrame(
447
+ {
448
+ "value": np.array(values, dtype=np.float64),
449
+ "flags": np.array(flags, dtype=str),
450
+ },
451
+ index=dates,
452
+ )
453
+ result.index.name = "date"
454
+ return result
455
+
456
+ def _localize_dates(self, dates):
457
+ try:
458
+ result = pd.to_datetime(dates)
459
+ except ValueError:
460
+ raise ValueError(
461
+ "Could not parse timestamps correctly. Maybe the CSV contains mixed "
462
+ "aware and naive timestamps."
463
+ )
464
+ if len(result) == 0 or (len(result) > 0 and result[0].tzinfo is None):
465
+ result = pd.to_datetime(dates).tz_localize(
466
+ self.tzinfo, ambiguous=len(dates) * [True]
467
+ )
468
+ return result
469
+
470
+ def _read_csv(self, f):
471
+ dates, values, flags = [], [], []
472
+ for row in csv.reader(f): # We don't use pd.read_csv() because it's much slower
473
+ if not len(row):
474
+ continue
475
+ dates.append(row[0])
476
+ if len(row) > 1 and row[1]:
477
+ values.append(row[1])
478
+ else:
479
+ values.append("NaN")
480
+ flags.append(row[2] if len(row) > 2 else "")
481
+ return dates, values, flags
482
+
483
+ def _check_there_are_no_duplicates(self, data):
484
+ _check_timeseries_index_has_no_duplicates(
485
+ data, error_message_prefix="Can't read time series"
486
+ )
487
+
488
+
489
+ class FormatAutoDetector:
490
+ def __init__(self, f):
491
+ self.f = f
492
+
493
+ def detect(self):
494
+ original_position = self.f.tell()
495
+ result = self._guess_format_from_first_nonempty_line()
496
+ self.f.seek(original_position)
497
+ return result
498
+
499
+ def _guess_format_from_first_nonempty_line(self):
500
+ line = self._get_first_nonempty_line()
501
+ if line and not line[0].isdigit():
502
+ return HTimeseries.FILE
503
+ else:
504
+ return HTimeseries.TEXT
505
+
506
+ def _get_first_nonempty_line(self):
507
+ for line in self.f:
508
+ if line.strip():
509
+ return line
510
+ return ""
511
+
512
+
513
+ class TimeseriesStreamWriter:
514
+ def __init__(self, htimeseries, f, *, format, version):
515
+ self.htimeseries = htimeseries
516
+ self.f = f
517
+ self.format = format
518
+ self.version = version
519
+
520
+ def write(self):
521
+ self._write_metadata()
522
+ self._write_records()
523
+
524
+ def _write_metadata(self):
525
+ if self.format == HTimeseries.FILE:
526
+ MetadataWriter(self.f, self.htimeseries, version=self.version).write_meta()
527
+ self.f.write("\r\n")
528
+
529
+ def _write_records(self):
530
+ TimeseriesRecordsWriter(self.htimeseries, self.f).write()
531
+
532
+
533
+ class TimeseriesRecordsWriter:
534
+ def __init__(self, htimeseries, f):
535
+ self.htimeseries = htimeseries
536
+ self.f = f
537
+
538
+ def write(self):
539
+ if self.htimeseries.data.empty:
540
+ return
541
+ self._check_there_are_no_duplicates()
542
+ self._setup_precision()
543
+ self._write_records()
544
+
545
+ def _check_there_are_no_duplicates(self):
546
+ _check_timeseries_index_has_no_duplicates(
547
+ self.htimeseries.data, error_message_prefix="Can't write time series"
548
+ )
549
+
550
+ def _setup_precision(self):
551
+ precision = getattr(self.htimeseries, "precision", None)
552
+ if precision is None:
553
+ self.float_format = "%f"
554
+ elif self.htimeseries.precision >= 0:
555
+ self.float_format = "%.{}f".format(self.htimeseries.precision)
556
+ else:
557
+ self.float_format = "%.0f"
558
+ self._prepare_records_for_negative_precision(precision)
559
+
560
+ def _prepare_records_for_negative_precision(self, precision):
561
+ assert precision < 0
562
+ datacol = self.htimeseries.data.columns[0]
563
+ m = 10 ** (-self.htimeseries.precision)
564
+ self.htimeseries.data[datacol] = np.rint(self.htimeseries.data[datacol] / m) * m
565
+
566
+ def _write_records(self):
567
+ self.htimeseries.data.to_csv(
568
+ self.f,
569
+ float_format=self.float_format,
570
+ header=False,
571
+ mode="w",
572
+ lineterminator="\r\n",
573
+ date_format="%Y-%m-%d %H:%M",
574
+ )
@@ -0,0 +1,44 @@
1
+ import datetime as dt
2
+
3
+
4
+ class TzinfoFromString(dt.tzinfo):
5
+ def __init__(self, string):
6
+ self.offset = None
7
+ self.name = ""
8
+ if not string:
9
+ return
10
+
11
+ # If string contains brackets, set tzname to whatever is before the
12
+ # brackets and retrieve the part inside the brackets.
13
+ i = string.find("(")
14
+ if i > 0:
15
+ self.name = string[:i].strip()
16
+ start = i + 1
17
+ s = string[start:]
18
+ i = s.find(")")
19
+ i = len(s) if i < 0 else i
20
+ s = s[:i]
21
+
22
+ # Remove any preceeding 'UTC' (as in "UTC+0200")
23
+ s = s[3:] if s.startswith("UTC") else s
24
+
25
+ # s should be in +0000 format
26
+ try:
27
+ if len(s) != 5:
28
+ raise ValueError()
29
+ sign = {"+": 1, "-": -1}[s[0]]
30
+ hours = int(s[1:3])
31
+ minutes = int(s[3:5])
32
+ except (ValueError, IndexError):
33
+ raise ValueError("Time zone {} is invalid".format(string))
34
+
35
+ self.offset = sign * dt.timedelta(hours=hours, minutes=minutes)
36
+
37
+ def utcoffset(self, adatetime):
38
+ return self.offset
39
+
40
+ def dst(self, adatetime):
41
+ return dt.timedelta(0)
42
+
43
+ def tzname(self, adatetime):
44
+ return self.name
pthelma/__init__.py ADDED
File without changes
pthelma/_version.py ADDED
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '1.0.0'
16
+ __version_tuple__ = version_tuple = (1, 0, 0)
@@ -0,0 +1,34 @@
1
+ ===================
2
+ License and credits
3
+ ===================
4
+
5
+ | Copyright (C) 2013-2018 TEI of Epirus
6
+ | Copyright (C) 2014-2016 Antonis Christofides
7
+ | Copyright (C) 2019 University of Ioannina
8
+ | Copyright (C) 2018-2021 National Technical University of Athens
9
+ | Copyright (C) 2018-2021 Institute of Communications and Computer Systems
10
+ | Copyright (C) 2022-2024 IRMASYS P.C.
11
+
12
+ pthelma is free software: you can redistribute it and/or modify it
13
+ under the terms of the GNU General Public License as published by the
14
+ Free Software Foundation, either version 3 of the License, or (at your
15
+ option) any later version.
16
+
17
+ This program is distributed in the hope that it will be useful, but
18
+ WITHOUT ANY WARRANTY; without even the implied warranty of
19
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20
+ General Public License for more details.
21
+
22
+ You should have received a copy of the GNU General Public License along
23
+ with this program. If not, see <http://www.gnu.org/licenses/>.
24
+
25
+ Pthelma was funded by several organizations:
26
+
27
+ * In 2013-2018 by the `TEI of Epirus`_ as part of the IRMA_ project.
28
+ * In 2018-2021 by NTUA_ and ICCS_ as part of the OpenHi_ project.
29
+
30
+ .. _ntua: http://www.ntua.gr/
31
+ .. _tei of epirus: http://www.teiep.gr/en/
32
+ .. _irma: http://www.irrigation-management.eu/
33
+ .. _iccs: https://www.iccs.gr
34
+ .. _openhi: https://openhi.net