floodmodeller-api 0.4.2.post1__py3-none-any.whl → 0.4.3__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 (172) hide show
  1. floodmodeller_api/__init__.py +8 -9
  2. floodmodeller_api/_base.py +184 -176
  3. floodmodeller_api/backup.py +273 -273
  4. floodmodeller_api/dat.py +909 -831
  5. floodmodeller_api/diff.py +136 -119
  6. floodmodeller_api/ied.py +307 -306
  7. floodmodeller_api/ief.py +647 -637
  8. floodmodeller_api/ief_flags.py +253 -253
  9. floodmodeller_api/inp.py +266 -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 +320 -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 +9 -8
  26. floodmodeller_api/test/test_backup.py +117 -117
  27. floodmodeller_api/test/test_dat.py +221 -92
  28. floodmodeller_api/test/test_data/All Units 4_6.DAT +1081 -1081
  29. floodmodeller_api/test/test_data/All Units 4_6.feb +1081 -1081
  30. floodmodeller_api/test/test_data/BRIDGE.DAT +926 -926
  31. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.dat +36 -36
  32. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.feb +36 -36
  33. floodmodeller_api/test/test_data/DamBreakADI.xml +52 -52
  34. floodmodeller_api/test/test_data/DamBreakFAST.xml +58 -58
  35. floodmodeller_api/test/test_data/DamBreakFAST_dy.xml +53 -53
  36. floodmodeller_api/test/test_data/DamBreakTVD.xml +55 -55
  37. floodmodeller_api/test/test_data/DefenceBreach.xml +53 -53
  38. floodmodeller_api/test/test_data/DefenceBreachFAST.xml +60 -60
  39. floodmodeller_api/test/test_data/DefenceBreachFAST_dy.xml +55 -55
  40. floodmodeller_api/test/test_data/Domain1+2_QH.xml +76 -76
  41. floodmodeller_api/test/test_data/Domain1_H.xml +41 -41
  42. floodmodeller_api/test/test_data/Domain1_Q.xml +41 -41
  43. floodmodeller_api/test/test_data/Domain1_Q_FAST.xml +48 -48
  44. floodmodeller_api/test/test_data/Domain1_Q_FAST_dy.xml +48 -48
  45. floodmodeller_api/test/test_data/Domain1_Q_xml_expected.json +263 -0
  46. floodmodeller_api/test/test_data/Domain1_W.xml +41 -41
  47. floodmodeller_api/test/test_data/EX1.DAT +321 -321
  48. floodmodeller_api/test/test_data/EX1.ext +107 -107
  49. floodmodeller_api/test/test_data/EX1.feb +320 -320
  50. floodmodeller_api/test/test_data/EX1.gxy +107 -107
  51. floodmodeller_api/test/test_data/EX17.DAT +421 -422
  52. floodmodeller_api/test/test_data/EX17.ext +213 -213
  53. floodmodeller_api/test/test_data/EX17.feb +422 -422
  54. floodmodeller_api/test/test_data/EX18.DAT +375 -375
  55. floodmodeller_api/test/test_data/EX18_DAT_expected.json +3876 -0
  56. floodmodeller_api/test/test_data/EX2.DAT +302 -302
  57. floodmodeller_api/test/test_data/EX3.DAT +926 -926
  58. floodmodeller_api/test/test_data/EX3_DAT_expected.json +16235 -0
  59. floodmodeller_api/test/test_data/EX3_IEF_expected.json +61 -0
  60. floodmodeller_api/test/test_data/EX6.DAT +2084 -2084
  61. floodmodeller_api/test/test_data/EX6.ext +532 -532
  62. floodmodeller_api/test/test_data/EX6.feb +2084 -2084
  63. floodmodeller_api/test/test_data/EX6_DAT_expected.json +31647 -0
  64. floodmodeller_api/test/test_data/Event Data Example.DAT +336 -336
  65. floodmodeller_api/test/test_data/Event Data Example.ext +107 -107
  66. floodmodeller_api/test/test_data/Event Data Example.feb +336 -336
  67. floodmodeller_api/test/test_data/Linked1D2D.xml +52 -52
  68. floodmodeller_api/test/test_data/Linked1D2DFAST.xml +53 -53
  69. floodmodeller_api/test/test_data/Linked1D2DFAST_dy.xml +48 -48
  70. floodmodeller_api/test/test_data/Linked1D2D_xml_expected.json +313 -0
  71. floodmodeller_api/test/test_data/blockage.dat +50 -50
  72. floodmodeller_api/test/test_data/blockage.ext +45 -45
  73. floodmodeller_api/test/test_data/blockage.feb +9 -9
  74. floodmodeller_api/test/test_data/blockage.gxy +71 -71
  75. floodmodeller_api/test/test_data/defaultUnits.dat +127 -127
  76. floodmodeller_api/test/test_data/defaultUnits.ext +45 -45
  77. floodmodeller_api/test/test_data/defaultUnits.feb +9 -9
  78. floodmodeller_api/test/test_data/defaultUnits.fmpx +58 -58
  79. floodmodeller_api/test/test_data/defaultUnits.gxy +85 -85
  80. floodmodeller_api/test/test_data/ex3.ief +20 -20
  81. floodmodeller_api/test/test_data/ex3.lf1 +2800 -2800
  82. floodmodeller_api/test/test_data/ex4.DAT +1374 -1374
  83. floodmodeller_api/test/test_data/ex4_changed.DAT +1374 -1374
  84. floodmodeller_api/test/test_data/example1.inp +329 -329
  85. floodmodeller_api/test/test_data/example2.inp +158 -158
  86. floodmodeller_api/test/test_data/example3.inp +297 -297
  87. floodmodeller_api/test/test_data/example4.inp +388 -388
  88. floodmodeller_api/test/test_data/example5.inp +147 -147
  89. floodmodeller_api/test/test_data/example6.inp +154 -154
  90. floodmodeller_api/test/test_data/jump.dat +176 -176
  91. floodmodeller_api/test/test_data/network.dat +1374 -1374
  92. floodmodeller_api/test/test_data/network.ext +45 -45
  93. floodmodeller_api/test/test_data/network.exy +1 -1
  94. floodmodeller_api/test/test_data/network.feb +45 -45
  95. floodmodeller_api/test/test_data/network.ied +45 -45
  96. floodmodeller_api/test/test_data/network.ief +20 -20
  97. floodmodeller_api/test/test_data/network.inp +147 -147
  98. floodmodeller_api/test/test_data/network.pxy +57 -57
  99. floodmodeller_api/test/test_data/network.zzd +122 -122
  100. floodmodeller_api/test/test_data/network_dat_expected.json +21837 -0
  101. floodmodeller_api/test/test_data/network_from_tabularCSV.csv +87 -87
  102. floodmodeller_api/test/test_data/network_ied_expected.json +287 -0
  103. floodmodeller_api/test/test_data/rnweir.dat +9 -9
  104. floodmodeller_api/test/test_data/rnweir.ext +45 -45
  105. floodmodeller_api/test/test_data/rnweir.feb +9 -9
  106. floodmodeller_api/test/test_data/rnweir.gxy +45 -45
  107. floodmodeller_api/test/test_data/rnweir_default.dat +74 -74
  108. floodmodeller_api/test/test_data/rnweir_default.ext +45 -45
  109. floodmodeller_api/test/test_data/rnweir_default.feb +9 -9
  110. floodmodeller_api/test/test_data/rnweir_default.fmpx +58 -58
  111. floodmodeller_api/test/test_data/rnweir_default.gxy +53 -53
  112. floodmodeller_api/test/test_data/unit checks.dat +16 -16
  113. floodmodeller_api/test/test_ied.py +29 -29
  114. floodmodeller_api/test/test_ief.py +125 -24
  115. floodmodeller_api/test/test_inp.py +47 -48
  116. floodmodeller_api/test/test_json.py +114 -0
  117. floodmodeller_api/test/test_logs_lf.py +48 -51
  118. floodmodeller_api/test/test_tool.py +165 -152
  119. floodmodeller_api/test/test_toolbox_structure_log.py +234 -239
  120. floodmodeller_api/test/test_xml2d.py +151 -156
  121. floodmodeller_api/test/test_zzn.py +36 -34
  122. floodmodeller_api/to_from_json.py +218 -0
  123. floodmodeller_api/tool.py +332 -329
  124. floodmodeller_api/toolbox/__init__.py +5 -5
  125. floodmodeller_api/toolbox/example_tool.py +45 -45
  126. floodmodeller_api/toolbox/model_build/__init__.py +2 -2
  127. floodmodeller_api/toolbox/model_build/add_siltation_definition.py +100 -98
  128. floodmodeller_api/toolbox/model_build/structure_log/__init__.py +1 -1
  129. floodmodeller_api/toolbox/model_build/structure_log/structure_log.py +287 -289
  130. floodmodeller_api/toolbox/model_build/structure_log_definition.py +76 -76
  131. floodmodeller_api/units/__init__.py +10 -10
  132. floodmodeller_api/units/_base.py +214 -212
  133. floodmodeller_api/units/boundaries.py +467 -467
  134. floodmodeller_api/units/comment.py +52 -55
  135. floodmodeller_api/units/conduits.py +382 -402
  136. floodmodeller_api/units/helpers.py +123 -131
  137. floodmodeller_api/units/iic.py +107 -101
  138. floodmodeller_api/units/losses.py +305 -306
  139. floodmodeller_api/units/sections.py +444 -446
  140. floodmodeller_api/units/structures.py +1690 -1683
  141. floodmodeller_api/units/units.py +93 -104
  142. floodmodeller_api/units/unsupported.py +44 -44
  143. floodmodeller_api/units/variables.py +87 -89
  144. floodmodeller_api/urban1d/__init__.py +11 -11
  145. floodmodeller_api/urban1d/_base.py +188 -179
  146. floodmodeller_api/urban1d/conduits.py +93 -85
  147. floodmodeller_api/urban1d/general_parameters.py +58 -58
  148. floodmodeller_api/urban1d/junctions.py +81 -79
  149. floodmodeller_api/urban1d/losses.py +81 -74
  150. floodmodeller_api/urban1d/outfalls.py +114 -110
  151. floodmodeller_api/urban1d/raingauges.py +111 -111
  152. floodmodeller_api/urban1d/subsections.py +92 -98
  153. floodmodeller_api/urban1d/xsections.py +147 -144
  154. floodmodeller_api/util.py +77 -21
  155. floodmodeller_api/validation/parameters.py +660 -660
  156. floodmodeller_api/validation/urban_parameters.py +388 -404
  157. floodmodeller_api/validation/validation.py +110 -108
  158. floodmodeller_api/version.py +1 -1
  159. floodmodeller_api/xml2d.py +688 -673
  160. floodmodeller_api/xml2d_template.py +37 -37
  161. floodmodeller_api/zzn.py +387 -363
  162. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.3.dist-info}/LICENSE.txt +13 -13
  163. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.3.dist-info}/METADATA +82 -82
  164. floodmodeller_api-0.4.3.dist-info/RECORD +179 -0
  165. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.3.dist-info}/WHEEL +1 -1
  166. floodmodeller_api/libifcoremd.dll +0 -0
  167. floodmodeller_api/test/test_data/EX3.bmp +0 -0
  168. floodmodeller_api/test/test_data/test_output.csv +0 -87
  169. floodmodeller_api/zzn_read.dll +0 -0
  170. floodmodeller_api-0.4.2.post1.dist-info/RECORD +0 -164
  171. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.3.dist-info}/entry_points.txt +0 -0
  172. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.3.dist-info}/top_level.txt +0 -0
floodmodeller_api/ief.py CHANGED
@@ -1,637 +1,647 @@
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 datetime as dt
20
+ import os
21
+ import subprocess
22
+ import time
23
+ from pathlib import Path
24
+ from subprocess import Popen
25
+ from typing import Callable
26
+
27
+ import pandas as pd
28
+ from tqdm import trange
29
+
30
+ from ._base import FMFile
31
+ from .ief_flags import flags
32
+ from .logs import lf_factory
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
+ OLD_FILE = 5
53
+ ERROR_MAX = 2000
54
+ WARNING_MAX = 3000
55
+ LOG_TIMEOUT = 10
56
+
57
+ 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")
70
+
71
+ def _read(self):
72
+ # Read IEF data
73
+ with open(self._filepath) as ief_file:
74
+ raw_data = [line.rstrip("\n") for line in ief_file.readlines()]
75
+ # Clean data and add as class properties
76
+ # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
77
+ prev_comment = None
78
+ self._ief_properties = []
79
+ for line in raw_data:
80
+ # Handle any comments here (prefixed with ;)
81
+ if line.lstrip().startswith(";"):
82
+ self._ief_properties.append(line) # Add comment in raw state
83
+ prev_comment = line.strip(";")
84
+
85
+ elif "=" in line:
86
+ # Using strip() method to remove any leading/trailing whitespace
87
+ prop, value = (itm.strip() for itm in line.split("=", 1))
88
+ # Handle 'EventData' properties so that multiple can be set
89
+ if prop.upper() == "EVENTDATA":
90
+ if prev_comment is None:
91
+ try:
92
+ event_data_title = Path(value).stem
93
+ except Exception:
94
+ event_data_title = value
95
+ else:
96
+ event_data_title = prev_comment
97
+ if hasattr(self, "EventData"):
98
+ # Append event data to list so multiple can be specified
99
+ self.EventData[event_data_title] = value
100
+ else:
101
+ self.EventData = {event_data_title: value}
102
+ self._ief_properties.append("EventData")
103
+
104
+ else:
105
+ # Sets the property and value as class properties so they can be edited.
106
+ setattr(self, prop, value)
107
+ self._ief_properties.append(prop)
108
+ prev_comment = None
109
+ else:
110
+ # This should add the [] bound headers
111
+ self._ief_properties.append(line)
112
+ prev_comment = None
113
+ del raw_data
114
+
115
+ def _write(self) -> str:
116
+ """Returns string representation of the current IEF data
117
+
118
+ Returns:
119
+ str: Full string representation of IEF in its most recent state (including changes not yet saved to disk)
120
+ """
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
130
+ 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
148
+
149
+ except Exception as e:
150
+ self._handle_exception(e, when="write")
151
+
152
+ def _create_from_blank(self):
153
+ # No filepath specified, create new 'blank' IEF in memory
154
+ blank_ief = [
155
+ "[ISIS Event Header]",
156
+ 'Title=""',
157
+ 'Datafile=""',
158
+ 'Results=""',
159
+ "[ISIS Event Details]",
160
+ "RunType=Steady",
161
+ "Start=0",
162
+ "ICsFrom=1",
163
+ ]
164
+
165
+ # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
166
+ self._ief_properties = []
167
+ for line in blank_ief:
168
+ if "=" in line:
169
+ prop, value = line.split("=")
170
+ # Sets the property and value as class properties so they can be edited.
171
+ setattr(self, prop, value)
172
+ self._ief_properties.append(prop)
173
+ else:
174
+ # This should add the [] bound headers
175
+ self._ief_properties.append(line)
176
+ del blank_ief
177
+
178
+ def _update_ief_properties(self): # noqa: C901
179
+ """Updates the list of properties included in the IEF file"""
180
+ # Add new properties
181
+ for prop, val in self.__dict__.copy().items():
182
+ if (prop not in self._ief_properties) and (not prop.startswith("_")) and prop != "file":
183
+ # Check if valid flag
184
+ if prop.upper() not in flags:
185
+ print(
186
+ f"Warning: '{prop}' is not a valid IEF flag, it will be ommited from the IEF\n",
187
+ )
188
+ continue
189
+
190
+ if prop.upper() == "EVENTDATA" and prop != "EventData":
191
+ # (1) This will be triggered in special case where eventdata has been added with different case, but case
192
+ # needs to be kept as 'EventData', to allow dealing wiht multiple IEDs
193
+ # (2) In case of EventData being added with correct case where it doesn't already
194
+ # exist, this stops it being deleted
195
+ # Add new values to EventData flag
196
+ delattr(self, prop)
197
+ self.EventData = val
198
+ prop = "EventData"
199
+
200
+ # Check ief group header
201
+ group = f"[{flags[prop.upper()]}]"
202
+ if group in self._ief_properties:
203
+ # If group already exists, add property to end of group
204
+ group_idx = False
205
+ # defaults to inserting in last place
206
+ insert_index = len(self._ief_properties)
207
+ for idx, item in enumerate(self._ief_properties):
208
+ if group_idx is True and item.startswith("["):
209
+ insert_index = idx
210
+ break
211
+ if item == group:
212
+ group_idx = True
213
+
214
+ self._ief_properties.insert(insert_index, prop)
215
+ else:
216
+ # Add group header to the end of list
217
+ self._ief_properties.append(group)
218
+ # Add property to end of list
219
+ self._ief_properties.append(prop)
220
+
221
+ # Remove any deleted properties
222
+ self._ief_properties = [
223
+ line
224
+ for line in self._ief_properties
225
+ if (line.startswith("[") or (line in dir(self)) or line.lstrip().startswith(";"))
226
+ ]
227
+
228
+ # Rearrange order of Flow Time Profiles group if present * Currently assuming all relevent flags included
229
+ if "[Flow Time Profiles]" in self._ief_properties:
230
+ self._update_flowtimeprofile_info()
231
+
232
+ # Ensure number of EventData entries is equal to length of EventData attribute
233
+ if hasattr(self, "EventData"):
234
+ self._update_eventdata_info()
235
+
236
+ def _update_eventdata_info(self): # noqa: C901
237
+ if not isinstance(self.EventData, dict):
238
+ # If attribute not a dict, adds the value as a single entry in list
239
+ raise AttributeError(
240
+ "The 'EventData' attribute should be a dictionary with keys defining the event"
241
+ " names and values referencing the IED files",
242
+ )
243
+
244
+ # Number of 'EventData' flags in ief
245
+ event_properties = self._ief_properties.count("EventData")
246
+ # Number of event data specified in class
247
+ events = len(self.EventData)
248
+ if event_properties < events:
249
+ # Need to add additional event properties to IEF to match number of events specified
250
+ to_add = events - event_properties
251
+ # Used for if no existing eventdata exists
252
+ insert_index = len(self._ief_properties)
253
+ for idx, itm in enumerate(reversed(self._ief_properties)):
254
+ if itm in ("EventData", "[ISIS Event Details]"):
255
+ insert_index = len(self._ief_properties) - idx
256
+ break
257
+
258
+ for _ in range(to_add):
259
+ # Add in required number of extra EventData after last one.
260
+ self._ief_properties.insert(insert_index, "EventData")
261
+
262
+ elif event_properties > events:
263
+ # Need to remove some event properties from IEF to match number of events specified
264
+ to_remove = event_properties - events
265
+ removed = 0 # Counter for number removed
266
+ num_props = len(self._ief_properties)
267
+ for idx, itm in enumerate(reversed(self._ief_properties)):
268
+ if itm == "EventData":
269
+ del self._ief_properties[num_props - 1 - idx]
270
+ # Also remove event data title comment if present
271
+ if self._ief_properties[num_props - 2 - idx].lstrip().startswith(";"):
272
+ del self._ief_properties[num_props - 2 - idx]
273
+ removed += 1
274
+ if removed == to_remove:
275
+ break
276
+
277
+ def _update_flowtimeprofile_info(self):
278
+ end_index = None
279
+ start_index = self._ief_properties.index("[Flow Time Profiles]")
280
+ for idx, item in enumerate(self._ief_properties[start_index:]):
281
+ if idx != 0 and item.startswith("["):
282
+ end_index = idx + start_index
283
+ break
284
+ flow_time_list = self._ief_properties[start_index:end_index]
285
+ flow_time_list = [
286
+ "[Flow Time Profiles]",
287
+ "NoOfFlowTimeProfiles",
288
+ "NoOfFlowTimeSeries",
289
+ ] + [i for i in flow_time_list if i.lower().startswith("flowtimeprofile")]
290
+
291
+ # sort list to ensure the flow time profiles are in order
292
+ def flow_sort(itm):
293
+ try:
294
+ num = int(itm.upper().replace("FLOWTIMEPROFILE", ""))
295
+ return (1, num)
296
+ except ValueError:
297
+ return (0, itm)
298
+
299
+ flow_time_list[3:] = sorted(flow_time_list[3:], key=flow_sort)
300
+
301
+ # Replace existing slice of ief properties with new reordered slice
302
+ self._ief_properties[start_index:end_index] = flow_time_list
303
+
304
+ # Update NoOfFlowTimeSeries
305
+ self.NoOfFlowTimeProfiles = str(len(flow_time_list[3:]))
306
+
307
+ def __getattr__(self, name):
308
+ for attr in self.__dict__.copy():
309
+ if name.lower() == attr.lower():
310
+ return self.__dict__[attr]
311
+ return self.__getattribute__(name)
312
+
313
+ def __setattr__(self, name, value):
314
+ existing_attr_updated = False
315
+ for attr in self.__dict__.copy():
316
+ if name.lower() == attr.lower():
317
+ self.__dict__[attr] = value
318
+ existing_attr_updated = True
319
+
320
+ if not existing_attr_updated:
321
+ self.__dict__[name] = value
322
+
323
+ def __delattr__(self, name):
324
+ existing_attr_deleted = False
325
+ for attr in self.__dict__.copy():
326
+ if name.lower() == attr.lower():
327
+ super().__delattr__(attr)
328
+ existing_attr_deleted = True
329
+
330
+ if not existing_attr_deleted:
331
+ super().__delattr__(name)
332
+
333
+ def diff(self, other: IEF, force_print: bool = False) -> None:
334
+ """Compares the IEF class against another IEF class to check whether they are
335
+ equivalent, or if not, what the differences are. Two instances of an IEF class are
336
+ deemed equivalent if all of their attributes are equal except for the filepath and
337
+ raw data.
338
+
339
+ The result is printed to the console. If you need to access the returned data, use
340
+ the method ``IEF._get_diff()``
341
+
342
+ Args:
343
+ other (floodmodeller_api.IEF): Other instance of an IEF class
344
+ force_print (bool): Forces the API to print every difference found, rather than
345
+ just the first 25 differences. Defaults to False.
346
+ """
347
+ self._diff(other, force_print=force_print)
348
+
349
+ def update(self) -> None:
350
+ """Updates the existing IEF based on any altered attributes"""
351
+ self._update()
352
+
353
+ def save(self, filepath: str | Path) -> None:
354
+ """Saves the IEF to the given location, if pointing to an existing file it will be overwritten.
355
+ 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
356
+ rather than the original source IEF used to construct the class
357
+
358
+ Args:
359
+ filepath (string): Full filepath to new location for ief file (including '.ief' extension)
360
+ """
361
+ self._save(filepath)
362
+
363
+ def simulate( # noqa: C901, PLR0912, PLR0913
364
+ self,
365
+ method: str = "WAIT",
366
+ raise_on_failure: bool = True,
367
+ precision: str = "DEFAULT",
368
+ enginespath: str = "",
369
+ range_function: Callable = trange,
370
+ range_settings: dict | None = None,
371
+ ) -> subprocess.Popen | None:
372
+ """Simulate the IEF file directly as a subprocess
373
+
374
+ Args:
375
+ method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
376
+ 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
377
+ 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
378
+ Defaults to 'WAIT'.
379
+ raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
380
+ If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
381
+ then this argument is ignored. Defaults to True.
382
+ precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
383
+ Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
384
+ this can be overwritten using 'SINGLE' or 'DOUBLE'.
385
+ enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
386
+ Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
387
+ anything other than ''.
388
+
389
+ Raises:
390
+ UserWarning: Raised if ief filepath not already specified
391
+
392
+ Returns:
393
+ subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
394
+ """
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
+ )
420
+
421
+ if precision.upper() == "SINGLE":
422
+ isis32_fp = str(Path(_enginespath, "ISISf32.exe"))
423
+ else:
424
+ isis32_fp = str(Path(_enginespath, "ISISf32_DoubleP.exe"))
425
+
426
+ if not Path(isis32_fp).exists():
427
+ raise Exception(f"Flood Modeller engine not found! Expected location: {isis32_fp}")
428
+
429
+ run_command = f'"{isis32_fp}" -sd "{self._filepath}"'
430
+
431
+ if method.upper() == "WAIT":
432
+ print("Executing simulation...")
433
+ # execute simulation
434
+ process = Popen(run_command, cwd=os.path.dirname(self._filepath))
435
+
436
+ # progress bar based on log files
437
+ self._init_log_file()
438
+ self._update_progress_bar(process)
439
+
440
+ while process.poll() is None:
441
+ # Process still running
442
+ time.sleep(1)
443
+
444
+ result, summary = self._summarise_exy()
445
+
446
+ if result == 1 and raise_on_failure:
447
+ raise RuntimeError(summary)
448
+ print(summary)
449
+
450
+ elif method.upper() == "RETURN_PROCESS":
451
+ print("Executing simulation...")
452
+ # execute simulation
453
+ return Popen(run_command, cwd=os.path.dirname(self._filepath))
454
+
455
+ return None
456
+
457
+ except Exception as e:
458
+ self._handle_exception(e, when="simulate")
459
+
460
+ def _get_result_filepath(self, suffix):
461
+ if hasattr(self, "Results"):
462
+ path = Path(self.Results).with_suffix("." + suffix)
463
+ if not path.is_absolute():
464
+ # set cwd to ief location and resolve path
465
+ path = Path(self._filepath.parent, path).resolve()
466
+
467
+ else:
468
+ path = self._filepath.with_suffix("." + suffix)
469
+
470
+ return path
471
+
472
+ def get_results(self) -> ZZN:
473
+ """If results for the simulation exist, this function returns them as a ZZN class object
474
+
475
+ Returns:
476
+ floodmodeller_api.ZZN class object
477
+ """
478
+
479
+ # Get zzn location
480
+ result_path = self._get_result_filepath(suffix="zzn")
481
+
482
+ if not result_path.exists():
483
+ raise FileNotFoundError("Simulation results file (zzn) not found")
484
+
485
+ return ZZN(result_path)
486
+
487
+ def get_log(self):
488
+ """If log files for the simulation exist, this function returns them as a LF1 class object
489
+
490
+ Returns:
491
+ floodmodeller_api.LF1 class object
492
+ """
493
+
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"""
581
+
582
+ print("No progress bar as " + reason + ". Simulation will continue as usual.")
583
+
584
+ def _update_progress_bar(self, process: Popen):
585
+ """Updates progress bar based on log file"""
586
+
587
+ # only if there is a log file
588
+ if self._lf is None:
589
+ return
590
+
591
+ # tqdm progress bar
592
+ for i in self._range_function(100, **self._range_settings):
593
+ # Process still running
594
+ while process.poll() is None:
595
+ time.sleep(0.1)
596
+
597
+ # Find progress
598
+ self._lf.read(suppress_final_step=True)
599
+ progress = self._lf.report_progress()
600
+
601
+ # Reached i% progress => move onto waiting for (i+1)%
602
+ if progress > i:
603
+ break
604
+
605
+ # Process stopped
606
+ if process.poll() is not None:
607
+ # Find final progress
608
+ self._lf.read(suppress_final_step=True)
609
+ progress = self._lf.report_progress()
610
+
611
+ if progress > i:
612
+ pass # stopped because it completed
613
+ else:
614
+ break # stopped for another reason
615
+
616
+ def _summarise_exy(self):
617
+ """Reads and summarises associated exy file if available"""
618
+
619
+ # Get results location
620
+ if hasattr(self, "Results"):
621
+ exy_path = Path(self.Results).with_suffix(".exy")
622
+ if not exy_path.is_absolute():
623
+ # set cwd to ief location and resolve path
624
+ exy_path = Path(self._filepath.parent, exy_path).resolve()
625
+
626
+ else:
627
+ exy_path = self._filepath.with_suffix(".exy")
628
+
629
+ if not exy_path.exists():
630
+ raise FileNotFoundError("Simulation results error log (.exy) not found")
631
+
632
+ exy_data = pd.read_csv(exy_path, names=["node", "timestep", "severity", "code", "summary"])
633
+ exy_data["type"] = exy_data["code"].apply(
634
+ lambda x: (
635
+ "Error" if x < self.ERROR_MAX else ("Warning" if x < self.WARNING_MAX else "Note")
636
+ ),
637
+ )
638
+ errors = len(exy_data[exy_data["type"] == "Error"])
639
+ warnings = len(exy_data[exy_data["type"] == "Warning"])
640
+ notes = len(exy_data[exy_data["type"] == "Note"])
641
+
642
+ details = f"({errors} Error(s), {warnings} Warning(s), {notes} Note(s) ) - Check ZZD for more details."
643
+
644
+ if errors > 0:
645
+ return 1, f"Simulation Failed! - {details}"
646
+
647
+ return 0, f"Simulation Completed! - {details}"