floodmodeller-api 0.5.0.post1__py3-none-any.whl → 0.5.1__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 (80) hide show
  1. floodmodeller_api/__init__.py +1 -1
  2. floodmodeller_api/_base.py +26 -16
  3. floodmodeller_api/backup.py +3 -2
  4. floodmodeller_api/dat.py +29 -30
  5. floodmodeller_api/diff.py +3 -3
  6. floodmodeller_api/hydrology_plus/hydrology_plus_export.py +14 -13
  7. floodmodeller_api/ied.py +6 -6
  8. floodmodeller_api/ief.py +27 -25
  9. floodmodeller_api/inp.py +3 -4
  10. floodmodeller_api/logs/lf.py +9 -16
  11. floodmodeller_api/logs/lf_helpers.py +18 -18
  12. floodmodeller_api/mapping.py +2 -0
  13. floodmodeller_api/test/__init__.py +2 -2
  14. floodmodeller_api/test/conftest.py +2 -3
  15. floodmodeller_api/test/test_backup.py +2 -2
  16. floodmodeller_api/test/test_conveyance.py +4 -3
  17. floodmodeller_api/test/test_dat.py +2 -2
  18. floodmodeller_api/test/test_data/structure_logs/EX17_expected.csv +4 -0
  19. floodmodeller_api/test/test_data/structure_logs/EX17_expected.json +69 -0
  20. floodmodeller_api/test/test_data/structure_logs/EX18_expected.csv +20 -0
  21. floodmodeller_api/test/test_data/structure_logs/EX18_expected.json +292 -0
  22. floodmodeller_api/test/test_data/structure_logs/EX6_expected.csv +4 -0
  23. floodmodeller_api/test/test_data/structure_logs/EX6_expected.json +35 -0
  24. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_flow.csv +182 -0
  25. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_fr.csv +182 -0
  26. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_mode.csv +182 -0
  27. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_stage.csv +182 -0
  28. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_state.csv +182 -0
  29. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzn_velocity.csv +182 -0
  30. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_left_fp_h.csv +182 -0
  31. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_left_fp_mode.csv +182 -0
  32. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_link_inflow.csv +182 -0
  33. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_max.csv +87 -0
  34. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_right_fp_h.csv +182 -0
  35. floodmodeller_api/test/test_data/tabular_csv_outputs/network_zzx_right_fp_mode.csv +182 -0
  36. floodmodeller_api/test/test_flowtimeprofile.py +2 -2
  37. floodmodeller_api/test/test_hydrology_plus_export.py +4 -2
  38. floodmodeller_api/test/test_ied.py +2 -2
  39. floodmodeller_api/test/test_ief.py +2 -2
  40. floodmodeller_api/test/test_inp.py +2 -2
  41. floodmodeller_api/test/test_json.py +5 -10
  42. floodmodeller_api/test/test_logs_lf.py +6 -6
  43. floodmodeller_api/test/test_read_file.py +1 -0
  44. floodmodeller_api/test/test_river.py +11 -11
  45. floodmodeller_api/test/test_tool.py +8 -5
  46. floodmodeller_api/test/test_toolbox_structure_log.py +149 -158
  47. floodmodeller_api/test/test_xml2d.py +9 -11
  48. floodmodeller_api/test/test_zz.py +143 -0
  49. floodmodeller_api/to_from_json.py +8 -8
  50. floodmodeller_api/tool.py +12 -6
  51. floodmodeller_api/toolbox/example_tool.py +5 -1
  52. floodmodeller_api/toolbox/model_build/add_siltation_definition.py +12 -8
  53. floodmodeller_api/toolbox/model_build/structure_log/structure_log.py +498 -196
  54. floodmodeller_api/toolbox/model_build/structure_log_definition.py +5 -1
  55. floodmodeller_api/units/_base.py +14 -10
  56. floodmodeller_api/units/conveyance.py +1 -1
  57. floodmodeller_api/units/helpers.py +1 -3
  58. floodmodeller_api/units/losses.py +2 -3
  59. floodmodeller_api/units/sections.py +14 -10
  60. floodmodeller_api/units/structures.py +9 -9
  61. floodmodeller_api/units/units.py +2 -0
  62. floodmodeller_api/urban1d/_base.py +6 -9
  63. floodmodeller_api/urban1d/outfalls.py +2 -1
  64. floodmodeller_api/urban1d/raingauges.py +2 -1
  65. floodmodeller_api/urban1d/subsections.py +2 -0
  66. floodmodeller_api/urban1d/xsections.py +3 -2
  67. floodmodeller_api/util.py +16 -2
  68. floodmodeller_api/validation/validation.py +2 -1
  69. floodmodeller_api/version.py +1 -1
  70. floodmodeller_api/xml2d.py +18 -20
  71. floodmodeller_api/zz.py +538 -0
  72. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/METADATA +20 -14
  73. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/RECORD +78 -60
  74. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/WHEEL +1 -1
  75. floodmodeller_api/test/test_zzn.py +0 -36
  76. floodmodeller_api/zzn.py +0 -414
  77. /floodmodeller_api/test/test_data/{network_from_tabularCSV.csv → tabular_csv_outputs/network_zzn_max.csv} +0 -0
  78. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/LICENSE.txt +0 -0
  79. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/entry_points.txt +0 -0
  80. {floodmodeller_api-0.5.0.post1.dist-info → floodmodeller_api-0.5.1.dist-info}/top_level.txt +0 -0
@@ -1,14 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
1
4
  import csv
5
+ from typing import TYPE_CHECKING
2
6
 
3
7
  from floodmodeller_api import DAT
4
8
 
9
+ if TYPE_CHECKING:
10
+ from floodmodeller_api._base import Unit
11
+ from floodmodeller_api.units import (
12
+ BRIDGE,
13
+ CONDUIT,
14
+ ORIFICE,
15
+ REPLICATE,
16
+ RNWEIR,
17
+ SLUICE,
18
+ SPILL,
19
+ WEIR,
20
+ )
21
+
22
+
23
+ def extract_attrs(object_input, keys):
24
+ """Extract attributes and their values from a given object and return as a dict.
25
+
26
+ No handling for where attributes don't exist but can ignore for when this is an issue, shouldn't be an issue here
27
+ """
28
+ return {key: getattr(object_input, key) for key in keys}
29
+
30
+
31
+ def serialise_keys(old_dict):
32
+ """Exchange tuple keys in dict for string keys.
33
+
34
+ Dictionary keys must be unique; but in FM the node label isn't always unique
35
+ however the (label,type) pair will always be unique; so we have to use that as our key.
36
+ json doesn't like tuples as keys so we have to convert them first if we need to serialise our dictionary output.
37
+
38
+ Replaces tuple keys with string versions of themselves, wrapped in ()
39
+
40
+ """
41
+ return {f"({','.join(key)})": value for key, value in old_dict.items()}
42
+
5
43
 
6
44
  class StructureLogBuilder:
7
- def __init__(self, input_path, output_path) -> None:
45
+ def __init__(self, input_path: str | None = None, output_path: str | None = None) -> None:
8
46
  self.dat_file_path = input_path
9
47
  self.csv_output_path = output_path
48
+ self._conduit_chains: dict[str | None, list[str | None]] = {}
49
+ self._already_in_chain: set[str | None] = set()
50
+ self.unit_store: dict[(str, str)] = {}
51
+ self._replicate_mimics: dict[str | None, str | None] = {}
10
52
 
11
- def _add_fields(self):
53
+ def _add_fields(self, writer) -> None:
12
54
  field = [
13
55
  "Unit Name",
14
56
  "Unit Type",
@@ -19,237 +61,449 @@ class StructureLogBuilder:
19
61
  "Weir Coefficient",
20
62
  "Culvert Inlet/Outlet Loss",
21
63
  ]
22
- self._writer.writerow(field)
64
+ writer.writerow(field)
23
65
 
24
- def _conduit_data(self, conduit):
25
- length = 0.0
26
- inlet = ""
27
- outlet = ""
28
- previous = self._dat.prev(conduit)
66
+ def _conduit_data(self, conduit: CONDUIT | REPLICATE) -> tuple[dict, Unit | None]:
67
+ conduit_data = {"length": conduit.dist_to_next}
68
+ # modified conduit crawler script
69
+ add_to_conduit_stack = None
70
+
71
+ # check if the previous node is an inlet
72
+ previous = self.dat.prev(conduit)
29
73
  if hasattr(previous, "subtype") and previous.subtype == "INLET":
30
- inlet = previous.ki
74
+ conduit_data["inlet"] = previous.ki
75
+
31
76
  current_conduit = conduit
32
- while current_conduit.dist_to_next != 0:
33
- length += current_conduit.dist_to_next
34
- current_conduit = self._dat.next(current_conduit)
35
- next_conduit = self._dat.next(current_conduit)
36
- if hasattr(next_conduit, "subtype") and next_conduit.subtype == "OUTLET":
37
- outlet = next_conduit.loss_coefficient
38
- return [length, inlet, outlet]
39
-
40
- def _culvert_loss_data(self, inlet, outlet):
41
- culvert_loss = ""
42
- if inlet != "" and outlet != "":
43
- culvert_loss = f"Ki: {inlet}, Ko: {outlet}"
44
- elif inlet != "":
45
- culvert_loss = f"Ki: {inlet}"
46
- elif outlet != "":
47
- culvert_loss = f"Ko: {outlet}"
48
- return culvert_loss
49
-
50
- def _circular_data(self, conduit, length):
51
- dimensions = f"dia: {conduit.diameter:.2f} x l: {length:.2f}"
52
- all_mannings = [
77
+
78
+ if current_conduit.name not in self._already_in_chain:
79
+ # if this conduit isn't part of a chain already, then it must be part of a new chain
80
+ total_length = 0
81
+ chain = []
82
+ while True:
83
+ self._already_in_chain.add(current_conduit.name)
84
+ chain.append(current_conduit.name)
85
+
86
+ if current_conduit._unit not in ("CONDUIT", "REPLICATE"):
87
+ # This occurs in cases where a conduit chain doesnt 'legally' end.
88
+ break
89
+
90
+ if current_conduit.dist_to_next == 0:
91
+ break
92
+
93
+ total_length += current_conduit.dist_to_next
94
+ current_conduit = self.dat.next(current_conduit)
95
+
96
+ self._conduit_chains[conduit.name] = chain.copy()
97
+ conduit_data["total_length"] = total_length
98
+
99
+ next_conduit = self.dat.next(conduit)
100
+ if next_conduit is not None:
101
+ if hasattr(next_conduit, "subtype") and next_conduit.subtype == "OUTLET":
102
+ conduit_data["outlet"] = next_conduit.loss_coefficient
103
+
104
+ if next_conduit._unit == "REPLICATE":
105
+ # for replicates, pass down the label of the unit it's copying from.
106
+ if current_conduit._unit == "REPLICATE":
107
+ self._replicate_mimics[next_conduit.name] = self._replicate_mimics[
108
+ current_conduit.name
109
+ ]
110
+ else:
111
+ self._replicate_mimics[next_conduit.name] = current_conduit.name
112
+
113
+ # Replicates aren't in the DAT.structures dict so we need to add them manually.
114
+ add_to_conduit_stack = copy.deepcopy(next_conduit)
115
+
116
+ return conduit_data, add_to_conduit_stack
117
+
118
+ def _circular_data(self, conduit: CONDUIT) -> dict:
119
+ dimensions = extract_attrs(conduit, {"diameter", "invert"})
120
+ all_friction = [
53
121
  conduit.friction_above_axis,
54
122
  conduit.friction_below_axis,
55
123
  ]
56
- mannings_set = {min(all_mannings), max(all_mannings)}
57
- if len(mannings_set) == 1:
58
- friction = f"Mannings: {mannings_set.pop()}"
59
- else:
60
- friction = f"Mannings: [min: {mannings_set.pop()}, max: {mannings_set.pop()}]"
124
+ friction_set = sorted(set(all_friction))
125
+ friction = {
126
+ "friction_eq": conduit.friction_eq,
127
+ "friction_set": friction_set,
128
+ "all_friction": all_friction,
129
+ }
61
130
 
62
- return [friction, dimensions]
131
+ return {"dimensions": dimensions, "friction": friction}
63
132
 
64
- def _sprungarch_data(self, conduit, length):
65
- dimensions = f"(Springing: {conduit.height_crown:.2f}, Crown: {conduit.height_springing:.2f}) x w: {conduit.width:.2f} x l: {length:.2f}"
66
- all_mannings = [
133
+ def _sprungarch_data(self, conduit: CONDUIT) -> dict:
134
+ dimensions = extract_attrs(
135
+ conduit,
136
+ {"width", "height_springing", "height_crown", "elevation_invert"},
137
+ )
138
+ # invert attribute is different for these, making homogenous
139
+ dimensions["invert"] = dimensions.pop("elevation_invert")
140
+ all_friction = [
67
141
  conduit.friction_on_invert,
68
142
  conduit.friction_on_soffit,
69
143
  conduit.friction_on_walls,
70
144
  ]
71
- mannings_set = {min(all_mannings), max(all_mannings)}
72
- if len(mannings_set) == 1:
73
- friction = f"Mannings: {mannings_set.pop()}"
74
- else:
75
- friction = f"Mannings: [min: {mannings_set.pop()}, max: {mannings_set.pop()}]"
145
+ friction_set = sorted(set(all_friction))
146
+ friction = {
147
+ "friction_eq": conduit.equation,
148
+ "friction_set": friction_set,
149
+ "all_friction": all_friction,
150
+ }
151
+ return {"dimensions": dimensions, "friction": friction}
76
152
 
77
- return [friction, dimensions]
78
-
79
- def _rectangular_data(self, conduit, length):
80
- dimensions = f"h: {conduit.height:.2f} x w: {conduit.width:.2f} x l: {length:.2f}"
81
- all_mannings = [
153
+ def _rectangular_data(self, conduit):
154
+ dimensions = extract_attrs(conduit, {"width", "height", "invert"})
155
+ all_friction = [
82
156
  conduit.friction_on_invert,
83
157
  conduit.friction_on_soffit,
84
158
  conduit.friction_on_walls,
85
159
  ]
86
- mannings_set = {min(all_mannings), max(all_mannings)}
87
- if len(mannings_set) == 1:
88
- friction = f"Mannings: {mannings_set.pop()}"
89
- else:
90
- friction = f"Mannings: [min: {mannings_set.pop()}, max: {mannings_set.pop()}]"
160
+ friction_set = sorted(set(all_friction))
161
+ friction = {
162
+ "friction_eq": conduit.friction_eq,
163
+ "friction_set": friction_set,
164
+ "all_friction": all_friction,
165
+ }
166
+
167
+ return {"dimensions": dimensions, "friction": friction}
91
168
 
92
- return [friction, dimensions]
169
+ def _section_data(self, conduit: CONDUIT) -> dict:
170
+ # Symmetrical conduits
171
+ # these have a lot of weirdness in terms of data validity and FM will rearrange data that it thinks is wrong
172
+ # so it's mostly worth just trusting the modeller here
93
173
 
94
- def _section_data(self, conduit, length):
95
174
  x_list = conduit.coords.x.tolist()
96
- width = (max(x_list) - min(x_list)) * 2
97
175
  y_list = conduit.coords.y.tolist()
176
+ all_friction = conduit.coords.cw_friction.tolist()
177
+ # want to serialise coords so converting out of dataframe
178
+ width = max(x_list) * 2
98
179
  height = max(y_list) - min(y_list)
99
- # currently this means that height goes to the top of the spike,
100
- # it is only meant to go up to the height of the majority of the area
101
- dimensions = f"h: {height:.2f} x w: {width:.2f} x l: {length:.2f}"
102
- all_cw_frictions = conduit.coords.cw_friction.tolist()
103
- cw_frictions_set = {min(all_cw_frictions), max(all_cw_frictions)}
104
- if len(cw_frictions_set) == 1:
105
- friction = f"Colebrook-White: {cw_frictions_set.pop()}"
106
- else:
107
- friction = (
108
- f"Colebrook-White: [min: {cw_frictions_set.pop()}, max: {cw_frictions_set.pop()}]"
109
- )
180
+ elevation_invert = min(y_list)
181
+ dimensions = {
182
+ "width": width,
183
+ "height": height,
184
+ "invert": elevation_invert,
185
+ "section_data": {"x": x_list, "y": y_list},
186
+ }
110
187
 
111
- return [friction, dimensions]
188
+ all_friction = conduit.coords.cw_friction.tolist()
189
+ friction_set = sorted(set(all_friction))
190
+ friction = {
191
+ "friction_eq": "COLEBROOK-WHITE",
192
+ "friction_set": friction_set,
193
+ "all_friction": all_friction,
194
+ }
112
195
 
113
- def _sprung_data(self, conduit, length):
114
- dimensions = f"(Springing: {conduit.height_crown:.2f}, Crown: {conduit.height_springing:.2f}) x w: {conduit.width:.2f} x l: {length:.2f}"
115
- all_mannings = [
196
+ return {"dimensions": dimensions, "friction": friction}
197
+
198
+ def _sprung_data(self, conduit: CONDUIT) -> dict:
199
+ dimensions = extract_attrs(
200
+ conduit,
201
+ {"width", "height_springing", "height_crown", "elevation_invert"},
202
+ )
203
+ # invert attribute is different for these, making homogenous
204
+ dimensions["invert"] = dimensions.pop("elevation_invert")
205
+ all_friction = [
116
206
  conduit.friction_on_invert,
117
207
  conduit.friction_on_soffit,
118
208
  conduit.friction_on_walls,
119
209
  ]
120
- mannings_set = {min(all_mannings), max(all_mannings)}
121
- if len(mannings_set) == 1:
122
- friction = f"Mannings: {mannings_set.pop()}"
123
- else:
124
- friction = f"Mannings: [min: {mannings_set.pop()}, max: {mannings_set.pop()}]"
125
-
126
- return [friction, dimensions]
127
-
128
- def _add_conduits(self):
129
- for conduit in self._dat.conduits.values():
130
- if conduit.subtype not in [
131
- "CIRCULAR",
132
- "SPRUNGARCH",
133
- "RECTANGULAR",
134
- "SECTION",
135
- "SPRUNG",
136
- ]:
137
- print(f"Conduit subtype: {conduit.subtype} not currently supported")
138
- self._write(conduit.name, conduit._unit, conduit.subtype)
139
- continue
140
- conduit_data = self._conduit_data(conduit)
141
- length = conduit_data[0]
142
- inlet = conduit_data[1]
143
- outlet = conduit_data[2]
210
+ friction_set = sorted(set(all_friction))
211
+ friction = {
212
+ "friction_eq": conduit.equation,
213
+ "friction_set": friction_set,
214
+ "all_friction": all_friction,
215
+ }
144
216
 
145
- if length == 0:
146
- continue
217
+ return {"dimensions": dimensions, "friction": friction}
147
218
 
148
- culvert_loss = self._culvert_loss_data(inlet, outlet)
149
- friction = ""
150
- dimensions = ""
151
- if conduit.subtype == "CIRCULAR":
152
- circular_data = self._circular_data(conduit, length)
153
- friction = circular_data[0]
154
- dimensions = circular_data[1]
155
- elif conduit.subtype == "SPRUNGARCH":
156
- sprungarch_data = self._sprungarch_data(conduit, length)
157
- friction = sprungarch_data[0]
158
- dimensions = sprungarch_data[1]
159
- elif conduit.subtype == "RECTANGULAR":
160
- rectangular_data = self._rectangular_data(conduit, length)
161
- friction = rectangular_data[0]
162
- dimensions = rectangular_data[1]
163
- elif conduit.subtype == "SECTION":
164
- section_data = self._section_data(conduit, length)
165
- friction = section_data[0]
166
- dimensions = section_data[1]
167
- elif conduit.subtype == "SPRUNG":
168
- sprung_data = self._sprung_data(conduit, length)
169
- friction = sprung_data[0]
170
- dimensions = sprung_data[1]
219
+ def _replicate_data(self, replicate: REPLICATE) -> dict:
220
+ dimensions = {
221
+ "bed_level_drop": replicate.bed_level_drop,
222
+ "mimic": self._replicate_mimics[replicate.name],
223
+ }
171
224
 
172
- self._write(
173
- conduit.name,
174
- conduit._unit,
175
- conduit.subtype,
176
- conduit.comment,
177
- friction,
178
- dimensions,
179
- "",
180
- culvert_loss,
181
- )
225
+ return {"dimensions": dimensions}
226
+
227
+ # TODO: a refactor to combine the _add_conduits and _add_structures together would be nice for clarity
228
+ def add_conduits(self):
229
+ conduit_stack = copy.deepcopy(list(self.dat.conduits.values()))
182
230
 
183
- def _orifice_dimensions(self, structure):
231
+ # this is a stack/while-loop because I want to add units to it as we go, to detail inline replicate units
232
+ while len(conduit_stack) > 0:
233
+ conduit = conduit_stack.pop(0)
234
+ self.unit_store[(conduit.name, conduit._unit)] = {
235
+ "name": conduit.name,
236
+ "type": conduit._unit,
237
+ "subtype": conduit.subtype,
238
+ "comment": conduit.comment,
239
+ }
240
+ if (conduit._unit, conduit.subtype) not in [
241
+ ("CONDUIT", "CIRCULAR"),
242
+ ("CONDUIT", "SPRUNGARCH"),
243
+ ("CONDUIT", "RECTANGULAR"),
244
+ ("CONDUIT", "SECTION"),
245
+ ("CONDUIT", "SPRUNG"),
246
+ ("REPLICATE", None),
247
+ ]:
248
+ print(
249
+ f'Conduit sub-type "{conduit.subtype}" is currently unsupported in structure log',
250
+ )
251
+ continue
252
+ conduit_dict, add_to_conduit_stack = self._conduit_data(conduit)
253
+ self.unit_store[(conduit.name, conduit._unit)]["conduit_data"] = conduit_dict
254
+ # now use individual functions to get friction and dimensional data in a way that is appropriate for each conduit type
255
+ match (conduit._unit, conduit.subtype):
256
+ case ("CONDUIT", "CIRCULAR"):
257
+ self.unit_store[(conduit.name, conduit._unit)] |= self._circular_data(conduit)
258
+ case ("CONDUIT", "SPRUNGARCH"):
259
+ self.unit_store[(conduit.name, conduit._unit)] |= self._sprungarch_data(conduit)
260
+ case ("CONDUIT", "RECTANGULAR"):
261
+ self.unit_store[(conduit.name, conduit._unit)] |= self._rectangular_data(conduit) # fmt: skip
262
+ case ("CONDUIT", "SECTION"):
263
+ self.unit_store[(conduit.name, conduit._unit)] |= self._section_data(conduit)
264
+ case ("CONDUIT", "SPRUNG"):
265
+ self.unit_store[(conduit.name, conduit._unit)] |= self._sprung_data(conduit)
266
+ case ("REPLICATE", None):
267
+ self.unit_store[(conduit.name, conduit._unit)] |= self._replicate_data(conduit)
268
+ case _:
269
+ pass
270
+
271
+ if add_to_conduit_stack is not None:
272
+ conduit_stack.insert(0, add_to_conduit_stack)
273
+
274
+ def _orifice_dimensions(self, structure: ORIFICE) -> dict:
275
+ dimensions = {
276
+ "shape": structure.shape,
277
+ "invert": structure.invert,
278
+ "soffit": structure.soffit,
279
+ "bore_area": structure.bore_area,
280
+ }
281
+ dimensions["height"] = structure.soffit - structure.invert
184
282
  if structure.shape == "RECTANGLE":
185
- height = structure.soffit - structure.invert
186
- width = structure.bore_area / height
187
- dimensions = f"h: {height:.2f} x w: {width:.2f}"
283
+ dimensions["width"] = structure.bore_area / dimensions["height"]
284
+ dimensions["bore_area"] = structure.bore_area
188
285
  elif structure.shape == "CIRCULAR":
189
- diameter = structure.soffit - structure.invert
190
- dimensions = f"dia: {diameter:.2f}"
191
- return dimensions
286
+ dimensions["width"] = dimensions["height"]
287
+ # calcuate bore area from given diameter
288
+ dimensions["bore_area"] = (dimensions["height"] ** 2) * (3.1415 * 0.25)
289
+ return {"dimensions": dimensions}
192
290
 
193
- def _spill_data(self, structure):
194
- elevation = min(structure.data.Y.tolist())
291
+ def _spill_data(self, structure: SPILL) -> dict:
195
292
  x_list = structure.data.X.tolist()
196
- width = max(x_list) - min(x_list)
197
- dimensions = f"Elevation: {elevation:.2f} x w: {width:.2f}"
198
- weir_coefficient = structure.weir_coefficient
199
- return [dimensions, weir_coefficient]
200
-
201
- def _bridge_data(self, structure):
202
- all_mannings = structure.section_data["Mannings n"].tolist()
203
- mannings_set = {min(all_mannings), max(all_mannings)}
204
- if len(mannings_set) == 1:
205
- friction = f"Mannings: {mannings_set.pop()}"
206
- else:
207
- friction = f"Mannings: [min: {mannings_set.pop()}, max: {mannings_set.pop()}]"
208
- height = structure.opening_data.values[0][3] - min(structure.section_data.Y.tolist())
209
- width = structure.opening_data.values[0][1] - structure.opening_data.values[0][0]
210
- dimensions = f"h: {height:.2f} x w: {width:.2f}"
211
- return [friction, dimensions]
212
-
213
- def _add_structures(self):
214
- for structure in self._dat.structures.values():
215
- friction = ""
216
- dimensions = ""
217
- weir_coefficient = ""
293
+ y_list = structure.data.Y.tolist()
294
+ dimensions = {
295
+ "invert": min(y_list),
296
+ "width": max(x_list) - min(x_list),
297
+ "weir_coefficient": structure.weir_coefficient,
298
+ "section_data": {"x": x_list, "y": y_list},
299
+ }
300
+ return {"dimensions": dimensions}
301
+
302
+ def _bridge_data(self, structure: BRIDGE) -> dict:
303
+ section_df = structure.section_data
304
+ all_friction = section_df["Mannings n"].tolist()
305
+ friction_set = sorted(set(all_friction))
306
+ friction = {"friction_set": friction_set, "all_friction": all_friction}
307
+
308
+ orifice_data = extract_attrs(
309
+ structure,
310
+ {
311
+ "orifice_flow",
312
+ "orifice_lower_transition_dist",
313
+ "orifice_upper_transition_dist",
314
+ "orifice_discharge_coefficient",
315
+ },
316
+ )
317
+ orifice_data["transition_width"] = (
318
+ structure.orifice_upper_transition_dist + structure.orifice_lower_transition_dist
319
+ )
320
+
321
+ opening_data = []
322
+ # if it has openings, not sure why it wouldn't but worth checking.
323
+ if structure.opening_nrows > 0:
324
+ opening_df = structure.opening_data
325
+ # this isn't 'proper' for a df but there should be few rows and we're doing special stuff
326
+ for _, row in opening_df.iterrows():
327
+ opening = {}
328
+ opening["width"] = row["Finish"] - row["Start"]
329
+
330
+ temp_df = section_df.loc[
331
+ (row["Start"] <= section_df["X"]) & (section_df["X"] <= row["Finish"])
332
+ ]
333
+ opening["bed_minimum"] = temp_df["Y"].min()
334
+
335
+ opening["soffit_level"] = row["Soffit Level"]
336
+ opening["opening_height"] = opening["soffit_level"] - opening["bed_minimum"]
337
+
338
+ opening_data.append(opening)
339
+
340
+ culvert_data = []
341
+ if hasattr(structure, "culvert_data") and structure.culvert_data.shape[0] > 1:
342
+ for _, row in structure.culvert_data:
343
+ culvert = {
344
+ "invert": row["Invert"],
345
+ "soffit": row["Soffit"],
346
+ "bore_area": "Section Area",
347
+ }
348
+ culvert["height"] = culvert["soffit"] - culvert["invert"]
349
+ culvert["average_width"] = culvert["bore_area"] / culvert["height"]
350
+ culvert_data.append(culvert)
351
+
352
+ return {
353
+ "opening_data": opening_data,
354
+ "friction": friction,
355
+ "orifice_data": orifice_data,
356
+ "culvert_data": culvert_data,
357
+ }
358
+
359
+ def _sluice_data(self, structure: SLUICE) -> dict:
360
+ # TODO: these could do with more attention, given more time
361
+ dimensions = extract_attrs(structure, {"crest_elevation", "weir_breadth", "weir_length"})
362
+
363
+ return {"dimensions": dimensions}
364
+
365
+ def _weir_data(self, structure: RNWEIR | WEIR) -> dict:
366
+ dimensions = extract_attrs(structure, {"weir_elevation", "weir_breadth"})
367
+
368
+ if structure._unit == "RNWEIR":
369
+ dimensions |= extract_attrs(
370
+ structure,
371
+ {"weir_length", "upstream_crest_height", "downstream_crest_height"},
372
+ )
373
+
374
+ return {"dimensions": dimensions}
375
+
376
+ def add_structures(self):
377
+ for structure in self.dat.structures.values():
378
+ self.unit_store[(structure.name, structure._unit)] = {
379
+ "name": structure.name,
380
+ "type": structure._unit,
381
+ "subtype": structure.subtype,
382
+ "comment": structure.comment,
383
+ }
218
384
  if structure._unit == "ORIFICE":
219
- dimensions = self._orifice_dimensions(structure)
385
+ self.unit_store[(structure.name, structure._unit)] |= self._orifice_dimensions(
386
+ structure,
387
+ )
220
388
  elif structure._unit == "SPILL":
221
- spill_data = self._spill_data(structure)
222
- dimensions = spill_data[0]
223
- weir_coefficient = spill_data[1]
389
+ self.unit_store[(structure.name, structure._unit)] |= self._spill_data(structure)
224
390
  elif structure._unit == "SLUICE":
225
- dimensions = f"Crest Elevation: {structure.crest_elevation:.2f} x w: {structure.weir_breadth:.2f} x l: {structure.weir_length:.2f}"
226
- elif structure._unit == "RNWEIR":
227
- dimensions = f"Crest Elevation: {structure.weir_elevation:.2f} x w: {structure.weir_breadth:.2f} x l: {structure.weir_length:.2f}"
228
- elif structure._unit == "WEIR":
229
- dimensions = f"Crest Elevation: {structure.weir_elevation:.2f} x w: {structure.weir_breadth:.2f}"
391
+ self.unit_store[(structure.name, structure._unit)] |= self._sluice_data(structure)
392
+ elif structure._unit in {"WEIR", "RNWEIR"}:
393
+ self.unit_store[(structure.name, structure._unit)] |= self._weir_data(structure)
230
394
  # Need weir coefficient (the velocity attribute??)
231
395
  elif structure._unit == "BRIDGE":
232
- bridge_data = self._bridge_data(structure)
233
- friction = bridge_data[0]
234
- dimensions = bridge_data[1]
396
+ self.unit_store[(structure.name, structure._unit)] |= self._bridge_data(structure)
235
397
  else:
236
- print(f"Structure: {structure._unit} not currently supported in structure log")
237
- self._write(structure.name, structure._unit, structure.subtype)
398
+ print(f'Structure "{structure._unit}" is currently unsupported in structure log')
238
399
  continue
239
400
 
240
- self._write(
241
- structure.name,
242
- structure._unit,
243
- structure.subtype,
244
- structure.comment,
245
- friction,
246
- dimensions,
247
- weir_coefficient,
248
- "",
249
- )
401
+ def _format_friction(self, unit_dict):
402
+ text = ""
403
+
404
+ if "friction" not in unit_dict:
405
+ return ""
406
+
407
+ try:
408
+ match unit_dict["friction"]["friction_eq"]:
409
+ case "MANNING":
410
+ text += "Mannings: "
411
+ case "COLEBROOK-WHITE":
412
+ text += "Colebrook-White: "
413
+ except KeyError:
414
+ text += "Mannings: "
415
+
416
+ friction_set = unit_dict["friction"]["friction_set"]
417
+ if len(friction_set) == 1:
418
+ text += f"{friction_set[0]:.3f}"
419
+ else:
420
+ text += f"[min: {friction_set[0]:.3f}, max: {friction_set[-1]:.3f}]"
421
+
422
+ return text
423
+
424
+ def _format_bridge_dimensions(self, unit_dict):
425
+
426
+ if len(unit_dict["opening_data"]) == 1:
427
+ opening = unit_dict["opening_data"][0]
428
+ height = opening["opening_height"]
429
+ width = opening["width"]
430
+ return f"h: {height:.2f} x w: {width:.2f}"
431
+
432
+ text = ""
433
+
434
+ for n, opening in enumerate(unit_dict["opening_data"]):
435
+ height = opening["opening_height"]
436
+ width = opening["width"]
437
+
438
+ text += f"Opening {n+1}: h: {height:.2f} x w: {width:.2f} "
439
+
440
+ return text.rstrip()
441
+
442
+ def _format_orifice_dimensions(self, unit_dict):
443
+ height = unit_dict["dimensions"]["height"]
444
+ width = unit_dict["dimensions"]["width"]
445
+ match unit_dict["dimensions"]["shape"]:
446
+ case "RECTANGLE":
447
+ return f"h: {height:.2f} x w: {width:.2f}"
448
+ case "CIRCULAR":
449
+ return f"dia: {width:.2f}"
450
+
451
+ def _format_spill_dimensions(self, unit_dict):
452
+ invert = unit_dict["dimensions"]["invert"]
453
+ width = unit_dict["dimensions"]["width"]
454
+
455
+ return f"Elevation: {invert:.2f} x w: {width:.2f}"
456
+
457
+ def _format_weir_dimensions(self, unit_dict):
458
+ text = ""
459
+ elevation = unit_dict["dimensions"]["weir_elevation"]
460
+ breadth = unit_dict["dimensions"]["weir_breadth"]
461
+
462
+ text += f"Crest Elevation: {elevation:.2f} x w: {breadth:.2f}"
463
+
464
+ if "weir_length" in unit_dict["dimensions"]:
465
+ length = unit_dict["dimensions"]["weir_length"]
466
+ text += f" x l: {length:.2f}"
467
+ return text
468
+
469
+ def _format_sluice_dimensions(self, unit_dict):
470
+ elevation = unit_dict["dimensions"]["crest_elevation"]
471
+ breadth = unit_dict["dimensions"]["weir_breadth"]
472
+ length = unit_dict["dimensions"]["weir_length"]
473
+
474
+ return f"Crest Elevation: {elevation:.2f} x w: {breadth:.2f} x l: {length:.2f}"
475
+
476
+ def _format_conduit_dimensions(self, unit_dict):
477
+ text = ""
478
+ culvert_loss = ""
479
+ match unit_dict["subtype"]:
480
+ case "CIRCULAR":
481
+ text += f'dia: {unit_dict["dimensions"]["diameter"]:.2f} x l: {unit_dict["conduit_data"]["length"]:.2f}'
482
+ case "SPRUNGARCH" | "SPRUNG":
483
+ text += f'(Springing: {unit_dict["dimensions"]["height_springing"]:.2f}, Crown: {unit_dict["dimensions"]["height_crown"]:.2f}) x w: {unit_dict["dimensions"]["width"]:.2f} x l: {unit_dict["conduit_data"]["length"]:.2f}'
484
+ case "RECTANGULAR":
485
+ text += f'h: {unit_dict["dimensions"]["height"]:.2f} x w: {unit_dict["dimensions"]["width"]:.2f} x l: {unit_dict["conduit_data"]["length"]:.2f}'
486
+ case "SECTION":
487
+ text += f'h: {unit_dict["dimensions"]["height"]:.2f} x w: {unit_dict["dimensions"]["width"]:.2f} x l: {unit_dict["conduit_data"]["length"]:.2f}'
488
+ case _:
489
+ return "", ""
490
+
491
+ if "total_length" in unit_dict["conduit_data"]:
492
+ text += f' (Total conduit length: {unit_dict["conduit_data"]["total_length"]:.2f})'
493
+
494
+ if "inlet" in unit_dict["conduit_data"]:
495
+ culvert_loss += f'Ki: {unit_dict["conduit_data"]["inlet"]}, '
496
+
497
+ if "outlet" in unit_dict["conduit_data"]:
498
+ culvert_loss += f'Ko: {unit_dict["conduit_data"]["outlet"]}, '
499
+
500
+ culvert_loss = culvert_loss.rstrip(", ")
501
+
502
+ return text, culvert_loss
250
503
 
251
504
  def _write( # noqa: PLR0913
252
505
  self,
506
+ writer,
253
507
  name,
254
508
  unit,
255
509
  subtype,
@@ -259,7 +513,7 @@ class StructureLogBuilder:
259
513
  weir_coefficient="",
260
514
  culvert_loss="",
261
515
  ):
262
- self._writer.writerow(
516
+ writer.writerow(
263
517
  [
264
518
  name,
265
519
  unit,
@@ -272,16 +526,64 @@ class StructureLogBuilder:
272
526
  ],
273
527
  )
274
528
 
275
- def create(self):
276
- # Read in the .dat file
277
- self._dat = DAT(self.dat_file_path)
529
+ def write_csv_output(self, file):
530
+ """
531
+ Take the current state of the instance (unit_store etc) and write it to the specified output file.
532
+ """
278
533
 
279
- # Create a new .csv file
280
- with open(self.csv_output_path, "w", newline="") as file:
281
- self._writer = csv.writer(file)
534
+ writer = csv.writer(file)
535
+
536
+ self._add_fields(writer)
537
+
538
+ for unit_dict in self.unit_store.values():
539
+ name = unit_dict["name"]
540
+ unit_type = unit_dict["type"]
541
+ subtype = unit_dict["subtype"]
542
+ comment = unit_dict["comment"]
282
543
 
283
- self._add_fields()
544
+ friction = self._format_friction(unit_dict)
284
545
 
285
- self._add_conduits()
546
+ culvert_loss = ""
286
547
 
287
- self._add_structures()
548
+ match (unit_type):
549
+ case "BRIDGE":
550
+ dimensions = self._format_bridge_dimensions(unit_dict)
551
+ case "ORIFICE":
552
+ dimensions = self._format_orifice_dimensions(unit_dict)
553
+ case "WEIR" | "RNWEIR":
554
+ dimensions = self._format_weir_dimensions(unit_dict)
555
+ case "SLUICE":
556
+ dimensions = self._format_sluice_dimensions(unit_dict)
557
+ case "SPILL":
558
+ dimensions = self._format_spill_dimensions(unit_dict)
559
+ case "CONDUIT":
560
+ dimensions, culvert_loss = self._format_conduit_dimensions(unit_dict)
561
+ case _:
562
+ dimensions = ""
563
+
564
+ try:
565
+ weir_coefficient = unit_dict["dimensions"]["weir_coefficient"]
566
+ except KeyError:
567
+ weir_coefficient = ""
568
+
569
+ self._write(
570
+ writer,
571
+ name,
572
+ unit_type,
573
+ subtype,
574
+ comment,
575
+ friction,
576
+ dimensions,
577
+ weir_coefficient,
578
+ culvert_loss,
579
+ )
580
+
581
+ def create(self):
582
+ """
583
+ When using the toolbox wrapper or commandline entry point, this is the entrypoint to the structure logger code
584
+ """
585
+ self.dat = DAT(self.dat_file_path)
586
+ self.add_conduits()
587
+ self.add_structures()
588
+ with open(self.csv_output_path, "w", newline="") as file:
589
+ self.write_csv_output(file)