floodmodeller-api 0.4.2.post1__py3-none-any.whl → 0.4.4__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 (178) hide show
  1. floodmodeller_api/__init__.py +8 -9
  2. floodmodeller_api/_base.py +169 -176
  3. floodmodeller_api/backup.py +273 -273
  4. floodmodeller_api/dat.py +889 -831
  5. floodmodeller_api/diff.py +136 -119
  6. floodmodeller_api/ied.py +302 -306
  7. floodmodeller_api/ief.py +553 -637
  8. floodmodeller_api/ief_flags.py +253 -253
  9. floodmodeller_api/inp.py +260 -266
  10. floodmodeller_api/libs/libifcoremd.dll +0 -0
  11. floodmodeller_api/libs/libifcoremt.so.5 +0 -0
  12. floodmodeller_api/libs/libifport.so.5 +0 -0
  13. floodmodeller_api/{libmmd.dll → libs/libimf.so} +0 -0
  14. floodmodeller_api/libs/libintlc.so.5 +0 -0
  15. floodmodeller_api/libs/libmmd.dll +0 -0
  16. floodmodeller_api/libs/libsvml.so +0 -0
  17. floodmodeller_api/libs/libzzn_read.so +0 -0
  18. floodmodeller_api/libs/zzn_read.dll +0 -0
  19. floodmodeller_api/logs/__init__.py +2 -2
  20. floodmodeller_api/logs/lf.py +364 -312
  21. floodmodeller_api/logs/lf_helpers.py +354 -352
  22. floodmodeller_api/logs/lf_params.py +643 -529
  23. floodmodeller_api/mapping.py +84 -0
  24. floodmodeller_api/test/__init__.py +4 -4
  25. floodmodeller_api/test/conftest.py +16 -8
  26. floodmodeller_api/test/test_backup.py +117 -117
  27. floodmodeller_api/test/test_conveyance.py +107 -0
  28. floodmodeller_api/test/test_dat.py +222 -92
  29. floodmodeller_api/test/test_data/All Units 4_6.DAT +1081 -1081
  30. floodmodeller_api/test/test_data/All Units 4_6.feb +1081 -1081
  31. floodmodeller_api/test/test_data/BRIDGE.DAT +926 -926
  32. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.dat +36 -36
  33. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.feb +36 -36
  34. floodmodeller_api/test/test_data/DamBreakADI.xml +52 -52
  35. floodmodeller_api/test/test_data/DamBreakFAST.xml +58 -58
  36. floodmodeller_api/test/test_data/DamBreakFAST_dy.xml +53 -53
  37. floodmodeller_api/test/test_data/DamBreakTVD.xml +55 -55
  38. floodmodeller_api/test/test_data/DefenceBreach.xml +53 -53
  39. floodmodeller_api/test/test_data/DefenceBreachFAST.xml +60 -60
  40. floodmodeller_api/test/test_data/DefenceBreachFAST_dy.xml +55 -55
  41. floodmodeller_api/test/test_data/Domain1+2_QH.xml +76 -76
  42. floodmodeller_api/test/test_data/Domain1_H.xml +41 -41
  43. floodmodeller_api/test/test_data/Domain1_Q.xml +41 -41
  44. floodmodeller_api/test/test_data/Domain1_Q_FAST.xml +48 -48
  45. floodmodeller_api/test/test_data/Domain1_Q_FAST_dy.xml +48 -48
  46. floodmodeller_api/test/test_data/Domain1_Q_xml_expected.json +263 -0
  47. floodmodeller_api/test/test_data/Domain1_W.xml +41 -41
  48. floodmodeller_api/test/test_data/EX1.DAT +321 -321
  49. floodmodeller_api/test/test_data/EX1.ext +107 -107
  50. floodmodeller_api/test/test_data/EX1.feb +320 -320
  51. floodmodeller_api/test/test_data/EX1.gxy +107 -107
  52. floodmodeller_api/test/test_data/EX17.DAT +421 -422
  53. floodmodeller_api/test/test_data/EX17.ext +213 -213
  54. floodmodeller_api/test/test_data/EX17.feb +422 -422
  55. floodmodeller_api/test/test_data/EX18.DAT +375 -375
  56. floodmodeller_api/test/test_data/EX18_DAT_expected.json +3876 -0
  57. floodmodeller_api/test/test_data/EX2.DAT +302 -302
  58. floodmodeller_api/test/test_data/EX3.DAT +926 -926
  59. floodmodeller_api/test/test_data/EX3_DAT_expected.json +16235 -0
  60. floodmodeller_api/test/test_data/EX3_IEF_expected.json +61 -0
  61. floodmodeller_api/test/test_data/EX6.DAT +2084 -2084
  62. floodmodeller_api/test/test_data/EX6.ext +532 -532
  63. floodmodeller_api/test/test_data/EX6.feb +2084 -2084
  64. floodmodeller_api/test/test_data/EX6_DAT_expected.json +31647 -0
  65. floodmodeller_api/test/test_data/Event Data Example.DAT +336 -336
  66. floodmodeller_api/test/test_data/Event Data Example.ext +107 -107
  67. floodmodeller_api/test/test_data/Event Data Example.feb +336 -336
  68. floodmodeller_api/test/test_data/Linked1D2D.xml +52 -52
  69. floodmodeller_api/test/test_data/Linked1D2DFAST.xml +53 -53
  70. floodmodeller_api/test/test_data/Linked1D2DFAST_dy.xml +48 -48
  71. floodmodeller_api/test/test_data/Linked1D2D_xml_expected.json +313 -0
  72. floodmodeller_api/test/test_data/blockage.dat +50 -50
  73. floodmodeller_api/test/test_data/blockage.ext +45 -45
  74. floodmodeller_api/test/test_data/blockage.feb +9 -9
  75. floodmodeller_api/test/test_data/blockage.gxy +71 -71
  76. floodmodeller_api/test/test_data/conveyance_test.dat +165 -0
  77. floodmodeller_api/test/test_data/conveyance_test.feb +116 -0
  78. floodmodeller_api/test/test_data/conveyance_test.gxy +85 -0
  79. floodmodeller_api/test/test_data/defaultUnits.dat +127 -127
  80. floodmodeller_api/test/test_data/defaultUnits.ext +45 -45
  81. floodmodeller_api/test/test_data/defaultUnits.feb +9 -9
  82. floodmodeller_api/test/test_data/defaultUnits.fmpx +58 -58
  83. floodmodeller_api/test/test_data/defaultUnits.gxy +85 -85
  84. floodmodeller_api/test/test_data/ex3.ief +20 -20
  85. floodmodeller_api/test/test_data/ex3.lf1 +2800 -2800
  86. floodmodeller_api/test/test_data/ex4.DAT +1374 -1374
  87. floodmodeller_api/test/test_data/ex4_changed.DAT +1374 -1374
  88. floodmodeller_api/test/test_data/example1.inp +329 -329
  89. floodmodeller_api/test/test_data/example2.inp +158 -158
  90. floodmodeller_api/test/test_data/example3.inp +297 -297
  91. floodmodeller_api/test/test_data/example4.inp +388 -388
  92. floodmodeller_api/test/test_data/example5.inp +147 -147
  93. floodmodeller_api/test/test_data/example6.inp +154 -154
  94. floodmodeller_api/test/test_data/expected_conveyance.csv +60 -0
  95. floodmodeller_api/test/test_data/jump.dat +176 -176
  96. floodmodeller_api/test/test_data/network.dat +1374 -1374
  97. floodmodeller_api/test/test_data/network.ext +45 -45
  98. floodmodeller_api/test/test_data/network.exy +1 -1
  99. floodmodeller_api/test/test_data/network.feb +45 -45
  100. floodmodeller_api/test/test_data/network.ied +45 -45
  101. floodmodeller_api/test/test_data/network.ief +20 -20
  102. floodmodeller_api/test/test_data/network.inp +147 -147
  103. floodmodeller_api/test/test_data/network.pxy +57 -57
  104. floodmodeller_api/test/test_data/network.zzd +122 -122
  105. floodmodeller_api/test/test_data/network_dat_expected.json +21837 -0
  106. floodmodeller_api/test/test_data/network_from_tabularCSV.csv +87 -87
  107. floodmodeller_api/test/test_data/network_ied_expected.json +287 -0
  108. floodmodeller_api/test/test_data/rnweir.dat +9 -9
  109. floodmodeller_api/test/test_data/rnweir.ext +45 -45
  110. floodmodeller_api/test/test_data/rnweir.feb +9 -9
  111. floodmodeller_api/test/test_data/rnweir.gxy +45 -45
  112. floodmodeller_api/test/test_data/rnweir_default.dat +74 -74
  113. floodmodeller_api/test/test_data/rnweir_default.ext +45 -45
  114. floodmodeller_api/test/test_data/rnweir_default.feb +9 -9
  115. floodmodeller_api/test/test_data/rnweir_default.fmpx +58 -58
  116. floodmodeller_api/test/test_data/rnweir_default.gxy +53 -53
  117. floodmodeller_api/test/test_data/unit checks.dat +16 -16
  118. floodmodeller_api/test/test_ied.py +29 -29
  119. floodmodeller_api/test/test_ief.py +136 -24
  120. floodmodeller_api/test/test_inp.py +47 -48
  121. floodmodeller_api/test/test_json.py +114 -0
  122. floodmodeller_api/test/test_logs_lf.py +102 -51
  123. floodmodeller_api/test/test_tool.py +165 -152
  124. floodmodeller_api/test/test_toolbox_structure_log.py +234 -239
  125. floodmodeller_api/test/test_xml2d.py +151 -156
  126. floodmodeller_api/test/test_zzn.py +36 -34
  127. floodmodeller_api/to_from_json.py +230 -0
  128. floodmodeller_api/tool.py +332 -329
  129. floodmodeller_api/toolbox/__init__.py +5 -5
  130. floodmodeller_api/toolbox/example_tool.py +45 -45
  131. floodmodeller_api/toolbox/model_build/__init__.py +2 -2
  132. floodmodeller_api/toolbox/model_build/add_siltation_definition.py +100 -98
  133. floodmodeller_api/toolbox/model_build/structure_log/__init__.py +1 -1
  134. floodmodeller_api/toolbox/model_build/structure_log/structure_log.py +287 -289
  135. floodmodeller_api/toolbox/model_build/structure_log_definition.py +76 -76
  136. floodmodeller_api/units/__init__.py +10 -10
  137. floodmodeller_api/units/_base.py +214 -212
  138. floodmodeller_api/units/boundaries.py +467 -467
  139. floodmodeller_api/units/comment.py +52 -55
  140. floodmodeller_api/units/conduits.py +382 -402
  141. floodmodeller_api/units/conveyance.py +301 -0
  142. floodmodeller_api/units/helpers.py +123 -131
  143. floodmodeller_api/units/iic.py +107 -101
  144. floodmodeller_api/units/losses.py +305 -306
  145. floodmodeller_api/units/sections.py +465 -446
  146. floodmodeller_api/units/structures.py +1690 -1683
  147. floodmodeller_api/units/units.py +93 -104
  148. floodmodeller_api/units/unsupported.py +44 -44
  149. floodmodeller_api/units/variables.py +87 -89
  150. floodmodeller_api/urban1d/__init__.py +11 -11
  151. floodmodeller_api/urban1d/_base.py +188 -179
  152. floodmodeller_api/urban1d/conduits.py +93 -85
  153. floodmodeller_api/urban1d/general_parameters.py +58 -58
  154. floodmodeller_api/urban1d/junctions.py +81 -79
  155. floodmodeller_api/urban1d/losses.py +81 -74
  156. floodmodeller_api/urban1d/outfalls.py +114 -110
  157. floodmodeller_api/urban1d/raingauges.py +111 -111
  158. floodmodeller_api/urban1d/subsections.py +92 -98
  159. floodmodeller_api/urban1d/xsections.py +147 -144
  160. floodmodeller_api/util.py +119 -21
  161. floodmodeller_api/validation/parameters.py +660 -660
  162. floodmodeller_api/validation/urban_parameters.py +388 -404
  163. floodmodeller_api/validation/validation.py +110 -108
  164. floodmodeller_api/version.py +1 -1
  165. floodmodeller_api/xml2d.py +632 -673
  166. floodmodeller_api/xml2d_template.py +37 -37
  167. floodmodeller_api/zzn.py +414 -363
  168. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/LICENSE.txt +13 -13
  169. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/METADATA +85 -82
  170. floodmodeller_api-0.4.4.dist-info/RECORD +185 -0
  171. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/WHEEL +1 -1
  172. floodmodeller_api/libifcoremd.dll +0 -0
  173. floodmodeller_api/test/test_data/EX3.bmp +0 -0
  174. floodmodeller_api/test/test_data/test_output.csv +0 -87
  175. floodmodeller_api/zzn_read.dll +0 -0
  176. floodmodeller_api-0.4.2.post1.dist-info/RECORD +0 -164
  177. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/entry_points.txt +0 -0
  178. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/top_level.txt +0 -0
floodmodeller_api/ief.py CHANGED
@@ -1,637 +1,553 @@
1
- """
2
- Flood Modeller Python API
3
- Copyright (C) 2023 Jacobs U.K. Limited
4
-
5
- This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License
6
- as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
7
-
8
- This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
9
- of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
10
-
11
- You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
12
-
13
- If you have any query about this program or this License, please contact us at support@floodmodeller.com or write to the following
14
- address: Jacobs UK Limited, Flood Modeller, Cottons Centre, Cottons Lane, London, SE1 2QG, United Kingdom.
15
- """
16
-
17
- import datetime as dt
18
- import os
19
- import subprocess
20
- import time
21
- from pathlib import Path
22
- from subprocess import Popen
23
- from typing import Callable, Optional, Union
24
-
25
- import pandas as pd
26
- from tqdm import trange
27
-
28
- from ._base import FMFile
29
- from .ief_flags import flags
30
- from .logs import lf_factory
31
- from .zzn import ZZN
32
-
33
-
34
- class IEF(FMFile):
35
- """Reads and write Flood Modeller event file format '.ief'
36
-
37
- Args:
38
- ief_filepath (str, optional): Full filepath to ief file. If not specified, a new IEF class will be created.. Defaults to None.
39
-
40
- Raises:
41
- TypeError: Raised if ief_filepath not pointing to valide IEF file
42
- FileNotFoundError: Raised if ief_filepath points to a non-existent location
43
-
44
- Output:
45
- Initiates 'IEF' class object
46
- """
47
-
48
- _filetype: str = "IEF"
49
- _suffix: str = ".ief"
50
-
51
- def __init__(self, ief_filepath: Optional[Union[str, Path]] = None):
52
- try:
53
- if ief_filepath is not None:
54
- FMFile.__init__(self, ief_filepath)
55
-
56
- self._read()
57
-
58
- else:
59
- self._create_from_blank()
60
- except Exception as e:
61
- self._handle_exception(e, when="read")
62
-
63
- def _read(self):
64
- # Read IEF data
65
- with open(self._filepath, "r") as ief_file:
66
- raw_data = [line.rstrip("\n") for line in ief_file.readlines()]
67
- # Clean data and add as class properties
68
- # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
69
- prev_comment = None
70
- self._ief_properties = []
71
- for line in raw_data:
72
- # Handle any comments here (prefixed with ;)
73
- if line.lstrip().startswith(";"):
74
- self._ief_properties.append(line) # Add comment in raw state
75
- prev_comment = line.strip(";")
76
-
77
- elif "=" in line:
78
- # Using strip() method to remove any leading/trailing whitespace
79
- prop, value = [itm.strip() for itm in line.split("=", 1)]
80
- # Handle 'EventData' properties so that multiple can be set
81
- if prop.upper() == "EVENTDATA":
82
- if prev_comment is None:
83
- try:
84
- event_data_title = Path(value).stem
85
- except Exception:
86
- event_data_title = value
87
- else:
88
- event_data_title = prev_comment
89
- if hasattr(self, "EventData"):
90
- # Append event data to list so multiple can be specified
91
- self.EventData[event_data_title] = value
92
- else:
93
- self.EventData = {event_data_title: value}
94
- self._ief_properties.append("EventData")
95
-
96
- else:
97
- # Sets the property and value as class properties so they can be edited.
98
- setattr(self, prop, value)
99
- self._ief_properties.append(prop)
100
- prev_comment = None
101
- else:
102
- # This should add the [] bound headers
103
- self._ief_properties.append(line)
104
- prev_comment = None
105
- del raw_data
106
-
107
- def _write(self) -> str:
108
- """Returns string representation of the current IEF data
109
-
110
- Returns:
111
- str: Full string representation of IEF in its most recent state (including changes not yet saved to disk)
112
- """
113
- try:
114
- # update _ief_properties
115
- self._update_ief_properties()
116
-
117
- ief_string = ""
118
- event = 0 # Used as a counter for multiple eventdata files
119
- for idx, prop in enumerate(self._ief_properties):
120
- if prop.startswith("["):
121
- # writes the [] bound headers to ief string
122
- ief_string += prop + "\n"
123
- elif prop.lstrip().startswith(";"):
124
- if not self._ief_properties[idx + 1].lower() == "eventdata":
125
- # Only write comment if not preceding event data
126
- ief_string += prop + "\n"
127
- elif prop.lower() == "eventdata":
128
- event_data = getattr(self, prop)
129
- # Add multiple EventData if present
130
- for event_idx, key in enumerate(event_data):
131
- if event_idx == event:
132
- ief_string += f";{key}\n{prop}={str(event_data[key])}\n"
133
- break
134
- event += 1
135
-
136
- else:
137
- # writes property and value to ief string
138
- ief_string += f"{prop}={str(getattr(self, prop))}\n"
139
- return ief_string
140
-
141
- except Exception as e:
142
- self._handle_exception(e, when="write")
143
-
144
- def _create_from_blank(self):
145
- # No filepath specified, create new 'blank' IEF in memory
146
- blank_ief = [
147
- "[ISIS Event Header]",
148
- 'Title=""',
149
- 'Datafile=""',
150
- 'Results=""',
151
- "[ISIS Event Details]",
152
- "RunType=Steady",
153
- "Start=0",
154
- "ICsFrom=1",
155
- ]
156
-
157
- # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
158
- self._ief_properties = []
159
- for line in blank_ief:
160
- if "=" in line:
161
- prop, value = line.split("=")
162
- # Sets the property and value as class properties so they can be edited.
163
- setattr(self, prop, value)
164
- self._ief_properties.append(prop)
165
- else:
166
- # This should add the [] bound headers
167
- self._ief_properties.append(line)
168
- del blank_ief
169
-
170
- def _update_ief_properties(self): # noqa: C901
171
- """Updates the list of properties included in the IEF file"""
172
- # Add new properties
173
- for prop, val in self.__dict__.copy().items():
174
- if (prop not in self._ief_properties) and (not prop.startswith("_")) and prop != "file":
175
- # Check if valid flag
176
- if prop.upper() not in flags:
177
- print(
178
- f"Warning: '{prop}' is not a valid IEF flag, it will be ommited from the IEF\n"
179
- )
180
- continue
181
-
182
- if prop.upper() == "EVENTDATA":
183
- # This will be triggered in special case where eventdata has been added with different case, but case
184
- # needs to be kept as 'EventData', to allow dealing wiht multiple IEDs
185
- if prop != "EventData":
186
- # In case of EventData being added with correct case where it doesn't already
187
- # exist, this stops it being deleted
188
- # Add new values to EventData flag
189
- delattr(self, prop)
190
- setattr(self, "EventData", val)
191
- prop = "EventData"
192
-
193
- # Check ief group header
194
- group = f"[{flags[prop.upper()]}]"
195
- if group in self._ief_properties:
196
- # If group already exists, add property to end of group
197
- group_idx = False
198
- # defaults to inserting in last place
199
- insert_index = len(self._ief_properties)
200
- for idx, item in enumerate(self._ief_properties):
201
- if group_idx is True and item.startswith("["):
202
- insert_index = idx
203
- break
204
- if item == group:
205
- group_idx = True
206
-
207
- self._ief_properties.insert(insert_index, prop)
208
- else:
209
- # Add group header to the end of list
210
- self._ief_properties.append(group)
211
- # Add property to end of list
212
- self._ief_properties.append(prop)
213
-
214
- # Remove any deleted properties
215
- self._ief_properties = [
216
- line
217
- for line in self._ief_properties
218
- if (line.startswith("[") or (line in dir(self)) or line.lstrip().startswith(";"))
219
- ]
220
-
221
- # Rearrange order of Flow Time Profiles group if present * Currently assuming all relevent flags included
222
- if "[Flow Time Profiles]" in self._ief_properties:
223
- self._update_flowtimeprofile_info()
224
-
225
- # Ensure number of EventData entries is equal to length of EventData attribute
226
- if hasattr(self, "EventData"):
227
- self._update_eventdata_info()
228
-
229
- def _update_eventdata_info(self): # noqa: C901
230
- if not isinstance(self.EventData, dict):
231
- # If attribute not a dict, adds the value as a single entry in list
232
- raise AttributeError(
233
- "The 'EventData' attribute should be a dictionary with keys defining the event"
234
- + " names and values referencing the IED files"
235
- )
236
-
237
- # Number of 'EventData' flags in ief
238
- event_properties = self._ief_properties.count("EventData")
239
- # Number of event data specified in class
240
- events = len(self.EventData)
241
- if event_properties < events:
242
- # Need to add additional event properties to IEF to match number of events specified
243
- to_add = events - event_properties
244
- # Used for if no existing eventdata exists
245
- insert_index = len(self._ief_properties)
246
- for idx, itm in enumerate(reversed(self._ief_properties)):
247
- if itm in ("EventData", "[ISIS Event Details]"):
248
- insert_index = len(self._ief_properties) - idx
249
- break
250
-
251
- for _ in range(to_add):
252
- # Add in required number of extra EventData after last one.
253
- self._ief_properties.insert(insert_index, "EventData")
254
-
255
- elif event_properties > events:
256
- # Need to remove some event properties from IEF to match number of events specified
257
- to_remove = event_properties - events
258
- removed = 0 # Counter for number removed
259
- num_props = len(self._ief_properties)
260
- for idx, itm in enumerate(reversed(self._ief_properties)):
261
- if itm == "EventData":
262
- del self._ief_properties[num_props - 1 - idx]
263
- # Also remove event data title comment if present
264
- if self._ief_properties[num_props - 2 - idx].lstrip().startswith(";"):
265
- del self._ief_properties[num_props - 2 - idx]
266
- removed += 1
267
- if removed == to_remove:
268
- break
269
-
270
- def _update_flowtimeprofile_info(self):
271
- end_index = None
272
- start_index = self._ief_properties.index("[Flow Time Profiles]")
273
- for idx, item in enumerate(self._ief_properties[start_index:]):
274
- if idx != 0 and item.startswith("["):
275
- end_index = idx + start_index
276
- break
277
- flow_time_list = self._ief_properties[start_index:end_index]
278
- flow_time_list = [
279
- "[Flow Time Profiles]",
280
- "NoOfFlowTimeProfiles",
281
- "NoOfFlowTimeSeries",
282
- ] + [i for i in flow_time_list if i.lower().startswith("flowtimeprofile")]
283
-
284
- # sort list to ensure the flow time profiles are in order
285
- def flow_sort(itm):
286
- try:
287
- num = int(itm.upper().replace("FLOWTIMEPROFILE", ""))
288
- return (1, num)
289
- except ValueError:
290
- return (0, itm)
291
-
292
- flow_time_list[3:] = sorted(flow_time_list[3:], key=flow_sort)
293
-
294
- # Replace existing slice of ief properties with new reordered slice
295
- self._ief_properties[start_index:end_index] = flow_time_list
296
-
297
- # Update NoOfFlowTimeSeries
298
- self.NoOfFlowTimeProfiles = str(len(flow_time_list[3:]))
299
-
300
- def __getattr__(self, name):
301
- for attr in self.__dict__.copy():
302
- if name.lower() == attr.lower():
303
- return self.__dict__[attr]
304
- return self.__getattribute__(name)
305
-
306
- def __setattr__(self, name, value):
307
- existing_attr_updated = False
308
- for attr in self.__dict__.copy():
309
- if name.lower() == attr.lower():
310
- self.__dict__[attr] = value
311
- existing_attr_updated = True
312
-
313
- if not existing_attr_updated:
314
- self.__dict__[name] = value
315
-
316
- def __delattr__(self, name):
317
- existing_attr_deleted = False
318
- for attr in self.__dict__.copy():
319
- if name.lower() == attr.lower():
320
- super().__delattr__(attr)
321
- existing_attr_deleted = True
322
-
323
- if not existing_attr_deleted:
324
- super().__delattr__(name)
325
-
326
- def diff(self, other: "IEF", force_print: bool = False) -> None:
327
- """Compares the IEF class against another IEF class to check whether they are
328
- equivalent, or if not, what the differences are. Two instances of an IEF class are
329
- deemed equivalent if all of their attributes are equal except for the filepath and
330
- raw data.
331
-
332
- The result is printed to the console. If you need to access the returned data, use
333
- the method ``IEF._get_diff()``
334
-
335
- Args:
336
- other (floodmodeller_api.IEF): Other instance of an IEF class
337
- force_print (bool): Forces the API to print every difference found, rather than
338
- just the first 25 differences. Defaults to False.
339
- """
340
- self._diff(other, force_print=force_print)
341
-
342
- def update(self) -> None:
343
- """Updates the existing IEF based on any altered attributes"""
344
- self._update()
345
-
346
- def save(self, filepath: Union[str, Path]) -> None:
347
- """Saves the IEF to the given location, if pointing to an existing file it will be overwritten.
348
- Once saved, the IEF() class will continue working from the saved location, therefore any further calls to IEF.update() will update in the latest saved location
349
- rather than the original source IEF used to construct the class
350
-
351
- Args:
352
- filepath (string): Full filepath to new location for ief file (including '.ief' extension)
353
- """
354
- self._save(filepath)
355
-
356
- def simulate( # noqa: C901
357
- self,
358
- method: str = "WAIT",
359
- raise_on_failure: bool = True,
360
- precision: str = "DEFAULT",
361
- enginespath: str = "",
362
- range_function: Callable = trange,
363
- range_settings: Optional[dict] = None,
364
- ) -> Optional[subprocess.Popen]:
365
- """Simulate the IEF file directly as a subprocess
366
-
367
- Args:
368
- method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
369
- 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
370
- 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
371
- Defaults to 'WAIT'.
372
- raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
373
- If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
374
- then this argument is ignored. Defaults to True.
375
- precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
376
- Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
377
- this can be overwritten using 'SINGLE' or 'DOUBLE'.
378
- enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
379
- Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
380
- anything other than ''.
381
-
382
- Raises:
383
- UserWarning: Raised if ief filepath not already specified
384
-
385
- Returns:
386
- subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
387
- """
388
- try:
389
- self._range_function = range_function
390
- self._range_settings = range_settings if range_settings else {}
391
- if self._filepath is None:
392
- raise UserWarning(
393
- "IEF must be saved to a specific filepath before simulate() can be called."
394
- )
395
- if precision.upper() == "DEFAULT":
396
- precision = "SINGLE" # Defaults to single...
397
- for attr in dir(self):
398
- if attr.upper() == "LAUNCHDOUBLEPRECISIONVERSION": # Unless DP specified
399
- if getattr(self, attr) == "1":
400
- precision = "DOUBLE"
401
- break
402
-
403
- if enginespath == "":
404
- _enginespath = r"C:\Program Files\Flood Modeller\bin" # Default location
405
- else:
406
- _enginespath = enginespath
407
- if not Path(_enginespath).exists():
408
- raise Exception(
409
- f"Flood Modeller non-default engine path not found! {str(_enginespath)}"
410
- )
411
-
412
- if precision.upper() == "SINGLE":
413
- isis32_fp = str(Path(_enginespath, "ISISf32.exe"))
414
- else:
415
- isis32_fp = str(Path(_enginespath, "ISISf32_DoubleP.exe"))
416
-
417
- if not Path(isis32_fp).exists():
418
- raise Exception(f"Flood Modeller engine not found! Expected location: {isis32_fp}")
419
-
420
- run_command = f'"{isis32_fp}" -sd "{self._filepath}"'
421
-
422
- if method.upper() == "WAIT":
423
- print("Executing simulation...")
424
- # execute simulation
425
- process = Popen(run_command, cwd=os.path.dirname(self._filepath))
426
-
427
- # progress bar based on log files
428
- self._init_log_file()
429
- self._update_progress_bar(process)
430
-
431
- while process.poll() is None:
432
- # Process still running
433
- time.sleep(1)
434
-
435
- result, summary = self._summarise_exy()
436
-
437
- if result == 1 and raise_on_failure:
438
- raise RuntimeError(summary)
439
- print(summary)
440
-
441
- elif method.upper() == "RETURN_PROCESS":
442
- print("Executing simulation...")
443
- # execute simulation
444
- process = Popen(run_command, cwd=os.path.dirname(self._filepath))
445
- return process
446
-
447
- return None
448
-
449
- except Exception as e:
450
- self._handle_exception(e, when="simulate")
451
-
452
- def _get_result_filepath(self, suffix):
453
- if hasattr(self, "Results"):
454
- path = Path(self.Results).with_suffix("." + suffix)
455
- if not path.is_absolute():
456
- # set cwd to ief location and resolve path
457
- path = Path(self._filepath.parent, path).resolve()
458
-
459
- else:
460
- path = self._filepath.with_suffix("." + suffix)
461
-
462
- return path
463
-
464
- def get_results(self) -> ZZN:
465
- """If results for the simulation exist, this function returns them as a ZZN class object
466
-
467
- Returns:
468
- floodmodeller_api.ZZN class object
469
- """
470
-
471
- # Get zzn location
472
- result_path = self._get_result_filepath(suffix="zzn")
473
-
474
- if not result_path.exists():
475
- raise FileNotFoundError("Simulation results file (zzn) not found")
476
-
477
- return ZZN(result_path)
478
-
479
- def get_log(self):
480
- """If log files for the simulation exist, this function returns them as a LF1 class object
481
-
482
- Returns:
483
- floodmodeller_api.LF1 class object
484
- """
485
-
486
- suffix, steady = self._determine_lf_type()
487
-
488
- # Get lf location
489
- lf_path = self._get_result_filepath(suffix)
490
-
491
- if not lf_path.exists():
492
- raise FileNotFoundError("Log file (" + suffix + ") not found")
493
-
494
- return lf_factory(lf_path, suffix, steady)
495
-
496
- def _determine_lf_type(self): # (str, bool) or (None, None):
497
- """Determine the log file type"""
498
-
499
- if self.RunType == "Unsteady":
500
- suffix = "lf1"
501
- steady = False
502
-
503
- elif self.RunType == "Steady":
504
- suffix = "lf1"
505
- steady = True
506
-
507
- else:
508
- raise ValueError(f'Unexpected run type "{self.RunType}"')
509
-
510
- return suffix, steady
511
-
512
- def _init_log_file(self):
513
- """Checks for a new log file, waiting for its creation if necessary"""
514
-
515
- # determine log file type based on self.RunType
516
- try:
517
- suffix, steady = self._determine_lf_type()
518
- except ValueError:
519
- self._no_log_file(f'run type "{self.RunType}" not supported')
520
- self._lf = None
521
- return
522
-
523
- # ensure progress bar is supported for that type
524
- if not (suffix == "lf1" and steady is False):
525
- self._no_log_file("only 1D unsteady runs are supported")
526
- self._lf = None
527
- return
528
-
529
- # find what log filepath should be
530
- lf_filepath = self._get_result_filepath(suffix)
531
-
532
- # wait for log file to exist
533
- log_file_exists = False
534
- max_time = time.time() + 10
535
-
536
- while not log_file_exists:
537
- time.sleep(0.1)
538
-
539
- log_file_exists = lf_filepath.is_file()
540
-
541
- # timeout
542
- if time.time() > max_time:
543
- self._no_log_file("log file is expected but not detected")
544
- self._lf = None
545
- return
546
-
547
- # wait for new log file
548
- old_log_file = True
549
- max_time = time.time() + 10
550
-
551
- while old_log_file:
552
- time.sleep(0.1)
553
-
554
- # difference between now and when log file was last modified
555
- last_modified_timestamp = lf_filepath.stat().st_mtime
556
- last_modified = dt.datetime.fromtimestamp(last_modified_timestamp)
557
- time_diff_sec = (dt.datetime.now() - last_modified).total_seconds()
558
-
559
- # it's old if it's over 5 seconds old (TODO: is this robust?)
560
- old_log_file = time_diff_sec > 5
561
-
562
- # timeout
563
- if time.time() > max_time:
564
- self._no_log_file("log file is from previous run")
565
- self._lf = None
566
- return
567
-
568
- # create LF instance
569
- self._lf = lf_factory(lf_filepath, suffix, steady)
570
-
571
- def _no_log_file(self, reason):
572
- """Warning that there will be no progress bar"""
573
-
574
- print("No progress bar as " + reason + ". Simulation will continue as usual.")
575
-
576
- def _update_progress_bar(self, process: Popen):
577
- """Updates progress bar based on log file"""
578
-
579
- # only if there is a log file
580
- if self._lf is None:
581
- return
582
-
583
- # tqdm progress bar
584
- for i in self._range_function(100, **self._range_settings):
585
- # Process still running
586
- while process.poll() is None:
587
- time.sleep(0.1)
588
-
589
- # Find progress
590
- self._lf.read(suppress_final_step=True)
591
- progress = self._lf.report_progress()
592
-
593
- # Reached i% progress => move onto waiting for (i+1)%
594
- if progress > i:
595
- break
596
-
597
- # Process stopped
598
- if process.poll() is not None:
599
- # Find final progress
600
- self._lf.read(suppress_final_step=True)
601
- progress = self._lf.report_progress()
602
-
603
- if progress > i:
604
- pass # stopped because it completed
605
- else:
606
- break # stopped for another reason
607
-
608
- def _summarise_exy(self):
609
- """Reads and summarises associated exy file if available"""
610
-
611
- # Get results location
612
- if hasattr(self, "Results"):
613
- exy_path = Path(self.Results).with_suffix(".exy")
614
- if not exy_path.is_absolute():
615
- # set cwd to ief location and resolve path
616
- exy_path = Path(self._filepath.parent, exy_path).resolve()
617
-
618
- else:
619
- exy_path = self._filepath.with_suffix(".exy")
620
-
621
- if not exy_path.exists():
622
- raise FileNotFoundError("Simulation results error log (.exy) not found")
623
-
624
- exy_data = pd.read_csv(exy_path, names=["node", "timestep", "severity", "code", "summary"])
625
- exy_data["type"] = exy_data["code"].apply(
626
- lambda x: "Error" if x < 2000 else ("Warning" if x < 3000 else "Note")
627
- )
628
- errors = len(exy_data[exy_data["type"] == "Error"])
629
- warnings = len(exy_data[exy_data["type"] == "Warning"])
630
- notes = len(exy_data[exy_data["type"] == "Note"])
631
-
632
- details = f"({errors} Error(s), {warnings} Warning(s), {notes} Note(s) ) - Check ZZD for more details."
633
-
634
- if errors > 0:
635
- return 1, f"Simulation Failed! - {details}"
636
-
637
- return 0, f"Simulation Completed! - {details}"
1
+ """
2
+ Flood Modeller Python API
3
+ Copyright (C) 2024 Jacobs U.K. Limited
4
+
5
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License
6
+ as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
7
+
8
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
9
+ of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
10
+
11
+ You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
12
+
13
+ If you have any query about this program or this License, please contact us at support@floodmodeller.com or write to the following
14
+ address: Jacobs UK Limited, Flood Modeller, Cottons Centre, Cottons Lane, London, SE1 2QG, United Kingdom.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import subprocess
21
+ import time
22
+ from pathlib import Path
23
+ from subprocess import Popen
24
+ from typing import Callable
25
+
26
+ import pandas as pd
27
+ from tqdm import trange
28
+
29
+ from ._base import FMFile
30
+ from .ief_flags import flags
31
+ from .logs import LF1, create_lf
32
+ from .util import handle_exception
33
+ from .zzn import ZZN
34
+
35
+
36
+ class IEF(FMFile):
37
+ """Reads and write Flood Modeller event file format '.ief'
38
+
39
+ Args:
40
+ ief_filepath (str, optional): Full filepath to ief file. If not specified, a new IEF class will be created.. Defaults to None.
41
+
42
+ Raises:
43
+ TypeError: Raised if ief_filepath not pointing to valide IEF file
44
+ FileNotFoundError: Raised if ief_filepath points to a non-existent location
45
+
46
+ Output:
47
+ Initiates 'IEF' class object
48
+ """
49
+
50
+ _filetype: str = "IEF"
51
+ _suffix: str = ".ief"
52
+ ERROR_MAX = 2000
53
+ WARNING_MAX = 3000
54
+
55
+ @handle_exception(when="read")
56
+ def __init__(self, ief_filepath: str | Path | None = None, from_json: bool = False):
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()
65
+
66
+ def _read(self):
67
+ # Read IEF data
68
+ with open(self._filepath) as ief_file:
69
+ raw_data = [line.rstrip("\n") for line in ief_file.readlines()]
70
+ # Clean data and add as class properties
71
+ # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
72
+ prev_comment = None
73
+ self._ief_properties = []
74
+ for line in raw_data:
75
+ # Handle any comments here (prefixed with ;)
76
+ if line.lstrip().startswith(";"):
77
+ self._ief_properties.append(line) # Add comment in raw state
78
+ prev_comment = line.strip(";")
79
+
80
+ elif "=" in line:
81
+ # Using strip() method to remove any leading/trailing whitespace
82
+ prop, value = (itm.strip() for itm in line.split("=", 1))
83
+ # Handle 'EventData' properties so that multiple can be set
84
+ if prop.upper() == "EVENTDATA":
85
+ if prev_comment is None:
86
+ try:
87
+ event_data_title = Path(value).stem
88
+ except Exception:
89
+ event_data_title = value
90
+ else:
91
+ event_data_title = prev_comment
92
+ if hasattr(self, "EventData"):
93
+ # Append event data to list so multiple can be specified
94
+ self.EventData[event_data_title] = value
95
+ else:
96
+ self.EventData = {event_data_title: value}
97
+ self._ief_properties.append("EventData")
98
+
99
+ else:
100
+ # Sets the property and value as class properties so they can be edited.
101
+ setattr(self, prop, value)
102
+ self._ief_properties.append(prop)
103
+ prev_comment = None
104
+ else:
105
+ # This should add the [] bound headers
106
+ self._ief_properties.append(line)
107
+ prev_comment = None
108
+ del raw_data
109
+
110
+ @handle_exception(when="write")
111
+ def _write(self) -> str:
112
+ """Returns string representation of the current IEF data
113
+
114
+ Returns:
115
+ str: Full string representation of IEF in its most recent state (including changes not yet saved to disk)
116
+ """
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
129
+ ief_string += prop + "\n"
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
138
+
139
+ else:
140
+ # writes property and value to ief string
141
+ ief_string += f"{prop}={str(getattr(self, prop))}\n"
142
+ return ief_string
143
+
144
+ def _create_from_blank(self):
145
+ # No filepath specified, create new 'blank' IEF in memory
146
+ blank_ief = [
147
+ "[ISIS Event Header]",
148
+ 'Title=""',
149
+ 'Datafile=""',
150
+ 'Results=""',
151
+ "[ISIS Event Details]",
152
+ "RunType=Steady",
153
+ "Start=0",
154
+ "ICsFrom=1",
155
+ ]
156
+
157
+ # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
158
+ self._ief_properties = []
159
+ for line in blank_ief:
160
+ if "=" in line:
161
+ prop, value = line.split("=")
162
+ # Sets the property and value as class properties so they can be edited.
163
+ setattr(self, prop, value)
164
+ self._ief_properties.append(prop)
165
+ else:
166
+ # This should add the [] bound headers
167
+ self._ief_properties.append(line)
168
+ del blank_ief
169
+
170
+ def _update_ief_properties(self): # noqa: C901
171
+ """Updates the list of properties included in the IEF file"""
172
+ # Add new properties
173
+ for prop, val in self.__dict__.copy().items():
174
+ if (prop not in self._ief_properties) and (not prop.startswith("_")) and prop != "file":
175
+ # Check if valid flag
176
+ if prop.upper() not in flags:
177
+ print(
178
+ f"Warning: '{prop}' is not a valid IEF flag, it will be ommited from the IEF\n",
179
+ )
180
+ continue
181
+
182
+ if prop.upper() == "EVENTDATA" and prop != "EventData":
183
+ # (1) This will be triggered in special case where eventdata has been added with different case, but case
184
+ # needs to be kept as 'EventData', to allow dealing wiht multiple IEDs
185
+ # (2) In case of EventData being added with correct case where it doesn't already
186
+ # exist, this stops it being deleted
187
+ # Add new values to EventData flag
188
+ delattr(self, prop)
189
+ self.EventData = val
190
+ prop = "EventData"
191
+
192
+ # Check ief group header
193
+ group = f"[{flags[prop.upper()]}]"
194
+ if group in self._ief_properties:
195
+ # If group already exists, add property to end of group
196
+ group_idx = False
197
+ # defaults to inserting in last place
198
+ insert_index = len(self._ief_properties)
199
+ for idx, item in enumerate(self._ief_properties):
200
+ if group_idx is True and item.startswith("["):
201
+ insert_index = idx
202
+ break
203
+ if item == group:
204
+ group_idx = True
205
+
206
+ self._ief_properties.insert(insert_index, prop)
207
+ else:
208
+ # Add group header to the end of list
209
+ self._ief_properties.append(group)
210
+ # Add property to end of list
211
+ self._ief_properties.append(prop)
212
+
213
+ # Remove any deleted properties
214
+ self._ief_properties = [
215
+ line
216
+ for line in self._ief_properties
217
+ if (line.startswith("[") or (line in dir(self)) or line.lstrip().startswith(";"))
218
+ ]
219
+
220
+ # Rearrange order of Flow Time Profiles group if present * Currently assuming all relevent flags included
221
+ if "[Flow Time Profiles]" in self._ief_properties:
222
+ self._update_flowtimeprofile_info()
223
+
224
+ # Ensure number of EventData entries is equal to length of EventData attribute
225
+ if hasattr(self, "EventData"):
226
+ self._update_eventdata_info()
227
+
228
+ def _update_eventdata_info(self): # noqa: C901
229
+ if not isinstance(self.EventData, dict):
230
+ # If attribute not a dict, adds the value as a single entry in list
231
+ raise AttributeError(
232
+ "The 'EventData' attribute should be a dictionary with keys defining the event"
233
+ " names and values referencing the IED files",
234
+ )
235
+
236
+ # Number of 'EventData' flags in ief
237
+ event_properties = self._ief_properties.count("EventData")
238
+ # Number of event data specified in class
239
+ events = len(self.EventData)
240
+ if event_properties < events:
241
+ # Need to add additional event properties to IEF to match number of events specified
242
+ to_add = events - event_properties
243
+ # Used for if no existing eventdata exists
244
+ insert_index = len(self._ief_properties)
245
+ for idx, itm in enumerate(reversed(self._ief_properties)):
246
+ if itm in ("EventData", "[ISIS Event Details]"):
247
+ insert_index = len(self._ief_properties) - idx
248
+ break
249
+
250
+ for _ in range(to_add):
251
+ # Add in required number of extra EventData after last one.
252
+ self._ief_properties.insert(insert_index, "EventData")
253
+
254
+ elif event_properties > events:
255
+ # Need to remove some event properties from IEF to match number of events specified
256
+ to_remove = event_properties - events
257
+ removed = 0 # Counter for number removed
258
+ num_props = len(self._ief_properties)
259
+ for idx, itm in enumerate(reversed(self._ief_properties)):
260
+ if itm == "EventData":
261
+ del self._ief_properties[num_props - 1 - idx]
262
+ # Also remove event data title comment if present
263
+ if self._ief_properties[num_props - 2 - idx].lstrip().startswith(";"):
264
+ del self._ief_properties[num_props - 2 - idx]
265
+ removed += 1
266
+ if removed == to_remove:
267
+ break
268
+
269
+ def _update_flowtimeprofile_info(self):
270
+ end_index = None
271
+ start_index = self._ief_properties.index("[Flow Time Profiles]")
272
+ for idx, item in enumerate(self._ief_properties[start_index:]):
273
+ if idx != 0 and item.startswith("["):
274
+ end_index = idx + start_index
275
+ break
276
+ flow_time_list = self._ief_properties[start_index:end_index]
277
+ flow_time_list = [
278
+ "[Flow Time Profiles]",
279
+ "NoOfFlowTimeProfiles",
280
+ "NoOfFlowTimeSeries",
281
+ ] + [i for i in flow_time_list if i.lower().startswith("flowtimeprofile")]
282
+
283
+ # sort list to ensure the flow time profiles are in order
284
+ def flow_sort(itm):
285
+ try:
286
+ num = int(itm.upper().replace("FLOWTIMEPROFILE", ""))
287
+ return (1, num)
288
+ except ValueError:
289
+ return (0, itm)
290
+
291
+ flow_time_list[3:] = sorted(flow_time_list[3:], key=flow_sort)
292
+
293
+ # Replace existing slice of ief properties with new reordered slice
294
+ self._ief_properties[start_index:end_index] = flow_time_list
295
+
296
+ # Update NoOfFlowTimeSeries
297
+ self.NoOfFlowTimeProfiles = str(len(flow_time_list[3:]))
298
+
299
+ def __getattr__(self, name):
300
+ for attr in self.__dict__.copy():
301
+ if name.lower() == attr.lower():
302
+ return self.__dict__[attr]
303
+ return self.__getattribute__(name)
304
+
305
+ def __setattr__(self, name, value):
306
+ existing_attr_updated = False
307
+ for attr in self.__dict__.copy():
308
+ if name.lower() == attr.lower():
309
+ self.__dict__[attr] = value
310
+ existing_attr_updated = True
311
+
312
+ if not existing_attr_updated:
313
+ self.__dict__[name] = value
314
+
315
+ def __delattr__(self, name):
316
+ existing_attr_deleted = False
317
+ for attr in self.__dict__.copy():
318
+ if name.lower() == attr.lower():
319
+ super().__delattr__(attr)
320
+ existing_attr_deleted = True
321
+
322
+ if not existing_attr_deleted:
323
+ super().__delattr__(name)
324
+
325
+ def diff(self, other: IEF, force_print: bool = False) -> None:
326
+ """Compares the IEF class against another IEF class to check whether they are
327
+ equivalent, or if not, what the differences are. Two instances of an IEF class are
328
+ deemed equivalent if all of their attributes are equal except for the filepath and
329
+ raw data.
330
+
331
+ The result is printed to the console. If you need to access the returned data, use
332
+ the method ``IEF._get_diff()``
333
+
334
+ Args:
335
+ other (floodmodeller_api.IEF): Other instance of an IEF class
336
+ force_print (bool): Forces the API to print every difference found, rather than
337
+ just the first 25 differences. Defaults to False.
338
+ """
339
+ self._diff(other, force_print=force_print)
340
+
341
+ def update(self) -> None:
342
+ """Updates the existing IEF based on any altered attributes"""
343
+ self._update()
344
+
345
+ def save(self, filepath: str | Path) -> None:
346
+ """Saves the IEF to the given location, if pointing to an existing file it will be overwritten.
347
+ Once saved, the IEF() class will continue working from the saved location, therefore any further calls to IEF.update() will update in the latest saved location
348
+ rather than the original source IEF used to construct the class
349
+
350
+ Args:
351
+ filepath (string): Full filepath to new location for ief file (including '.ief' extension)
352
+ """
353
+ self._save(filepath)
354
+
355
+ @handle_exception(when="simulate")
356
+ def simulate( # noqa: C901, PLR0912, PLR0913
357
+ self,
358
+ method: str = "WAIT",
359
+ raise_on_failure: bool = True,
360
+ precision: str = "DEFAULT",
361
+ enginespath: str = "",
362
+ range_function: Callable = trange,
363
+ range_settings: dict | None = None,
364
+ ) -> subprocess.Popen | None:
365
+ """Simulate the IEF file directly as a subprocess
366
+
367
+ Args:
368
+ method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
369
+ 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
370
+ 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
371
+ Defaults to 'WAIT'.
372
+ raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
373
+ If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
374
+ then this argument is ignored. Defaults to True.
375
+ precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
376
+ Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
377
+ this can be overwritten using 'SINGLE' or 'DOUBLE'.
378
+ enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
379
+ Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
380
+ anything other than ''.
381
+
382
+ Raises:
383
+ UserWarning: Raised if ief filepath not already specified
384
+
385
+ Returns:
386
+ subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
387
+ """
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
403
+
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
+ )
412
+
413
+ if precision.upper() == "SINGLE":
414
+ isis32_fp = str(Path(_enginespath, "ISISf32.exe"))
415
+ else:
416
+ isis32_fp = str(Path(_enginespath, "ISISf32_DoubleP.exe"))
417
+
418
+ if not Path(isis32_fp).exists():
419
+ raise Exception(f"Flood Modeller engine not found! Expected location: {isis32_fp}")
420
+
421
+ run_command = f'"{isis32_fp}" -sd "{self._filepath}"'
422
+
423
+ if method.upper() == "WAIT":
424
+ print("Executing simulation...")
425
+ # execute simulation
426
+ process = Popen(run_command, cwd=os.path.dirname(self._filepath))
427
+
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)
432
+
433
+ while process.poll() is None:
434
+ # Process still running
435
+ time.sleep(1)
436
+
437
+ result, summary = self._summarise_exy()
438
+
439
+ if result == 1 and raise_on_failure:
440
+ raise RuntimeError(summary)
441
+ print(summary)
442
+
443
+ elif method.upper() == "RETURN_PROCESS":
444
+ print("Executing simulation...")
445
+ # execute simulation
446
+ return Popen(run_command, cwd=os.path.dirname(self._filepath))
447
+
448
+ return None
449
+
450
+ def _get_result_filepath(self, suffix):
451
+ if hasattr(self, "Results") and self.Results != '""': # because blank IEF has 'Results=""'
452
+ path = Path(self.Results).with_suffix("." + suffix)
453
+ if not path.is_absolute():
454
+ # set cwd to ief location and resolve path
455
+ path = Path(self._filepath.parent, path).resolve()
456
+
457
+ else:
458
+ path = self._filepath.with_suffix("." + suffix)
459
+
460
+ return path
461
+
462
+ def get_results(self) -> ZZN:
463
+ """If results for the simulation exist, this function returns them as a ZZN class object
464
+
465
+ Returns:
466
+ floodmodeller_api.ZZN class object
467
+ """
468
+
469
+ # Get zzn location
470
+ result_path = self._get_result_filepath(suffix="zzn")
471
+
472
+ if not result_path.exists():
473
+ raise FileNotFoundError("Simulation results file (zzn) not found")
474
+
475
+ return ZZN(result_path)
476
+
477
+ def get_log(self):
478
+ """If log files for the simulation exist, this function returns them as a LF1 class object
479
+
480
+ Returns:
481
+ floodmodeller_api.LF1 class object
482
+ """
483
+
484
+ if not self._log_path.exists():
485
+ raise FileNotFoundError("Log file (LF1) not found")
486
+
487
+ steady = self.RunType == "Steady"
488
+ return LF1(self._log_path, steady)
489
+
490
+ def _update_progress_bar(self, process: Popen):
491
+ """Updates progress bar based on log file"""
492
+
493
+ # only if there is a log file
494
+ if self._lf is None:
495
+ return
496
+
497
+ # tqdm progress bar
498
+ for i in self._range_function(100, **self._range_settings):
499
+ # Process still running
500
+ while process.poll() is None:
501
+ time.sleep(0.1)
502
+
503
+ # Find progress
504
+ self._lf.read(suppress_final_step=True)
505
+ progress = self._lf.report_progress()
506
+
507
+ # Reached i% progress => move onto waiting for (i+1)%
508
+ if progress > i:
509
+ break
510
+
511
+ # Process stopped
512
+ if process.poll() is not None:
513
+ # Find final progress
514
+ self._lf.read(suppress_final_step=True)
515
+ progress = self._lf.report_progress()
516
+
517
+ if progress > i:
518
+ pass # stopped because it completed
519
+ else:
520
+ break # stopped for another reason
521
+
522
+ def _summarise_exy(self):
523
+ """Reads and summarises associated exy file if available"""
524
+
525
+ # Get results location
526
+ if hasattr(self, "Results"):
527
+ exy_path = Path(self.Results).with_suffix(".exy")
528
+ if not exy_path.is_absolute():
529
+ # set cwd to ief location and resolve path
530
+ exy_path = Path(self._filepath.parent, exy_path).resolve()
531
+
532
+ else:
533
+ exy_path = self._filepath.with_suffix(".exy")
534
+
535
+ if not exy_path.exists():
536
+ raise FileNotFoundError("Simulation results error log (.exy) not found")
537
+
538
+ exy_data = pd.read_csv(exy_path, names=["node", "timestep", "severity", "code", "summary"])
539
+ exy_data["type"] = exy_data["code"].apply(
540
+ lambda x: (
541
+ "Error" if x < self.ERROR_MAX else ("Warning" if x < self.WARNING_MAX else "Note")
542
+ ),
543
+ )
544
+ errors = len(exy_data[exy_data["type"] == "Error"])
545
+ warnings = len(exy_data[exy_data["type"] == "Warning"])
546
+ notes = len(exy_data[exy_data["type"] == "Note"])
547
+
548
+ details = f"({errors} Error(s), {warnings} Warning(s), {notes} Note(s) ) - Check ZZD for more details."
549
+
550
+ if errors > 0:
551
+ return 1, f"Simulation Failed! - {details}"
552
+
553
+ return 0, f"Simulation Completed! - {details}"