floodmodeller-api 0.4.3__py3-none-any.whl → 0.4.4.post1__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.
Files changed (30) hide show
  1. floodmodeller_api/_base.py +22 -37
  2. floodmodeller_api/dat.py +165 -185
  3. floodmodeller_api/ied.py +82 -87
  4. floodmodeller_api/ief.py +92 -186
  5. floodmodeller_api/inp.py +64 -70
  6. floodmodeller_api/logs/__init__.py +1 -1
  7. floodmodeller_api/logs/lf.py +61 -17
  8. floodmodeller_api/test/conftest.py +7 -0
  9. floodmodeller_api/test/test_conveyance.py +107 -0
  10. floodmodeller_api/test/test_dat.py +5 -4
  11. floodmodeller_api/test/test_data/conveyance_test.dat +165 -0
  12. floodmodeller_api/test/test_data/conveyance_test.feb +116 -0
  13. floodmodeller_api/test/test_data/conveyance_test.gxy +85 -0
  14. floodmodeller_api/test/test_data/expected_conveyance.csv +60 -0
  15. floodmodeller_api/test/test_ief.py +26 -15
  16. floodmodeller_api/test/test_logs_lf.py +54 -0
  17. floodmodeller_api/to_from_json.py +24 -12
  18. floodmodeller_api/units/boundaries.py +6 -0
  19. floodmodeller_api/units/conveyance.py +301 -0
  20. floodmodeller_api/units/sections.py +21 -0
  21. floodmodeller_api/util.py +42 -0
  22. floodmodeller_api/version.py +1 -1
  23. floodmodeller_api/xml2d.py +80 -136
  24. floodmodeller_api/zzn.py +166 -139
  25. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/METADATA +4 -1
  26. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/RECORD +30 -24
  27. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/WHEEL +1 -1
  28. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/LICENSE.txt +0 -0
  29. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/entry_points.txt +0 -0
  30. {floodmodeller_api-0.4.3.dist-info → floodmodeller_api-0.4.4.post1.dist-info}/top_level.txt +0 -0
floodmodeller_api/ied.py CHANGED
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
20
20
 
21
21
  from . import units
22
22
  from ._base import FMFile
23
+ from .util import handle_exception
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from pathlib import Path
@@ -41,24 +42,21 @@ class IED(FMFile):
41
42
  _filetype: str = "IED"
42
43
  _suffix: str = ".ied"
43
44
 
45
+ @handle_exception(when="read")
44
46
  def __init__(self, ied_filepath: str | Path | None = None, from_json: bool = False):
45
- try:
46
- if from_json:
47
- return
48
- if ied_filepath is not None:
49
- FMFile.__init__(self, ied_filepath)
47
+ if from_json:
48
+ return
49
+ if ied_filepath is not None:
50
+ FMFile.__init__(self, ied_filepath)
50
51
 
51
- self._read()
52
+ self._read()
52
53
 
53
- else:
54
- # No filepath specified, create new 'blank' IED in memory
55
- self._ied_struct: list[dict[str, Any]] = []
56
- self._raw_data: list[str] = []
57
-
58
- self._get_unit_definitions()
54
+ else:
55
+ # No filepath specified, create new 'blank' IED in memory
56
+ self._ied_struct: list[dict[str, Any]] = []
57
+ self._raw_data: list[str] = []
59
58
 
60
- except Exception as e:
61
- self._handle_exception(e, when="read")
59
+ self._get_unit_definitions()
62
60
 
63
61
  def _read(self):
64
62
  # Read IED data
@@ -68,82 +66,79 @@ class IED(FMFile):
68
66
  # Generate IED structure
69
67
  self._update_ied_struct()
70
68
 
69
+ @handle_exception(when="write")
71
70
  def _write(self) -> str: # noqa: C901, PLR0912
72
71
  """Returns string representation of the current IED data"""
73
- try:
74
- block_shift = 0
75
- existing_units: dict[str, list[str]] = {
76
- "boundaries": [],
77
- "structures": [],
78
- "sections": [],
79
- "conduits": [],
80
- "losses": [],
81
- }
82
-
83
- for block in self._ied_struct:
84
- # Check for all supported boundary types
85
- if block["Type"] in units.SUPPORTED_UNIT_TYPES:
86
- unit_data = self._raw_data[
87
- block["start"] + block_shift : block["end"] + 1 + block_shift
88
- ]
89
- prev_block_len = len(unit_data)
90
- if units.SUPPORTED_UNIT_TYPES[block["Type"]]["has_subtype"]:
91
- unit_name = unit_data[2][:12].strip()
92
- else:
93
- unit_name = unit_data[1][:12].strip()
94
-
95
- # Get unit object
96
- unit_group = getattr(self, units.SUPPORTED_UNIT_TYPES[block["Type"]]["group"])
97
- if unit_name in unit_group:
98
- # block still exists
99
- new_unit_data = unit_group[unit_name]._write()
100
- existing_units[units.SUPPORTED_UNIT_TYPES[block["Type"]]["group"]].append(
101
- unit_name,
72
+ block_shift = 0
73
+ existing_units: dict[str, list[str]] = {
74
+ "boundaries": [],
75
+ "structures": [],
76
+ "sections": [],
77
+ "conduits": [],
78
+ "losses": [],
79
+ }
80
+
81
+ for block in self._ied_struct:
82
+ # Check for all supported boundary types
83
+ if block["Type"] in units.SUPPORTED_UNIT_TYPES:
84
+ unit_data = self._raw_data[
85
+ block["start"] + block_shift : block["end"] + 1 + block_shift
86
+ ]
87
+ prev_block_len = len(unit_data)
88
+ if units.SUPPORTED_UNIT_TYPES[block["Type"]]["has_subtype"]:
89
+ unit_name = unit_data[2][:12].strip()
90
+ else:
91
+ unit_name = unit_data[1][:12].strip()
92
+
93
+ # Get unit object
94
+ unit_group = getattr(self, units.SUPPORTED_UNIT_TYPES[block["Type"]]["group"])
95
+ if unit_name in unit_group:
96
+ # block still exists
97
+ new_unit_data = unit_group[unit_name]._write()
98
+ existing_units[units.SUPPORTED_UNIT_TYPES[block["Type"]]["group"]].append(
99
+ unit_name,
100
+ )
101
+ else:
102
+ # Bdy block has been deleted
103
+ new_unit_data = []
104
+
105
+ new_block_len = len(new_unit_data)
106
+ self._raw_data[block["start"] + block_shift : block["end"] + 1 + block_shift] = (
107
+ new_unit_data
108
+ )
109
+ # adjust block shift for change in number of lines in bdy block
110
+ block_shift += new_block_len - prev_block_len
111
+
112
+ # Add any new units
113
+ for group_name, _units in existing_units.items():
114
+ for name, unit in getattr(self, group_name).items():
115
+ if name not in _units:
116
+ # Newly added unit
117
+ # Ensure that the 'name' attribute matches name key in boundaries
118
+ self._raw_data.extend(unit._write())
119
+
120
+ # Update ied_struct
121
+ self._update_ied_struct()
122
+
123
+ # Update unit names
124
+ for unit_group, unit_group_name in [
125
+ (self.boundaries, "boundaries"),
126
+ (self.sections, "sections"),
127
+ (self.structures, "structures"),
128
+ (self.conduits, "conduits"),
129
+ (self.losses, "losses"),
130
+ ]:
131
+ for name, unit in unit_group.copy().items():
132
+ if name != unit.name:
133
+ # Check if new name already exists as a label
134
+ if unit.name in unit_group:
135
+ raise Exception(
136
+ f'Error: Cannot update label "{name}" to "{unit.name}" because "{unit.name}" already exists in the Network {unit_group_name} group',
102
137
  )
103
- else:
104
- # Bdy block has been deleted
105
- new_unit_data = []
106
-
107
- new_block_len = len(new_unit_data)
108
- self._raw_data[
109
- block["start"] + block_shift : block["end"] + 1 + block_shift
110
- ] = new_unit_data
111
- # adjust block shift for change in number of lines in bdy block
112
- block_shift += new_block_len - prev_block_len
113
-
114
- # Add any new units
115
- for group_name, _units in existing_units.items():
116
- for name, unit in getattr(self, group_name).items():
117
- if name not in _units:
118
- # Newly added unit
119
- # Ensure that the 'name' attribute matches name key in boundaries
120
- self._raw_data.extend(unit._write())
121
-
122
- # Update ied_struct
123
- self._update_ied_struct()
124
-
125
- # Update unit names
126
- for unit_group, unit_group_name in [
127
- (self.boundaries, "boundaries"),
128
- (self.sections, "sections"),
129
- (self.structures, "structures"),
130
- (self.conduits, "conduits"),
131
- (self.losses, "losses"),
132
- ]:
133
- for name, unit in unit_group.copy().items():
134
- if name != unit.name:
135
- # Check if new name already exists as a label
136
- if unit.name in unit_group:
137
- raise Exception(
138
- f'Error: Cannot update label "{name}" to "{unit.name}" because "{unit.name}" already exists in the Network {unit_group_name} group',
139
- )
140
- unit_group[unit.name] = unit
141
- del unit_group[name]
142
-
143
- return "\n".join(self._raw_data) + "\n"
144
-
145
- except Exception as e:
146
- self._handle_exception(e, when="write")
138
+ unit_group[unit.name] = unit
139
+ del unit_group[name]
140
+
141
+ return "\n".join(self._raw_data) + "\n"
147
142
 
148
143
  def _get_unit_definitions(self):
149
144
  # Get unit definitions
floodmodeller_api/ief.py CHANGED
@@ -16,7 +16,6 @@ address: Jacobs UK Limited, Flood Modeller, Cottons Centre, Cottons Lane, London
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- import datetime as dt
20
19
  import os
21
20
  import subprocess
22
21
  import time
@@ -29,7 +28,8 @@ from tqdm import trange
29
28
 
30
29
  from ._base import FMFile
31
30
  from .ief_flags import flags
32
- from .logs import lf_factory
31
+ from .logs import LF1, create_lf
32
+ from .util import handle_exception
33
33
  from .zzn import ZZN
34
34
 
35
35
 
@@ -49,24 +49,19 @@ class IEF(FMFile):
49
49
 
50
50
  _filetype: str = "IEF"
51
51
  _suffix: str = ".ief"
52
- OLD_FILE = 5
53
52
  ERROR_MAX = 2000
54
53
  WARNING_MAX = 3000
55
- LOG_TIMEOUT = 10
56
54
 
55
+ @handle_exception(when="read")
57
56
  def __init__(self, ief_filepath: str | Path | None = None, from_json: bool = False):
58
- try:
59
- if from_json:
60
- return
61
- if ief_filepath is not None:
62
- FMFile.__init__(self, ief_filepath)
63
-
64
- self._read()
65
-
66
- else:
67
- self._create_from_blank()
68
- except Exception as e:
69
- self._handle_exception(e, when="read")
57
+ if from_json:
58
+ return
59
+ if ief_filepath is not None:
60
+ FMFile.__init__(self, ief_filepath)
61
+ self._read()
62
+ self._log_path = self._get_result_filepath("lf1")
63
+ else:
64
+ self._create_from_blank()
70
65
 
71
66
  def _read(self):
72
67
  # Read IEF data
@@ -112,42 +107,39 @@ class IEF(FMFile):
112
107
  prev_comment = None
113
108
  del raw_data
114
109
 
110
+ @handle_exception(when="write")
115
111
  def _write(self) -> str:
116
112
  """Returns string representation of the current IEF data
117
113
 
118
114
  Returns:
119
115
  str: Full string representation of IEF in its most recent state (including changes not yet saved to disk)
120
116
  """
121
- try:
122
- # update _ief_properties
123
- self._update_ief_properties()
124
-
125
- ief_string = ""
126
- event = 0 # Used as a counter for multiple eventdata files
127
- for idx, prop in enumerate(self._ief_properties):
128
- if prop.startswith("["):
129
- # writes the [] bound headers to ief string
117
+ # update _ief_properties
118
+ self._update_ief_properties()
119
+
120
+ ief_string = ""
121
+ event = 0 # Used as a counter for multiple eventdata files
122
+ for idx, prop in enumerate(self._ief_properties):
123
+ if prop.startswith("["):
124
+ # writes the [] bound headers to ief string
125
+ ief_string += prop + "\n"
126
+ elif prop.lstrip().startswith(";"):
127
+ if self._ief_properties[idx + 1].lower() != "eventdata":
128
+ # Only write comment if not preceding event data
130
129
  ief_string += prop + "\n"
131
- elif prop.lstrip().startswith(";"):
132
- if self._ief_properties[idx + 1].lower() != "eventdata":
133
- # Only write comment if not preceding event data
134
- ief_string += prop + "\n"
135
- elif prop.lower() == "eventdata":
136
- event_data = getattr(self, prop)
137
- # Add multiple EventData if present
138
- for event_idx, key in enumerate(event_data):
139
- if event_idx == event:
140
- ief_string += f";{key}\n{prop}={str(event_data[key])}\n"
141
- break
142
- event += 1
143
-
144
- else:
145
- # writes property and value to ief string
146
- ief_string += f"{prop}={str(getattr(self, prop))}\n"
147
- return ief_string
130
+ elif prop.lower() == "eventdata":
131
+ event_data = getattr(self, prop)
132
+ # Add multiple EventData if present
133
+ for event_idx, key in enumerate(event_data):
134
+ if event_idx == event:
135
+ ief_string += f";{key}\n{prop}={str(event_data[key])}\n"
136
+ break
137
+ event += 1
148
138
 
149
- except Exception as e:
150
- self._handle_exception(e, when="write")
139
+ else:
140
+ # writes property and value to ief string
141
+ ief_string += f"{prop}={str(getattr(self, prop))}\n"
142
+ return ief_string
151
143
 
152
144
  def _create_from_blank(self):
153
145
  # No filepath specified, create new 'blank' IEF in memory
@@ -360,6 +352,7 @@ class IEF(FMFile):
360
352
  """
361
353
  self._save(filepath)
362
354
 
355
+ @handle_exception(when="simulate")
363
356
  def simulate( # noqa: C901, PLR0912, PLR0913
364
357
  self,
365
358
  method: str = "WAIT",
@@ -392,73 +385,70 @@ class IEF(FMFile):
392
385
  Returns:
393
386
  subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
394
387
  """
395
- try:
396
- self._range_function = range_function
397
- self._range_settings = range_settings if range_settings else {}
398
- if self._filepath is None:
399
- raise UserWarning(
400
- "IEF must be saved to a specific filepath before simulate() can be called.",
401
- )
402
- if precision.upper() == "DEFAULT":
403
- precision = "SINGLE" # Defaults to single...
404
- for attr in dir(self):
405
- if (
406
- attr.upper() == "LAUNCHDOUBLEPRECISIONVERSION" # Unless DP specified
407
- and int(getattr(self, attr)) == 1
408
- ):
409
- precision = "DOUBLE"
410
- break
411
-
412
- if enginespath == "":
413
- _enginespath = r"C:\Program Files\Flood Modeller\bin" # Default location
414
- else:
415
- _enginespath = enginespath
416
- if not Path(_enginespath).exists():
417
- raise Exception(
418
- f"Flood Modeller non-default engine path not found! {str(_enginespath)}",
419
- )
388
+ self._range_function = range_function
389
+ self._range_settings = range_settings if range_settings else {}
390
+ if self._filepath is None:
391
+ raise UserWarning(
392
+ "IEF must be saved to a specific filepath before simulate() can be called.",
393
+ )
394
+ if precision.upper() == "DEFAULT":
395
+ precision = "SINGLE" # Defaults to single...
396
+ for attr in dir(self):
397
+ if (
398
+ attr.upper() == "LAUNCHDOUBLEPRECISIONVERSION" # Unless DP specified
399
+ and int(getattr(self, attr)) == 1
400
+ ):
401
+ precision = "DOUBLE"
402
+ break
420
403
 
421
- if precision.upper() == "SINGLE":
422
- isis32_fp = str(Path(_enginespath, "ISISf32.exe"))
423
- else:
424
- isis32_fp = str(Path(_enginespath, "ISISf32_DoubleP.exe"))
404
+ if enginespath == "":
405
+ _enginespath = r"C:\Program Files\Flood Modeller\bin" # Default location
406
+ else:
407
+ _enginespath = enginespath
408
+ if not Path(_enginespath).exists():
409
+ raise Exception(
410
+ f"Flood Modeller non-default engine path not found! {str(_enginespath)}",
411
+ )
425
412
 
426
- if not Path(isis32_fp).exists():
427
- raise Exception(f"Flood Modeller engine not found! Expected location: {isis32_fp}")
413
+ if precision.upper() == "SINGLE":
414
+ isis32_fp = str(Path(_enginespath, "ISISf32.exe"))
415
+ else:
416
+ isis32_fp = str(Path(_enginespath, "ISISf32_DoubleP.exe"))
428
417
 
429
- run_command = f'"{isis32_fp}" -sd "{self._filepath}"'
418
+ if not Path(isis32_fp).exists():
419
+ raise Exception(f"Flood Modeller engine not found! Expected location: {isis32_fp}")
430
420
 
431
- if method.upper() == "WAIT":
432
- print("Executing simulation...")
433
- # execute simulation
434
- process = Popen(run_command, cwd=os.path.dirname(self._filepath))
421
+ run_command = f'"{isis32_fp}" -sd "{self._filepath}"'
435
422
 
436
- # progress bar based on log files
437
- self._init_log_file()
438
- self._update_progress_bar(process)
423
+ if method.upper() == "WAIT":
424
+ print("Executing simulation...")
425
+ # execute simulation
426
+ process = Popen(run_command, cwd=os.path.dirname(self._filepath))
439
427
 
440
- while process.poll() is None:
441
- # Process still running
442
- time.sleep(1)
428
+ # progress bar based on log files
429
+ steady = self.RunType == "Steady"
430
+ self._lf = create_lf(self._log_path, "lf1") if not steady else None
431
+ self._update_progress_bar(process)
443
432
 
444
- result, summary = self._summarise_exy()
433
+ while process.poll() is None:
434
+ # Process still running
435
+ time.sleep(1)
445
436
 
446
- if result == 1 and raise_on_failure:
447
- raise RuntimeError(summary)
448
- print(summary)
437
+ result, summary = self._summarise_exy()
449
438
 
450
- elif method.upper() == "RETURN_PROCESS":
451
- print("Executing simulation...")
452
- # execute simulation
453
- return Popen(run_command, cwd=os.path.dirname(self._filepath))
439
+ if result == 1 and raise_on_failure:
440
+ raise RuntimeError(summary)
441
+ print(summary)
454
442
 
455
- return None
443
+ elif method.upper() == "RETURN_PROCESS":
444
+ print("Executing simulation...")
445
+ # execute simulation
446
+ return Popen(run_command, cwd=os.path.dirname(self._filepath))
456
447
 
457
- except Exception as e:
458
- self._handle_exception(e, when="simulate")
448
+ return None
459
449
 
460
450
  def _get_result_filepath(self, suffix):
461
- if hasattr(self, "Results"):
451
+ if hasattr(self, "Results") and self.Results != '""': # because blank IEF has 'Results=""'
462
452
  path = Path(self.Results).with_suffix("." + suffix)
463
453
  if not path.is_absolute():
464
454
  # set cwd to ief location and resolve path
@@ -491,95 +481,11 @@ class IEF(FMFile):
491
481
  floodmodeller_api.LF1 class object
492
482
  """
493
483
 
494
- suffix, steady = self._determine_lf_type()
495
-
496
- # Get lf location
497
- lf_path = self._get_result_filepath(suffix)
498
-
499
- if not lf_path.exists():
500
- raise FileNotFoundError("Log file (" + suffix + ") not found")
501
-
502
- return lf_factory(lf_path, suffix, steady)
503
-
504
- def _determine_lf_type(self): # (str, bool) or (None, None):
505
- """Determine the log file type"""
506
-
507
- if self.RunType == "Unsteady":
508
- suffix = "lf1"
509
- steady = False
510
-
511
- elif self.RunType == "Steady":
512
- suffix = "lf1"
513
- steady = True
514
-
515
- else:
516
- raise ValueError(f'Unexpected run type "{self.RunType}"')
517
-
518
- return suffix, steady
519
-
520
- def _init_log_file(self):
521
- """Checks for a new log file, waiting for its creation if necessary"""
522
-
523
- # determine log file type based on self.RunType
524
- try:
525
- suffix, steady = self._determine_lf_type()
526
- except ValueError:
527
- self._no_log_file(f'run type "{self.RunType}" not supported')
528
- self._lf = None
529
- return
530
-
531
- # ensure progress bar is supported for that type
532
- if not (suffix == "lf1" and steady is False):
533
- self._no_log_file("only 1D unsteady runs are supported")
534
- self._lf = None
535
- return
536
-
537
- # find what log filepath should be
538
- lf_filepath = self._get_result_filepath(suffix)
539
-
540
- # wait for log file to exist
541
- log_file_exists = False
542
- max_time = time.time() + self.LOG_TIMEOUT
543
-
544
- while not log_file_exists:
545
- time.sleep(0.1)
546
-
547
- log_file_exists = lf_filepath.is_file()
548
-
549
- # timeout
550
- if time.time() > max_time:
551
- self._no_log_file("log file is expected but not detected")
552
- self._lf = None
553
- return
554
-
555
- # wait for new log file
556
- old_log_file = True
557
- max_time = time.time() + self.LOG_TIMEOUT
558
-
559
- while old_log_file:
560
- time.sleep(0.1)
561
-
562
- # difference between now and when log file was last modified
563
- last_modified_timestamp = lf_filepath.stat().st_mtime
564
- last_modified = dt.datetime.fromtimestamp(last_modified_timestamp)
565
- time_diff_sec = (dt.datetime.now() - last_modified).total_seconds()
566
-
567
- # it's old if it's over self.OLD_FILE seconds old (TODO: is this robust?)
568
- old_log_file = time_diff_sec > self.OLD_FILE
569
-
570
- # timeout
571
- if time.time() > max_time:
572
- self._no_log_file("log file is from previous run")
573
- self._lf = None
574
- return
575
-
576
- # create LF instance
577
- self._lf = lf_factory(lf_filepath, suffix, steady)
578
-
579
- def _no_log_file(self, reason):
580
- """Warning that there will be no progress bar"""
484
+ if not self._log_path.exists():
485
+ raise FileNotFoundError("Log file (LF1) not found")
581
486
 
582
- print("No progress bar as " + reason + ". Simulation will continue as usual.")
487
+ steady = self.RunType == "Steady"
488
+ return LF1(self._log_path, steady)
583
489
 
584
490
  def _update_progress_bar(self, process: Popen):
585
491
  """Updates progress bar based on log file"""