floodmodeller-api 0.4.2__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 (178) 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 -838
  5. floodmodeller_api/diff.py +136 -119
  6. floodmodeller_api/ied.py +307 -311
  7. floodmodeller_api/ief.py +647 -646
  8. floodmodeller_api/ief_flags.py +253 -253
  9. floodmodeller_api/inp.py +266 -268
  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 -314
  21. floodmodeller_api/logs/lf_helpers.py +354 -346
  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 -154
  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 -330
  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 -94
  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 -72
  131. floodmodeller_api/units/__init__.py +10 -10
  132. floodmodeller_api/units/_base.py +214 -209
  133. floodmodeller_api/units/boundaries.py +467 -469
  134. floodmodeller_api/units/comment.py +52 -55
  135. floodmodeller_api/units/conduits.py +382 -403
  136. floodmodeller_api/units/helpers.py +123 -132
  137. floodmodeller_api/units/iic.py +107 -101
  138. floodmodeller_api/units/losses.py +305 -308
  139. floodmodeller_api/units/sections.py +444 -445
  140. floodmodeller_api/units/structures.py +1690 -1684
  141. floodmodeller_api/units/units.py +93 -102
  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 -177
  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 -107
  151. floodmodeller_api/urban1d/raingauges.py +111 -108
  152. floodmodeller_api/urban1d/subsections.py +92 -93
  153. floodmodeller_api/urban1d/xsections.py +147 -141
  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 -112
  158. floodmodeller_api/version.py +1 -1
  159. floodmodeller_api/xml2d.py +688 -684
  160. floodmodeller_api/xml2d_template.py +37 -37
  161. floodmodeller_api/zzn.py +387 -365
  162. {floodmodeller_api-0.4.2.dist-info → floodmodeller_api-0.4.3.dist-info}/LICENSE.txt +13 -13
  163. {floodmodeller_api-0.4.2.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.dist-info → floodmodeller_api-0.4.3.dist-info}/WHEEL +1 -1
  166. floodmodeller_api-0.4.3.dist-info/entry_points.txt +3 -0
  167. floodmodeller_api/libifcoremd.dll +0 -0
  168. floodmodeller_api/test/test_data/EX3.bmp +0 -0
  169. floodmodeller_api/test/test_data/test_output.csv +0 -87
  170. floodmodeller_api/zzn_read.dll +0 -0
  171. floodmodeller_api-0.4.2.data/scripts/fmapi-add_siltation.bat +0 -2
  172. floodmodeller_api-0.4.2.data/scripts/fmapi-add_siltation.py +0 -3
  173. floodmodeller_api-0.4.2.data/scripts/fmapi-structure_log.bat +0 -2
  174. floodmodeller_api-0.4.2.data/scripts/fmapi-structure_log.py +0 -3
  175. floodmodeller_api-0.4.2.data/scripts/fmapi-toolbox.bat +0 -2
  176. floodmodeller_api-0.4.2.data/scripts/fmapi-toolbox.py +0 -41
  177. floodmodeller_api-0.4.2.dist-info/RECORD +0 -169
  178. {floodmodeller_api-0.4.2.dist-info → floodmodeller_api-0.4.3.dist-info}/top_level.txt +0 -0
floodmodeller_api/ief.py CHANGED
@@ -1,646 +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 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
- self._filepath = ief_filepath
54
- if self._filepath is not None:
55
- FMFile.__init__(self)
56
-
57
- self._read()
58
-
59
- else:
60
- self._create_from_blank()
61
- except Exception as e:
62
- self._handle_exception(e, when="read")
63
-
64
- def _read(self):
65
- # Read IEF data
66
- with open(self._filepath, "r") as ief_file:
67
- raw_data = [line.rstrip("\n") for line in ief_file.readlines()]
68
- # Clean data and add as class properties
69
- # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
70
- prev_comment = None
71
- self._ief_properties = []
72
- for line in raw_data:
73
- # Handle any comments here (prefixed with ;)
74
- if line.lstrip().startswith(";"):
75
- self._ief_properties.append(line) # Add comment in raw state
76
- prev_comment = line.strip(";")
77
-
78
- elif "=" in line:
79
- # Using strip() method to remove any leading/trailing whitespace
80
- prop, value = [itm.strip() for itm in line.split("=", 1)]
81
- # Handle 'EventData' properties so that multiple can be set
82
- if prop.upper() == "EVENTDATA":
83
- if prev_comment is None:
84
- try:
85
- event_data_title = Path(value).stem
86
- except Exception:
87
- event_data_title = value
88
- else:
89
- event_data_title = prev_comment
90
- if hasattr(self, "EventData"):
91
- # Append event data to list so multiple can be specified
92
- self.EventData[event_data_title] = value
93
- else:
94
- self.EventData = {event_data_title: value}
95
- self._ief_properties.append("EventData")
96
-
97
- else:
98
- # Sets the property and value as class properties so they can be edited.
99
- setattr(self, prop, value)
100
- self._ief_properties.append(prop)
101
- prev_comment = None
102
- else:
103
- # This should add the [] bound headers
104
- self._ief_properties.append(line)
105
- prev_comment = None
106
- del raw_data
107
-
108
- def _write(self) -> str:
109
- """Returns string representation of the current IEF data
110
-
111
- Returns:
112
- str: Full string representation of IEF in its most recent state (including changes not yet saved to disk)
113
- """
114
- try:
115
- # update _ief_properties
116
- self._update_ief_properties()
117
-
118
- ief_string = ""
119
- event = 0 # Used as a counter for multiple eventdata files
120
- for idx, prop in enumerate(self._ief_properties):
121
- if prop.startswith("["):
122
- # writes the [] bound headers to ief string
123
- ief_string += prop + "\n"
124
- elif prop.lstrip().startswith(";"):
125
- if not self._ief_properties[idx + 1].lower() == "eventdata":
126
- # Only write comment if not preceding event data
127
- ief_string += prop + "\n"
128
- elif prop.lower() == "eventdata":
129
- event_data = getattr(self, prop)
130
- # Add multiple EventData if present
131
- for event_idx, key in enumerate(event_data):
132
- if event_idx == event:
133
- ief_string += f";{key}\n{prop}={str(event_data[key])}\n"
134
- break
135
- event += 1
136
-
137
- else:
138
- # writes property and value to ief string
139
- ief_string += f"{prop}={str(getattr(self, prop))}\n"
140
- return ief_string
141
-
142
- except Exception as e:
143
- self._handle_exception(e, when="write")
144
-
145
- def _create_from_blank(self):
146
- # No filepath specified, create new 'blank' IEF in memory
147
- blank_ief = [
148
- "[ISIS Event Header]",
149
- 'Title=""',
150
- 'Datafile=""',
151
- 'Results=""',
152
- "[ISIS Event Details]",
153
- "RunType=Steady",
154
- "Start=0",
155
- "ICsFrom=1",
156
- ]
157
-
158
- # Create a list to store the properties which are to be saved in IEF, so as to ignore any temp properties.
159
- self._ief_properties = []
160
- for line in blank_ief:
161
- if "=" in line:
162
- prop, value = line.split("=")
163
- # Sets the property and value as class properties so they can be edited.
164
- setattr(self, prop, value)
165
- self._ief_properties.append(prop)
166
- else:
167
- # This should add the [] bound headers
168
- self._ief_properties.append(line)
169
- del blank_ief
170
-
171
- def _update_ief_properties(self): # noqa: C901
172
- """Updates the list of properties included in the IEF file"""
173
- # Add new properties
174
- for prop, val in self.__dict__.copy().items():
175
- if (prop not in self._ief_properties) and (not prop.startswith("_")) and prop != "file":
176
- # Check if valid flag
177
- if prop.upper() not in flags:
178
- print(
179
- f"Warning: '{prop}' is not a valid IEF flag, it will be ommited from the IEF\n"
180
- )
181
- continue
182
-
183
- if prop.upper() == "EVENTDATA":
184
- # This will be triggered in special case where eventdata has been added with different case, but case
185
- # needs to be kept as 'EventData', to allow dealing wiht multiple IEDs
186
- if prop != "EventData":
187
- # In case of EventData being added with correct case where it doesn't already
188
- # exist, this stops it being deleted
189
- # Add new values to EventData flag
190
- delattr(self, prop)
191
- setattr(self, "EventData", val)
192
- prop = "EventData"
193
-
194
- # Check ief group header
195
- group = f"[{flags[prop.upper()]}]"
196
- if group in self._ief_properties:
197
- # If group already exists, add property to end of group
198
- group_idx = False
199
- # defaults to inserting in last place
200
- insert_index = len(self._ief_properties)
201
- for idx, item in enumerate(self._ief_properties):
202
- if group_idx is True and item.startswith("["):
203
- insert_index = idx
204
- break
205
- if item == group:
206
- group_idx = True
207
-
208
- self._ief_properties.insert(insert_index, prop)
209
- else:
210
- # Add group header to the end of list
211
- self._ief_properties.append(group)
212
- # Add property to end of list
213
- self._ief_properties.append(prop)
214
-
215
- # Remove any deleted properties
216
- self._ief_properties = [
217
- line
218
- for line in self._ief_properties
219
- if (line.startswith("[") or (line in dir(self)) or line.lstrip().startswith(";"))
220
- ]
221
-
222
- # Rearrange order of Flow Time Profiles group if present * Currently assuming all relevent flags included
223
- if "[Flow Time Profiles]" in self._ief_properties:
224
- self._update_flowtimeprofile_info()
225
-
226
- # Ensure number of EventData entries is equal to length of EventData attribute
227
- if hasattr(self, "EventData"):
228
- self._update_eventdata_info()
229
-
230
- def _update_eventdata_info(self): # noqa: C901
231
- if not isinstance(self.EventData, dict):
232
- # If attribute not a dict, adds the value as a single entry in list
233
- raise AttributeError(
234
- "The 'EventData' attribute should be a dictionary with keys defining the event"
235
- + " names and values referencing the IED files"
236
- )
237
-
238
- # Number of 'EventData' flags in ief
239
- event_properties = self._ief_properties.count("EventData")
240
- # Number of event data specified in class
241
- events = len(self.EventData)
242
- if event_properties < events:
243
- # Need to add additional event properties to IEF to match number of events specified
244
- to_add = events - event_properties
245
- # Used for if no existing eventdata exists
246
- insert_index = len(self._ief_properties)
247
- for idx, itm in enumerate(reversed(self._ief_properties)):
248
- if itm == "EventData" or itm == "[ISIS Event Details]":
249
- insert_index = len(self._ief_properties) - idx
250
- break
251
-
252
- for i in range(to_add):
253
- # Add in required number of extra EventData after last one.
254
- self._ief_properties.insert(insert_index, "EventData")
255
-
256
- elif event_properties > events:
257
- # Need to remove some event properties from IEF to match number of events specified
258
- to_remove = event_properties - events
259
- removed = 0 # Counter for number removed
260
- num_props = len(self._ief_properties)
261
- for idx, itm in enumerate(reversed(self._ief_properties)):
262
- if itm == "EventData":
263
- del self._ief_properties[num_props - 1 - idx]
264
- # Also remove event data title comment if present
265
- if self._ief_properties[num_props - 2 - idx].lstrip().startswith(";"):
266
- del self._ief_properties[num_props - 2 - idx]
267
- removed += 1
268
- if removed == to_remove:
269
- break
270
-
271
- def _update_flowtimeprofile_info(self):
272
- end_index = None
273
- start_index = self._ief_properties.index("[Flow Time Profiles]")
274
- for idx, item in enumerate(self._ief_properties[start_index:]):
275
- if idx != 0 and item.startswith("["):
276
- end_index = idx + start_index
277
- break
278
- flow_time_list = self._ief_properties[start_index:end_index]
279
- flow_time_list = [
280
- "[Flow Time Profiles]",
281
- "NoOfFlowTimeProfiles",
282
- "NoOfFlowTimeSeries",
283
- ] + [i for i in flow_time_list if i.lower().startswith("flowtimeprofile")]
284
-
285
- # sort list to ensure the flow time profiles are in order
286
- def flow_sort(itm):
287
- try:
288
- num = int(itm.upper().replace("FLOWTIMEPROFILE", ""))
289
- return (1, num)
290
- except ValueError:
291
- return (0, itm)
292
-
293
- flow_time_list[3:] = sorted(flow_time_list[3:], key=flow_sort)
294
-
295
- # Replace existing slice of ief properties with new reordered slice
296
- self._ief_properties[start_index:end_index] = flow_time_list
297
-
298
- # Update NoOfFlowTimeSeries
299
- self.NoOfFlowTimeProfiles = str(len(flow_time_list[3:]))
300
-
301
- def __getattr__(self, name):
302
- for attr in self.__dict__.copy():
303
- if name.lower() == attr.lower():
304
- return self.__dict__[attr]
305
- return self.__getattribute__(name)
306
-
307
- def __setattr__(self, name, value):
308
- existing_attr_updated = False
309
- for attr in self.__dict__.copy():
310
- if name.lower() == attr.lower():
311
- self.__dict__[attr] = value
312
- existing_attr_updated = True
313
-
314
- if not existing_attr_updated:
315
- self.__dict__[name] = value
316
-
317
- def __delattr__(self, name):
318
- existing_attr_deleted = False
319
- for attr in self.__dict__.copy():
320
- if name.lower() == attr.lower():
321
- super().__delattr__(attr)
322
- existing_attr_deleted = True
323
-
324
- if not existing_attr_deleted:
325
- super().__delattr__(name)
326
-
327
- def diff(self, other: "IEF", force_print: bool = False) -> None:
328
- """Compares the IEF class against another IEF class to check whether they are
329
- equivalent, or if not, what the differences are. Two instances of an IEF class are
330
- deemed equivalent if all of their attributes are equal except for the filepath and
331
- raw data.
332
-
333
- The result is printed to the console. If you need to access the returned data, use
334
- the method ``IEF._get_diff()``
335
-
336
- Args:
337
- other (floodmodeller_api.IEF): Other instance of an IEF class
338
- force_print (bool): Forces the API to print every difference found, rather than
339
- just the first 25 differences. Defaults to False.
340
- """
341
- self._diff(other, force_print=force_print)
342
-
343
- def update(self) -> None:
344
- """Updates the existing IEF based on any altered attributes"""
345
- self._update()
346
-
347
- def save(self, filepath: Union[str, Path]) -> None:
348
- """Saves the IEF to the given location, if pointing to an existing file it will be overwritten.
349
- 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
350
- rather than the original source IEF used to construct the class
351
-
352
- Args:
353
- filepath (string): Full filepath to new location for ief file (including '.ief' extension)
354
- """
355
- self._save(filepath)
356
-
357
- def simulate( # noqa: C901
358
- self,
359
- method: Optional[str] = "WAIT",
360
- raise_on_failure: Optional[bool] = True,
361
- precision: Optional[str] = "DEFAULT",
362
- enginespath: Optional[str] = "",
363
- range_function: Optional[callable] = trange,
364
- range_settings: Optional[dict] = {},
365
- ) -> Optional[subprocess.Popen]:
366
- """Simulate the IEF file directly as a subprocess
367
-
368
- Args:
369
- method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
370
- 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
371
- 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
372
- Defaults to 'WAIT'.
373
- raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
374
- If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
375
- then this argument is ignored. Defaults to True.
376
- precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
377
- Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
378
- this can be overwritten using 'SINGLE' or 'DOUBLE'.
379
- enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
380
- Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
381
- anything other than ''.
382
-
383
- Raises:
384
- UserWarning: Raised if ief filepath not already specified
385
-
386
- Returns:
387
- subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
388
- """
389
- try:
390
- self._range_function = range_function
391
- self._range_settings = range_settings
392
- if self._filepath is None:
393
- raise UserWarning(
394
- "IEF must be saved to a specific filepath before simulate() can be called."
395
- )
396
- if precision.upper() == "DEFAULT":
397
- precision = "SINGLE" # Defaults to single...
398
- for attr in dir(self):
399
- if attr.upper() == "LAUNCHDOUBLEPRECISIONVERSION": # Unless DP specified
400
- if getattr(self, attr) == "1":
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
- # Executing simulation...
425
- print("Executing simulation...")
426
- process = Popen(
427
- run_command, cwd=os.path.dirname(self._filepath)
428
- ) # execute simulation
429
-
430
- # progress bar based on log files
431
- self._init_log_file()
432
- self._update_progress_bar(process)
433
-
434
- while process.poll() is None:
435
- # Process still running
436
- time.sleep(1)
437
-
438
- result, summary = self._summarise_exy()
439
-
440
- if result == 1 and raise_on_failure:
441
- raise RuntimeError(summary)
442
- else:
443
- print(summary)
444
-
445
- elif method.upper() == "RETURN_PROCESS":
446
- # Executing simulation...
447
- print("Executing simulation...")
448
- process = Popen(
449
- run_command, cwd=os.path.dirname(self._filepath)
450
- ) # execute simulation
451
- return process
452
-
453
- except Exception as e:
454
- self._handle_exception(e, when="simulate")
455
-
456
- def _get_result_filepath(self, suffix):
457
- if hasattr(self, "Results"):
458
- path = Path(self.Results).with_suffix("." + suffix)
459
- if not path.is_absolute():
460
- # set cwd to ief location and resolve path
461
- path = Path(self._filepath.parent, path).resolve()
462
-
463
- else:
464
- path = self._filepath.with_suffix("." + suffix)
465
-
466
- return path
467
-
468
- def get_results(self) -> ZZN:
469
- """If results for the simulation exist, this function returns them as a ZZN class object
470
-
471
- Returns:
472
- floodmodeller_api.ZZN class object
473
- """
474
-
475
- # Get zzn location
476
- result_path = self._get_result_filepath(suffix="zzn")
477
-
478
- if result_path.exists():
479
- return ZZN(result_path)
480
-
481
- else:
482
- raise FileNotFoundError("Simulation results file (zzn) not found")
483
-
484
- def get_log(self):
485
- """If log files for the simulation exist, this function returns them as a LF1 class object
486
-
487
- Returns:
488
- floodmodeller_api.LF1 class object
489
- """
490
-
491
- suffix, steady = self._determine_lf_type()
492
-
493
- # Get lf location
494
- lf_path = self._get_result_filepath(suffix)
495
-
496
- if not lf_path.exists():
497
- raise FileNotFoundError("Log file (" + suffix + ") not found")
498
-
499
- return lf_factory(lf_path, suffix, steady)
500
-
501
- def _determine_lf_type(self): # (str, bool) or (None, None):
502
- """Determine the log file type"""
503
-
504
- if self.RunType == "Unsteady":
505
- suffix = "lf1"
506
- steady = False
507
-
508
- elif self.RunType == "Steady":
509
- suffix = "lf1"
510
- steady = True
511
-
512
- else:
513
- raise ValueError(f'Unexpected run type "{self.RunType}"')
514
-
515
- return suffix, steady
516
-
517
- def _init_log_file(self):
518
- """Checks for a new log file, waiting for its creation if necessary"""
519
-
520
- # determine log file type based on self.RunType
521
- try:
522
- suffix, steady = self._determine_lf_type()
523
- except ValueError:
524
- self._no_log_file(f'run type "{self.RunType}" not supported')
525
- self._lf = None
526
- return
527
-
528
- # ensure progress bar is supported for that type
529
- if not (suffix == "lf1" and steady is False):
530
- self._no_log_file("only 1D unsteady runs are supported")
531
- self._lf = None
532
- return
533
-
534
- # find what log filepath should be
535
- lf_filepath = self._get_result_filepath(suffix)
536
-
537
- # wait for log file to exist
538
- log_file_exists = False
539
- max_time = time.time() + 10
540
-
541
- while not log_file_exists:
542
- time.sleep(0.1)
543
-
544
- log_file_exists = lf_filepath.is_file()
545
-
546
- # timeout
547
- if time.time() > max_time:
548
- self._no_log_file("log file is expected but not detected")
549
- self._lf = None
550
- return
551
-
552
- # wait for new log file
553
- old_log_file = True
554
- max_time = time.time() + 10
555
-
556
- while old_log_file:
557
- time.sleep(0.1)
558
-
559
- # difference between now and when log file was last modified
560
- last_modified_timestamp = lf_filepath.stat().st_mtime
561
- last_modified = dt.datetime.fromtimestamp(last_modified_timestamp)
562
- time_diff_sec = (dt.datetime.now() - last_modified).total_seconds()
563
-
564
- # it's old if it's over 5 seconds old (TODO: is this robust?)
565
- old_log_file = time_diff_sec > 5
566
-
567
- # timeout
568
- if time.time() > max_time:
569
- self._no_log_file("log file is from previous run")
570
- self._lf = None
571
- return
572
-
573
- # create LF instance
574
- self._lf = lf_factory(lf_filepath, suffix, steady)
575
-
576
- def _no_log_file(self, reason):
577
- """Warning that there will be no progress bar"""
578
-
579
- print("No progress bar as " + reason + ". Simulation will continue as usual.")
580
-
581
- def _update_progress_bar(self, process: Popen):
582
- """Updates progress bar based on log file"""
583
-
584
- # only if there is a log file
585
- if self._lf is None:
586
- return
587
-
588
- # tqdm progress bar
589
- for i in self._range_function(100, **self._range_settings):
590
- # Process still running
591
- while process.poll() is None:
592
- time.sleep(0.1)
593
-
594
- # Find progress
595
- self._lf.read(suppress_final_step=True)
596
- progress = self._lf.report_progress()
597
-
598
- # Reached i% progress => move onto waiting for (i+1)%
599
- if progress > i:
600
- break
601
-
602
- # Process stopped
603
- if process.poll() is not None:
604
- # Find final progress
605
- self._lf.read(suppress_final_step=True)
606
- progress = self._lf.report_progress()
607
-
608
- if progress > i:
609
- pass # stopped because it completed
610
- else:
611
- break # stopped for another reason
612
-
613
- def _summarise_exy(self):
614
- """Reads and summarises associated exy file if available"""
615
-
616
- # Get results location
617
- if hasattr(self, "Results"):
618
- exy_path = Path(self.Results).with_suffix(".exy")
619
- if not exy_path.is_absolute():
620
- # set cwd to ief location and resolve path
621
- exy_path = Path(self._filepath.parent, exy_path).resolve()
622
-
623
- else:
624
- exy_path = self._filepath.with_suffix(".exy")
625
-
626
- if exy_path.exists():
627
- exy_data = pd.read_csv(
628
- exy_path, names=["node", "timestep", "severity", "code", "summary"]
629
- )
630
- exy_data["type"] = exy_data["code"].apply(
631
- lambda x: "Error" if x < 2000 else ("Warning" if x < 3000 else "Note")
632
- )
633
- errors = len(exy_data[exy_data["type"] == "Error"])
634
- warnings = len(exy_data[exy_data["type"] == "Warning"])
635
- notes = len(exy_data[exy_data["type"] == "Note"])
636
-
637
- details = f"({errors} Error(s), {warnings} Warning(s), {notes} Note(s) ) - Check ZZD for more details."
638
-
639
- if errors > 0:
640
- return 1, f"Simulation Failed! - {details}"
641
-
642
- else:
643
- return 0, f"Simulation Completed! - {details}"
644
-
645
- else:
646
- raise FileNotFoundError("Simulation results error log (.exy) not found")
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}"