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
@@ -1,673 +1,688 @@
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 io
19
- import os
20
- import time
21
- from copy import deepcopy
22
- from pathlib import Path
23
- from subprocess import DEVNULL, Popen
24
- from typing import Callable, List, Optional, Union
25
-
26
- from lxml import etree
27
- from tqdm import trange
28
-
29
- from floodmodeller_api._base import FMFile
30
-
31
- from .logs import error_2d_dict, lf_factory
32
- from .xml2d_template import xml2d_template
33
-
34
-
35
- def value_from_string(value: Union[str, List[str]]):
36
- try:
37
- if isinstance(value, list):
38
- return value
39
- return float(value) if "." in value else int(value)
40
- except ValueError:
41
- return value
42
-
43
-
44
- def categorical_sort(itm, order, ns):
45
- try:
46
- return order[itm.tag.replace(ns, "")]
47
- except Exception:
48
- return 0
49
-
50
-
51
- class XML2D(FMFile):
52
- """Reads and write Flood Modeller 2D XML format '.xml'
53
-
54
- Args:
55
- xml_filepath (str, optional): Full filepath to xml file.
56
-
57
- Output:
58
- Initiates 'XML' class object
59
-
60
- Raises:
61
- TypeError: Raised if xml_filepath does not point to a .xml file
62
- FileNotFoundError: Raised if xml_filepath points to a file which does not exist
63
- """
64
-
65
- _filetype: str = "XML2D"
66
- _suffix: str = ".xml"
67
- _xsd_loc: str = "http://schema.floodmodeller.com/6.2/2d.xsd"
68
-
69
- def __init__(self, xml_filepath: Optional[Union[str, Path]] = None):
70
- try:
71
- if xml_filepath is not None:
72
- FMFile.__init__(self, xml_filepath)
73
- self._read()
74
- self._log_path = self._filepath.with_suffix(".lf2")
75
- else:
76
- self._read(from_blank=True)
77
-
78
- except Exception as e:
79
- self._handle_exception(e, when="read")
80
-
81
- def _read(self, from_blank=False):
82
- # Read xml data
83
- self._ns = "{https://www.floodmodeller.com}"
84
- # etree.register_namespace('', 'https://www.floodmodeller.com')
85
- if from_blank:
86
- self._xmltree = etree.parse(io.StringIO(xml2d_template))
87
- else:
88
- self._xmltree = etree.parse(self._filepath)
89
- self._xsd = etree.parse(self._xsd_loc)
90
- self._xsdschema = etree.XMLSchema(self._xsd)
91
- self._get_multi_value_keys()
92
-
93
- self._create_dict()
94
- # self._create_schema_dict()
95
- for key, data in self._data.items():
96
- if key == "domain":
97
- self.domains = {domain["domain_id"]: domain for domain in data}
98
- else:
99
- setattr(self, key, data)
100
- for attr in [
101
- "name",
102
- "link1d",
103
- "logfile",
104
- "domains",
105
- "restart_options",
106
- "advanced_options",
107
- "processor",
108
- "unit_system",
109
- "description",
110
- ]:
111
- if attr not in self.__dict__:
112
- setattr(self, attr, None)
113
-
114
- def _create_dict(self):
115
- """Iterate through XML Tree to add all elements as class attributes"""
116
- xml_dict = {}
117
- root = self._xmltree.getroot()
118
-
119
- xml_dict.update({"name": root.attrib["name"]})
120
-
121
- xml_dict = self._recursive_elements_to_dict(xml_dict, root)
122
- self._raw_data = xml_dict
123
- self._data = deepcopy(self._raw_data)
124
-
125
- def _recursive_elements_to_dict(self, xml_dict, tree):
126
- # Some elements can have multiple instances e.g. domains.
127
- # In these cases we need to have the id of that instance as a new key on the domain
128
- # e.g. xml.domains[domain_id]["computational_area"]... etc
129
-
130
- for child in tree:
131
- if isinstance(child, etree._Comment):
132
- continue # Skips comments in xml
133
- child_key = child.tag.replace(self._ns, "")
134
- if child_key in self._multi_value_keys:
135
- if child_key in xml_dict:
136
- xml_dict[child_key].append({})
137
- else:
138
- xml_dict[child_key] = [{}]
139
- child_dict = xml_dict[child_key][-1]
140
- else:
141
- xml_dict[child_key] = {} # Create new key for element
142
- child_dict = xml_dict[child_key]
143
- value = "" if child.text is None else child.text.strip()
144
- if "\n" in value:
145
- value = value.split("\n") # Only used for output variables
146
- if len(child.attrib) != 0:
147
- child_dict.update(child.attrib)
148
- if value != "":
149
- child_dict.update({"value": value_from_string(value)})
150
-
151
- self._recursive_elements_to_dict(child_dict, child)
152
-
153
- elif value == "":
154
- self._recursive_elements_to_dict(child_dict, child)
155
-
156
- else:
157
- if child_key in self._multi_value_keys:
158
- xml_dict[child_key] = xml_dict[child_key][:-1] # remove unused dict
159
- xml_dict[child_key].append(value_from_string(value))
160
- else:
161
- xml_dict[child_key] = value_from_string(value)
162
-
163
- return xml_dict
164
-
165
- def _recursive_reorder_xml(self, parent="ROOT"):
166
- if parent == "ROOT":
167
- parent = self._xmltree.getroot()
168
- parent[:] = self._sort_from_schema(parent)
169
-
170
- for child in parent:
171
- if not isinstance(child, etree._Comment):
172
- self._recursive_reorder_xml(child)
173
-
174
- def _sort_from_schema(self, parent):
175
- # find element in schema
176
- parent_name = parent.tag.replace(self._ns, "")
177
- schema_elem = self._xsd.find(
178
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent_name}']"
179
- )
180
- if "type" in schema_elem.attrib:
181
- schema_elem = self._xsd.find(
182
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{schema_elem.attrib['type']}']"
183
- )
184
- else:
185
- schema_elem = schema_elem.find("{http://www.w3.org/2001/XMLSchema}complexType")
186
- if schema_elem is None:
187
- return parent.getchildren()
188
-
189
- seq = schema_elem.find("{http://www.w3.org/2001/XMLSchema}sequence")
190
- if seq is None:
191
- return parent.getchildren()
192
-
193
- categorical_order = {sub_element.attrib["name"]: idx for idx, sub_element in enumerate(seq)}
194
- return sorted(
195
- parent.getchildren(),
196
- key=lambda x: categorical_sort(x, categorical_order, self._ns),
197
- )
198
-
199
- def _validate(self):
200
- try:
201
- self._xsdschema.assert_(self._xmltree)
202
- except AssertionError as err:
203
- msg = (
204
- f"XML Validation Error for {repr(self)}:\n"
205
- f" {err.args[0].replace(self._ns, '')}"
206
- )
207
- raise ValueError(msg) from err
208
-
209
- def _recursive_update_xml(self, new_dict, orig_dict, parent_key, list_idx=None): # noqa: C901
210
- # TODO: Handle removing params
211
-
212
- for key, item in new_dict.items():
213
- if key in self._multi_value_keys and not isinstance(item, list):
214
- raise Exception(f"Element: '{key}' must be added as list")
215
- if parent_key == "ROOT":
216
- parent = self._xmltree.getroot()
217
- else:
218
- parent = self._xmltree.findall(f".//{self._ns}{parent_key}")[list_idx or 0]
219
-
220
- if key not in orig_dict:
221
- # New key added, add recursively
222
- self._recursive_add_element(parent=parent, add_item=item, add_key=key)
223
-
224
- elif isinstance(item, dict):
225
- self._recursive_update_xml(item, orig_dict[key], key, list_idx)
226
- elif isinstance(item, list) and isinstance(item[0], dict):
227
- for i, _item in enumerate(item):
228
- if isinstance(_item, dict):
229
- try:
230
- self._recursive_update_xml(_item, orig_dict[key][i], key, list_idx=i)
231
- except IndexError:
232
- # New thing added, Add it all recursively
233
- self._recursive_add_element(
234
- parent=parent,
235
- add_item=_item,
236
- add_key=key,
237
- from_list=True,
238
- )
239
-
240
- else:
241
- if parent_key == "ROOT":
242
- item = getattr(self, key)
243
- try:
244
- if not item == orig_dict[key]:
245
- if key == "value":
246
- # Value has been updated
247
- parent.text = str(item)
248
- else:
249
- # Attribute has been updated
250
- elems = parent.findall(f"{self._ns}{key}")
251
- if isinstance(item, list) and key != "variables":
252
- # Handle multiple similar elements
253
- if len(elems) < len(item):
254
- while len(elems) < len(item):
255
- elems.append(etree.SubElement(parent, f"{self._ns}{key}"))
256
- elif len(elems) > len(item):
257
- while len(elems) > len(item):
258
- parent.remove(elems.pop())
259
-
260
- for i in range(len(elems)):
261
- elems[i].text = item[i]
262
-
263
- elif len(elems) == 1:
264
- elem = elems[0]
265
- if isinstance(item, list):
266
- elem.text = "\n".join(item)
267
- else:
268
- elem.text = str(item)
269
-
270
- else:
271
- parent.set(key, str(item))
272
- except KeyError:
273
- # New value/attribute added
274
- self._recursive_add_element(parent=parent, add_item=item, add_key=key)
275
-
276
- def _recursive_add_element(self, parent, add_item, add_key, from_list=False): # noqa: C901
277
- if add_key in self._multi_value_keys and not isinstance(add_item, list) and not from_list:
278
- raise Exception(f"Element: '{add_key}' must be added as list")
279
- if isinstance(add_item, dict):
280
- new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
281
- for key, item in add_item.items():
282
- self._recursive_add_element(parent=new_element, add_item=item, add_key=key)
283
- elif isinstance(add_item, list):
284
- # new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
285
- if add_key == "variables":
286
- # Variables is special case where we have list but add to one element
287
- new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
288
- new_element.text = "\n".join(add_item)
289
- else:
290
- for item in add_item:
291
- self._recursive_add_element(
292
- parent=parent, add_item=item, add_key=add_key, from_list=True
293
- )
294
- else:
295
- if add_key == "value": # Value has been added
296
- parent.text = str(add_item)
297
- else: # Attribute or element added
298
- # Check schema to see if we should use parent.set for attribute
299
- # or etree.subelement() and set text
300
- schema_elem = self._xsd.findall(
301
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']"
302
- )
303
- if len(schema_elem) == 1:
304
- schema_elem = schema_elem[0]
305
- else:
306
- # This is just here for when there's multiple schema elements with same
307
- # name, e.g. 'frequency'
308
- parent_schema_elem = self._xsd.find(
309
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent.tag.replace(self._ns, '')}']"
310
- )
311
- if "type" in parent_schema_elem.attrib:
312
- parent_schema_elem = self._xsd.find(
313
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent_schema_elem.attrib['type']}']"
314
- )
315
- schema_elem = parent_schema_elem.find(
316
- f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']"
317
- )
318
-
319
- if schema_elem.tag.endswith("attribute"):
320
- parent.set(add_key, str(add_item))
321
-
322
- else:
323
- new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
324
- new_element.text = str(add_item)
325
-
326
- def _recursive_remove_data_xml(self, new_dict, parent, list_idx=None):
327
- # This method will recursively work through the original dictionary and remove any
328
- # items that are not in the new_dictionary and need to be removed.
329
- list_idx = 0
330
- list_idx_key = ""
331
- for elem in parent:
332
- if isinstance(elem, etree._Comment):
333
- continue # Skips comments in xml
334
- # Check each element is in the new_dict somewhere, delete if not
335
- elem_key = elem.tag.replace(self._ns, "")
336
- if elem_key in self._multi_value_keys:
337
- if not list_idx_key == elem_key:
338
- list_idx_key = elem_key
339
- list_idx = 0
340
- try:
341
- self._recursive_remove_data_xml(new_dict[elem_key][list_idx], elem)
342
- list_idx += 1
343
- except (IndexError, KeyError):
344
- parent.remove(elem)
345
-
346
- elif elem_key in new_dict:
347
- self._recursive_remove_data_xml(new_dict[elem_key], elem)
348
-
349
- else:
350
- parent.remove(elem)
351
-
352
- def _update_dict(self):
353
- self._data = {}
354
- for attr in [
355
- "name",
356
- "link1d",
357
- "logfile",
358
- "domains",
359
- "restart_options",
360
- "advanced_options",
361
- "processor",
362
- "unit_system",
363
- "description",
364
- ]:
365
- if getattr(self, attr) is not None:
366
- if attr == "domains":
367
- self._data["domain"] = [domain for _, domain in self.domains.items()]
368
- else:
369
- try:
370
- self._data[attr] = getattr(self, attr)
371
- except AttributeError:
372
- self._data[attr] = None
373
-
374
- def _write(self) -> str:
375
- orig_xml_tree = deepcopy(self._xmltree)
376
- try:
377
- self._update_dict()
378
- self._recursive_update_xml(self._data, self._raw_data, "ROOT")
379
- self._recursive_remove_data_xml(self._data, self._xmltree.getroot())
380
- etree.indent(self._xmltree, space=" ")
381
- try:
382
- self._validate()
383
- except Exception:
384
- self._recursive_reorder_xml()
385
- self._validate()
386
-
387
- self._raw_data = deepcopy(self._data) # reset raw data to equal data
388
-
389
- return f'<?xml version="1.0" standalone="yes"?>\n{etree.tostring(self._xmltree.getroot()).decode()}'
390
-
391
- except Exception as e:
392
- self._xmltree = orig_xml_tree
393
- self._handle_exception(e, when="write")
394
-
395
- def _get_multi_value_keys(self):
396
- self._multi_value_keys = []
397
- root = self._xsd.getroot()
398
- for elem in root.findall(".//{http://www.w3.org/2001/XMLSchema}element"):
399
- if elem.attrib.get("maxOccurs") not in (None, "0", "1"):
400
- self._multi_value_keys.append(elem.attrib["name"])
401
- self._multi_value_keys = set(self._multi_value_keys)
402
-
403
- def diff(self, other: "XML2D", force_print: bool = False) -> None:
404
- """Compares the XML2D class against another XML2D class to check whether they are
405
- equivalent, or if not, what the differences are. Two instances of a XML2D class are
406
- deemed equivalent if all of their attributes are equal except for the filepath and
407
- raw data. For example, two XML2D files from different filepaths that had the same
408
- data except maybe some differences in decimal places and some default parameters
409
- ommitted, would be classed as equaivalent as they would produce the same XML2D instance
410
- and write the exact same data.
411
-
412
- The result is printed to the console. If you need to access the returned data, use
413
- the method ``XML2D._get_diff()``
414
-
415
- Args:
416
- other (floodmodeller_api.XML2D): Other instance of a XML2D class
417
- force_print (bool): Forces the API to print every difference found, rather than
418
- just the first 25 differences. Defaults to False.
419
- """
420
- self._diff(other, force_print=force_print)
421
-
422
- def update(self) -> None:
423
- """Updates the existing XML based on any altered attributes"""
424
- self._update()
425
-
426
- # Update XML dict and tree
427
- self._read()
428
-
429
- def save(self, filepath: Optional[Union[str, Path]]):
430
- """Saves the XML to the given location, if pointing to an existing file it will be overwritten.
431
- Once saved, the XML() class will continue working from the saved location, therefore any further calls to XML.update() will
432
- update in the latest saved location rather than the original source XML used to construct the class
433
-
434
- Args:
435
- filepath (str): Filepath to new save location including the name and '.xml' extension
436
-
437
- Raises:
438
- TypeError: Raised if given filepath doesn't point to a file suffixed '.xml'
439
- """
440
-
441
- self._save(filepath)
442
-
443
- # Update XML dict and tree
444
- self._read()
445
- self._log_path = self._filepath.with_suffix(".lf2")
446
-
447
- def simulate( # noqa: C901
448
- self,
449
- method: str = "WAIT",
450
- raise_on_failure: bool = True,
451
- precision: str = "DEFAULT",
452
- enginespath: str = "",
453
- console_output: str = "simple",
454
- range_function: Callable = trange,
455
- range_settings: Optional[dict] = None,
456
- ) -> Optional[Popen]:
457
- """Simulate the XML2D file directly as a subprocess.
458
-
459
- Args:
460
- method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
461
- 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
462
- 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
463
- Defaults to 'WAIT'.
464
- raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
465
- If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
466
- then this argument is ignored. Defaults to True.
467
- precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
468
- Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
469
- this can be overwritten using 'SINGLE' or 'DOUBLE'.
470
- enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
471
- Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
472
- anything other than ''.
473
- console_output (str, optional): {'simple'} | 'standard' | 'detailed'
474
- 'simple' - A simple progress bar for the simulation is presented in the console
475
- 'standard' - The standard Flood Modeller 2D output is presented in the console
476
- 'detailed' - The most detailed Flood Modeller 2D output is presented in the console
477
- Defaults to 'WAIT'.
478
-
479
-
480
- Raises:
481
- UserWarning: Raised if ief filepath not already specified
482
-
483
- Returns:
484
- subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
485
-
486
- """
487
-
488
- # TODO:
489
- # - Clean up the lf code?
490
- # - Remove or sort out get results
491
-
492
- self.range_function = range_function
493
- self.range_settings = range_settings if range_settings else {}
494
-
495
- try:
496
- if self._filepath is None:
497
- raise UserWarning(
498
- "xml2D must be saved to a specific filepath before simulate() can be called."
499
- )
500
- if precision.upper() == "DEFAULT":
501
- precision = "SINGLE" # defaults to single precision
502
- for _, domain in self.domains.items():
503
- if domain["run_data"].get("double_precision") == "required":
504
- precision = "DOUBLE"
505
- break
506
-
507
- if enginespath == "":
508
- # Default location
509
- _enginespath = r"C:\Program Files\Flood Modeller\bin"
510
- else:
511
- _enginespath = enginespath
512
- if not Path(_enginespath).exists():
513
- raise Exception(
514
- f"Flood Modeller non-default engine path not found! {str(_enginespath)}"
515
- )
516
-
517
- # checking if all schemes used are fast, if so will use FAST.exe
518
- # TODO: Add in option to choose to use or not to use if you can
519
- is_fast = True
520
- for _, domain in self.domains.items():
521
- if domain["run_data"]["scheme"] != "FAST":
522
- is_fast = False
523
- break
524
-
525
- if is_fast is True:
526
- isis2d_fp = str(Path(_enginespath, "FAST.exe"))
527
- elif precision.upper() == "SINGLE":
528
- isis2d_fp = str(Path(_enginespath, "ISIS2d.exe"))
529
- else:
530
- isis2d_fp = str(Path(_enginespath, "ISIS2d_DP.exe"))
531
-
532
- if not Path(isis2d_fp).exists():
533
- raise Exception(f"Flood Modeller engine not found! Expected location: {isis2d_fp}")
534
-
535
- console_output = console_output.lower()
536
- run_command = (
537
- f'"{isis2d_fp}" {"-q" if console_output != "detailed" else ""} "{self._filepath}"'
538
- )
539
- stdout = DEVNULL if console_output == "simple" else None
540
-
541
- if method.upper() == "WAIT":
542
- print("Executing simulation ... ")
543
- # execute simulation
544
- process = Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
545
-
546
- # progress bar based on log files:
547
- if console_output == "simple":
548
- self._init_log_file()
549
- self._update_progress_bar(process)
550
-
551
- while process.poll() is None:
552
- # process is still running
553
- time.sleep(1)
554
-
555
- exitcode = process.returncode
556
- self._interpret_exit_code(exitcode, raise_on_failure)
557
-
558
- elif method.upper() == "RETURN_PROCESS":
559
- print("Executing simulation ...")
560
- # execute simulation
561
- process = Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
562
- return process
563
-
564
- return None
565
-
566
- except Exception as e:
567
- self._handle_exception(e, when="simulate")
568
-
569
- def get_log(self):
570
- """If log files for the simulation exist, this function returns them as a LF2 class object
571
-
572
- Returns:
573
- floodmodeller_api.LF2 class object
574
- """
575
- if not self._log_path.exists():
576
- raise FileNotFoundError("Log file (LF2) not found")
577
-
578
- return lf_factory(self._log_path, "lf2", False)
579
-
580
- def _init_log_file(self):
581
- """Checks for a new log file, waiting for its creation if necessary"""
582
- # wait for log file to exist
583
- log_file_exists = False
584
- max_time = time.time() + 10
585
-
586
- while not log_file_exists:
587
- time.sleep(0.1)
588
- log_file_exists = self._log_path.is_file()
589
-
590
- # timeout
591
- if time.time() > max_time:
592
- self._no_log_file("log file is expected but not detected")
593
- self._lf = None
594
- return
595
-
596
- # wait for new log file
597
- old_log_file = True
598
- max_time = time.time() + 10
599
-
600
- while old_log_file:
601
- time.sleep(0.1)
602
-
603
- # difference between now and when log file was last modified
604
- last_modified_timestamp = self._log_path.stat().st_mtime
605
- last_modified = dt.datetime.fromtimestamp(last_modified_timestamp)
606
- time_diff_sec = (dt.datetime.now() - last_modified).total_seconds()
607
-
608
- # it's old if it's over 5 seconds old (TODO: is this robust?)
609
- old_log_file = time_diff_sec > 5
610
-
611
- # timeout
612
- if time.time() > max_time:
613
- self._no_log_file("log file is from previous run")
614
- self._lf = None
615
- return
616
-
617
- # create LF instance
618
- self._lf = lf_factory(self._log_path, "lf2", False)
619
-
620
- def _no_log_file(self, reason):
621
- """Warning that there will be no progress bar"""
622
-
623
- print("No progress bar as " + reason + ". Simulation will continue as usual.")
624
-
625
- def _update_progress_bar(self, process: Popen):
626
- """Updates progress bar based on log file"""
627
-
628
- # only if there is a log file
629
- if self._lf is None:
630
- return
631
-
632
- # tqdm progress bar
633
- for i in self.range_function(100, **self.range_settings):
634
- # Process still running
635
- while process.poll() is None:
636
- time.sleep(0.1)
637
-
638
- # Find progress
639
- self._lf.read(suppress_final_step=True)
640
- progress = self._lf.report_progress()
641
-
642
- # Reached i% progress => move onto waiting for (i+1)%
643
- if progress > i:
644
- break
645
-
646
- # Process stopped
647
- if process.poll() is not None:
648
- # Find final progress
649
- self._lf.read(suppress_final_step=True)
650
- progress = self._lf.report_progress()
651
-
652
- if progress > i:
653
- pass # stopped because it completed
654
- else:
655
- break # stopped for another reason
656
-
657
- def _interpret_exit_code(self, exitcode: int, raise_on_failure: bool):
658
- """This function will interpret the exit code and tell us if this is good or bad
659
-
660
- Args:
661
- exitcode - this is the exitcode from the simulation
662
-
663
- Return:
664
- String that explains the exitcode - this might be too much!
665
- """
666
- try:
667
- msg = f"Exit with {exitcode}: {error_2d_dict[exitcode]}"
668
- except Exception:
669
- msg = f"Exit with {exitcode}: Unknown error occurred!"
670
-
671
- if raise_on_failure and exitcode != 100:
672
- raise Exception(msg)
673
- print(msg)
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 io
21
+ import os
22
+ import time
23
+ from copy import deepcopy
24
+ from pathlib import Path
25
+ from subprocess import DEVNULL, Popen
26
+ from typing import Callable
27
+
28
+ from lxml import etree
29
+ from tqdm import trange
30
+
31
+ from floodmodeller_api._base import FMFile
32
+
33
+ from .logs import error_2d_dict, lf_factory
34
+ from .xml2d_template import xml2d_template
35
+
36
+
37
+ def value_from_string(value: str | list[str]):
38
+ try:
39
+ if isinstance(value, list):
40
+ return value
41
+ return float(value) if "." in value else int(value)
42
+ except ValueError:
43
+ return value
44
+
45
+
46
+ def categorical_sort(itm, order, ns):
47
+ try:
48
+ return order[itm.tag.replace(ns, "")]
49
+ except Exception:
50
+ return 0
51
+
52
+
53
+ class XML2D(FMFile):
54
+ """Reads and write Flood Modeller 2D XML format '.xml'
55
+
56
+ Args:
57
+ xml_filepath (str, optional): Full filepath to xml file.
58
+
59
+ Output:
60
+ Initiates 'XML' class object
61
+
62
+ Raises:
63
+ TypeError: Raised if xml_filepath does not point to a .xml file
64
+ FileNotFoundError: Raised if xml_filepath points to a file which does not exist
65
+ """
66
+
67
+ _filetype: str = "XML2D"
68
+ _suffix: str = ".xml"
69
+ _xsd_loc: str = "http://schema.floodmodeller.com/6.2/2d.xsd"
70
+ OLD_FILE = 5
71
+ GOOD_EXIT_CODE = 100
72
+
73
+ def __init__(self, xml_filepath: str | Path | None = None, from_json: bool = False):
74
+ try:
75
+ if from_json:
76
+ return
77
+ if xml_filepath is not None:
78
+ FMFile.__init__(self, xml_filepath)
79
+ self._read()
80
+ self._log_path = self._filepath.with_suffix(".lf2")
81
+ else:
82
+ self._read(from_blank=True)
83
+
84
+ except Exception as e:
85
+ self._handle_exception(e, when="read")
86
+
87
+ def _read(self, from_blank=False):
88
+ # Read xml data
89
+ self._ns = "{https://www.floodmodeller.com}"
90
+ if from_blank:
91
+ self._xmltree = etree.parse(io.StringIO(xml2d_template))
92
+ else:
93
+ self._xmltree = etree.parse(self._filepath)
94
+ self._xsd = etree.parse(self._xsd_loc)
95
+ self._xsdschema = etree.XMLSchema(self._xsd)
96
+ self._get_multi_value_keys()
97
+
98
+ self._create_dict()
99
+ for key, data in self._data.items():
100
+ if key == "domain":
101
+ self.domains = {domain["domain_id"]: domain for domain in data}
102
+ else:
103
+ setattr(self, key, data)
104
+ for attr in [
105
+ "name",
106
+ "link1d",
107
+ "logfile",
108
+ "domains",
109
+ "restart_options",
110
+ "advanced_options",
111
+ "processor",
112
+ "unit_system",
113
+ "description",
114
+ ]:
115
+ if attr not in self.__dict__:
116
+ setattr(self, attr, None)
117
+
118
+ def _create_dict(self):
119
+ """Iterate through XML Tree to add all elements as class attributes"""
120
+ xml_dict = {}
121
+ root = self._xmltree.getroot()
122
+
123
+ xml_dict.update({"name": root.attrib["name"]})
124
+
125
+ xml_dict = self._recursive_elements_to_dict(xml_dict, root)
126
+ self._raw_data = xml_dict
127
+ self._data = deepcopy(self._raw_data)
128
+
129
+ def _recursive_elements_to_dict(self, xml_dict, tree):
130
+ # Some elements can have multiple instances e.g. domains.
131
+ # In these cases we need to have the id of that instance as a new key on the domain
132
+ # e.g. xml.domains[domain_id]["computational_area"]... etc
133
+
134
+ for child in tree:
135
+ if isinstance(child, etree._Comment):
136
+ continue # Skips comments in xml
137
+ child_key = child.tag.replace(self._ns, "")
138
+ if child_key in self._multi_value_keys:
139
+ if child_key in xml_dict:
140
+ xml_dict[child_key].append({})
141
+ else:
142
+ xml_dict[child_key] = [{}]
143
+ child_dict = xml_dict[child_key][-1]
144
+ else:
145
+ xml_dict[child_key] = {} # Create new key for element
146
+ child_dict = xml_dict[child_key]
147
+ value = "" if child.text is None else child.text.strip()
148
+ if "\n" in value:
149
+ value = value.split("\n") # Only used for output variables
150
+ if len(child.attrib) != 0:
151
+ child_dict.update(child.attrib)
152
+ if value != "":
153
+ child_dict.update({"value": value_from_string(value)})
154
+
155
+ self._recursive_elements_to_dict(child_dict, child)
156
+
157
+ elif value == "":
158
+ self._recursive_elements_to_dict(child_dict, child)
159
+
160
+ elif child_key in self._multi_value_keys:
161
+ xml_dict[child_key] = xml_dict[child_key][:-1] # remove unused dict
162
+ xml_dict[child_key].append(value_from_string(value))
163
+ else:
164
+ xml_dict[child_key] = value_from_string(value)
165
+
166
+ return xml_dict
167
+
168
+ def _recursive_reorder_xml(self, parent="ROOT"):
169
+ if parent == "ROOT":
170
+ parent = self._xmltree.getroot()
171
+ parent[:] = self._sort_from_schema(parent)
172
+
173
+ for child in parent:
174
+ if not isinstance(child, etree._Comment):
175
+ self._recursive_reorder_xml(child)
176
+
177
+ def _sort_from_schema(self, parent):
178
+ # find element in schema
179
+ parent_name = parent.tag.replace(self._ns, "")
180
+ schema_elem = self._xsd.find(
181
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent_name}']",
182
+ )
183
+ if "type" in schema_elem.attrib:
184
+ schema_elem = self._xsd.find(
185
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{schema_elem.attrib['type']}']",
186
+ )
187
+ else:
188
+ schema_elem = schema_elem.find("{http://www.w3.org/2001/XMLSchema}complexType")
189
+ if schema_elem is None:
190
+ return parent.getchildren()
191
+
192
+ seq = schema_elem.find("{http://www.w3.org/2001/XMLSchema}sequence")
193
+ if seq is None:
194
+ return parent.getchildren()
195
+
196
+ categorical_order = {sub_element.attrib["name"]: idx for idx, sub_element in enumerate(seq)}
197
+ return sorted(
198
+ parent.getchildren(),
199
+ key=lambda x: categorical_sort(x, categorical_order, self._ns),
200
+ )
201
+
202
+ def _validate(self):
203
+ try:
204
+ self._xsdschema.assert_(self._xmltree)
205
+ except AssertionError as err:
206
+ msg = (
207
+ f"XML Validation Error for {repr(self)}:\n"
208
+ f" {err.args[0].replace(self._ns, '')}"
209
+ )
210
+ raise ValueError(msg) from err
211
+
212
+ def _recursive_update_xml( # noqa: C901, PLR0912
213
+ self,
214
+ new_dict,
215
+ orig_dict,
216
+ parent_key,
217
+ list_idx=None,
218
+ ):
219
+ # TODO: Handle removing params
220
+
221
+ for key, item in new_dict.items():
222
+ if key in self._multi_value_keys and not isinstance(item, list):
223
+ raise Exception(f"Element: '{key}' must be added as list")
224
+ if parent_key == "ROOT":
225
+ parent = self._xmltree.getroot()
226
+ else:
227
+ parent = self._xmltree.findall(f".//{self._ns}{parent_key}")[list_idx or 0]
228
+
229
+ if key not in orig_dict:
230
+ # New key added, add recursively
231
+ self._recursive_add_element(parent=parent, add_item=item, add_key=key)
232
+
233
+ elif isinstance(item, dict):
234
+ self._recursive_update_xml(item, orig_dict[key], key, list_idx)
235
+ elif isinstance(item, list) and isinstance(item[0], dict):
236
+ for i, _item in enumerate(item):
237
+ if isinstance(_item, dict):
238
+ try:
239
+ self._recursive_update_xml(_item, orig_dict[key][i], key, list_idx=i)
240
+ except IndexError:
241
+ # New thing added, Add it all recursively
242
+ self._recursive_add_element(
243
+ parent=parent,
244
+ add_item=_item,
245
+ add_key=key,
246
+ from_list=True,
247
+ )
248
+
249
+ else:
250
+ if parent_key == "ROOT":
251
+ item = getattr(self, key)
252
+ try:
253
+ if item != orig_dict[key]:
254
+ if key == "value":
255
+ # Value has been updated
256
+ parent.text = str(item)
257
+ else:
258
+ # Attribute has been updated
259
+ elems = parent.findall(f"{self._ns}{key}")
260
+ if isinstance(item, list) and key != "variables":
261
+ # Handle multiple similar elements
262
+ if len(elems) < len(item):
263
+ while len(elems) < len(item):
264
+ elems.append(etree.SubElement(parent, f"{self._ns}{key}"))
265
+ elif len(elems) > len(item):
266
+ while len(elems) > len(item):
267
+ parent.remove(elems.pop())
268
+
269
+ for i in range(len(elems)):
270
+ elems[i].text = item[i]
271
+
272
+ elif len(elems) == 1:
273
+ elem = elems[0]
274
+ if isinstance(item, list):
275
+ elem.text = "\n".join(item)
276
+ else:
277
+ elem.text = str(item)
278
+
279
+ else:
280
+ parent.set(key, str(item))
281
+ except KeyError:
282
+ # New value/attribute added
283
+ self._recursive_add_element(parent=parent, add_item=item, add_key=key)
284
+
285
+ def _recursive_add_element( # noqa: C901, PLR0912
286
+ self,
287
+ parent,
288
+ add_item,
289
+ add_key,
290
+ from_list=False,
291
+ ):
292
+ if add_key in self._multi_value_keys and not isinstance(add_item, list) and not from_list:
293
+ raise Exception(f"Element: '{add_key}' must be added as list")
294
+ if isinstance(add_item, dict):
295
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
296
+ for key, item in add_item.items():
297
+ self._recursive_add_element(parent=new_element, add_item=item, add_key=key)
298
+ elif isinstance(add_item, list):
299
+ if add_key == "variables":
300
+ # Variables is special case where we have list but add to one element
301
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
302
+ new_element.text = "\n".join(add_item)
303
+ else:
304
+ for item in add_item:
305
+ self._recursive_add_element(
306
+ parent=parent,
307
+ add_item=item,
308
+ add_key=add_key,
309
+ from_list=True,
310
+ )
311
+ elif add_key == "value": # Value has been added
312
+ parent.text = str(add_item)
313
+ else: # Attribute or element added
314
+ # Check schema to see if we should use parent.set for attribute
315
+ # or etree.subelement() and set text
316
+ schema_elem = self._xsd.findall(
317
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']",
318
+ )
319
+ if len(schema_elem) == 1:
320
+ schema_elem = schema_elem[0]
321
+ else:
322
+ # This is just here for when there's multiple schema elements with same
323
+ # name, e.g. 'frequency'
324
+ parent_schema_elem = self._xsd.find(
325
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent.tag.replace(self._ns, '')}']",
326
+ )
327
+ if "type" in parent_schema_elem.attrib:
328
+ parent_schema_elem = self._xsd.find(
329
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent_schema_elem.attrib['type']}']",
330
+ )
331
+ schema_elem = parent_schema_elem.find(
332
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']",
333
+ )
334
+
335
+ if schema_elem.tag.endswith("attribute"):
336
+ parent.set(add_key, str(add_item))
337
+
338
+ else:
339
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
340
+ new_element.text = str(add_item)
341
+
342
+ def _recursive_remove_data_xml(self, new_dict, parent, list_idx=None):
343
+ # This method will recursively work through the original dictionary and remove any
344
+ # items that are not in the new_dictionary and need to be removed.
345
+ list_idx = 0
346
+ list_idx_key = ""
347
+ for elem in parent:
348
+ if isinstance(elem, etree._Comment):
349
+ continue # Skips comments in xml
350
+ # Check each element is in the new_dict somewhere, delete if not
351
+ elem_key = elem.tag.replace(self._ns, "")
352
+ if elem_key in self._multi_value_keys:
353
+ if list_idx_key != elem_key:
354
+ list_idx_key = elem_key
355
+ list_idx = 0
356
+ try:
357
+ self._recursive_remove_data_xml(new_dict[elem_key][list_idx], elem)
358
+ list_idx += 1
359
+ except (IndexError, KeyError):
360
+ parent.remove(elem)
361
+
362
+ elif elem_key in new_dict:
363
+ self._recursive_remove_data_xml(new_dict[elem_key], elem)
364
+
365
+ else:
366
+ parent.remove(elem)
367
+
368
+ def _update_dict(self):
369
+ self._data = {}
370
+ for attr in [
371
+ "name",
372
+ "link1d",
373
+ "logfile",
374
+ "domains",
375
+ "restart_options",
376
+ "advanced_options",
377
+ "processor",
378
+ "unit_system",
379
+ "description",
380
+ ]:
381
+ if getattr(self, attr) is not None:
382
+ if attr == "domains":
383
+ self._data["domain"] = [domain for _, domain in self.domains.items()]
384
+ else:
385
+ try:
386
+ self._data[attr] = getattr(self, attr)
387
+ except AttributeError:
388
+ self._data[attr] = None
389
+
390
+ def _write(self) -> str:
391
+ orig_xml_tree = deepcopy(self._xmltree)
392
+ try:
393
+ self._update_dict()
394
+ self._recursive_update_xml(self._data, self._raw_data, "ROOT")
395
+ self._recursive_remove_data_xml(self._data, self._xmltree.getroot())
396
+ etree.indent(self._xmltree, space=" ")
397
+ try:
398
+ self._validate()
399
+ except Exception:
400
+ self._recursive_reorder_xml()
401
+ self._validate()
402
+
403
+ self._raw_data = deepcopy(self._data) # reset raw data to equal data
404
+
405
+ return f'<?xml version="1.0" standalone="yes"?>\n{etree.tostring(self._xmltree.getroot()).decode()}'
406
+
407
+ except Exception as e:
408
+ self._xmltree = orig_xml_tree
409
+ self._handle_exception(e, when="write")
410
+
411
+ def _get_multi_value_keys(self):
412
+ self._multi_value_keys = []
413
+ root = self._xsd.getroot()
414
+ for elem in root.findall(".//{http://www.w3.org/2001/XMLSchema}element"):
415
+ if elem.attrib.get("maxOccurs") not in (None, "0", "1"):
416
+ self._multi_value_keys.append(elem.attrib["name"])
417
+ self._multi_value_keys = set(self._multi_value_keys)
418
+
419
+ def diff(self, other: XML2D, force_print: bool = False) -> None:
420
+ """Compares the XML2D class against another XML2D class to check whether they are
421
+ equivalent, or if not, what the differences are. Two instances of a XML2D class are
422
+ deemed equivalent if all of their attributes are equal except for the filepath and
423
+ raw data. For example, two XML2D files from different filepaths that had the same
424
+ data except maybe some differences in decimal places and some default parameters
425
+ ommitted, would be classed as equaivalent as they would produce the same XML2D instance
426
+ and write the exact same data.
427
+
428
+ The result is printed to the console. If you need to access the returned data, use
429
+ the method ``XML2D._get_diff()``
430
+
431
+ Args:
432
+ other (floodmodeller_api.XML2D): Other instance of a XML2D class
433
+ force_print (bool): Forces the API to print every difference found, rather than
434
+ just the first 25 differences. Defaults to False.
435
+ """
436
+ self._diff(other, force_print=force_print)
437
+
438
+ def update(self) -> None:
439
+ """Updates the existing XML based on any altered attributes"""
440
+ self._update()
441
+
442
+ # Update XML dict and tree
443
+ self._read()
444
+
445
+ def save(self, filepath: str | Path | None):
446
+ """Saves the XML to the given location, if pointing to an existing file it will be overwritten.
447
+ Once saved, the XML() class will continue working from the saved location, therefore any further calls to XML.update() will
448
+ update in the latest saved location rather than the original source XML used to construct the class
449
+
450
+ Args:
451
+ filepath (str): Filepath to new save location including the name and '.xml' extension
452
+
453
+ Raises:
454
+ TypeError: Raised if given filepath doesn't point to a file suffixed '.xml'
455
+ """
456
+
457
+ self._save(filepath)
458
+
459
+ # Update XML dict and tree
460
+ self._read()
461
+ self._log_path = self._filepath.with_suffix(".lf2")
462
+
463
+ def simulate( # noqa: C901, PLR0912, PLR0913
464
+ self,
465
+ method: str = "WAIT",
466
+ raise_on_failure: bool = True,
467
+ precision: str = "DEFAULT",
468
+ enginespath: str = "",
469
+ console_output: str = "simple",
470
+ range_function: Callable = trange,
471
+ range_settings: dict | None = None,
472
+ ) -> Popen | None:
473
+ """Simulate the XML2D file directly as a subprocess.
474
+
475
+ Args:
476
+ method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
477
+ 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
478
+ 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
479
+ Defaults to 'WAIT'.
480
+ raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
481
+ If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
482
+ then this argument is ignored. Defaults to True.
483
+ precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
484
+ Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
485
+ this can be overwritten using 'SINGLE' or 'DOUBLE'.
486
+ enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
487
+ Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
488
+ anything other than ''.
489
+ console_output (str, optional): {'simple'} | 'standard' | 'detailed'
490
+ 'simple' - A simple progress bar for the simulation is presented in the console
491
+ 'standard' - The standard Flood Modeller 2D output is presented in the console
492
+ 'detailed' - The most detailed Flood Modeller 2D output is presented in the console
493
+ Defaults to 'WAIT'.
494
+
495
+
496
+ Raises:
497
+ UserWarning: Raised if ief filepath not already specified
498
+
499
+ Returns:
500
+ subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
501
+
502
+ """
503
+
504
+ # TODO:
505
+ # - Clean up the lf code?
506
+ # - Remove or sort out get results
507
+
508
+ self.range_function = range_function
509
+ self.range_settings = range_settings if range_settings else {}
510
+
511
+ try:
512
+ if self._filepath is None:
513
+ raise UserWarning(
514
+ "xml2D must be saved to a specific filepath before simulate() can be called.",
515
+ )
516
+ if precision.upper() == "DEFAULT":
517
+ precision = "SINGLE" # defaults to single precision
518
+ for _, domain in self.domains.items():
519
+ if domain["run_data"].get("double_precision") == "required":
520
+ precision = "DOUBLE"
521
+ break
522
+
523
+ if enginespath == "":
524
+ # Default location
525
+ _enginespath = r"C:\Program Files\Flood Modeller\bin"
526
+ else:
527
+ _enginespath = enginespath
528
+ if not Path(_enginespath).exists():
529
+ raise Exception(
530
+ f"Flood Modeller non-default engine path not found! {str(_enginespath)}",
531
+ )
532
+
533
+ # checking if all schemes used are fast, if so will use FAST.exe
534
+ # TODO: Add in option to choose to use or not to use if you can
535
+ is_fast = True
536
+ for _, domain in self.domains.items():
537
+ if domain["run_data"]["scheme"] != "FAST":
538
+ is_fast = False
539
+ break
540
+
541
+ if is_fast is True:
542
+ isis2d_fp = str(Path(_enginespath, "FAST.exe"))
543
+ elif precision.upper() == "SINGLE":
544
+ isis2d_fp = str(Path(_enginespath, "ISIS2d.exe"))
545
+ else:
546
+ isis2d_fp = str(Path(_enginespath, "ISIS2d_DP.exe"))
547
+
548
+ if not Path(isis2d_fp).exists():
549
+ raise Exception(f"Flood Modeller engine not found! Expected location: {isis2d_fp}")
550
+
551
+ console_output = console_output.lower()
552
+ run_command = (
553
+ f'"{isis2d_fp}" {"-q" if console_output != "detailed" else ""} "{self._filepath}"'
554
+ )
555
+ stdout = DEVNULL if console_output == "simple" else None
556
+
557
+ if method.upper() == "WAIT":
558
+ print("Executing simulation ... ")
559
+ # execute simulation
560
+ process = Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
561
+
562
+ # progress bar based on log files:
563
+ if console_output == "simple":
564
+ self._init_log_file()
565
+ self._update_progress_bar(process)
566
+
567
+ while process.poll() is None:
568
+ # process is still running
569
+ time.sleep(1)
570
+
571
+ exitcode = process.returncode
572
+ self._interpret_exit_code(exitcode, raise_on_failure)
573
+
574
+ elif method.upper() == "RETURN_PROCESS":
575
+ print("Executing simulation ...")
576
+ # execute simulation
577
+ return Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
578
+
579
+ return None
580
+
581
+ except Exception as e:
582
+ self._handle_exception(e, when="simulate")
583
+
584
+ def get_log(self):
585
+ """If log files for the simulation exist, this function returns them as a LF2 class object
586
+
587
+ Returns:
588
+ floodmodeller_api.LF2 class object
589
+ """
590
+ if not self._log_path.exists():
591
+ raise FileNotFoundError("Log file (LF2) not found")
592
+
593
+ return lf_factory(self._log_path, "lf2", False)
594
+
595
+ def _init_log_file(self):
596
+ """Checks for a new log file, waiting for its creation if necessary"""
597
+ # wait for log file to exist
598
+ log_file_exists = False
599
+ max_time = time.time() + 10
600
+
601
+ while not log_file_exists:
602
+ time.sleep(0.1)
603
+ log_file_exists = self._log_path.is_file()
604
+
605
+ # timeout
606
+ if time.time() > max_time:
607
+ self._no_log_file("log file is expected but not detected")
608
+ self._lf = None
609
+ return
610
+
611
+ # wait for new log file
612
+ old_log_file = True
613
+ max_time = time.time() + 10
614
+
615
+ while old_log_file:
616
+ time.sleep(0.1)
617
+
618
+ # difference between now and when log file was last modified
619
+ last_modified_timestamp = self._log_path.stat().st_mtime
620
+ last_modified = dt.datetime.fromtimestamp(last_modified_timestamp)
621
+ time_diff_sec = (dt.datetime.now() - last_modified).total_seconds()
622
+
623
+ # it's old if it's over self.OLD_FILE seconds old (TODO: is this robust?)
624
+ old_log_file = time_diff_sec > self.OLD_FILE
625
+
626
+ # timeout
627
+ if time.time() > max_time:
628
+ self._no_log_file("log file is from previous run")
629
+ self._lf = None
630
+ return
631
+
632
+ # create LF instance
633
+ self._lf = lf_factory(self._log_path, "lf2", False)
634
+
635
+ def _no_log_file(self, reason):
636
+ """Warning that there will be no progress bar"""
637
+
638
+ print("No progress bar as " + reason + ". Simulation will continue as usual.")
639
+
640
+ def _update_progress_bar(self, process: Popen):
641
+ """Updates progress bar based on log file"""
642
+
643
+ # only if there is a log file
644
+ if self._lf is None:
645
+ return
646
+
647
+ # tqdm progress bar
648
+ for i in self.range_function(100, **self.range_settings):
649
+ # Process still running
650
+ while process.poll() is None:
651
+ time.sleep(0.1)
652
+
653
+ # Find progress
654
+ self._lf.read(suppress_final_step=True)
655
+ progress = self._lf.report_progress()
656
+
657
+ # Reached i% progress => move onto waiting for (i+1)%
658
+ if progress > i:
659
+ break
660
+
661
+ # Process stopped
662
+ if process.poll() is not None:
663
+ # Find final progress
664
+ self._lf.read(suppress_final_step=True)
665
+ progress = self._lf.report_progress()
666
+
667
+ if progress > i:
668
+ pass # stopped because it completed
669
+ else:
670
+ break # stopped for another reason
671
+
672
+ def _interpret_exit_code(self, exitcode: int, raise_on_failure: bool):
673
+ """This function will interpret the exit code and tell us if this is good or bad
674
+
675
+ Args:
676
+ exitcode - this is the exitcode from the simulation
677
+
678
+ Return:
679
+ String that explains the exitcode - this might be too much!
680
+ """
681
+ try:
682
+ msg = f"Exit with {exitcode}: {error_2d_dict[exitcode]}"
683
+ except Exception:
684
+ msg = f"Exit with {exitcode}: Unknown error occurred!"
685
+
686
+ if raise_on_failure and exitcode != self.GOOD_EXIT_CODE:
687
+ raise Exception(msg)
688
+ print(msg)