floodmodeller-api 0.4.2.post1__py3-none-any.whl → 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. floodmodeller_api/__init__.py +8 -9
  2. floodmodeller_api/_base.py +169 -176
  3. floodmodeller_api/backup.py +273 -273
  4. floodmodeller_api/dat.py +889 -831
  5. floodmodeller_api/diff.py +136 -119
  6. floodmodeller_api/ied.py +302 -306
  7. floodmodeller_api/ief.py +553 -637
  8. floodmodeller_api/ief_flags.py +253 -253
  9. floodmodeller_api/inp.py +260 -266
  10. floodmodeller_api/libs/libifcoremd.dll +0 -0
  11. floodmodeller_api/libs/libifcoremt.so.5 +0 -0
  12. floodmodeller_api/libs/libifport.so.5 +0 -0
  13. floodmodeller_api/{libmmd.dll → libs/libimf.so} +0 -0
  14. floodmodeller_api/libs/libintlc.so.5 +0 -0
  15. floodmodeller_api/libs/libmmd.dll +0 -0
  16. floodmodeller_api/libs/libsvml.so +0 -0
  17. floodmodeller_api/libs/libzzn_read.so +0 -0
  18. floodmodeller_api/libs/zzn_read.dll +0 -0
  19. floodmodeller_api/logs/__init__.py +2 -2
  20. floodmodeller_api/logs/lf.py +364 -312
  21. floodmodeller_api/logs/lf_helpers.py +354 -352
  22. floodmodeller_api/logs/lf_params.py +643 -529
  23. floodmodeller_api/mapping.py +84 -0
  24. floodmodeller_api/test/__init__.py +4 -4
  25. floodmodeller_api/test/conftest.py +16 -8
  26. floodmodeller_api/test/test_backup.py +117 -117
  27. floodmodeller_api/test/test_conveyance.py +107 -0
  28. floodmodeller_api/test/test_dat.py +222 -92
  29. floodmodeller_api/test/test_data/All Units 4_6.DAT +1081 -1081
  30. floodmodeller_api/test/test_data/All Units 4_6.feb +1081 -1081
  31. floodmodeller_api/test/test_data/BRIDGE.DAT +926 -926
  32. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.dat +36 -36
  33. floodmodeller_api/test/test_data/Culvert_Inlet_Outlet.feb +36 -36
  34. floodmodeller_api/test/test_data/DamBreakADI.xml +52 -52
  35. floodmodeller_api/test/test_data/DamBreakFAST.xml +58 -58
  36. floodmodeller_api/test/test_data/DamBreakFAST_dy.xml +53 -53
  37. floodmodeller_api/test/test_data/DamBreakTVD.xml +55 -55
  38. floodmodeller_api/test/test_data/DefenceBreach.xml +53 -53
  39. floodmodeller_api/test/test_data/DefenceBreachFAST.xml +60 -60
  40. floodmodeller_api/test/test_data/DefenceBreachFAST_dy.xml +55 -55
  41. floodmodeller_api/test/test_data/Domain1+2_QH.xml +76 -76
  42. floodmodeller_api/test/test_data/Domain1_H.xml +41 -41
  43. floodmodeller_api/test/test_data/Domain1_Q.xml +41 -41
  44. floodmodeller_api/test/test_data/Domain1_Q_FAST.xml +48 -48
  45. floodmodeller_api/test/test_data/Domain1_Q_FAST_dy.xml +48 -48
  46. floodmodeller_api/test/test_data/Domain1_Q_xml_expected.json +263 -0
  47. floodmodeller_api/test/test_data/Domain1_W.xml +41 -41
  48. floodmodeller_api/test/test_data/EX1.DAT +321 -321
  49. floodmodeller_api/test/test_data/EX1.ext +107 -107
  50. floodmodeller_api/test/test_data/EX1.feb +320 -320
  51. floodmodeller_api/test/test_data/EX1.gxy +107 -107
  52. floodmodeller_api/test/test_data/EX17.DAT +421 -422
  53. floodmodeller_api/test/test_data/EX17.ext +213 -213
  54. floodmodeller_api/test/test_data/EX17.feb +422 -422
  55. floodmodeller_api/test/test_data/EX18.DAT +375 -375
  56. floodmodeller_api/test/test_data/EX18_DAT_expected.json +3876 -0
  57. floodmodeller_api/test/test_data/EX2.DAT +302 -302
  58. floodmodeller_api/test/test_data/EX3.DAT +926 -926
  59. floodmodeller_api/test/test_data/EX3_DAT_expected.json +16235 -0
  60. floodmodeller_api/test/test_data/EX3_IEF_expected.json +61 -0
  61. floodmodeller_api/test/test_data/EX6.DAT +2084 -2084
  62. floodmodeller_api/test/test_data/EX6.ext +532 -532
  63. floodmodeller_api/test/test_data/EX6.feb +2084 -2084
  64. floodmodeller_api/test/test_data/EX6_DAT_expected.json +31647 -0
  65. floodmodeller_api/test/test_data/Event Data Example.DAT +336 -336
  66. floodmodeller_api/test/test_data/Event Data Example.ext +107 -107
  67. floodmodeller_api/test/test_data/Event Data Example.feb +336 -336
  68. floodmodeller_api/test/test_data/Linked1D2D.xml +52 -52
  69. floodmodeller_api/test/test_data/Linked1D2DFAST.xml +53 -53
  70. floodmodeller_api/test/test_data/Linked1D2DFAST_dy.xml +48 -48
  71. floodmodeller_api/test/test_data/Linked1D2D_xml_expected.json +313 -0
  72. floodmodeller_api/test/test_data/blockage.dat +50 -50
  73. floodmodeller_api/test/test_data/blockage.ext +45 -45
  74. floodmodeller_api/test/test_data/blockage.feb +9 -9
  75. floodmodeller_api/test/test_data/blockage.gxy +71 -71
  76. floodmodeller_api/test/test_data/conveyance_test.dat +165 -0
  77. floodmodeller_api/test/test_data/conveyance_test.feb +116 -0
  78. floodmodeller_api/test/test_data/conveyance_test.gxy +85 -0
  79. floodmodeller_api/test/test_data/defaultUnits.dat +127 -127
  80. floodmodeller_api/test/test_data/defaultUnits.ext +45 -45
  81. floodmodeller_api/test/test_data/defaultUnits.feb +9 -9
  82. floodmodeller_api/test/test_data/defaultUnits.fmpx +58 -58
  83. floodmodeller_api/test/test_data/defaultUnits.gxy +85 -85
  84. floodmodeller_api/test/test_data/ex3.ief +20 -20
  85. floodmodeller_api/test/test_data/ex3.lf1 +2800 -2800
  86. floodmodeller_api/test/test_data/ex4.DAT +1374 -1374
  87. floodmodeller_api/test/test_data/ex4_changed.DAT +1374 -1374
  88. floodmodeller_api/test/test_data/example1.inp +329 -329
  89. floodmodeller_api/test/test_data/example2.inp +158 -158
  90. floodmodeller_api/test/test_data/example3.inp +297 -297
  91. floodmodeller_api/test/test_data/example4.inp +388 -388
  92. floodmodeller_api/test/test_data/example5.inp +147 -147
  93. floodmodeller_api/test/test_data/example6.inp +154 -154
  94. floodmodeller_api/test/test_data/expected_conveyance.csv +60 -0
  95. floodmodeller_api/test/test_data/jump.dat +176 -176
  96. floodmodeller_api/test/test_data/network.dat +1374 -1374
  97. floodmodeller_api/test/test_data/network.ext +45 -45
  98. floodmodeller_api/test/test_data/network.exy +1 -1
  99. floodmodeller_api/test/test_data/network.feb +45 -45
  100. floodmodeller_api/test/test_data/network.ied +45 -45
  101. floodmodeller_api/test/test_data/network.ief +20 -20
  102. floodmodeller_api/test/test_data/network.inp +147 -147
  103. floodmodeller_api/test/test_data/network.pxy +57 -57
  104. floodmodeller_api/test/test_data/network.zzd +122 -122
  105. floodmodeller_api/test/test_data/network_dat_expected.json +21837 -0
  106. floodmodeller_api/test/test_data/network_from_tabularCSV.csv +87 -87
  107. floodmodeller_api/test/test_data/network_ied_expected.json +287 -0
  108. floodmodeller_api/test/test_data/rnweir.dat +9 -9
  109. floodmodeller_api/test/test_data/rnweir.ext +45 -45
  110. floodmodeller_api/test/test_data/rnweir.feb +9 -9
  111. floodmodeller_api/test/test_data/rnweir.gxy +45 -45
  112. floodmodeller_api/test/test_data/rnweir_default.dat +74 -74
  113. floodmodeller_api/test/test_data/rnweir_default.ext +45 -45
  114. floodmodeller_api/test/test_data/rnweir_default.feb +9 -9
  115. floodmodeller_api/test/test_data/rnweir_default.fmpx +58 -58
  116. floodmodeller_api/test/test_data/rnweir_default.gxy +53 -53
  117. floodmodeller_api/test/test_data/unit checks.dat +16 -16
  118. floodmodeller_api/test/test_ied.py +29 -29
  119. floodmodeller_api/test/test_ief.py +136 -24
  120. floodmodeller_api/test/test_inp.py +47 -48
  121. floodmodeller_api/test/test_json.py +114 -0
  122. floodmodeller_api/test/test_logs_lf.py +102 -51
  123. floodmodeller_api/test/test_tool.py +165 -152
  124. floodmodeller_api/test/test_toolbox_structure_log.py +234 -239
  125. floodmodeller_api/test/test_xml2d.py +151 -156
  126. floodmodeller_api/test/test_zzn.py +36 -34
  127. floodmodeller_api/to_from_json.py +230 -0
  128. floodmodeller_api/tool.py +332 -329
  129. floodmodeller_api/toolbox/__init__.py +5 -5
  130. floodmodeller_api/toolbox/example_tool.py +45 -45
  131. floodmodeller_api/toolbox/model_build/__init__.py +2 -2
  132. floodmodeller_api/toolbox/model_build/add_siltation_definition.py +100 -98
  133. floodmodeller_api/toolbox/model_build/structure_log/__init__.py +1 -1
  134. floodmodeller_api/toolbox/model_build/structure_log/structure_log.py +287 -289
  135. floodmodeller_api/toolbox/model_build/structure_log_definition.py +76 -76
  136. floodmodeller_api/units/__init__.py +10 -10
  137. floodmodeller_api/units/_base.py +214 -212
  138. floodmodeller_api/units/boundaries.py +467 -467
  139. floodmodeller_api/units/comment.py +52 -55
  140. floodmodeller_api/units/conduits.py +382 -402
  141. floodmodeller_api/units/conveyance.py +301 -0
  142. floodmodeller_api/units/helpers.py +123 -131
  143. floodmodeller_api/units/iic.py +107 -101
  144. floodmodeller_api/units/losses.py +305 -306
  145. floodmodeller_api/units/sections.py +465 -446
  146. floodmodeller_api/units/structures.py +1690 -1683
  147. floodmodeller_api/units/units.py +93 -104
  148. floodmodeller_api/units/unsupported.py +44 -44
  149. floodmodeller_api/units/variables.py +87 -89
  150. floodmodeller_api/urban1d/__init__.py +11 -11
  151. floodmodeller_api/urban1d/_base.py +188 -179
  152. floodmodeller_api/urban1d/conduits.py +93 -85
  153. floodmodeller_api/urban1d/general_parameters.py +58 -58
  154. floodmodeller_api/urban1d/junctions.py +81 -79
  155. floodmodeller_api/urban1d/losses.py +81 -74
  156. floodmodeller_api/urban1d/outfalls.py +114 -110
  157. floodmodeller_api/urban1d/raingauges.py +111 -111
  158. floodmodeller_api/urban1d/subsections.py +92 -98
  159. floodmodeller_api/urban1d/xsections.py +147 -144
  160. floodmodeller_api/util.py +119 -21
  161. floodmodeller_api/validation/parameters.py +660 -660
  162. floodmodeller_api/validation/urban_parameters.py +388 -404
  163. floodmodeller_api/validation/validation.py +110 -108
  164. floodmodeller_api/version.py +1 -1
  165. floodmodeller_api/xml2d.py +632 -673
  166. floodmodeller_api/xml2d_template.py +37 -37
  167. floodmodeller_api/zzn.py +414 -363
  168. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/LICENSE.txt +13 -13
  169. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/METADATA +85 -82
  170. floodmodeller_api-0.4.4.dist-info/RECORD +185 -0
  171. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/WHEEL +1 -1
  172. floodmodeller_api/libifcoremd.dll +0 -0
  173. floodmodeller_api/test/test_data/EX3.bmp +0 -0
  174. floodmodeller_api/test/test_data/test_output.csv +0 -87
  175. floodmodeller_api/zzn_read.dll +0 -0
  176. floodmodeller_api-0.4.2.post1.dist-info/RECORD +0 -164
  177. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/entry_points.txt +0 -0
  178. {floodmodeller_api-0.4.2.post1.dist-info → floodmodeller_api-0.4.4.dist-info}/top_level.txt +0 -0
@@ -1,673 +1,632 @@
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 io
20
+ import os
21
+ import time
22
+ from copy import deepcopy
23
+ from pathlib import Path
24
+ from subprocess import DEVNULL, Popen
25
+ from typing import Callable
26
+
27
+ from lxml import etree
28
+ from tqdm import trange
29
+
30
+ from floodmodeller_api._base import FMFile
31
+
32
+ from .logs import LF2, create_lf, error_2d_dict
33
+ from .util import handle_exception
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
+ @handle_exception(when="read")
74
+ def __init__(self, xml_filepath: str | Path | None = None, from_json: bool = False):
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
+ def _read(self, from_blank=False):
85
+ # Read xml data
86
+ self._ns = "{https://www.floodmodeller.com}"
87
+ if from_blank:
88
+ self._xmltree = etree.parse(io.StringIO(xml2d_template))
89
+ else:
90
+ self._xmltree = etree.parse(self._filepath)
91
+ self._xsd = etree.parse(self._xsd_loc)
92
+ self._xsdschema = etree.XMLSchema(self._xsd)
93
+ self._get_multi_value_keys()
94
+
95
+ self._create_dict()
96
+ for key, data in self._data.items():
97
+ if key == "domain":
98
+ self.domains = {domain["domain_id"]: domain for domain in data}
99
+ else:
100
+ setattr(self, key, data)
101
+ for attr in [
102
+ "name",
103
+ "link1d",
104
+ "logfile",
105
+ "domains",
106
+ "restart_options",
107
+ "advanced_options",
108
+ "processor",
109
+ "unit_system",
110
+ "description",
111
+ ]:
112
+ if attr not in self.__dict__:
113
+ setattr(self, attr, None)
114
+
115
+ def _create_dict(self):
116
+ """Iterate through XML Tree to add all elements as class attributes"""
117
+ xml_dict = {}
118
+ root = self._xmltree.getroot()
119
+
120
+ xml_dict.update({"name": root.attrib["name"]})
121
+
122
+ xml_dict = self._recursive_elements_to_dict(xml_dict, root)
123
+ self._raw_data = xml_dict
124
+ self._data = deepcopy(self._raw_data)
125
+
126
+ def _recursive_elements_to_dict(self, xml_dict, tree):
127
+ # Some elements can have multiple instances e.g. domains.
128
+ # In these cases we need to have the id of that instance as a new key on the domain
129
+ # e.g. xml.domains[domain_id]["computational_area"]... etc
130
+
131
+ for child in tree:
132
+ if isinstance(child, etree._Comment):
133
+ continue # Skips comments in xml
134
+ child_key = child.tag.replace(self._ns, "")
135
+ if child_key in self._multi_value_keys:
136
+ if child_key in xml_dict:
137
+ xml_dict[child_key].append({})
138
+ else:
139
+ xml_dict[child_key] = [{}]
140
+ child_dict = xml_dict[child_key][-1]
141
+ else:
142
+ xml_dict[child_key] = {} # Create new key for element
143
+ child_dict = xml_dict[child_key]
144
+ value = "" if child.text is None else child.text.strip()
145
+ if "\n" in value:
146
+ value = value.split("\n") # Only used for output variables
147
+ if len(child.attrib) != 0:
148
+ child_dict.update(child.attrib)
149
+ if value != "":
150
+ child_dict.update({"value": value_from_string(value)})
151
+
152
+ self._recursive_elements_to_dict(child_dict, child)
153
+
154
+ elif value == "":
155
+ self._recursive_elements_to_dict(child_dict, child)
156
+
157
+ elif 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( # noqa: C901, PLR0912
210
+ self,
211
+ new_dict,
212
+ orig_dict,
213
+ parent_key,
214
+ list_idx=None,
215
+ ):
216
+ # TODO: Handle removing params
217
+
218
+ for key, item in new_dict.items():
219
+ if key in self._multi_value_keys and not isinstance(item, list):
220
+ raise Exception(f"Element: '{key}' must be added as list")
221
+ if parent_key == "ROOT":
222
+ parent = self._xmltree.getroot()
223
+ else:
224
+ parent = self._xmltree.findall(f".//{self._ns}{parent_key}")[list_idx or 0]
225
+
226
+ if key not in orig_dict:
227
+ # New key added, add recursively
228
+ self._recursive_add_element(parent=parent, add_item=item, add_key=key)
229
+
230
+ elif isinstance(item, dict):
231
+ self._recursive_update_xml(item, orig_dict[key], key, list_idx)
232
+ elif isinstance(item, list) and isinstance(item[0], dict):
233
+ for i, _item in enumerate(item):
234
+ if isinstance(_item, dict):
235
+ try:
236
+ self._recursive_update_xml(_item, orig_dict[key][i], key, list_idx=i)
237
+ except IndexError:
238
+ # New thing added, Add it all recursively
239
+ self._recursive_add_element(
240
+ parent=parent,
241
+ add_item=_item,
242
+ add_key=key,
243
+ from_list=True,
244
+ )
245
+
246
+ else:
247
+ if parent_key == "ROOT":
248
+ item = getattr(self, key)
249
+ try:
250
+ if item != orig_dict[key]:
251
+ if key == "value":
252
+ # Value has been updated
253
+ parent.text = str(item)
254
+ else:
255
+ # Attribute has been updated
256
+ elems = parent.findall(f"{self._ns}{key}")
257
+ if isinstance(item, list) and key != "variables":
258
+ # Handle multiple similar elements
259
+ if len(elems) < len(item):
260
+ while len(elems) < len(item):
261
+ elems.append(etree.SubElement(parent, f"{self._ns}{key}"))
262
+ elif len(elems) > len(item):
263
+ while len(elems) > len(item):
264
+ parent.remove(elems.pop())
265
+
266
+ for i in range(len(elems)):
267
+ elems[i].text = item[i]
268
+
269
+ elif len(elems) == 1:
270
+ elem = elems[0]
271
+ if isinstance(item, list):
272
+ elem.text = "\n".join(item)
273
+ else:
274
+ elem.text = str(item)
275
+
276
+ else:
277
+ parent.set(key, str(item))
278
+ except KeyError:
279
+ # New value/attribute added
280
+ self._recursive_add_element(parent=parent, add_item=item, add_key=key)
281
+
282
+ def _recursive_add_element( # noqa: C901, PLR0912
283
+ self,
284
+ parent,
285
+ add_item,
286
+ add_key,
287
+ from_list=False,
288
+ ):
289
+ if add_key in self._multi_value_keys and not isinstance(add_item, list) and not from_list:
290
+ raise Exception(f"Element: '{add_key}' must be added as list")
291
+ if isinstance(add_item, dict):
292
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
293
+ for key, item in add_item.items():
294
+ self._recursive_add_element(parent=new_element, add_item=item, add_key=key)
295
+ elif isinstance(add_item, list):
296
+ if add_key == "variables":
297
+ # Variables is special case where we have list but add to one element
298
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
299
+ new_element.text = "\n".join(add_item)
300
+ else:
301
+ for item in add_item:
302
+ self._recursive_add_element(
303
+ parent=parent,
304
+ add_item=item,
305
+ add_key=add_key,
306
+ from_list=True,
307
+ )
308
+ elif add_key == "value": # Value has been added
309
+ parent.text = str(add_item)
310
+ else: # Attribute or element added
311
+ # Check schema to see if we should use parent.set for attribute
312
+ # or etree.subelement() and set text
313
+ schema_elem = self._xsd.findall(
314
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']",
315
+ )
316
+ if len(schema_elem) == 1:
317
+ schema_elem = schema_elem[0]
318
+ else:
319
+ # This is just here for when there's multiple schema elements with same
320
+ # name, e.g. 'frequency'
321
+ parent_schema_elem = self._xsd.find(
322
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent.tag.replace(self._ns, '')}']",
323
+ )
324
+ if "type" in parent_schema_elem.attrib:
325
+ parent_schema_elem = self._xsd.find(
326
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{parent_schema_elem.attrib['type']}']",
327
+ )
328
+ schema_elem = parent_schema_elem.find(
329
+ f".//{{http://www.w3.org/2001/XMLSchema}}*[@name='{add_key}']",
330
+ )
331
+
332
+ if schema_elem.tag.endswith("attribute"):
333
+ parent.set(add_key, str(add_item))
334
+
335
+ else:
336
+ new_element = etree.SubElement(parent, f"{self._ns}{add_key}")
337
+ new_element.text = str(add_item)
338
+
339
+ def _recursive_remove_data_xml(self, new_dict, parent, list_idx=None):
340
+ # This method will recursively work through the original dictionary and remove any
341
+ # items that are not in the new_dictionary and need to be removed.
342
+ list_idx = 0
343
+ list_idx_key = ""
344
+ for elem in parent:
345
+ if isinstance(elem, etree._Comment):
346
+ continue # Skips comments in xml
347
+ # Check each element is in the new_dict somewhere, delete if not
348
+ elem_key = elem.tag.replace(self._ns, "")
349
+ if elem_key in self._multi_value_keys:
350
+ if list_idx_key != elem_key:
351
+ list_idx_key = elem_key
352
+ list_idx = 0
353
+ try:
354
+ self._recursive_remove_data_xml(new_dict[elem_key][list_idx], elem)
355
+ list_idx += 1
356
+ except (IndexError, KeyError):
357
+ parent.remove(elem)
358
+
359
+ elif elem_key in new_dict:
360
+ self._recursive_remove_data_xml(new_dict[elem_key], elem)
361
+
362
+ else:
363
+ parent.remove(elem)
364
+
365
+ def _update_dict(self):
366
+ self._data = {}
367
+ for attr in [
368
+ "name",
369
+ "link1d",
370
+ "logfile",
371
+ "domains",
372
+ "restart_options",
373
+ "advanced_options",
374
+ "processor",
375
+ "unit_system",
376
+ "description",
377
+ ]:
378
+ if getattr(self, attr) is not None:
379
+ if attr == "domains":
380
+ self._data["domain"] = [domain for _, domain in self.domains.items()]
381
+ else:
382
+ try:
383
+ self._data[attr] = getattr(self, attr)
384
+ except AttributeError:
385
+ self._data[attr] = None
386
+
387
+ @handle_exception(when="write")
388
+ def _write(self) -> str:
389
+ self._update_dict()
390
+ self._recursive_update_xml(self._data, self._raw_data, "ROOT")
391
+ self._recursive_remove_data_xml(self._data, self._xmltree.getroot())
392
+ etree.indent(self._xmltree, space=" ")
393
+ try:
394
+ self._validate()
395
+ except Exception:
396
+ self._recursive_reorder_xml()
397
+ self._validate()
398
+
399
+ self._raw_data = deepcopy(self._data) # reset raw data to equal data
400
+
401
+ return f'<?xml version="1.0" standalone="yes"?>\n{etree.tostring(self._xmltree.getroot()).decode()}'
402
+
403
+ def _get_multi_value_keys(self):
404
+ self._multi_value_keys = []
405
+ root = self._xsd.getroot()
406
+ for elem in root.findall(".//{http://www.w3.org/2001/XMLSchema}element"):
407
+ if elem.attrib.get("maxOccurs") not in (None, "0", "1"):
408
+ self._multi_value_keys.append(elem.attrib["name"])
409
+ self._multi_value_keys = set(self._multi_value_keys)
410
+
411
+ def diff(self, other: XML2D, force_print: bool = False) -> None:
412
+ """Compares the XML2D class against another XML2D class to check whether they are
413
+ equivalent, or if not, what the differences are. Two instances of a XML2D class are
414
+ deemed equivalent if all of their attributes are equal except for the filepath and
415
+ raw data. For example, two XML2D files from different filepaths that had the same
416
+ data except maybe some differences in decimal places and some default parameters
417
+ ommitted, would be classed as equaivalent as they would produce the same XML2D instance
418
+ and write the exact same data.
419
+
420
+ The result is printed to the console. If you need to access the returned data, use
421
+ the method ``XML2D._get_diff()``
422
+
423
+ Args:
424
+ other (floodmodeller_api.XML2D): Other instance of a XML2D class
425
+ force_print (bool): Forces the API to print every difference found, rather than
426
+ just the first 25 differences. Defaults to False.
427
+ """
428
+ self._diff(other, force_print=force_print)
429
+
430
+ def update(self) -> None:
431
+ """Updates the existing XML based on any altered attributes"""
432
+ self._update()
433
+
434
+ # Update XML dict and tree
435
+ self._read()
436
+
437
+ def save(self, filepath: str | Path | None):
438
+ """Saves the XML to the given location, if pointing to an existing file it will be overwritten.
439
+ Once saved, the XML() class will continue working from the saved location, therefore any further calls to XML.update() will
440
+ update in the latest saved location rather than the original source XML used to construct the class
441
+
442
+ Args:
443
+ filepath (str): Filepath to new save location including the name and '.xml' extension
444
+
445
+ Raises:
446
+ TypeError: Raised if given filepath doesn't point to a file suffixed '.xml'
447
+ """
448
+
449
+ self._save(filepath)
450
+
451
+ # Update XML dict and tree
452
+ self._read()
453
+ self._log_path = self._filepath.with_suffix(".lf2")
454
+
455
+ @handle_exception(when="simulate")
456
+ def simulate( # noqa: C901, PLR0912, PLR0913
457
+ self,
458
+ method: str = "WAIT",
459
+ raise_on_failure: bool = True,
460
+ precision: str = "DEFAULT",
461
+ enginespath: str = "",
462
+ console_output: str = "simple",
463
+ range_function: Callable = trange,
464
+ range_settings: dict | None = None,
465
+ ) -> Popen | None:
466
+ """Simulate the XML2D file directly as a subprocess.
467
+
468
+ Args:
469
+ method (str, optional): {'WAIT'} | 'RETURN_PROCESS'
470
+ 'WAIT' - The function waits for the simulation to complete before continuing (This is default)
471
+ 'RETURN_PROCESS' - The function sets the simulation running in background and immediately continues, whilst returning the process object.
472
+ Defaults to 'WAIT'.
473
+ raise_on_failure (bool, optional): If True, an exception will be raised if the simulation fails to complete without errors.
474
+ If set to False, then the script will continue to run even if the simulation fails. If 'method' is set to 'RETURN_PROCESS'
475
+ then this argument is ignored. Defaults to True.
476
+ precision (str, optional): {'DEFAULT'} | 'SINGLE' | 'DOUBLE'
477
+ Define which engine to use for simulation, if set to 'DEFAULT' it will use the precision specified in the IEF. Alternatively,
478
+ this can be overwritten using 'SINGLE' or 'DOUBLE'.
479
+ enginespath (str, optional): {''} | '/absolute/path/to/engine/executables'
480
+ Define where the engine executables are located. This replaces the default location (usual installation folder) if set to
481
+ anything other than ''.
482
+ console_output (str, optional): {'simple'} | 'standard' | 'detailed'
483
+ 'simple' - A simple progress bar for the simulation is presented in the console
484
+ 'standard' - The standard Flood Modeller 2D output is presented in the console
485
+ 'detailed' - The most detailed Flood Modeller 2D output is presented in the console
486
+ Defaults to 'WAIT'.
487
+
488
+
489
+ Raises:
490
+ UserWarning: Raised if ief filepath not already specified
491
+
492
+ Returns:
493
+ subprocess.Popen(): If method == 'RETURN_PROCESS', the Popen() instance of the process is returned.
494
+
495
+ """
496
+
497
+ # TODO:
498
+ # - Clean up the lf code?
499
+ # - Remove or sort out get results
500
+
501
+ self.range_function = range_function
502
+ self.range_settings = range_settings if range_settings else {}
503
+
504
+ if self._filepath is None:
505
+ raise UserWarning(
506
+ "xml2D must be saved to a specific filepath before simulate() can be called.",
507
+ )
508
+ if precision.upper() == "DEFAULT":
509
+ precision = "SINGLE" # defaults to single precision
510
+ for _, domain in self.domains.items():
511
+ if domain["run_data"].get("double_precision") == "required":
512
+ precision = "DOUBLE"
513
+ break
514
+
515
+ if enginespath == "":
516
+ # Default location
517
+ _enginespath = r"C:\Program Files\Flood Modeller\bin"
518
+ else:
519
+ _enginespath = enginespath
520
+ if not Path(_enginespath).exists():
521
+ raise Exception(
522
+ f"Flood Modeller non-default engine path not found! {str(_enginespath)}",
523
+ )
524
+
525
+ # checking if all schemes used are fast, if so will use FAST.exe
526
+ # TODO: Add in option to choose to use or not to use if you can
527
+ is_fast = True
528
+ for _, domain in self.domains.items():
529
+ if domain["run_data"]["scheme"] != "FAST":
530
+ is_fast = False
531
+ break
532
+
533
+ if is_fast is True:
534
+ isis2d_fp = str(Path(_enginespath, "FAST.exe"))
535
+ elif precision.upper() == "SINGLE":
536
+ isis2d_fp = str(Path(_enginespath, "ISIS2d.exe"))
537
+ else:
538
+ isis2d_fp = str(Path(_enginespath, "ISIS2d_DP.exe"))
539
+
540
+ if not Path(isis2d_fp).exists():
541
+ raise Exception(f"Flood Modeller engine not found! Expected location: {isis2d_fp}")
542
+
543
+ console_output = console_output.lower()
544
+ run_command = (
545
+ f'"{isis2d_fp}" {"-q" if console_output != "detailed" else ""} "{self._filepath}"'
546
+ )
547
+ stdout = DEVNULL if console_output == "simple" else None
548
+
549
+ if method.upper() == "WAIT":
550
+ print("Executing simulation ... ")
551
+ # execute simulation
552
+ process = Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
553
+
554
+ # progress bar based on log files:
555
+ if console_output == "simple":
556
+ self._lf = create_lf(self._log_path, "lf2")
557
+ self._update_progress_bar(process)
558
+
559
+ while process.poll() is None:
560
+ # process is still running
561
+ time.sleep(1)
562
+
563
+ exitcode = process.returncode
564
+ self._interpret_exit_code(exitcode, raise_on_failure)
565
+
566
+ elif method.upper() == "RETURN_PROCESS":
567
+ print("Executing simulation ...")
568
+ # execute simulation
569
+ return Popen(run_command, cwd=os.path.dirname(self._filepath), stdout=stdout)
570
+
571
+ return None
572
+
573
+ def get_log(self):
574
+ """If log files for the simulation exist, this function returns them as a LF2 class object
575
+
576
+ Returns:
577
+ floodmodeller_api.LF2 class object
578
+ """
579
+ if not self._log_path.exists():
580
+ raise FileNotFoundError("Log file (LF2) not found")
581
+
582
+ return LF2(self._log_path)
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 _interpret_exit_code(self, exitcode: int, raise_on_failure: bool):
617
+ """This function will interpret the exit code and tell us if this is good or bad
618
+
619
+ Args:
620
+ exitcode - this is the exitcode from the simulation
621
+
622
+ Return:
623
+ String that explains the exitcode - this might be too much!
624
+ """
625
+ try:
626
+ msg = f"Exit with {exitcode}: {error_2d_dict[exitcode]}"
627
+ except Exception:
628
+ msg = f"Exit with {exitcode}: Unknown error occurred!"
629
+
630
+ if raise_on_failure and exitcode != self.GOOD_EXIT_CODE:
631
+ raise Exception(msg)
632
+ print(msg)