easysewer 0.0.1__py3-none-any.whl → 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
easysewer/Node.py CHANGED
@@ -21,6 +21,7 @@ class Node:
21
21
  coordinate (list): [x, y] coordinates of the node
22
22
  elevation (float): Node invert elevation
23
23
  """
24
+
24
25
  def __init__(self):
25
26
  self.name = ''
26
27
  self.coordinate = [0.0, 0.0]
@@ -46,6 +47,7 @@ class Junction(Node):
46
47
  dwf_patterns (list): Time patterns for dry weather flow
47
48
  inflow (dict): Inflow characteristics and time series
48
49
  """
50
+
49
51
  def __init__(self):
50
52
  Node.__init__(self)
51
53
  self.maximum_depth = 0
@@ -72,6 +74,7 @@ class Outfall(Node):
72
74
  flap_gate (bool): Whether backflow prevention is present
73
75
  route_to (str): Routing destination for diverted flow
74
76
  """
77
+
75
78
  def __init__(self):
76
79
  Node.__init__(self)
77
80
  self.flap_gate = False
@@ -79,44 +82,246 @@ class Outfall(Node):
79
82
 
80
83
 
81
84
  class OutfallFree(Outfall):
85
+ """
86
+ Free outfall node type.
87
+
88
+ Represents an outfall with free boundary condition where water can freely exit the system.
89
+
90
+ Attributes:
91
+ Inherits all attributes from Outfall class
92
+ """
93
+
82
94
  def __init__(self):
83
95
  Outfall.__init__(self)
84
96
 
85
97
 
86
98
  class OutfallNormal(Outfall):
99
+ """
100
+ Normal outfall node type.
101
+
102
+ Represents an outfall with normal boundary condition where water exits with normal depth.
103
+
104
+ Attributes:
105
+ Inherits all attributes from Outfall class
106
+ """
107
+
87
108
  def __init__(self):
88
109
  Outfall.__init__(self)
89
110
 
90
111
 
91
112
  class OutfallFixed(Outfall):
113
+ """
114
+ Fixed outfall node type.
115
+
116
+ Represents an outfall with fixed boundary condition where water exits at a fixed stage.
117
+
118
+ Attributes:
119
+ Inherits all attributes from Outfall class
120
+ stage (float): Fixed water surface elevation at the outfall
121
+ """
122
+
92
123
  def __init__(self):
93
124
  Outfall.__init__(self)
94
125
  self.stage = 0.0
95
126
 
96
127
 
97
128
  class OutfallTidal(Outfall):
129
+ """
130
+ Tidal outfall node type.
131
+
132
+ Represents an outfall with tidal boundary condition where water level varies with tides.
133
+
134
+ Attributes:
135
+ Inherits all attributes from Outfall class
136
+ tidal (str): Tidal condition identifier or time series name
137
+ """
138
+
98
139
  def __init__(self):
99
140
  Outfall.__init__(self)
100
141
  self.tidal = ''
101
142
 
102
143
 
103
144
  class OutfallTimeseries(Outfall):
145
+ """
146
+ Timeseries outfall node type.
147
+
148
+ Represents an outfall with time-varying boundary condition specified by a time series.
149
+
150
+ Attributes:
151
+ Inherits all attributes from Outfall class
152
+ time_series (str): Name of time series defining water surface elevation
153
+ """
154
+
104
155
  def __init__(self):
105
156
  Outfall.__init__(self)
106
157
  self.time_series = ''
107
158
 
108
159
 
160
+ class Polygon:
161
+ """
162
+ Geometric representation of a node's boundary.
163
+
164
+ Stores the polygon vertices that define the boundary of a node.
165
+
166
+ Attributes:
167
+ node_name (str): Name of the associated node
168
+ x (list): List of x-coordinates of polygon vertices
169
+ y (list): List of y-coordinates of polygon vertices
170
+ """
171
+
172
+ def __init__(self):
173
+ self.node_name = None
174
+ self.x = []
175
+ self.y = []
176
+
177
+
178
+ class Storage(Node):
179
+ """
180
+ Base class for storage nodes in the drainage network.
181
+
182
+ Represents a storage unit in the drainage network with volume and depth properties.
183
+
184
+ Attributes:
185
+ maximum_depth (float): Maximum water depth in storage unit
186
+ initial_depth (float): Initial water depth at start of simulation
187
+ overload_depth (float): Depth above which overflows occur
188
+ evaporation_factor (float): Factor adjusting evaporation rate
189
+ seepage_suction_head (float): Soil suction head for seepage calculations
190
+ seepage_conductivity (float): Soil hydraulic conductivity for seepage
191
+ seepage_initial_deficit (float): Initial soil moisture deficit
192
+ inflow (dict): Inflow characteristics and time series
193
+ polygon (Polygon): Geometric representation of the storage node boundary
194
+ """
195
+
196
+ def __init__(self):
197
+ Node.__init__(self)
198
+ self.maximum_depth = 0
199
+ self.initial_depth = 0
200
+ self.overload_depth = 0
201
+ self.evaporation_factor = 0
202
+ self.seepage_suction_head = None
203
+ self.seepage_conductivity = None
204
+ self.seepage_initial_deficit = None
205
+ #
206
+ # inflow
207
+ self.inflow = None
208
+ #
209
+ # polygon boundary
210
+ self.polygon = Polygon()
211
+
212
+
213
+ class StorageFunctional(Storage):
214
+ """
215
+ Functional storage node type.
216
+
217
+ Represents a storage unit with volume defined by a functional relationship.
218
+
219
+ Attributes:
220
+ Inherits all attributes from Storage class
221
+ constant (float): Constant term in functional relationship
222
+ coefficient (float): Coefficient in functional relationship
223
+ exponent (float): Exponent in functional relationship
224
+ """
225
+
226
+ def __init__(self):
227
+ Storage.__init__(self)
228
+ self.constant = 0.0
229
+ self.coefficient = 0.0
230
+ self.exponent = 0.0
231
+
232
+
233
+ class StorageCylindrical(Storage):
234
+ """
235
+ Cylindrical storage node type.
236
+
237
+ Represents a storage unit with cylindrical shape.
238
+
239
+ Attributes:
240
+ Inherits all attributes from Storage class
241
+ major_axis_length (float): Length of major axis
242
+ minor_axis_length (float): Length of minor axis
243
+ """
244
+
245
+ def __init__(self):
246
+ Storage.__init__(self)
247
+ self.major_axis_length = 0.0
248
+ self.minor_axis_length = 0.0
249
+
250
+
251
+ class StorageTabular(Storage):
252
+ """
253
+ Tabular storage node type.
254
+
255
+ Represents a storage unit with volume defined by a tabular curve.
256
+
257
+ Attributes:
258
+ Inherits all attributes from Storage class
259
+ storage_curve_name (str): Name of curve defining storage volume
260
+ """
261
+
262
+ def __init__(self):
263
+ Storage.__init__(self)
264
+ self.storage_curve_name = ''
265
+
266
+
109
267
  class NodeList:
268
+ """
269
+ A collection class for managing nodes in a drainage network.
270
+
271
+ This class provides storage and management for various types of nodes (junctions, outfalls),
272
+ with methods for adding, accessing, and processing nodes. It maintains spatial bounds
273
+ information for all contained nodes.
274
+
275
+ Attributes:
276
+ data (list): List containing all node objects
277
+ bounds (dict): Dictionary tracking spatial bounds of all nodes with keys:
278
+ 'min_x' (float): Minimum x-coordinate
279
+ 'min_y' (float): Minimum y-coordinate
280
+ 'max_x' (float): Maximum x-coordinate
281
+ 'max_y' (float): Maximum y-coordinate
282
+ """
283
+
110
284
  def __init__(self):
111
285
  self.data = []
286
+ self.bounds = {
287
+ 'min_x': float('inf'),
288
+ 'min_y': float('inf'),
289
+ 'max_x': float('-inf'),
290
+ 'max_y': float('-inf')
291
+ }
112
292
 
113
293
  def __repr__(self):
294
+ """
295
+ Returns a string representation of the NodeList.
296
+
297
+ Returns:
298
+ str: A string showing the count of nodes in the list
299
+ """
114
300
  return f'{len(self.data)} Nodes'
115
301
 
116
302
  def __len__(self):
303
+ """
304
+ Returns the number of nodes in the list.
305
+
306
+ Returns:
307
+ int: Number of nodes in the list
308
+ """
117
309
  return len(self.data)
118
310
 
119
311
  def __getitem__(self, key):
312
+ """
313
+ Gets a node by index or name.
314
+
315
+ Args:
316
+ key (int|str): Index or name of node to retrieve
317
+
318
+ Returns:
319
+ Node: The requested node
320
+
321
+ Raises:
322
+ KeyError: If node name not found
323
+ TypeError: If key is not int or str
324
+ """
120
325
  if isinstance(key, int):
121
326
  return self.data[key]
122
327
  elif isinstance(key, str):
@@ -128,219 +333,795 @@ class NodeList:
128
333
  raise TypeError("Key must be an integer or a string")
129
334
 
130
335
  def __iter__(self):
336
+ """
337
+ Returns an iterator for the node list.
338
+
339
+ Returns:
340
+ iterator: Iterator for the nodes
341
+ """
131
342
  return iter(self.data)
132
343
 
133
344
  def __contains__(self, item):
345
+ """
346
+ Checks if a node exists in the list.
347
+
348
+ Args:
349
+ item: Node to check for
350
+
351
+ Returns:
352
+ bool: True if node exists in list
353
+ """
134
354
  return item in self.data
135
355
 
136
- def add_node(self, node_type, node_information):
137
- def execute(func1, func2):
138
- def inner():
139
- # new an object according to node_type
140
- new_node = func1()
141
- # add essential information
142
- if 'name' in node_information:
143
- new_node.name = node_information['name']
144
- else: # if it can not find name, raise error
145
- # print('Unknown Node: Can not recognize node name')
146
- return -1
147
- if 'coordinate' in node_information:
148
- new_node.coordinate = node_information['coordinate']
149
- if 'elevation' in node_information:
150
- new_node.elevation = node_information['elevation']
151
- # for Outfalls
152
- if 'flap_gate' in node_information:
153
- new_node.flap_gate = True if node_information['flap_gate'] == 'YES' else False
154
- if 'route_to' in node_information:
155
- new_node.route_to = node_information['route_to']
156
- # add node_type related information
157
- func2(new_node)
158
- # update node_list
159
- self.data.append(new_node)
160
- return 0
161
-
162
- return inner
163
-
164
- match node_type:
165
- case 'junction' | 'Junction':
166
- def junction_type(new_node):
167
- if 'maximum_depth' in node_information:
168
- new_node.maximum_depth = node_information['maximum_depth']
169
- if 'initial_depth' in node_information:
170
- new_node.initial_depth = node_information['initial_depth']
171
- if 'overload_depth' in node_information:
172
- new_node.overload_depth = node_information['overload_depth']
173
- if 'surface_ponding_area' in node_information:
174
- new_node.surface_ponding_area = node_information['surface_ponding_area']
175
- if 'dwf_base_value' in node_information:
176
- new_node.dwf_base_value = node_information['dwf_base_value']
177
- if 'dwf_patterns' in node_information:
178
- new_node.dwf_patterns = node_information['dwf_patterns']
179
-
180
- return execute(Junction, junction_type)()
181
-
182
- case 'outfall_free' | 'OutfallFree':
183
- def outfall_free_type(_):
184
- pass
185
-
186
- return execute(OutfallFree, outfall_free_type)()
187
-
188
- case 'outfall_normal' | 'OutfallNormal':
189
- def outfall_normal_type(_):
190
- pass
191
-
192
- return execute(OutfallNormal, outfall_normal_type)()
193
-
194
- case 'outfall_fixed' | 'OutfallFixed':
195
- def outfall_fixed_type(new_node):
196
- if 'stage' in node_information:
197
- new_node.stage = node_information['stage']
198
-
199
- return execute(OutfallFixed, outfall_fixed_type)()
200
-
201
- case 'outfall_tidal' | 'OutfallTidal':
202
- def outfall_tidal_type(new_node):
203
- if 'tidal' in node_information:
204
- new_node.tidal = node_information['tidal']
205
-
206
- return execute(OutfallTidal, outfall_tidal_type)()
207
-
208
- case 'outfall_time_series' | 'OutfallTimeseries':
209
- def outfall_time_series_type(new_node):
210
- if 'time_series' in node_information:
211
- new_node.time_series = node_information['time_series']
212
-
213
- return execute(OutfallTimeseries, outfall_time_series_type)()
214
-
215
- case _:
216
- raise TypeError(f"Unknown node type, failed to add {node_information['name']}")
356
+ def add_node(self, node_type, node_information=None):
357
+ """
358
+ Add a new node to the data structure based on its type and information.
359
+ Generates default values for missing essential attributes.
360
+
361
+ Args:
362
+ node_type (str): Type of node to add (e.g., 'junction', 'outfall_free')
363
+ node_information (dict, optional): Dictionary containing node attributes
364
+ Defaults to empty dict if None
365
+
366
+ Returns:
367
+ Node: The newly created node object
368
+
369
+ Raises:
370
+ TypeError: If node_type is not recognized
371
+ ValueError: If a node with the same name already exists
372
+ or generated default name conflicts
373
+
374
+ Example:
375
+ >>> nodes = NodeList()
376
+ >>> nodes.add_node('junction', {'name': 'J1', 'elevation': 100.0})
377
+ <Node J1>
378
+ """
379
+ # Initialize node_information dict if not provided
380
+ if node_information is None:
381
+ node_information = {}
382
+
383
+ # Normalize node type: lowercase and remove underscores
384
+ normalized_type = node_type.lower().replace('_', '')
385
+
386
+ # Check if a name is provided and if it already exists in the collection
387
+ if 'name' in node_information:
388
+ requested_name = node_information['name']
389
+ if any(node.name == requested_name for node in self.data):
390
+ raise ValueError(f"Node with name '{requested_name}' already exists")
391
+
392
+ # Define attribute hierarchy based on class inheritance
393
+ # Level 1: Common attributes for all Node types with defaults
394
+ node_base_attrs = {
395
+ 'name': lambda node_type, info: info.get('name', self._generate_default_name(node_type)),
396
+ 'coordinate': lambda _, info: info.get('coordinate', self._generate_default_coordinate()),
397
+ 'elevation': lambda _, info: info.get('elevation', 0.0)
398
+ }
399
+
400
+ # Level 2: Attributes by node category with defaults
401
+ junction_attrs = {
402
+ 'maximum_depth': lambda _, info: info.get('maximum_depth', 10.0),
403
+ 'initial_depth': lambda _, info: info.get('initial_depth', 0.0),
404
+ 'overload_depth': lambda _, info: info.get('overload_depth', 0.0),
405
+ 'surface_ponding_area': lambda _, info: info.get('surface_ponding_area', 0.0),
406
+ 'dwf_base_value': lambda _, info: info.get('dwf_base_value', 0.0),
407
+ 'dwf_patterns': lambda _, info: info.get('dwf_patterns', None)
408
+ }
409
+
410
+ outfall_base_attrs = {
411
+ 'flap_gate': lambda _, info: True if info.get('flap_gate') in ('YES', True) else False,
412
+ 'route_to': lambda _, info: info.get('route_to', None)
413
+ }
414
+
415
+ # Level 3: Specific attributes for outfall subtypes with defaults
416
+ outfall_specific_attrs = {
417
+ 'outfallfixed': {
418
+ 'stage': lambda _, info: info.get('stage', 0.0)
419
+ },
420
+ 'outfalltidal': {
421
+ 'tidal': lambda _, info: info.get('tidal', None)
422
+ },
423
+ 'outfalltimeseries': {
424
+ 'time_series': lambda _, info: info.get('time_series', None)
425
+ },
426
+ 'outfallfree': {},
427
+ 'outfallnormal': {}
428
+ }
429
+
430
+ # Level 2: Attributes for storage nodes with defaults
431
+ storage_attrs = {
432
+ 'maximum_depth': lambda _, info: info.get('maximum_depth', 10.0),
433
+ 'initial_depth': lambda _, info: info.get('initial_depth', 0.0),
434
+ 'overload_depth': lambda _, info: info.get('overload_depth', 0.0),
435
+ 'evaporation_factor': lambda _, info: info.get('evaporation_factor', 0.0),
436
+ 'seepage_suction_head': lambda _, info: info.get('seepage_suction_head', None),
437
+ 'seepage_conductivity': lambda _, info: info.get('seepage_conductivity', None),
438
+ 'seepage_initial_deficit': lambda _, info: info.get('seepage_initial_deficit', None)
439
+ }
440
+
441
+ # Level 3: Specific attributes for storage subtypes with defaults
442
+ storage_specific_attrs = {
443
+ 'storagefunctional': {
444
+ 'constant': lambda _, info: info.get('constant', 0.0),
445
+ 'coefficient': lambda _, info: info.get('coefficient', 0.0),
446
+ 'exponent': lambda _, info: info.get('exponent', 0.0)
447
+ },
448
+ 'storagecylindrical': {
449
+ 'major_axis_length': lambda _, info: info.get('major_axis_length', 0.0),
450
+ 'minor_axis_length': lambda _, info: info.get('minor_axis_length', 0.0)
451
+ },
452
+ 'storagetabular': {
453
+ 'storage_curve_name': lambda _, info: info.get('storage_curve_name', '')
454
+ }
455
+ }
456
+
457
+ # Define node type configurations
458
+ node_types = {
459
+ 'junction': {
460
+ 'class': Junction,
461
+ 'attrs': {**node_base_attrs, **junction_attrs}
462
+ },
463
+ 'outfallfree': {
464
+ 'class': OutfallFree,
465
+ 'attrs': {**node_base_attrs, **outfall_base_attrs, **outfall_specific_attrs['outfallfree']}
466
+ },
467
+ 'outfallnormal': {
468
+ 'class': OutfallNormal,
469
+ 'attrs': {**node_base_attrs, **outfall_base_attrs, **outfall_specific_attrs['outfallnormal']}
470
+ },
471
+ 'outfallfixed': {
472
+ 'class': OutfallFixed,
473
+ 'attrs': {**node_base_attrs, **outfall_base_attrs, **outfall_specific_attrs['outfallfixed']}
474
+ },
475
+ 'outfalltidal': {
476
+ 'class': OutfallTidal,
477
+ 'attrs': {**node_base_attrs, **outfall_base_attrs, **outfall_specific_attrs['outfalltidal']}
478
+ },
479
+ 'outfalltimeseries': {
480
+ 'class': OutfallTimeseries,
481
+ 'attrs': {**node_base_attrs, **outfall_base_attrs, **outfall_specific_attrs['outfalltimeseries']}
482
+ },
483
+ 'storagefunctional': {
484
+ 'class': StorageFunctional,
485
+ 'attrs': {**node_base_attrs, **storage_attrs, **storage_specific_attrs['storagefunctional']}
486
+ },
487
+ 'storagecylindrical': {
488
+ 'class': StorageCylindrical,
489
+ 'attrs': {**node_base_attrs, **storage_attrs, **storage_specific_attrs['storagecylindrical']}
490
+ },
491
+ 'storagetabular': {
492
+ 'class': StorageTabular,
493
+ 'attrs': {**node_base_attrs, **storage_attrs, **storage_specific_attrs['storagetabular']}
494
+ }
495
+ }
496
+
497
+ # Check if normalized node type exists
498
+ if normalized_type not in node_types:
499
+ raise TypeError(
500
+ f"Unknown node type '{node_type}', failed to add {node_information.get('name', 'unnamed node')}"
501
+ )
502
+
503
+ # Get node configuration
504
+ node_config = node_types[normalized_type]
505
+ node_class = node_config['class']
506
+ attrs = node_config['attrs']
507
+
508
+ # Create new node
509
+ new_node = node_class()
510
+
511
+ # Set all applicable attributes according to hierarchy, using default generators
512
+ for attr, default_generator in attrs.items():
513
+ value = default_generator(normalized_type, node_information)
514
+ setattr(new_node, attr, value)
515
+
516
+ # Check if the generated default name is unique (when name wasn't explicitly provided)
517
+ if 'name' not in node_information and any(node.name == new_node.name for node in self.data):
518
+ raise ValueError(f"Generated default name '{new_node.name}' already exists")
519
+
520
+ # Update coordinate bounds if coordinate is set
521
+ if hasattr(new_node, 'coordinate') and new_node.coordinate:
522
+ self._update_bounds(new_node.coordinate)
523
+
524
+ # Add node to data structure
525
+ self.data.append(new_node)
526
+
527
+ return new_node # Return the created node for immediate use if needed
528
+
529
+ def _update_bounds(self, coordinate):
530
+ """
531
+ Update the coordinate bounds based on a new node's position.
532
+
533
+ Args:
534
+ coordinate (list): [x, y] coordinates of the node
535
+ """
536
+ if not coordinate:
537
+ return
538
+
539
+ x, y = coordinate
540
+ self.bounds['min_x'] = min(self.bounds['min_x'], x)
541
+ self.bounds['min_y'] = min(self.bounds['min_y'], y)
542
+ self.bounds['max_x'] = max(self.bounds['max_x'], x)
543
+ self.bounds['max_y'] = max(self.bounds['max_y'], y)
544
+
545
+ def _generate_default_name(self, node_type):
546
+ """
547
+ Generate a default name for a node based on its type and existing nodes count.
548
+
549
+ Args:
550
+ node_type (str): Type of node (e.g. 'junction', 'outfall_free')
551
+
552
+ Returns:
553
+ str: Generated name in format 'TYPE##' where TYPE is first 3 letters of node type
554
+ and ## is sequential number
555
+ """
556
+ # Get count of nodes with the same type prefix
557
+ prefix = node_type[:3].upper() # First 3 letters of node type
558
+ existing_count = sum(1 for node in self.data if hasattr(node, 'name') and
559
+ node.name and node.name.startswith(prefix))
560
+
561
+ # Generate name with next number
562
+ return f"{prefix}{existing_count + 1}"
563
+
564
+ def _generate_default_coordinate(self):
565
+ """
566
+ Generate a sensible default coordinate based on existing nodes.
567
+
568
+ Returns:
569
+ tuple: (x, y) coordinates
570
+
571
+ Logic:
572
+ 1. If no nodes exist, returns (0, 0)
573
+ 2. If bounds are established, returns center with slight offset
574
+ 3. Otherwise places near last node with offset
575
+ """
576
+ # If no nodes exist yet, start at origin
577
+ if not self.data:
578
+ return 0, 0
579
+
580
+ # If bounds are established, place in center with slight offset
581
+ if self.bounds['min_x'] != float('inf'):
582
+ center_x = (self.bounds['min_x'] + self.bounds['max_x']) / 2
583
+ center_y = (self.bounds['min_y'] + self.bounds['max_y']) / 2
584
+ # Add a small offset to avoid perfect overlap
585
+ offset = len(self.data) * 10
586
+ return center_x + offset, center_y + offset
587
+
588
+ # Fallback - place near the last node
589
+ last_node = self.data[-1]
590
+ if hasattr(last_node, 'coordinate') and last_node.coordinate:
591
+ last_x, last_y = last_node.coordinate
592
+ return last_x + 50, last_y + 50
593
+
594
+ return 0, 0
217
595
 
218
596
  def read_from_swmm_inp(self, filename):
219
- junction_contents = get_swmm_inp_content(filename, '[JUNCTIONS]')
220
- coordinates = get_swmm_inp_content(filename, '[COORDINATES]')
221
- outfall_contents = get_swmm_inp_content(filename, '[OUTFALLS]')
222
- dwf_contents = get_swmm_inp_content(filename, '[DWF]')
223
- inflow_contents = get_swmm_inp_content(filename, '[INFLOWS]')
597
+ """
598
+ Read node data from a SWMM input file.
599
+
600
+ Processes the following sections from SWMM input file:
601
+ - [JUNCTIONS]
602
+ - [OUTFALLS]
603
+ - [STORAGE]
604
+ - [COORDINATES]
605
+ - [DWF]
606
+ - [INFLOWS]
607
+ - [Polygons] (for Storage nodes)
608
+
609
+ Args:
610
+ filename (str): Path to the SWMM input file
611
+
612
+ Returns:
613
+ int: 0 if successful
614
+
615
+ Raises:
616
+ FileNotFoundError: If the input file doesn't exist
617
+ ValueError: If required sections are missing
618
+ Exception: For unsupported inflow types
619
+
620
+ Note:
621
+ Continues processing other nodes if errors occur with individual nodes
622
+ """
623
+ try:
624
+ # Read all required sections
625
+ junction_contents = get_swmm_inp_content(filename, '[JUNCTIONS]')
626
+ coordinates = get_swmm_inp_content(filename, '[COORDINATES]')
627
+ outfall_contents = get_swmm_inp_content(filename, '[OUTFALLS]')
628
+ storage_contents = get_swmm_inp_content(filename, '[STORAGE]')
629
+ dwf_contents = get_swmm_inp_content(filename, '[DWF]')
630
+ inflow_contents = get_swmm_inp_content(filename, '[INFLOWS]')
631
+ polygon_contents = get_swmm_inp_content(filename, '[Polygons]')
224
632
 
225
- # coordinate list
633
+ # Process coordinates (needed by all node types)
634
+ coordinates_dic = self._process_coordinates(coordinates)
635
+
636
+ # Process each node type
637
+ self._process_junctions(junction_contents, coordinates_dic)
638
+ self._process_outfalls(outfall_contents, coordinates_dic)
639
+ self._process_storage(storage_contents, coordinates_dic)
640
+ self._process_dry_weather_flows(dwf_contents)
641
+ self._process_inflows(inflow_contents)
642
+ self._process_polygons(polygon_contents)
643
+
644
+ return 0
645
+ except Exception as e:
646
+ # Re-raise with more context
647
+ raise type(e)(f"Error reading SWMM input file: {str(e)}")
648
+
649
+ @staticmethod
650
+ def _process_coordinates(coordinates):
651
+ """Process coordinates data from SWMM input file."""
226
652
  coordinates_dic = {}
227
653
  for line in coordinates:
228
654
  keys = line.split()
229
- coordinates_dic[keys[0]] = [float(keys[1]), float(keys[2])]
230
- # process junctions
655
+ if len(keys) >= 3: # Ensure we have at least node name, x, y
656
+ coordinates_dic[keys[0]] = [float(keys[1]), float(keys[2])]
657
+ return coordinates_dic
658
+
659
+ def _process_junctions(self, junction_contents, coordinates_dic):
660
+ """Process junction data from SWMM input file."""
231
661
  for line in junction_contents:
232
- pair = line.split()
233
- dic = {'name': pair[0], 'coordinate': [0.0, 0.0], 'elevation': float(pair[1]),
234
- 'maximum_depth': float(pair[2]), 'initial_depth': float(pair[3]),
235
- 'overload_depth': float(pair[4]), 'surface_ponding_area': float(pair[5])}
236
- dic['coordinate'] = coordinates_dic[dic['name']]
237
- self.add_node('junction', dic)
238
- # process outfalls
662
+ parts = line.split()
663
+ if len(parts) < 6: # Skip lines with insufficient data
664
+ continue
665
+
666
+ try:
667
+ dic = {
668
+ 'name': parts[0],
669
+ 'coordinate': coordinates_dic.get(parts[0], [0.0, 0.0]),
670
+ 'elevation': float(parts[1]),
671
+ 'maximum_depth': float(parts[2]),
672
+ 'initial_depth': float(parts[3]),
673
+ 'overload_depth': float(parts[4]),
674
+ 'surface_ponding_area': float(parts[5])
675
+ }
676
+ self.add_node('junction', dic)
677
+ except (ValueError, KeyError) as e:
678
+ # Log error but continue processing other junctions
679
+ print(f"Warning: Error processing junction '{parts[0]}': {str(e)}")
680
+
681
+ def _process_outfalls(self, outfall_contents, coordinates_dic):
682
+ """Process outfall data from SWMM input file."""
239
683
  for line in outfall_contents:
240
- pair = line.split()
241
- dic = {'name': pair[0], 'coordinate': [0.0, 0.0], 'elevation': float(pair[1])}
242
- dic['coordinate'] = coordinates_dic[dic['name']]
243
- #
244
- if pair[-1] == 'YES':
245
- dic['flap_gate'] = 'YES'
246
- elif pair[-1] == 'NO':
247
- dic['flap_gate'] = 'NO'
248
- else:
249
- dic['flap_gate'] = pair[-2]
250
- dic['route_to'] = pair[-1]
251
- #
252
- match pair[2]:
253
- case 'FREE':
684
+ parts = line.split()
685
+ if len(parts) < 3: # Skip lines with insufficient data
686
+ continue
687
+
688
+ try:
689
+ # Set up common attributes
690
+ dic = {
691
+ 'name': parts[0],
692
+ 'coordinate': coordinates_dic.get(parts[0], [0.0, 0.0]),
693
+ 'elevation': float(parts[1])
694
+ }
695
+
696
+ # Process flap gate and route to parameters (last elements)
697
+ if parts[-1] == 'YES':
698
+ dic['flap_gate'] = 'YES'
699
+ elif parts[-1] == 'NO':
700
+ dic['flap_gate'] = 'NO'
701
+ else:
702
+ dic['flap_gate'] = parts[-2]
703
+ dic['route_to'] = parts[-1]
704
+
705
+ # Process outfall type
706
+ outfall_type = parts[2]
707
+ if outfall_type == 'FREE':
254
708
  self.add_node('outfall_free', dic)
255
- case 'NORMAL':
709
+ elif outfall_type == 'NORMAL':
256
710
  self.add_node('outfall_normal', dic)
257
- case 'FIXED':
258
- dic['stage'] = float(pair[2])
711
+ elif outfall_type == 'FIXED':
712
+ dic['stage'] = float(parts[3])
259
713
  self.add_node('outfall_fixed', dic)
260
- case 'TIDAL':
261
- dic['tidal'] = float(pair[2])
714
+ elif outfall_type == 'TIDAL':
715
+ dic['tidal'] = parts[3]
262
716
  self.add_node('outfall_tidal', dic)
263
- case 'TIMESERIES':
264
- dic['time_series'] = float(pair[2])
717
+ elif outfall_type == 'TIMESERIES':
718
+ dic['time_series'] = parts[3]
265
719
  self.add_node('outfall_time_series', dic)
266
- case _:
267
- pass
268
- # process DWF
720
+ except (ValueError, KeyError) as e:
721
+ # Log error but continue processing other outfalls
722
+ print(f"Warning: Error processing outfall '{parts[0]}': {str(e)}")
723
+
724
+ def _process_storage(self, storage_contents, coordinates_dic):
725
+ """Process storage data from SWMM input file."""
726
+ for line in storage_contents:
727
+ parts = line.split()
728
+ if len(parts) < 5: # Skip lines with insufficient data
729
+ continue
730
+
731
+ try:
732
+ # Set up common attributes
733
+ dic = {
734
+ 'name': parts[0],
735
+ 'coordinate': coordinates_dic.get(parts[0], [0.0, 0.0]),
736
+ 'elevation': float(parts[1]),
737
+ 'maximum_depth': float(parts[2]),
738
+ 'initial_depth': float(parts[3])
739
+ }
740
+
741
+ # Process storage type - check the 5th element (index 4)
742
+ storage_type = parts[4]
743
+
744
+ # Process specific storage type parameters based on storage type
745
+ if storage_type == 'FUNCTIONAL':
746
+ # For FUNCTIONAL: coefficient exponent constant
747
+ if len(parts) > 7: # Ensure we have at least the required parameters
748
+ dic['coefficient'] = float(parts[5])
749
+ dic['exponent'] = float(parts[6])
750
+ dic['constant'] = float(parts[7])
751
+
752
+ # Process required parameters: overload_depth and evaporation_factor
753
+ idx = 8 # Start index for additional parameters
754
+
755
+ # overload_depth and evaporation_factor are required
756
+ if len(parts) > idx + 1: # Need both overload_depth and evaporation_factor
757
+ dic['overload_depth'] = float(parts[idx])
758
+ dic['evaporation_factor'] = float(parts[idx + 1])
759
+ idx += 2
760
+
761
+ # Check for seepage parameters - all three must be present or none
762
+ if len(parts) > idx + 2: # Need all three seepage parameters
763
+ dic['seepage_suction_head'] = float(parts[idx])
764
+ dic['seepage_conductivity'] = float(parts[idx + 1])
765
+ dic['seepage_initial_deficit'] = float(parts[idx + 2])
766
+ else:
767
+ # Default values for required parameters if not provided
768
+ dic['overload_depth'] = 0.0
769
+ dic['evaporation_factor'] = 0.0
770
+
771
+ self.add_node('storage_functional', dic)
772
+
773
+ elif storage_type == 'CYLINDRICAL':
774
+ # For CYLINDRICAL: major_axis_length minor_axis_length 0
775
+ if len(parts) > 7: # Ensure we have at least the required parameters
776
+ dic['major_axis_length'] = float(parts[5])
777
+ dic['minor_axis_length'] = float(parts[6])
778
+ # Skip the placeholder value '0' at index 7
779
+
780
+ # Process required parameters: overload_depth and evaporation_factor
781
+ idx = 8 # Start index for additional parameters
782
+
783
+ # overload_depth and evaporation_factor are required
784
+ if len(parts) > idx + 1: # Need both overload_depth and evaporation_factor
785
+ dic['overload_depth'] = float(parts[idx])
786
+ dic['evaporation_factor'] = float(parts[idx + 1])
787
+ idx += 2
788
+
789
+ # Check for seepage parameters - all three must be present or none
790
+ if len(parts) > idx + 2: # Need all three seepage parameters
791
+ dic['seepage_suction_head'] = float(parts[idx])
792
+ dic['seepage_conductivity'] = float(parts[idx + 1])
793
+ dic['seepage_initial_deficit'] = float(parts[idx + 2])
794
+ else:
795
+ # Default values for required parameters if not provided
796
+ dic['overload_depth'] = 0.0
797
+ dic['evaporation_factor'] = 0.0
798
+
799
+ self.add_node('storage_cylindrical', dic)
800
+
801
+ elif storage_type == 'TABULAR':
802
+ # For TABULAR: storage_curve_name
803
+ if len(parts) > 5: # Ensure we have at least the required parameters
804
+ dic['storage_curve_name'] = parts[5]
805
+
806
+ # Process required parameters: overload_depth and evaporation_factor
807
+ idx = 6 # Start index for additional parameters
808
+
809
+ # overload_depth and evaporation_factor are required
810
+ if len(parts) > idx + 1: # Need both overload_depth and evaporation_factor
811
+ dic['overload_depth'] = float(parts[idx])
812
+ dic['evaporation_factor'] = float(parts[idx + 1])
813
+ idx += 2
814
+
815
+ # Check for seepage parameters - all three must be present or none
816
+ if len(parts) > idx + 2: # Need all three seepage parameters
817
+ dic['seepage_suction_head'] = float(parts[idx])
818
+ dic['seepage_conductivity'] = float(parts[idx + 1])
819
+ dic['seepage_initial_deficit'] = float(parts[idx + 2])
820
+ else:
821
+ # Default values for required parameters if not provided
822
+ dic['overload_depth'] = 0.0
823
+ dic['evaporation_factor'] = 0.0
824
+
825
+ self.add_node('storage_tabular', dic)
826
+
827
+ except (ValueError, KeyError) as e:
828
+ # Log error but continue processing other storage nodes
829
+ print(f"Warning: Error processing storage '{parts[0]}': {str(e)}")
830
+
831
+ def _process_dry_weather_flows(self, dwf_contents):
832
+ """Process dry weather flow data from SWMM input file."""
269
833
  for line in dwf_contents:
270
- pair = line.split()
271
- for node in self.data:
272
- if node.name == pair[0]:
273
- node.dwf_base_value = pair[2]
274
- for pattern in pair[3::]:
275
- node.dwf_patterns.append(pattern)
276
- # process inflow
834
+ parts = line.split()
835
+ if len(parts) < 3: # Skip lines with insufficient data
836
+ continue
837
+
838
+ node_name = parts[0]
839
+ try:
840
+ node = self.index_of(node_name, return_node=True)
841
+ except ValueError:
842
+ node = None
843
+
844
+ if node and hasattr(node, 'dwf_base_value'):
845
+ node.dwf_base_value = parts[2]
846
+ node.dwf_patterns = parts[3:] if len(parts) > 3 else []
847
+
848
+ def _process_inflows(self, inflow_contents):
849
+ """Process inflow data from SWMM input file."""
277
850
  for line in inflow_contents:
278
- pair = line.split()
279
- if pair[1] != 'FLOW':
851
+ parts = line.split()
852
+ if len(parts) < 8: # Skip lines with insufficient data
853
+ continue
854
+
855
+ if parts[1] != 'FLOW':
280
856
  raise Exception('Unsupported inflow type, only FLOW is accepted.')
281
- for node in self.data:
282
- if node.name == pair[0]:
283
- result = {'time_series': pair[2], 'type': pair[3], 'm_factor': float(pair[4]),
284
- 's_factor': float(pair[5]), 'baseline': float(pair[6]), 'pattern': pair[7]}
285
- node.inflow = result
286
- return 0
857
+
858
+ node_name = parts[0]
859
+ try:
860
+ node = self.index_of(node_name, return_node=True)
861
+ except ValueError:
862
+ node = None
863
+
864
+ if node:
865
+ inflow_data = {
866
+ 'time_series': parts[2],
867
+ 'type': parts[3],
868
+ 'm_factor': float(parts[4]),
869
+ 's_factor': float(parts[5]),
870
+ 'baseline': float(parts[6]),
871
+ 'pattern': parts[7]
872
+ }
873
+ if hasattr(node, 'inflow'):
874
+ node.inflow = inflow_data
875
+
876
+ def _process_polygons(self, polygon_contents):
877
+ """Process polygon data from SWMM input file for Storage nodes."""
878
+ for line in polygon_contents:
879
+ try:
880
+ parts = line.split()
881
+ node_name = parts[0]
882
+ x_coord = float(parts[1])
883
+ y_coord = float(parts[2])
884
+
885
+ for node in self.data:
886
+ if isinstance(node, Storage) and node.name == node_name:
887
+ node.polygon.x.append(x_coord)
888
+ node.polygon.y.append(y_coord)
889
+ node.polygon.node_name = node_name
890
+ except (IndexError, ValueError) as e:
891
+ # Log warning but continue processing other polygons
892
+ print(f"Warning: Error processing polygon data: {str(e)}")
287
893
 
288
894
  def write_to_swmm_inp(self, filename):
289
- with open(filename, 'a', encoding='utf-8') as f:
290
- f.write('\n\n[JUNCTIONS]\n')
291
- f.write(';;Name Elevation MaxDepth InitDepth SurDepth Ponding\n')
292
- for node in self.data:
293
- if isinstance(node, Junction):
294
- f.write(
295
- f'{node.name:8} {node.elevation:8.3f} {node.maximum_depth:8.3f} {node.initial_depth:8.3f} {node.overload_depth:8.3f} {node.surface_ponding_area:8.3f}\n')
296
- #
297
- f.write('\n\n[OUTFALLS]\n')
298
- f.write(';;Name Elevation Type // Gated RouteTo\n')
299
- for node in self.data:
300
- if isinstance(node, OutfallFree):
301
- msg = 'YES' if node.flap_gate else 'NO'
302
- f.write(f'{node.name:8} {node.elevation:8.3f} FREE {msg:8} {node.route_to}\n')
303
- if isinstance(node, OutfallNormal):
304
- msg = 'YES' if node.flap_gate else 'NO'
305
- f.write(f'{node.name:8} {node.elevation:8.3f} NORMAL {msg:8} {node.route_to}\n')
306
- if isinstance(node, OutfallFixed):
307
- msg = 'YES' if node.flap_gate else 'NO'
308
- f.write(
309
- f'{node.name:8} {node.elevation:8.3f} FIXED {node.stage:8} {msg} {node.route_to}\n')
310
- if isinstance(node, OutfallTidal):
311
- msg = 'YES' if node.flap_gate else 'NO'
312
- f.write(
313
- f'{node.name:8} {node.elevation:8.3f} TIDAL {node.tidal:8} {msg} {node.route_to}\n')
314
- if isinstance(node, OutfallTimeseries):
315
- msg = 'YES' if node.flap_gate else 'NO'
316
- f.write(
317
- f'{node.name:8} {node.elevation:8.3f} TIMESERIES {node.time_series:8} {msg} {node.route_to}\n')
318
- #
319
- f.write('\n\n[COORDINATES]\n')
320
- f.write(';;Name X-Coord Y-Coord\n')
321
- for node in self.data:
322
- f.write(f'{node.name:8} {node.coordinate[0]:8.2f} {node.coordinate[1]:8.2f}\n')
323
- #
324
- f.write('\n\n[DWF]\n')
325
- f.write(';;Node Constituent Baseline Patterns \n')
326
- for node in self.data:
327
- if isinstance(node, Junction):
328
- if node.dwf_base_value != 0:
329
- string = ' '.join(node.dwf_patterns)
330
- f.write(f'{node.name} FLOW {node.dwf_base_value} {string}\n')
331
- #
332
- f.write('\n\n[INFLOWS]\n')
333
- f.write(';;Node Constituent Time Series Type Mfactor Sfactor Baseline Pattern\n')
334
- for node in self.data:
335
- if isinstance(node, Junction):
336
- if node.inflow is not None:
337
- res = [str(i) for i in list(node.inflow.values())]
338
- res = ' '.join(res)
339
- f.write(f'{node.name} FLOW {res} \n')
340
- return 0
341
-
342
- def index_of(self, node_name):
895
+ """
896
+ Write node data to a SWMM input file.
897
+
898
+ Writes the following sections to SWMM input file:
899
+ - [JUNCTIONS]
900
+ - [OUTFALLS]
901
+ - [STORAGE]
902
+ - [COORDINATES]
903
+ - [DWF]
904
+ - [INFLOWS]
905
+ - [Polygons] (for Storage nodes)
906
+
907
+ Args:
908
+ filename (str): Path to the SWMM input file
909
+
910
+ Returns:
911
+ int: 0 if successful
912
+
913
+ Raises:
914
+ IOError: If there's an error writing to the file
915
+
916
+ Note:
917
+ Appends to existing file content
918
+ """
919
+ try:
920
+ with open(filename, 'a', encoding='utf-8') as f:
921
+ # Write junctions section
922
+ self._write_junctions_section(f)
923
+
924
+ # Write outfalls section
925
+ self._write_outfalls_section(f)
926
+
927
+ # Write storage section
928
+ self._write_storage_section(f)
929
+
930
+ # Write coordinates section
931
+ self._write_coordinates_section(f)
932
+
933
+ # Write DWF section
934
+ self._write_dwf_section(f)
935
+
936
+ # Write inflows section
937
+ self._write_inflows_section(f)
938
+
939
+ # Write polygons section for Storage nodes
940
+ self._write_polygons_section(f)
941
+
942
+ return 0
943
+ except IOError as e:
944
+ raise IOError(f"Error writing to SWMM input file: {str(e)}")
945
+
946
+ def _write_junctions_section(self, file):
947
+ """Write junctions section to the SWMM input file."""
948
+ file.write('\n\n[JUNCTIONS]\n')
949
+ file.write(';;Name Elevation MaxDepth InitDepth SurDepth Ponding\n')
950
+
951
+ for node in self.data:
952
+ if isinstance(node, Junction):
953
+ file.write(
954
+ f'{node.name:8} {node.elevation:8.3f} {node.maximum_depth:8.3f} '
955
+ f'{node.initial_depth:8.3f} {node.overload_depth:8.3f} {node.surface_ponding_area:8.3f}\n')
956
+
957
+ def _write_outfalls_section(self, file):
958
+ """Write outfalls section to the SWMM input file."""
959
+ file.write('\n\n[OUTFALLS]\n')
960
+ file.write(';;Name Elevation Type // Gated RouteTo\n')
961
+
962
+ outfall_types = {
963
+ OutfallFree: ('FREE', None),
964
+ OutfallNormal: ('NORMAL', None),
965
+ OutfallFixed: ('FIXED', 'stage'),
966
+ OutfallTidal: ('TIDAL', 'tidal'),
967
+ OutfallTimeseries: ('TIMESERIES', 'time_series')
968
+ }
969
+
970
+ for node in self.data:
971
+ for outfall_class, (type_name, extra_attr) in outfall_types.items():
972
+ if isinstance(node, outfall_class):
973
+ route_to = node.route_to if hasattr(node, 'route_to') and node.route_to else ''
974
+ gate_flag = 'YES' if node.flap_gate else 'NO'
975
+
976
+ if extra_attr:
977
+ extra_value = getattr(node, extra_attr, '')
978
+ file.write(
979
+ f'{node.name:8} {node.elevation:8.3f} {type_name} '
980
+ f'{extra_value:8} {gate_flag} {route_to}\n')
981
+ else:
982
+ file.write(
983
+ f'{node.name:8} {node.elevation:8.3f} {type_name} '
984
+ f'{gate_flag:8} {route_to}\n')
985
+
986
+ def _write_coordinates_section(self, file):
987
+ """Write coordinates section to the SWMM input file."""
988
+ file.write('\n\n[COORDINATES]\n')
989
+ file.write(';;Name X-Coord Y-Coord\n')
990
+
991
+ for node in self.data:
992
+ if hasattr(node, 'coordinate') and len(node.coordinate) >= 2:
993
+ file.write(f'{node.name:8} {node.coordinate[0]:8.2f} {node.coordinate[1]:8.2f}\n')
994
+
995
+ def _write_dwf_section(self, file):
996
+ """Write dry weather flow section to the SWMM input file."""
997
+ file.write('\n\n[DWF]\n')
998
+ file.write(';;Node Constituent Baseline Patterns \n')
999
+
1000
+ for node in self.data:
1001
+ if isinstance(node, Junction) and hasattr(node, 'dwf_base_value') and node.dwf_base_value != 0:
1002
+ patterns = ' '.join(node.dwf_patterns if hasattr(node, 'dwf_patterns') and node.dwf_patterns else [])
1003
+ file.write(f'{node.name} FLOW {node.dwf_base_value} {patterns}\n')
1004
+
1005
+ def _write_storage_section(self, file):
1006
+ """Write storage section to the SWMM input file."""
1007
+ file.write('\n\n[STORAGE]\n')
1008
+ file.write(
1009
+ ';;Name Elev. MaxDepth InitDepth Shape Curve/Params SurDepth Fevap Seepage\n')
1010
+
1011
+ for node in self.data:
1012
+ if isinstance(node, Storage):
1013
+ # Common parameters for all storage types
1014
+ base_params = f'{node.name:14} {node.elevation:8.3f} {node.maximum_depth:8.3f} {node.initial_depth:8.3f} '
1015
+
1016
+ # Storage type specific parameters
1017
+ if isinstance(node, StorageFunctional):
1018
+ # For FUNCTIONAL: coefficient exponent constant
1019
+ shape_params = f'FUNCTIONAL {node.coefficient:8.3f} {node.exponent:8.3f} {node.constant:8.3f} '
1020
+ elif isinstance(node, StorageCylindrical):
1021
+ # For CYLINDRICAL: major_axis_length minor_axis_length 0
1022
+ shape_params = f'CYLINDRICAL {node.major_axis_length:8.3f} {node.minor_axis_length:8.3f} 0 '
1023
+ elif isinstance(node, StorageTabular):
1024
+ # For TABULAR: storage_curve_name
1025
+ shape_params = f'TABULAR {node.storage_curve_name:8} '
1026
+ else:
1027
+ # Skip unknown storage types
1028
+ continue
1029
+
1030
+ # Required parameters (overload_depth and evaporation_factor are always included)
1031
+ required_params = f'{node.overload_depth:8.3f} {node.evaporation_factor:8.3f} '
1032
+
1033
+ # Seepage parameters (if present)
1034
+ if (node.seepage_suction_head is not None and
1035
+ node.seepage_conductivity is not None and
1036
+ node.seepage_initial_deficit is not None):
1037
+ seepage_params = f'{node.seepage_suction_head:8.3f} {node.seepage_conductivity:8.3f} {node.seepage_initial_deficit:8.3f}'
1038
+ else:
1039
+ seepage_params = ''
1040
+
1041
+ # Write the complete line
1042
+ file.write(f'{base_params}{shape_params}{required_params}{seepage_params}\n')
1043
+
1044
+ def _write_inflows_section(self, file):
1045
+ """Write inflows section to the SWMM input file."""
1046
+ file.write('\n\n[INFLOWS]\n')
1047
+ file.write(';;Node Constituent Time Series Type Mfactor Sfactor Baseline Pattern\n')
1048
+
1049
+ for node in self.data:
1050
+ if (isinstance(node, Junction) or isinstance(node, Storage)) and hasattr(node,'inflow') and node.inflow is not None:
1051
+ values = list(node.inflow.values())
1052
+ formatted_values = ' '.join(str(value) for value in values)
1053
+ file.write(f'{node.name} FLOW {formatted_values} \n')
1054
+
1055
+ def _write_polygons_section(self, file):
1056
+ """Write polygons section to the SWMM input file for Storage nodes."""
1057
+ # Check if any Storage nodes have polygon data
1058
+ has_polygons = any(isinstance(node, Storage) and
1059
+ hasattr(node, 'polygon') and
1060
+ node.polygon.node_name is not None and
1061
+ len(node.polygon.x) > 0 for node in self.data)
1062
+
1063
+ if has_polygons:
1064
+ # Read file line by line to find sections
1065
+ with open(file.name, 'r') as read_file:
1066
+ lines = read_file.readlines()
1067
+ # Find [Polygons] section
1068
+ polygons_line = -1
1069
+ next_section_line = -1
1070
+ for i, line in enumerate(lines):
1071
+ if line.strip() == '[Polygons]':
1072
+ polygons_line = i
1073
+ elif polygons_line != -1 and line.strip().startswith('['):
1074
+ next_section_line = i
1075
+ break
1076
+ if polygons_line == -1:
1077
+ # No existing section, create new one at current position
1078
+ file.write('\n\n[Polygons]\n')
1079
+ file.write(';;Name X-Coord Y-Coord\n')
1080
+
1081
+ # Write polygon data
1082
+ for node in self.data:
1083
+ if (isinstance(node, Storage) and
1084
+ hasattr(node, 'polygon') and
1085
+ node.polygon.node_name is not None):
1086
+ for xi, yi in zip(node.polygon.x, node.polygon.y):
1087
+ file.write(f'{node.polygon.node_name} {xi} {yi}\n')
1088
+ else:
1089
+ # Section exists, we need to modify file content
1090
+ # Insert our polygon data just after the header line
1091
+ insert_position = polygons_line + 2 # +1 for the header, +1 for the column labels
1092
+
1093
+ # Prepare polygon data lines
1094
+ new_lines = []
1095
+ for node in self.data:
1096
+ if (isinstance(node, Storage) and
1097
+ hasattr(node, 'polygon') and
1098
+ node.polygon.node_name is not None):
1099
+ for xi, yi in zip(node.polygon.x, node.polygon.y):
1100
+ new_lines.append(f'{node.polygon.node_name} {xi} {yi}\n')
1101
+
1102
+ # Insert the new lines at the appropriate position
1103
+ lines[insert_position:insert_position] = new_lines
1104
+
1105
+ # Rewrite the entire file
1106
+ file.seek(0)
1107
+ file.writelines(lines)
1108
+ file.truncate()
1109
+
1110
+ def index_of(self, node_name, return_node=False):
1111
+ """
1112
+ Find a node's index by name.
1113
+
1114
+ Args:
1115
+ node_name (str): The name of the node to find
1116
+ return_node (bool): If True, returns the node object instead of index
1117
+
1118
+ Returns:
1119
+ int or object: The index of the node or the node object if return_node is True
1120
+
1121
+ Raises:
1122
+ ValueError: If no node with the given name is found
1123
+ """
343
1124
  for index, item in enumerate(self.data):
344
1125
  if item.name == node_name:
345
- return index
1126
+ return item if return_node else index
346
1127
  raise ValueError(f"No item found with name '{node_name}'")