swmm-pandas 0.6.0__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.
@@ -0,0 +1,2309 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from numbers import Number
5
+ import warnings
6
+ from abc import ABC, abstractmethod
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING
9
+ from calendar import month_abbr
10
+ import re
11
+ import textwrap
12
+ import copy
13
+ import pandas as pd
14
+ from pandas._libs.missing import NAType
15
+ import numpy as np
16
+
17
+ if TYPE_CHECKING:
18
+ from typing import Self, TypeGuard
19
+ from collections.abc import Iterable, Iterator
20
+
21
+ TRow = list[str | float | int | pd.Timestamp | pd.Timedelta | NAType]
22
+
23
+ _logger = logging.getLogger(__name__)
24
+
25
+
26
+ class classproperty:
27
+ def __init__(self, f):
28
+ self.f = f
29
+
30
+ def __get__(self, obj, owner):
31
+ return self.f(owner)
32
+
33
+
34
+ # class ClassPropertyDescriptor(object):
35
+
36
+ # def __init__(self, fget, fset=None):
37
+ # self.fget = fget
38
+ # self.fset = fset
39
+
40
+ # def __get__(self, obj, klass=None):
41
+ # if klass is None:
42
+ # klass = type(obj)
43
+ # return self.fget.__get__(obj, klass)()
44
+
45
+ # def __set__(self, obj, value):
46
+ # if not self.fset:
47
+ # raise AttributeError("can't set attribute")
48
+ # type_ = type(obj)
49
+ # return self.fset.__get__(obj, type_)(value)
50
+
51
+ # def setter(self, func):
52
+ # if not isinstance(func, (classmethod, staticmethod)):
53
+ # func = classmethod(func)
54
+ # self.fset = func
55
+ # return self
56
+
57
+ # def classproperty(func):
58
+ # if not isinstance(func, (classmethod, staticmethod)):
59
+ # func = classmethod(func)
60
+
61
+ # return ClassPropertyDescriptor(func)
62
+
63
+
64
+ def _coerce_numeric(data: str) -> str | float | int:
65
+ try:
66
+ number = float(data)
67
+ number = int(number) if number.is_integer() and "." not in data else number
68
+ return number
69
+ # if str(number) == data:
70
+ # return number
71
+ except ValueError:
72
+ pass
73
+
74
+ return data
75
+
76
+
77
+ def _strip_comment(line: str) -> tuple[str, str]:
78
+ """
79
+ Splits a line into its data and comments
80
+
81
+
82
+ Examples
83
+ --------
84
+ >>> _strip_comment(" JUNC1 1.5 10.25 0 0 5000 ; This is my fav junction ")
85
+ ["JUNC1 1.5 10.25 0 0 5000 ", "This is my fav junction"]
86
+
87
+
88
+ """
89
+ try:
90
+ return line[: line.index(";")].strip(), line[line.index(";") + 1 :].strip()
91
+
92
+ except ValueError:
93
+ return line, ""
94
+
95
+
96
+ def _is_line_comment(line: str) -> bool:
97
+ """Determines if a line in the inp file is a comment line"""
98
+ try:
99
+ return line.strip()[0] == ";"
100
+ except IndexError:
101
+ return False
102
+
103
+
104
+ def _is_data(line: str):
105
+ """
106
+ Determines if an inp file line has data by checking if the line
107
+ is a table header (starting with `;;`) or a section header (starting with a `[`)
108
+ """
109
+ if len(line) == 0 or line.strip()[0:2] == ";;" or line.strip()[0] == "[":
110
+ return False
111
+ return True
112
+
113
+
114
+ def comment_formatter(line: str):
115
+ if len(line) > 0:
116
+ line = ";" + line.strip().strip("\n").strip()
117
+ line = line.replace("\n", "\n;") + "\n"
118
+ return line
119
+
120
+
121
+ class SectionSeries(pd.Series):
122
+ @property
123
+ def _constructor(self):
124
+ return SectionSeries
125
+
126
+ @property
127
+ def _constructor_expanddim(self):
128
+ return SectionDf
129
+
130
+ # def _constructor_from_mgr(self, mgr, axes) -> Self:
131
+ # # required override for pandas
132
+ # return self.__class__._from_mgr(mgr, axes)
133
+
134
+
135
+ class SectionBase(ABC):
136
+
137
+ _section_name: str
138
+
139
+ @classmethod
140
+ @abstractmethod
141
+ def from_section_text(cls, text: str, *args, **kwargs) -> Self: ...
142
+
143
+ @classmethod
144
+ @abstractmethod
145
+ def _from_section_text(cls, text: str, *args, **kwargs) -> Self: ...
146
+ @classmethod
147
+ @abstractmethod
148
+ def _new_empty(cls) -> Self: ...
149
+
150
+ @classmethod
151
+ @abstractmethod
152
+ def _newobj(cls, *args, **kwargs) -> Self: ...
153
+
154
+ @abstractmethod
155
+ def to_swmm_string(self) -> str: ...
156
+
157
+
158
+ class SectionText(SectionBase, str):
159
+ @classmethod
160
+ def from_section_text(cls, text: str) -> Self:
161
+ """Construct an instance of the class from the section inp text"""
162
+ return cls._from_section_text(text)
163
+
164
+ @classmethod
165
+ def _from_section_text(cls, text: str) -> Self:
166
+ return cls(text)
167
+
168
+ @classmethod
169
+ def _new_empty(cls) -> Self:
170
+ return cls("")
171
+
172
+ @classmethod
173
+ def _newobj(cls, *args, **kwargs) -> Self:
174
+ return cls(*args, **kwargs)
175
+
176
+ def to_swmm_string(self) -> str:
177
+ return ";;Project Title/Notes\n" + self
178
+
179
+
180
+ class SectionDf(SectionBase, pd.DataFrame):
181
+ _metadata = ["_ncol", "_headings", "headings"]
182
+ _ncol: int = 0
183
+ _headings: list[str] = []
184
+ _index_col: list[str] | str | None = None
185
+
186
+ @classmethod
187
+ def _data_cols(cls, desc: bool = True) -> list[str]:
188
+ if isinstance(cls._index_col, str):
189
+ idx = [copy.deepcopy(cls._index_col)]
190
+ else:
191
+ idx = copy.deepcopy(cls._index_col)
192
+
193
+ if not desc:
194
+ idx.append("desc")
195
+
196
+ return [col for col in cls.headings if col not in idx]
197
+
198
+ @classproperty
199
+ def headings(cls) -> list[str]:
200
+ return (
201
+ cls._headings
202
+ + [f"param{i+1}" for i in range(cls._ncol - len(cls._headings))]
203
+ + ["desc"]
204
+ )
205
+
206
+ def __init__(self, *args, **kwargs):
207
+ super().__init__(*args, **kwargs)
208
+ # self._validate_headings()
209
+
210
+ @classmethod
211
+ def from_section_text(cls, text: str) -> Self:
212
+ """Construct an instance of the class from the section inp text"""
213
+ raise NotImplementedError
214
+
215
+ @classmethod
216
+ def _from_section_text(cls, text: str, ncols: int) -> Self:
217
+ """
218
+
219
+ Parse the SWMM section t ext into a dataframe
220
+
221
+ This is a generic parser that assumes the SWMM section is tabular with the each row
222
+ having the same number of tokens (i.e. columns). Comments preceeding a row in the inp file
223
+ are added to the dataframe in a comments column.
224
+
225
+ """
226
+ rows = text.split("\n")
227
+ data: list[TRow] = []
228
+ line_comment = ""
229
+ for row in rows:
230
+ # check if row contains data
231
+ if not _is_data(row):
232
+ continue
233
+
234
+ elif _is_line_comment(row):
235
+ line_comment += _strip_comment(row)[1] + "\n"
236
+ continue
237
+
238
+ line, comment = _strip_comment(row)
239
+ if len(comment) > 0:
240
+ line_comment += comment + "\n"
241
+
242
+ # split row into tokens coercing numerics into floats
243
+ split_data = [_coerce_numeric(val) for val in line.split()]
244
+
245
+ # parse tokenzied data into uniform tabular shape so each
246
+ # row has the same number of columns
247
+ table_data = cls._tabulate(split_data)
248
+
249
+ data += cls._get_rows(
250
+ table_data=table_data,
251
+ ncols=ncols,
252
+ line_comment=line_comment,
253
+ )
254
+ line_comment = ""
255
+
256
+ # instantiate DataFrame
257
+ df = cls(data=data, columns=cls.headings, dtype=object)
258
+ return cls(df.set_index(cls._index_col)) if cls._index_col else df
259
+
260
+ # if cls._index_col is not None:
261
+ # df.set_index(cls._index_col)
262
+ # return df
263
+
264
+ @staticmethod
265
+ def _is_nested_list(l: TRow | list[TRow]) -> TypeGuard[list[TRow]]:
266
+ return isinstance(l[0], list)
267
+
268
+ @staticmethod
269
+ def _is_not_nested_list(l: TRow | list[TRow]) -> TypeGuard[TRow]:
270
+ return not isinstance(l[0], list)
271
+
272
+ @classmethod
273
+ def _get_rows(
274
+ cls,
275
+ table_data: TRow | list[TRow],
276
+ ncols: int,
277
+ line_comment: str,
278
+ ) -> list[TRow]:
279
+
280
+ _table_data: list[TRow]
281
+ if cls._is_nested_list(table_data):
282
+ _table_data = table_data
283
+ elif cls._is_not_nested_list(table_data):
284
+ _table_data = [table_data]
285
+ else:
286
+ raise Exception(f"Error parsing row {table_data}")
287
+
288
+ rows: list[TRow] = []
289
+ for row in _table_data:
290
+ # create and empty row
291
+ row_data: TRow = [""] * (ncols + 1)
292
+ # assign data to row
293
+ row_data[:ncols] = row
294
+ # add comments to last column
295
+ row_data[-1] = line_comment.strip("\n")
296
+ rows.append(row_data)
297
+ return rows
298
+
299
+ @classmethod
300
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
301
+ """
302
+ Function to convert tokenized data into a table row with an expected number of columns
303
+
304
+ This function allows the parser to accomodate lines in a SWWM section that might have
305
+ different numbers of tokens.
306
+
307
+ This is the generic version of the method that assumes all tokens in the line
308
+ are assign the front of the table row and any left over spaces in the row are left
309
+ blank. Various sections require custom implementations of thie method.
310
+
311
+ """
312
+ out: TRow = [""] * cls._ncol
313
+ out[: len(line)] = line
314
+ return out
315
+
316
+ @classmethod
317
+ def _new_empty(cls) -> Self:
318
+ """Construct and empty instance"""
319
+ df = cls(data=[], columns=cls.headings)
320
+ return df.set_index(cls._index_col) if cls._index_col else df
321
+
322
+ @classmethod
323
+ def _newobj(cls, *args, **kwargs) -> Self:
324
+ df = cls(*args, **kwargs)
325
+ return df
326
+
327
+ def _validate_headings(self) -> None:
328
+ missing = []
329
+ for heading in self.headings:
330
+ if heading not in self.reset_index().columns:
331
+ missing.append(heading)
332
+ if len(missing) > 0:
333
+ # print('cols: ',self.columns)
334
+ raise ValueError(
335
+ f"{self.__class__.__name__} section is missing columns {missing}"
336
+ )
337
+ # self.reindex(self.headings,inplace=True)
338
+
339
+ def add_element(self, **kwargs) -> Self:
340
+ # Create a new row with NaN values for all columns
341
+ headings = self.headings.copy()
342
+ idx_name: str | tuple[str, ...]
343
+ try:
344
+ if isinstance(self._index_col, str):
345
+ idx_name = self._index_col
346
+ idx = kwargs[idx_name]
347
+ headings.remove(idx_name)
348
+ kwargs.pop(idx_name)
349
+
350
+ elif isinstance(self._index_col, (list, tuple)):
351
+ idx_name = tuple(self._index_col)
352
+ idx = []
353
+ for col in idx_name:
354
+ idx.append(kwargs[col])
355
+ headings.remove(col)
356
+ kwargs.pop(col)
357
+ idx = tuple(idx)
358
+
359
+ except KeyError:
360
+ raise KeyError(
361
+ f"Missing index column {self._index_col!r} in provided values. Please provide a value for {self._index_col!r}"
362
+ )
363
+ new_row = pd.Series(index=headings, name=idx, dtype=object)
364
+
365
+ # Update the new row with provided values
366
+ for col, value in kwargs.items():
367
+ if col in headings:
368
+ new_row.loc[col] = value
369
+ else:
370
+ print(
371
+ f"Warning: Column '{col}' not found in the DataFrame. Skipping this value."
372
+ )
373
+ # Append the new row to the DataFrame
374
+ self.loc[idx, :] = new_row
375
+ return self
376
+
377
+ @property
378
+ def _constructor(self):
379
+ # required override for pandas
380
+ # https://pandas.pydata.org/docs/development/extending.html#override-constructor-properties
381
+ return self.__class__
382
+
383
+ @property
384
+ def _constructor_sliced(self):
385
+ # required override for pandas
386
+ # https://pandas.pydata.org/docs/development/extending.html#override-constructor-properties
387
+ return SectionSeries
388
+
389
+ def _constructor_from_mgr(self, mgr, axes) -> Self:
390
+ # required override for pandas
391
+ return self.__class__._from_mgr(mgr, axes)
392
+
393
+ def _constructor_sliced_from_mgr(self, mgr, axes) -> SectionSeries:
394
+ # required override for pandas
395
+ return SectionSeries._from_mgr(mgr, axes)
396
+
397
+ def to_swmm_string(self) -> str:
398
+ """Create a string representation of section"""
399
+ self._validate_headings()
400
+ # reset index
401
+ out_df = (
402
+ self.reset_index(self._index_col)
403
+ .reindex(self.headings, axis=1)
404
+ .infer_objects(copy=False)
405
+ .fillna("")
406
+ )
407
+
408
+ # determine the longest variable in each column of the table
409
+ # used to figure out how wide to make the columns
410
+ max_data = (
411
+ out_df.astype(str)
412
+ .map(
413
+ len,
414
+ )
415
+ .max()
416
+ )
417
+ # determine the length of the header names
418
+ max_header = out_df.columns.to_series().apply(len)
419
+
420
+ max_header.iloc[
421
+ 0
422
+ ] += 2 # add 2 to first header to account for comment formatting
423
+
424
+ # determine the column widths by finding the max legnth out of data
425
+ # and headers
426
+ col_widths = pd.concat([max_header, max_data], axis=1).max(axis=1) + 2
427
+
428
+ # create format strings for header, divider, and data
429
+ header_format = ""
430
+ header_divider = ""
431
+ data_format = ""
432
+ for i, col in enumerate(col_widths.drop("desc")):
433
+ data_format += f"{{:<{col}}}"
434
+ header_format += f";;{{:<{col-2}}}" if i == 0 else f"{{:<{col}}}"
435
+ header_divider += f";;{'-'*(col-4)} " if i == 0 else f"{'-'*(col-2)} "
436
+ data_format += "\n"
437
+ header_format += "\n"
438
+ header_divider += "\n"
439
+
440
+ # loop over data and format each each row of data as a string
441
+ outstr = ""
442
+ for i, row in enumerate(out_df.drop("desc", axis=1).values):
443
+ desc = out_df.loc[i, "desc"]
444
+ if (not pd.isna(desc)) and (len(strdesc := str(desc)) > 0):
445
+ outstr += comment_formatter(strdesc)
446
+ outstr += data_format.format(*row)
447
+
448
+ header = header_format.format(*out_df.drop("desc", axis=1).columns)
449
+ # concatenate the header, divider, and data
450
+ return header + header_divider + outstr
451
+
452
+
453
+ class Title(SectionText):
454
+ _section_name = "TITLE"
455
+
456
+
457
+ class Option(SectionDf):
458
+ """
459
+ Index: Option
460
+ Columns: Value
461
+ """
462
+
463
+ _section_name = "OPTIONS"
464
+ _ncol = 2
465
+ _headings = ["Option", "Value"]
466
+ _index_col = "Option"
467
+
468
+ @classmethod
469
+ def from_section_text(cls, text: str):
470
+ return super()._from_section_text(text, cls._ncol)
471
+
472
+ def _ipython_key_completions_(self):
473
+ return list(["Value"])
474
+
475
+
476
+ class Report(SectionBase):
477
+ _section_name = "REPORT"
478
+
479
+ @dataclass
480
+ class LIDReportEntry:
481
+ Name: str
482
+ Subcatch: str
483
+ Fname: str
484
+
485
+ class LIDReport(list[LIDReportEntry]):
486
+ def __init__(self, entries: Iterable[Report.LIDReportEntry]):
487
+ for i in entries:
488
+ if not isinstance(i, Report.LIDReportEntry):
489
+ raise ValueError(
490
+ f"LIDReport is instantiated with a sequence of LIDReportEntries, got {type(i)}",
491
+ )
492
+ super().__init__(entries)
493
+
494
+ def add(self, lid_name: str, subcatch: str, Fname: str) -> None:
495
+ self.append(
496
+ Report.LIDReportEntry(
497
+ Name=lid_name,
498
+ Subcatch=subcatch,
499
+ Fname=Fname,
500
+ ),
501
+ )
502
+
503
+ def delete(self, lid_name: str) -> None:
504
+ for i, v in enumerate(self):
505
+ if v.Name == lid_name:
506
+ break
507
+ self.pop(i)
508
+
509
+ def __repr__(self):
510
+ rep = "LIDReportList(\n"
511
+ for lid in self:
512
+ rep += f" {lid}.__repr__()\n"
513
+ return f"{rep})"
514
+
515
+ def __init__(
516
+ self,
517
+ disabled: str | None = None,
518
+ input: str | None = None,
519
+ continuity: str | None = None,
520
+ flowstats: str | None = None,
521
+ controls: str | None = None,
522
+ averages: str | None = None,
523
+ subcatchments: list[str] = [],
524
+ nodes: list[str] = [],
525
+ links: list[str] = [],
526
+ lids: list[dict] = [],
527
+ ):
528
+ self.DISABLED = disabled
529
+ self.INPUT = input
530
+ self.CONTINUITY = continuity
531
+ self.FLOWSTATS = flowstats
532
+ self.CONTROLS = controls
533
+ self.AVERAGES = averages
534
+ self.SUBCATCHMENTS = subcatchments
535
+ self.NODES = nodes
536
+ self.LINKS = links
537
+ self.LID = self.LIDReport([])
538
+
539
+ for lid in lids:
540
+ self.LID.add(lid["name"], lid["subcatch"], lid["fname"])
541
+
542
+ @classmethod
543
+ def from_section_text(cls, text: str, *args, **kwargs) -> Self:
544
+ rows = text.split("\n")
545
+
546
+ obj = cls()
547
+
548
+ for row in rows:
549
+ # check if row contains data
550
+ if not _is_data(row):
551
+ continue
552
+
553
+ if ";" in row:
554
+ warnings.warn(
555
+ "swmm.pandas does not currently support comments in the [REPORT] section. Truncating...",
556
+ )
557
+ if _is_line_comment(row):
558
+ continue
559
+
560
+ tokens = row.split()
561
+ report_type = tokens[0].upper()
562
+ if not hasattr(obj, report_type):
563
+ warnings.warn(
564
+ f"{report_type} is not a supported report type, skipping..."
565
+ )
566
+ continue
567
+ elif report_type in ("SUBCATCHMENTS", "NODES", "LINKS"):
568
+ setattr(
569
+ obj,
570
+ report_type,
571
+ getattr(obj, report_type) + tokens[1:],
572
+ )
573
+ elif report_type == "LID":
574
+ obj.LID.add(
575
+ lid_name=tokens[1],
576
+ subcatch=tokens[2],
577
+ Fname=tokens[3],
578
+ )
579
+ else:
580
+ setattr(obj, report_type, tokens[1])
581
+
582
+ return obj
583
+
584
+ @classmethod
585
+ def _from_section_text(cls, text: str, *args, **kwargs) -> Self:
586
+ raise NotImplementedError
587
+
588
+ @classmethod
589
+ def _new_empty(cls) -> Self:
590
+ return cls()
591
+
592
+ @classmethod
593
+ def _newobj(cls, *args, **kwargs) -> Self:
594
+ return cls(*args, **kwargs)
595
+
596
+ def to_swmm_string(self) -> str:
597
+ return ";;Reporting Options\n" + self.__repr__()
598
+
599
+ def __repr__(self) -> str:
600
+ out_str = ""
601
+ for switch in (
602
+ "DISABLED",
603
+ "INPUT",
604
+ "CONTINUITY",
605
+ "FLOWSTATS",
606
+ "CONTROLS",
607
+ "AVERAGES",
608
+ ):
609
+ if (value := getattr(self, switch)) is not None:
610
+ out_str += f"{switch} {value}\n"
611
+
612
+ for seq in ("SUBCATCHMENTS", "NODES", "LINKS"):
613
+ if len(items := getattr(self, seq)) > 0:
614
+ i = 0
615
+ while i < len(items):
616
+ out_str += f"{seq} {' '.join(items[i:i+5])}\n"
617
+ i += 5
618
+ if len(self.LID) > 0:
619
+ for lid in self.LID:
620
+ out_str += f"LID {lid.Name} {lid.Subcatch} {lid.Fname}\n"
621
+
622
+ return out_str
623
+
624
+ def __len__(self):
625
+ length = 0
626
+ for switch in ("DISABLED", "INPUT", "CONTINUITY", "FLOWSTATS", "CONTROLS"):
627
+ if getattr(self, switch) is not None:
628
+ length += 1
629
+
630
+ for seq in ("SUBCATCHMENTS", "NODES", "LINKS"):
631
+ length += len(getattr(self, seq))
632
+
633
+ length += len(self.LID)
634
+ return length
635
+
636
+
637
+ class Files(SectionText):
638
+ _section_name = "FILES"
639
+
640
+
641
+ class Event(SectionDf):
642
+ _section_name = "EVENT"
643
+ _ncol = 2
644
+ _headings = ["Start", "End"]
645
+
646
+ @classmethod
647
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
648
+ out: TRow = [""] * cls._ncol
649
+ if len(line) != 4:
650
+ raise ValueError(f"Event lines must have 4 values but found {len(line)}")
651
+
652
+ start_time = " ".join(line[:2]) # type: ignore
653
+ end_time = " ".join(line[2:]) # type: ignore
654
+
655
+ try:
656
+ out[0] = pd.to_datetime(start_time)
657
+ out[1] = pd.to_datetime(end_time)
658
+ return out
659
+ except Exception as e:
660
+ print(f"Error parsing event dates: {start_time} or {end_time}")
661
+ raise e
662
+
663
+ @classmethod
664
+ def from_section_text(cls, text: str):
665
+ return super()._from_section_text(text, cls._ncol)
666
+
667
+ def to_swmm_string(self) -> str:
668
+ df = self.copy()
669
+
670
+ df["Start"] = pd.to_datetime(df["Start"]).dt.strftime("%m/%d/%Y %H:%M")
671
+ df["End"] = pd.to_datetime(df["End"]).dt.strftime("%m/%d/%Y %H:%M")
672
+ return super(Event, df).to_swmm_string()
673
+
674
+
675
+ class Raingage(SectionDf):
676
+ _section_name = "RAINGAGES"
677
+ _ncol = 8
678
+ _headings = [
679
+ "Name",
680
+ "Format",
681
+ "Interval",
682
+ "SCF",
683
+ "Source_Type",
684
+ "Source",
685
+ "Station",
686
+ "Units",
687
+ ]
688
+ _index_col = "Name"
689
+
690
+ @classmethod
691
+ def from_section_text(cls, text: str):
692
+ return super()._from_section_text(text, cls._ncol)
693
+
694
+
695
+ class Evap(SectionDf):
696
+ _section_name = "EVAPORATION"
697
+ _ncol = 13
698
+ _headings = ["Type"]
699
+ _index_col = "Type"
700
+
701
+ @classmethod
702
+ def from_section_text(cls, text: str):
703
+ return super()._from_section_text(text, cls._ncol)
704
+
705
+
706
+ class Temperature(SectionDf):
707
+ _section_name = "TEMPERATURE"
708
+ _ncol = 14
709
+ _headings = ["Option"]
710
+ _index_col = "Option"
711
+
712
+ @classmethod
713
+ def from_section_text(cls, text: str):
714
+ return super()._from_section_text(text, cls._ncol)
715
+
716
+
717
+ class Subcatchment(SectionDf):
718
+ _section_name = "SUBCATCHMENTS"
719
+ _ncol = 9
720
+ _headings = [
721
+ "Name",
722
+ "RainGage",
723
+ "Outlet",
724
+ "Area",
725
+ "PctImp",
726
+ "Width",
727
+ "Slope",
728
+ "CurbLeng",
729
+ "SnowPack",
730
+ ]
731
+ _index_col = "Name"
732
+
733
+ @classmethod
734
+ def from_section_text(cls, text: str):
735
+ return super()._from_section_text(text, cls._ncol)
736
+
737
+
738
+ class Subarea(SectionDf):
739
+ _section_name = "SUBAREAS"
740
+ _ncol = 8
741
+ _headings = [
742
+ "Subcatchment",
743
+ "Nimp",
744
+ "Nperv",
745
+ "Simp",
746
+ "Sperv",
747
+ "PctZero",
748
+ "RouteTo",
749
+ "PctRouted",
750
+ ]
751
+ _index_col = "Subcatchment"
752
+
753
+ @classmethod
754
+ def from_section_text(cls, text: str):
755
+ return super()._from_section_text(text, cls._ncol)
756
+
757
+
758
+ class Infil(SectionDf):
759
+ _section_name = "INFILTRATION"
760
+ _ncol = 7
761
+ _headings = [
762
+ "Subcatchment",
763
+ "param1",
764
+ "param2",
765
+ "param3",
766
+ "param4",
767
+ "param5",
768
+ "Method",
769
+ ]
770
+ _index_col = "Subcatchment"
771
+ _infiltration_methods = (
772
+ "HORTON",
773
+ "MODIFIED_HORTON",
774
+ "GREEN_AMPT",
775
+ "MODIFIED_GREEN_AMPT",
776
+ "CURVE_NUMBER",
777
+ )
778
+
779
+ @classmethod
780
+ def from_section_text(cls, text: str):
781
+ return super()._from_section_text(text, cls._ncol)
782
+
783
+ @classmethod
784
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
785
+ out: TRow = [""] * cls._ncol
786
+
787
+ # pop first entry in the line (subcatch name)
788
+ out[0] = line.pop(0)
789
+
790
+ # add catchment specific method if present
791
+ if line[-1] in cls._infiltration_methods:
792
+ out[cls._headings.index("Method")] = line.pop(-1)
793
+
794
+ # add params
795
+ out[1 : 1 + len(line)] = line
796
+ return out
797
+
798
+
799
+ class Aquifer(SectionDf):
800
+ _section_name = "AQUIFERS"
801
+ _ncol = 14
802
+ _headings = [
803
+ "Name",
804
+ "Por",
805
+ "WP",
806
+ "FC",
807
+ "Ksat",
808
+ "Kslope",
809
+ "Tslope",
810
+ "ETu",
811
+ "ETs",
812
+ "Seep",
813
+ "Ebot",
814
+ "Egw",
815
+ "Umc",
816
+ "ETupat",
817
+ ]
818
+ _index_col = "Name"
819
+
820
+ @classmethod
821
+ def from_section_text(cls, text: str):
822
+ return super()._from_section_text(text, cls._ncol)
823
+
824
+
825
+ class Groundwater(SectionDf):
826
+ _section_name = "GROUNDWATER"
827
+ _ncol = 14
828
+ _headings = [
829
+ "Subcatchment",
830
+ "Aquifer",
831
+ "Node",
832
+ "Esurf",
833
+ "A1",
834
+ "B1",
835
+ "A2",
836
+ "B2",
837
+ "A3",
838
+ "Dsw",
839
+ "Egwt",
840
+ "Ebot",
841
+ "Wgr",
842
+ "Umc",
843
+ ]
844
+ _index_col = "Subcatchment"
845
+
846
+ @classmethod
847
+ def from_section_text(cls, text: str):
848
+ return super()._from_section_text(text, cls._ncol)
849
+
850
+
851
+ class GWF(SectionDf):
852
+ _section_name = "GWF"
853
+ _ncol = 3
854
+ _headings = [
855
+ "Subcatch",
856
+ "Type",
857
+ "Expr",
858
+ ]
859
+ _index_col = ["Subcatch", "Type"]
860
+
861
+ @classmethod
862
+ def from_section_text(cls, text: str):
863
+ return super()._from_section_text(text, cls._ncol)
864
+
865
+ @classmethod
866
+ def _tabulate(cls, line: list[str | float]) -> TRow | list[TRow]:
867
+ out: TRow = [""] * cls._ncol
868
+ out[0] = line.pop(0)
869
+ out[1] = line.pop(0)
870
+ out[2] = "".join([str(s).strip() for s in line])
871
+ return out
872
+
873
+
874
+ class Snowpack(SectionDf):
875
+ _section_name = "SNOWPACKS"
876
+ _ncol = 9
877
+ _headings = ["Name", "Surface"]
878
+ _index_col = ["Name", "Surface"]
879
+
880
+ @classmethod
881
+ def from_section_text(cls, text: str):
882
+ return super()._from_section_text(text, cls._ncol)
883
+
884
+
885
+ class Junc(SectionDf):
886
+ _section_name = "JUNCTIONS"
887
+ _ncol = 6
888
+ _headings = [
889
+ "Name",
890
+ "Elevation",
891
+ "MaxDepth",
892
+ "InitDepth",
893
+ "SurDepth",
894
+ "Aponded",
895
+ ]
896
+ _index_col = "Name"
897
+
898
+ @classmethod
899
+ def from_section_text(cls, text: str):
900
+ return super()._from_section_text(text, cls._ncol)
901
+
902
+
903
+ class Outfall(SectionDf):
904
+ _section_name = "OUTFALLS"
905
+ _ncol = 6
906
+ _headings = ["Name", "Elevation", "Type", "StageData", "Gated", "RouteTo"]
907
+ _index_col = "Name"
908
+
909
+ @classmethod
910
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
911
+ out: TRow = [""] * cls._ncol
912
+
913
+ # pop first three entries in the line
914
+ # (required entries for every outfall type)
915
+ out[:3] = line[:3]
916
+ outfall_type = str(out[2]).lower()
917
+ del line[:3]
918
+ try:
919
+ if outfall_type in ("free", "normal"):
920
+ out[4 : 4 + len(line)] = line
921
+ return out
922
+ else:
923
+ out[3 : 3 + len(line)] = line
924
+ return out
925
+ except Exception as e:
926
+ print("Error parsing Outfall line: {line}")
927
+ raise e
928
+
929
+ @classmethod
930
+ def from_section_text(cls, text: str):
931
+ return super()._from_section_text(text, cls._ncol)
932
+
933
+
934
+ class Storage(SectionDf):
935
+ _section_name = "STORAGE"
936
+ _ncol = 14
937
+ _headings = [
938
+ "Name",
939
+ "Elev",
940
+ "MaxDepth",
941
+ "InitDepth",
942
+ "Shape",
943
+ "CurveName",
944
+ "A1_L",
945
+ "A2_W",
946
+ "A0_Z",
947
+ "SurDepth",
948
+ "Fevap",
949
+ "Psi",
950
+ "Ksat",
951
+ "IMD",
952
+ ]
953
+ _index_col = "Name"
954
+
955
+ @classmethod
956
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
957
+ out: TRow = [""] * cls._ncol
958
+ out[: cls._headings.index("CurveName")] = line[:5]
959
+ line = line[5:]
960
+ shape = str(out[cls._headings.index("Shape")]).lower()
961
+ if shape in ("functional", "cylindrical", "conical", "paraboloid", "pyramidal"):
962
+ out[6 : 6 + len(line)] = line
963
+ return out
964
+ elif shape == "tabular":
965
+ out[cls._headings.index("CurveName")] = line.pop(0)
966
+ out[
967
+ cls._headings.index("SurDepth") : cls._headings.index("SurDepth")
968
+ + len(line)
969
+ ] = line
970
+ return out
971
+ else:
972
+ raise ValueError(f"Unexpected line in storage section ({line})")
973
+
974
+ @classmethod
975
+ def from_section_text(cls, text: str):
976
+ return super()._from_section_text(text, cls._ncol)
977
+
978
+
979
+ class Divider(SectionDf):
980
+ _section_name = "DIVIDERS"
981
+ _ncol = 12
982
+ _headings = [
983
+ "Name",
984
+ "Elevation",
985
+ "DivLink",
986
+ "DivType",
987
+ "DivCurve",
988
+ "Qmin",
989
+ "Height",
990
+ "Cd",
991
+ "Ymax",
992
+ "Y0",
993
+ "Ysur",
994
+ "Apond",
995
+ ]
996
+ _index_col = "Name"
997
+
998
+ @classmethod
999
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1000
+ out: TRow = [""] * cls._ncol
1001
+
1002
+ # pop first four entries in the line
1003
+ # (required entries for every Divider type)
1004
+ out[:4] = line[:4]
1005
+ div_type = str(out[3]).lower()
1006
+ del line[:4]
1007
+ try:
1008
+ if div_type == "overflow":
1009
+ out[8 : 8 + len(line)] = line
1010
+
1011
+ elif div_type == "cutoff":
1012
+ out[5] = line.pop(0)
1013
+ out[8 : 8 + len(line)] = line
1014
+ elif div_type == "tabular":
1015
+ out[4] = line.pop(0)
1016
+ out[8 : 8 + len(line)] = line
1017
+ elif div_type == "weir":
1018
+ out[5 : 5 + len(line)] = line
1019
+ else:
1020
+ raise ValueError(f"Unexpected divider type: {div_type!r}")
1021
+ return out
1022
+
1023
+ except Exception as e:
1024
+ print("Error parsing Divider line: {line!r}")
1025
+ raise e
1026
+
1027
+ @classmethod
1028
+ def from_section_text(cls, text: str):
1029
+ return super()._from_section_text(text, cls._ncol)
1030
+
1031
+
1032
+ class Conduit(SectionDf):
1033
+ _section_name = "CONDUITS"
1034
+ _ncol = 9
1035
+ _headings = [
1036
+ "Name",
1037
+ "FromNode",
1038
+ "ToNode",
1039
+ "Length",
1040
+ "Roughness",
1041
+ "InOffset",
1042
+ "OutOffset",
1043
+ "InitFlow",
1044
+ "MaxFlow",
1045
+ ]
1046
+ _index_col = "Name"
1047
+
1048
+ @classmethod
1049
+ def from_section_text(cls, text: str):
1050
+ return super()._from_section_text(text, cls._ncol)
1051
+
1052
+
1053
+ class Pump(SectionDf):
1054
+ _section_name = "PUMPS"
1055
+ _ncol = 7
1056
+ _headings = [
1057
+ "Name",
1058
+ "FromNode",
1059
+ "ToNode",
1060
+ "PumpCurve",
1061
+ "Status",
1062
+ "Startup",
1063
+ "Shutoff",
1064
+ ]
1065
+ _index_col = "Name"
1066
+
1067
+ @classmethod
1068
+ def from_section_text(cls, text: str):
1069
+ return super()._from_section_text(text, cls._ncol)
1070
+
1071
+
1072
+ class Orifice(SectionDf):
1073
+ _section_name = "ORIFICES"
1074
+ _ncol = 8
1075
+ _headings = [
1076
+ "Name",
1077
+ "FromNode",
1078
+ "ToNode",
1079
+ "Type",
1080
+ "Offset",
1081
+ "Qcoeff",
1082
+ "Gated",
1083
+ "CloseTime",
1084
+ ]
1085
+ _index_col = "Name"
1086
+
1087
+ @classmethod
1088
+ def from_section_text(cls, text: str):
1089
+ return super()._from_section_text(text, cls._ncol)
1090
+
1091
+
1092
+ class Weir(SectionDf):
1093
+ _section_name = "WEIRS"
1094
+ _ncol = 13
1095
+ _headings = [
1096
+ "Name",
1097
+ "FromNode",
1098
+ "ToNode",
1099
+ "Type",
1100
+ "CrestHt",
1101
+ "Qcoeff",
1102
+ "Gated",
1103
+ "EndCon",
1104
+ "EndCoeff",
1105
+ "Surcharge",
1106
+ "RoadWidth",
1107
+ "RoadSurf",
1108
+ "CoeffCurve",
1109
+ ]
1110
+ _index_col = "Name"
1111
+
1112
+ @classmethod
1113
+ def from_section_text(cls, text: str):
1114
+ return super()._from_section_text(text, cls._ncol)
1115
+
1116
+
1117
+ class Outlet(SectionDf):
1118
+ _section_name = "OUTLETS"
1119
+ _ncol = 9
1120
+ _headings = [
1121
+ "Name",
1122
+ "FromNode",
1123
+ "ToNode",
1124
+ "Offset",
1125
+ "Type",
1126
+ "CurveName",
1127
+ "Qcoeff",
1128
+ "Qexpon",
1129
+ "Gated",
1130
+ ]
1131
+ _index_col = "Name"
1132
+
1133
+ @classmethod
1134
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1135
+ out: TRow = [""] * cls._ncol
1136
+ out[: cls._headings.index("CurveName")] = line[:5]
1137
+ line = line[5:]
1138
+
1139
+ if "functional" in str(out[cls._headings.index("Type")]).lower():
1140
+ out[6 : 6 + len(line)] = line
1141
+ return out
1142
+ elif "tabular" in str(out[cls._headings.index("Type")]).lower():
1143
+ out[cls._headings.index("CurveName")] = line[0]
1144
+ if len(line) > 1:
1145
+ out[cls._headings.index("Gated")] = line[1]
1146
+ return out
1147
+ else:
1148
+ raise ValueError(f"Unexpected line in outlet section ({line})")
1149
+
1150
+ @classmethod
1151
+ def from_section_text(cls, text: str):
1152
+ return super()._from_section_text(text, cls._ncol)
1153
+
1154
+
1155
+ class Xsections(SectionDf):
1156
+ _section_name = "XSECTIONS"
1157
+ _shapes = (
1158
+ "CIRCULAR",
1159
+ "FORCE_MAIN",
1160
+ "FILLED_CIRCULAR",
1161
+ "DUMMY",
1162
+ "RECT_CLOSED",
1163
+ "RECT_OPEN",
1164
+ "TRAPEZOIDAL",
1165
+ "TRIANGULAR",
1166
+ "HORIZ_ELLIPSE",
1167
+ "VERT_ELLIPSE",
1168
+ "ARCH",
1169
+ "PARABOLIC",
1170
+ "POWER",
1171
+ "RECT_TRIANGULAR",
1172
+ "RECT_ROUND",
1173
+ "MODBASKETHANDLE",
1174
+ "EGG",
1175
+ "HORSESHOE",
1176
+ "GOTHIC",
1177
+ "CATENARY",
1178
+ "SEMIELLIPTICAL",
1179
+ "BASKETHANDLE",
1180
+ "SEMICIRCULAR",
1181
+ "CUSTOM",
1182
+ )
1183
+
1184
+ _ncol = 9
1185
+ _headings = [
1186
+ "Link",
1187
+ "Shape",
1188
+ "Geom1",
1189
+ "Curve",
1190
+ "Geom2",
1191
+ "Geom3",
1192
+ "Geom4",
1193
+ "Barrels",
1194
+ "Culvert",
1195
+ ]
1196
+ _index_col = "Link"
1197
+
1198
+ @classmethod
1199
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1200
+ out: TRow = [""] * cls._ncol
1201
+ out[:2] = line[:2]
1202
+ line = line[2:]
1203
+
1204
+ if str(out[1]).lower() == "custom" and len(line) >= 2:
1205
+ out[cls._headings.index("Curve")], out[cls._headings.index("Geom1")] = (
1206
+ line[1],
1207
+ line[0],
1208
+ )
1209
+ ## TODO: Fix this depending on results from https://github.com/USEPA/Stormwater-Management-Model/issues/193
1210
+ # out[cls.headings.index("Barrels")] = line[2] if len(line) > 2 else 1
1211
+ out[
1212
+ cls._headings.index("Geom3") : cls._headings.index("Geom3")
1213
+ + len(line)
1214
+ - 2
1215
+ ] = line[2:]
1216
+ return out
1217
+ elif str(out[1]).lower() == "irregular":
1218
+ out[cls._headings.index("Curve")] = line[0]
1219
+ return out
1220
+ elif str(out[1]).upper() in cls._shapes:
1221
+ out[cls._headings.index("Geom1")] = line.pop(0)
1222
+ out[
1223
+ cls._headings.index("Geom2") : cls._headings.index("Geom2") + len(line)
1224
+ ] = line
1225
+ return out
1226
+ else:
1227
+ raise ValueError(f"Unexpected line in xsection section ({line})")
1228
+
1229
+ @classmethod
1230
+ def from_section_text(cls, text: str):
1231
+ return super()._from_section_text(text, cls._ncol)
1232
+
1233
+ def to_swmm_string(self) -> str:
1234
+ df = self.copy(deep=True)
1235
+
1236
+ # fill geoms
1237
+ mask = df["Shape"].isin(self._shapes)
1238
+ geom_cols = [f"Geom{i}" for i in range(1, 5)]
1239
+ df.loc[mask, geom_cols] = (
1240
+ df.loc[mask, geom_cols]
1241
+ .infer_objects(copy=False)
1242
+ .fillna(0)
1243
+ .infer_objects(copy=False)
1244
+ )
1245
+ df.loc[mask, geom_cols] = (
1246
+ df.loc[mask, geom_cols]
1247
+ .infer_objects(copy=False)
1248
+ .replace("", 0)
1249
+ .infer_objects(copy=False)
1250
+ )
1251
+
1252
+ # fix custom shapes, Geom2 needs to be empty since the curve goes there
1253
+ mask = df["Shape"].astype(str).str.upper() == "CUSTOM"
1254
+ df.loc[mask, "Geom2"] = ""
1255
+
1256
+ return super(Xsections, df).to_swmm_string()
1257
+
1258
+
1259
+ class Street(SectionDf):
1260
+ _section_name = "STREETS"
1261
+ _ncol = 11
1262
+ _headings = [
1263
+ "Name",
1264
+ "Tcrown",
1265
+ "Hcurb",
1266
+ "Sroad",
1267
+ "nRoad",
1268
+ "Hdep",
1269
+ "Wdep",
1270
+ "Sides",
1271
+ "Wback",
1272
+ "Sback",
1273
+ "nBack",
1274
+ ]
1275
+ _index_col = "Name"
1276
+
1277
+ @classmethod
1278
+ def from_section_text(cls, text: str):
1279
+ return super()._from_section_text(text, cls._ncol)
1280
+
1281
+
1282
+ class Transects(SectionText):
1283
+ _section_name = "TRANSECTS"
1284
+
1285
+
1286
+ class Timeseries(SectionBase):
1287
+ _section_name = "TIMESERIES"
1288
+
1289
+ def __init__(self, ts: dict):
1290
+ self._timeseries = ts
1291
+
1292
+ @dataclass
1293
+ class TimeseriesFile:
1294
+ name: str
1295
+ Fname: str
1296
+ desc: str = ""
1297
+
1298
+ def to_swmm(self):
1299
+ desc = comment_formatter(self.desc)
1300
+ return f"{self.desc}{self.name} FILE {self.Fname}\n\n"
1301
+
1302
+ @staticmethod
1303
+ def _timeseries_to_swmm_dat(df, name):
1304
+ def df_time_formatter(x):
1305
+ if isinstance(x, pd.Timedelta):
1306
+ total_seconds = x.total_seconds()
1307
+ hours = int(total_seconds // 3600) # Get the total hours
1308
+ minutes = int((total_seconds % 3600) // 60) # Get the remaining minutes
1309
+ return f"{hours:02}:{minutes:02}"
1310
+ elif isinstance(x, pd.Timestamp):
1311
+ return x.strftime("%m/%d/%Y %H:%M")
1312
+ elif isinstance(x, (float, int)):
1313
+ return x
1314
+
1315
+ def df_comment_formatter(x):
1316
+ if len(x) > 0:
1317
+ return comment_formatter(x).strip("\n")
1318
+ else:
1319
+ return ""
1320
+
1321
+ df["name"] = name
1322
+
1323
+ if len(comment := df.attrs.get("desc", "")) > 0:
1324
+ comment_line = df_comment_formatter(comment) + "\n"
1325
+ else:
1326
+ comment_line = ""
1327
+ return (
1328
+ comment_line
1329
+ + df.reset_index(names="time")
1330
+ .reindex(["name", "time", "value", "desc"], axis=1)
1331
+ .fillna("")
1332
+ .to_string(
1333
+ formatters=dict(time=df_time_formatter, desc=df_comment_formatter),
1334
+ index=False,
1335
+ header=False,
1336
+ )
1337
+ + "\n\n"
1338
+ )
1339
+
1340
+ @classmethod
1341
+ def from_section_text(cls, text: str):
1342
+ def is_valid_time_format(time_string):
1343
+ pattern = r"^\d+:\d+$"
1344
+ return bool(re.match(pattern, time_string))
1345
+
1346
+ def is_valid_date(date_str):
1347
+ # Regex pattern to match mm/dd/yyyy, m/d/yyyy, m/dd/yyyy, or mm/d/yyyy
1348
+ pattern = r"^(0?[1-9]|1[0-2])/([0-2]?[0-9]|3[01])/(\d{4})$"
1349
+
1350
+ # Check if the date string matches the pattern
1351
+ match = re.match(pattern, date_str)
1352
+
1353
+ return bool(match)
1354
+
1355
+ timeseries: dict[str, pd.DataFrame | Timeseries.TimeseriesFile] = {}
1356
+
1357
+ rows = text.split("\n")
1358
+ line_comment = ""
1359
+ ts_comment = ""
1360
+ current_time_series_name = ""
1361
+ current_time_series_data: list[TRow] = []
1362
+ for row in rows:
1363
+ # check if row contains data
1364
+ if not _is_data(row):
1365
+ continue
1366
+
1367
+ elif _is_line_comment(row):
1368
+ line_comment += _strip_comment(row)[1] + "\n"
1369
+ continue
1370
+
1371
+ line, comment = _strip_comment(row)
1372
+ if len(comment) > 0:
1373
+ line_comment += comment + "\n"
1374
+
1375
+ # split row into tokens coercing numerics into floats
1376
+ split_data = [_coerce_numeric(val) for val in line.split()]
1377
+
1378
+ ts_name = str(split_data.pop(0))
1379
+ if ts_name != current_time_series_name:
1380
+
1381
+ if len(current_time_series_data) > 0:
1382
+ df = pd.DataFrame(
1383
+ current_time_series_data, columns=["time", "value", "desc"]
1384
+ ).set_index("time")
1385
+ df.attrs["desc"] = ts_comment
1386
+ timeseries[current_time_series_name] = df
1387
+
1388
+ current_time_series_name = ts_name
1389
+ current_time_series_data = []
1390
+ ts_comment = line_comment
1391
+ line_comment = ""
1392
+
1393
+ if str(split_data[0]).upper() == "FILE" and len(split_data) == 2:
1394
+ timeseries[ts_name] = cls.TimeseriesFile(
1395
+ name=ts_name, Fname=str(split_data[1]), desc=line_comment
1396
+ )
1397
+ continue
1398
+ time: pd.Timedelta | pd.Timestamp
1399
+ while len(split_data) > 0:
1400
+ if isinstance(split_data[0], Number):
1401
+ time = pd.Timedelta(hours=float(split_data.pop(0)))
1402
+ value = float(split_data.pop(0))
1403
+ elif is_valid_time_format(split_data[0]):
1404
+ hours, minutes = str(split_data.pop(0)).split(":")
1405
+ time = pd.Timedelta(hours=int(hours), minutes=int(minutes))
1406
+ value = float(split_data.pop(0))
1407
+ elif is_valid_date(split_data[0]):
1408
+ date = pd.to_datetime(split_data.pop(0))
1409
+ if not is_valid_time_format(split_data[0]):
1410
+ raise ValueError(
1411
+ f"Error parsing timeseries {ts_name!r} time: {split_data[0]}"
1412
+ )
1413
+ hours, minutes = str(split_data.pop(0)).split(":")
1414
+ _time = pd.Timedelta(hours=int(hours), minutes=int(minutes))
1415
+ time = date + _time
1416
+ value = float(split_data.pop(0))
1417
+ else:
1418
+ raise ValueError(f"Error parsing Timeseries row {split_data}")
1419
+
1420
+ current_time_series_data.append([time, value, line_comment])
1421
+
1422
+ line_comment = ""
1423
+
1424
+ # instantiate DataFrame
1425
+ return cls(ts=timeseries)
1426
+
1427
+ @classmethod
1428
+ def _from_section_text(cls, text: str, *args, **kwargs) -> Self:
1429
+ raise NotImplementedError
1430
+
1431
+ @classmethod
1432
+ def _new_empty(cls) -> Self:
1433
+ return cls(ts={})
1434
+
1435
+ @classmethod
1436
+ def _newobj(cls, *args, **kwargs) -> Self:
1437
+ return cls(*args, **kwargs)
1438
+
1439
+ def to_swmm_string(self) -> str:
1440
+ out_str = textwrap.dedent(
1441
+ """\
1442
+ ;;Name Date Time Value
1443
+ ;;-------------- ---------- ---------- ----------
1444
+ """
1445
+ )
1446
+ for ts_name, ts_data in self._timeseries.items():
1447
+ if isinstance(ts_data, pd.DataFrame):
1448
+ out_str += self._timeseries_to_swmm_dat(ts_data, ts_name)
1449
+ elif isinstance(ts_data, self.TimeseriesFile):
1450
+ out_str += ts_data.to_swmm()
1451
+ return out_str
1452
+
1453
+ def add_file_timeseries(self, name: str, Fname: str, comment: str = "") -> Self:
1454
+ self._timeseries[name] = self.TimeseriesFile(
1455
+ name=name, Fname=Fname, desc=comment
1456
+ )
1457
+ return self
1458
+
1459
+ def __setitem__(self, key, data) -> None:
1460
+ if isinstance(data, pd.DataFrame):
1461
+ if "value" not in data.columns:
1462
+ raise ValueError(
1463
+ f"Expected 'value' columns in dataframe, got {data.columns!r}"
1464
+ )
1465
+
1466
+ self._timeseries[key] = data.reindex(["value", "comment"], axis=1)
1467
+ else:
1468
+ raise TypeError(
1469
+ f"__setitem__ currently only supports dataframes, got {type(data)}. "
1470
+ "Use the `add_file_timeseries` method to add file-based timeseries"
1471
+ )
1472
+
1473
+ def __getitem__(self, name) -> TimeseriesFile | pd.DataFrame:
1474
+ return self._timeseries[name]
1475
+
1476
+ def __repr__(self) -> str:
1477
+ longest_name = max(map(len, self._timeseries.keys()))
1478
+ width = longest_name + 2
1479
+ reprstr = ""
1480
+ for name, value in self._timeseries.items():
1481
+ if isinstance(value, self.TimeseriesFile):
1482
+ reprstr += f"{name:{width}}| TimeseriesFile(Fname={value.Fname!r}, desc={value.desc!r})\n"
1483
+ elif isinstance(value, pd.DataFrame):
1484
+ reprstr += f"{name:{width}}| DataFrame(start={value.index[0]!r}, end={value.index[-1]!r},len={len(value)})\n"
1485
+ return reprstr
1486
+
1487
+ def __iter__(self) -> Iterator[TimeseriesFile | pd.DataFrame]:
1488
+ return iter(self._timeseries.values())
1489
+
1490
+ def __len__(self) -> int:
1491
+ return len(self._timeseries)
1492
+
1493
+ def _ipython_key_completions_(self) -> list[str]:
1494
+ """Provide method for the key-autocompletions in IPython.
1495
+ See http://ipython.readthedocs.io/en/stable/config/integrating.html#tab-completion
1496
+ For the details.
1497
+ """
1498
+
1499
+ return list(self._timeseries.keys())
1500
+
1501
+
1502
+ class Patterns(SectionDf):
1503
+ _section_name = "PATTERNS"
1504
+ _ncol = 3
1505
+ _headings = ["Name", "Type", "Multiplier"]
1506
+ _index_col = ["Name"]
1507
+ _valid_types = [
1508
+ "MONTHLY",
1509
+ "DAILY",
1510
+ "HOURLY",
1511
+ "WEEKEND",
1512
+ ]
1513
+
1514
+ @classmethod
1515
+ def _tabulate(cls, line: list[str | float]) -> TRow | list[TRow]:
1516
+ out: list[TRow] = []
1517
+ name = line.pop(0)
1518
+
1519
+ pattern_type: str | NAType
1520
+ if str(line[0]).upper() in cls._valid_types:
1521
+ pattern_type = str(line.pop(0)).upper()
1522
+ elif isinstance(line[0], Number):
1523
+ pattern_type = pd.NA
1524
+ else:
1525
+ raise ValueError(f"Error parsing pattern line {[name]+line!r}")
1526
+
1527
+ for value in line:
1528
+ row: TRow = [""] * cls._ncol
1529
+ float_val = float(value)
1530
+ row[0:3] = name, pattern_type, float_val
1531
+ out.append(row)
1532
+ return out
1533
+
1534
+ @classmethod
1535
+ def _validate_pattern_types(cls, df: pd.DataFrame) -> dict[str, str]:
1536
+ unique_patterns = df.reset_index()[["Name", "Type"]].dropna().drop_duplicates()
1537
+ if unique_patterns["Name"].duplicated().any():
1538
+ raise ValueError(
1539
+ "Pattern with duplicate types found in input file. "
1540
+ "Each pattern must only specify a single type to work with swmm.pandas"
1541
+ )
1542
+ if not all(
1543
+ bools := [pattern in cls._valid_types for pattern in unique_patterns.Type]
1544
+ ):
1545
+ invalid_patterns = unique_patterns["Type"].loc[~np.array(bools)].to_list()
1546
+ raise ValueError(f"Unknown curves {invalid_patterns!r}")
1547
+
1548
+ return unique_patterns.set_index("Name")["Type"].to_dict()
1549
+
1550
+ @classmethod
1551
+ def from_section_text(cls, text: str) -> Self:
1552
+ df = super()._from_section_text(text, cls._ncol)
1553
+ pattern_types = cls._validate_pattern_types(df)
1554
+ df = df.reset_index().drop("Type", axis=1)
1555
+ df["Pattern_Index"] = df.groupby("Name").cumcount()
1556
+ df = cls(df.set_index(["Name", "Pattern_Index"]))
1557
+ df.attrs = pattern_types # type: ignore
1558
+ return df
1559
+
1560
+ def to_swmm_string(self) -> str:
1561
+ df = self.copy(deep=True)
1562
+
1563
+ # add type back into frame in first row of curve
1564
+ type_idx = pd.MultiIndex.from_frame(
1565
+ df.index.to_frame()
1566
+ .drop("Name", axis=1)
1567
+ .groupby("Name")["Pattern_Index"]
1568
+ .min()
1569
+ .reset_index()
1570
+ )
1571
+ type_values = type_idx.get_level_values(0).map(df.attrs).to_numpy()
1572
+ df.loc[:, "Type"] = ""
1573
+ df.loc[type_idx, "Type"] = type_values
1574
+
1575
+ # sort by name and index then drop the curve index field since swmm doesn't use it
1576
+ df = df.sort_index(ascending=[True, True])
1577
+ df.index = df.index.droplevel("Pattern_Index")
1578
+ return super(Patterns, df).to_swmm_string()
1579
+
1580
+
1581
+ class Inlet(SectionDf):
1582
+ _section_name = "INLETS"
1583
+ _ncol = 7
1584
+ _headings = [
1585
+ "Name",
1586
+ "Type",
1587
+ ]
1588
+ _index_col = ["Name", "Type"]
1589
+
1590
+ @classmethod
1591
+ def from_section_text(cls, text: str):
1592
+ return super()._from_section_text(text, cls._ncol)
1593
+
1594
+
1595
+ class Inlet_Usage(SectionDf):
1596
+ _section_name = "INLET_USAGE"
1597
+ _ncol = 9
1598
+ _headings = [
1599
+ "Conduit",
1600
+ "Inlet",
1601
+ "Node",
1602
+ "Number",
1603
+ "%Clogged",
1604
+ "MaxFlow",
1605
+ "hDStore",
1606
+ "wDStore",
1607
+ "Placement",
1608
+ ]
1609
+ _index_col = "Conduit"
1610
+
1611
+ @classmethod
1612
+ def from_section_text(cls, text: str):
1613
+ return super()._from_section_text(text, cls._ncol)
1614
+
1615
+
1616
+ class Losses(SectionDf):
1617
+ _section_name = "LOSSES"
1618
+ _ncol = 6
1619
+ _headings = ["Link", "Kentry", "Kexit", "Kavg", "FlapGate", "Seepage"]
1620
+ _index_col = "Link"
1621
+
1622
+ @classmethod
1623
+ def from_section_text(cls, text: str):
1624
+ return super()._from_section_text(text, cls._ncol)
1625
+
1626
+ def to_swmm_string(self) -> str:
1627
+ df = self.copy(deep=True)
1628
+
1629
+ for col in self._data_cols(desc=False):
1630
+ if col != "FlapGate":
1631
+ df[col] = df[col].infer_objects(copy=False).fillna(0.0)
1632
+ else:
1633
+ df[col] = df[col].infer_objects(copy=False).fillna("NO")
1634
+
1635
+ return super(Losses, df).to_swmm_string()
1636
+
1637
+
1638
+ class Controls(SectionText):
1639
+ _section_name = "CONTROLS"
1640
+
1641
+
1642
+ class Pollutants(SectionDf):
1643
+ _section_name = "POLLUTANTS"
1644
+ _ncol = 11
1645
+ _headings = [
1646
+ "Name",
1647
+ "Units",
1648
+ "Crain",
1649
+ "Cgw",
1650
+ "Crdii",
1651
+ "Kdecay",
1652
+ "SnowOnly",
1653
+ "CoPollutant",
1654
+ "CoFrac",
1655
+ "Cdwf",
1656
+ "Cinit",
1657
+ ]
1658
+ _index_col = "Name"
1659
+
1660
+ @classmethod
1661
+ def from_section_text(cls, text: str):
1662
+ return super()._from_section_text(text, cls._ncol)
1663
+
1664
+
1665
+ class LandUse(SectionDf):
1666
+ _section_name = "LANDUSES"
1667
+ _ncol = 4
1668
+ _headings = ["Name", "SweepInterval", "Availability", "LastSweep"]
1669
+ _index_col = "Name"
1670
+
1671
+ @classmethod
1672
+ def from_section_text(cls, text: str):
1673
+ return super()._from_section_text(text, cls._ncol)
1674
+
1675
+ def to_swmm_string(self) -> str:
1676
+ for col in self.columns:
1677
+ self[col] = self[col].infer_objects(copy=False).fillna(0.0)
1678
+ return super().to_swmm_string()
1679
+
1680
+
1681
+ class Coverage(SectionDf):
1682
+ _section_name = "COVERAGES"
1683
+ _ncol = 3
1684
+ _headings = ["Subcatchment", "landuse", "Percent"]
1685
+ _index_col = ["Subcatchment", "landuse"]
1686
+
1687
+ @classmethod
1688
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1689
+ if len(line) > 3:
1690
+ raise Exception(
1691
+ "swmm.pandas doesn't yet support having multiple land "
1692
+ "uses on a single coverage line. Separate your land use "
1693
+ "coverages onto individual lines first",
1694
+ )
1695
+ return super()._tabulate(line)
1696
+
1697
+ @classmethod
1698
+ def from_section_text(cls, text: str):
1699
+ return super()._from_section_text(text, cls._ncol)
1700
+
1701
+
1702
+ class Loading(SectionDf):
1703
+ _section_name = "LOADINGS"
1704
+ _ncol = 3
1705
+ _headings = ["Subcatchment", "Pollutant", "InitBuildup"]
1706
+ _index_col = ["Subcatchment", "Pollutant"]
1707
+
1708
+ @classmethod
1709
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1710
+ if len(line) > 3:
1711
+ raise Exception(
1712
+ "swmm.pandas doesn't yet support having multiple pollutants "
1713
+ "uses on a single loading line. Separate your pollutant "
1714
+ "loadings onto individual lines first",
1715
+ )
1716
+ return super()._tabulate(line)
1717
+
1718
+ @classmethod
1719
+ def from_section_text(cls, text: str):
1720
+ return super()._from_section_text(text, cls._ncol)
1721
+
1722
+
1723
+ class Buildup(SectionDf):
1724
+ _section_name = "BUILDUP"
1725
+ _ncol = 4
1726
+ _headings = ["Landuse", "Pollutant", "FuncType", "C1", "C2", "C3", "PerUnit"]
1727
+ _index_col = ["Landuse", "Pollutant"]
1728
+
1729
+ @classmethod
1730
+ def from_section_text(cls, text: str):
1731
+ return super()._from_section_text(text, cls._ncol)
1732
+
1733
+
1734
+ class Washoff(SectionDf):
1735
+ _section_name = "WASHOFF"
1736
+ _ncol = 4
1737
+ _headings = ["Landuse", "Pollutant", "FuncType", "C1", "C2", "SweepRmvl", "BmpRmvl"]
1738
+ _index_col = ["Landuse", "Pollutant"]
1739
+
1740
+ @classmethod
1741
+ def from_section_text(cls, text: str):
1742
+ return super()._from_section_text(text, cls._ncol)
1743
+
1744
+
1745
+ class Treatment(SectionDf):
1746
+ _section_name = "TREATMENT"
1747
+ _ncol = 3
1748
+ _headings = ["Node", "Pollutant", "Func"]
1749
+ _index_col = ["Node", "Pollutant"]
1750
+
1751
+ @classmethod
1752
+ def from_section_text(cls, text: str):
1753
+ return super()._from_section_text(text, cls._ncol)
1754
+
1755
+ @classmethod
1756
+ def _tabulate(cls, line: list[str | float]) -> TRow | list[TRow]:
1757
+ node = str(line.pop(0))
1758
+ poll = str(line.pop(0))
1759
+ eqn = " ".join(str(v) for v in line)
1760
+ out: TRow = [node, poll, eqn]
1761
+ return out
1762
+
1763
+
1764
+ class Inflow(SectionDf):
1765
+ _section_name = "INFLOWS"
1766
+ _ncol = 8
1767
+ _headings = [
1768
+ "Node",
1769
+ "Constituent",
1770
+ "TimeSeries",
1771
+ "InflowType",
1772
+ "Mfactor",
1773
+ "Sfactor",
1774
+ "Baseline",
1775
+ "Pattern",
1776
+ ]
1777
+ _index_col = ["Node", "Constituent"]
1778
+
1779
+ @classmethod
1780
+ def from_section_text(cls, text: str):
1781
+ return super()._from_section_text(text, cls._ncol)
1782
+
1783
+ @classmethod
1784
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1785
+ return [v.replace('"', "") if isinstance(v, str) else v for v in line]
1786
+
1787
+ def to_swmm_string(self) -> str:
1788
+ df = self.copy(deep=True)
1789
+ df["Mfactor"] = df["Mfactor"].infer_objects(copy=False).fillna(1.0)
1790
+ df["Sfactor"] = df["Sfactor"].infer_objects(copy=False).fillna(1.0)
1791
+
1792
+ # strip out any existing double quotes
1793
+ df["TimeSeries"] = df["TimeSeries"].fillna("").str.replace('"', "")
1794
+ df["TimeSeries"] = '"' + df["TimeSeries"].astype(str) + '"'
1795
+ return super(Inflow, df).to_swmm_string()
1796
+
1797
+
1798
+ class DWF(SectionDf):
1799
+ _section_name = "DWF"
1800
+ _ncol = 7
1801
+ _headings = [
1802
+ "Node",
1803
+ "Constituent",
1804
+ "AvgValue",
1805
+ "Pat1",
1806
+ "Pat2",
1807
+ "Pat3",
1808
+ "Pat4",
1809
+ ]
1810
+ _index_col = ["Node", "Constituent"]
1811
+
1812
+ @classmethod
1813
+ def from_section_text(cls, text: str):
1814
+ return super()._from_section_text(text, cls._ncol)
1815
+
1816
+ @classmethod
1817
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
1818
+ return [v.replace('"', "") if isinstance(v, str) else v for v in line]
1819
+
1820
+ def to_swmm_string(self) -> str:
1821
+ df = self.copy(deep=True)
1822
+ df["AvgValue"] = df["AvgValue"].infer_objects(copy=False).fillna(0.0)
1823
+
1824
+ for ipat in range(1, 5):
1825
+ col = f"Pat{ipat}"
1826
+ df[col] = df[col].fillna("").str.replace('"', "")
1827
+ df[col] = '"' + df[col].astype(str) + '"'
1828
+
1829
+ return super(DWF, df).to_swmm_string()
1830
+
1831
+
1832
+ class RDII(SectionDf):
1833
+ _section_name = "RDII"
1834
+ _ncol = 3
1835
+ _headings = ["Node", "UHgroup", "SewerArea"]
1836
+ _index_col = "Node"
1837
+
1838
+ @classmethod
1839
+ def from_section_text(cls, text: str):
1840
+ return super()._from_section_text(text, cls._ncol)
1841
+
1842
+
1843
+ class Hydrographs(SectionDf):
1844
+ _section_name = "HYDROGRAPHS"
1845
+ _ncol = 9
1846
+ _headings = [
1847
+ "Name",
1848
+ "Month_RG",
1849
+ "Response",
1850
+ "R",
1851
+ "T",
1852
+ "K",
1853
+ "IA_max",
1854
+ "IA_rec",
1855
+ "IA_ini",
1856
+ ]
1857
+ _index_col = ["Name", "Month_RG", "Response"]
1858
+
1859
+ @classmethod
1860
+ def from_section_text(cls, text: str) -> Self:
1861
+
1862
+ df = super()._from_section_text(text, cls._ncol).reset_index()
1863
+ rg_rows = cls._find_rain_gauge_rows(df)
1864
+ rgs = df.loc[rg_rows].set_index("Name")["Month_RG"].to_dict()
1865
+ df.drop(rg_rows, inplace=True)
1866
+ df = cls(df.set_index(cls._index_col).sort_index())
1867
+ df.attrs = rgs
1868
+ return df
1869
+
1870
+ @property
1871
+ def rain_gauges(self) -> dict[str, str]:
1872
+ return self.attrs # type: ignore
1873
+
1874
+ @staticmethod
1875
+ def _find_rain_gauge_rows(df) -> pd.Index:
1876
+ # Function to check if a row matches the raingauge criteria
1877
+ def is_raingauge_row(row):
1878
+ return (row != "").sum() == 2
1879
+
1880
+ # Apply the function to each row and get the indices where it's True
1881
+ raingauge_indices = df.loc[df.apply(is_raingauge_row, axis=1)].index
1882
+
1883
+ return raingauge_indices
1884
+
1885
+ def to_swmm_string(self) -> str:
1886
+
1887
+ def month_to_number(month):
1888
+ try:
1889
+ return list(month_abbr).index(month.capitalize())
1890
+ except ValueError:
1891
+ return -1 # This will sort unrecognized months to the top
1892
+
1893
+ def index_mapper(index):
1894
+ if index.name == "Month_RG":
1895
+ return index.map(month_to_number)
1896
+ else:
1897
+ return index
1898
+
1899
+ # add rain gauge rows
1900
+ _temp = self.__class__._new_empty()
1901
+ for name in self.index.get_level_values("Name").unique():
1902
+ try:
1903
+ _temp.add_element(
1904
+ Name=name, Month_RG=self.rain_gauges[name], Response=""
1905
+ )
1906
+ except KeyError:
1907
+ raise KeyError(
1908
+ f"Raingauge for hydrograph {name!r} not found in hydrographs.rain_gauges property. "
1909
+ f"Only found {self.rain_gauges!r}"
1910
+ )
1911
+
1912
+ df = pd.concat([self, _temp])
1913
+ # sort by name, month, and response after adding in raingauges
1914
+ df = Hydrographs(df.sort_index(ascending=[True, True, False], key=index_mapper))
1915
+ return super(Hydrographs, df).to_swmm_string()
1916
+
1917
+
1918
+ class Curves(SectionDf):
1919
+ _section_name = "CURVES"
1920
+ _ncol = 4
1921
+ _headings = ["Name", "Type", "X_Value", "Y_Value"]
1922
+ _index_col = ["Name"]
1923
+ _valid_types = [
1924
+ "STORAGE",
1925
+ "SHAPE",
1926
+ "DIVERSION",
1927
+ "TIDAL",
1928
+ "PUMP1",
1929
+ "PUMP2",
1930
+ "PUMP3",
1931
+ "PUMP4",
1932
+ "PUMP5",
1933
+ "RATING",
1934
+ "CONTROL",
1935
+ "WEIR",
1936
+ ]
1937
+
1938
+ @classmethod
1939
+ def _tabulate(cls, line: list[str | float]) -> TRow | list[TRow]:
1940
+ out = []
1941
+ name = line.pop(0)
1942
+
1943
+ curve_type: str | NAType
1944
+ if str(line[0]).upper() in cls._valid_types:
1945
+ curve_type = str(line.pop(0)).upper()
1946
+ elif isinstance(line[0], Number):
1947
+ curve_type = pd.NA
1948
+ else:
1949
+ raise ValueError(f"Error parsing curve line {[name]+line!r}")
1950
+
1951
+ for chunk in range(0, len(line), 2):
1952
+ row: TRow = [""] * cls._ncol
1953
+ x_value, y_value = line[chunk : chunk + 2]
1954
+ row[0:4] = name, curve_type, x_value, y_value
1955
+ out.append(row)
1956
+ return out
1957
+
1958
+ @classmethod
1959
+ def _validate_curve_types(cls, df: pd.DataFrame) -> dict[str, str]:
1960
+ unique_curves = df.reset_index()[["Name", "Type"]].dropna().drop_duplicates()
1961
+ if unique_curves["Name"].duplicated().any():
1962
+ raise ValueError(
1963
+ "Curve with duplicate types found in input file. "
1964
+ "Each curve must only specify a single type to work with swmm.pandas"
1965
+ )
1966
+ if not all(
1967
+ bools := [curve in cls._valid_types for curve in unique_curves.Type]
1968
+ ):
1969
+ invalid_curves = unique_curves["Type"].loc[~np.array(bools)].to_list()
1970
+ raise ValueError(f"Unknown curves {invalid_curves!r}")
1971
+
1972
+ return unique_curves.set_index("Name")["Type"].to_dict()
1973
+
1974
+ @classmethod
1975
+ def from_section_text(cls, text: str) -> Self:
1976
+ df = super()._from_section_text(text, cls._ncol)
1977
+ curve_types = cls._validate_curve_types(df)
1978
+ df = df.reset_index().drop("Type", axis=1)
1979
+ df["Curve_Index"] = df.groupby("Name").cumcount()
1980
+ df = cls(df.set_index(["Name", "Curve_Index"]))
1981
+ df.attrs = curve_types # type: ignore
1982
+ return df
1983
+
1984
+ def to_swmm_string(self) -> str:
1985
+ df = self.copy(deep=True)
1986
+
1987
+ # add type back into frame in first row of curve
1988
+ type_idx = pd.MultiIndex.from_frame(
1989
+ df.index.to_frame()
1990
+ .drop("Name", axis=1)
1991
+ .groupby("Name")["Curve_Index"]
1992
+ .min()
1993
+ .reset_index()
1994
+ )
1995
+ type_values = type_idx.get_level_values(0).map(df.attrs).to_numpy()
1996
+ df.loc[:, "Type"] = ""
1997
+ df.loc[type_idx, "Type"] = type_values
1998
+
1999
+ # sort by name and index then drop the curve index field since swmm doesn't use it
2000
+ df = Curves(df.sort_index(ascending=[True, True]))
2001
+ df.index = df.index.droplevel("Curve_Index")
2002
+ return super(Curves, df).to_swmm_string()
2003
+
2004
+
2005
+ class Coordinates(SectionDf):
2006
+ _section_name = "COORDINATES"
2007
+ _ncol = 3
2008
+ _headings = ["Node", "X", "Y"]
2009
+ _index_col = "Node"
2010
+
2011
+ @classmethod
2012
+ def from_section_text(cls, text: str):
2013
+ return super()._from_section_text(text, cls._ncol)
2014
+
2015
+
2016
+ class Vertices(SectionDf):
2017
+ _section_name = "VERTICIES"
2018
+ _ncol = 3
2019
+ _headings = ["Link", "X", "Y"]
2020
+ _index_col = "Link"
2021
+
2022
+ @classmethod
2023
+ def from_section_text(cls, text: str):
2024
+ return super()._from_section_text(text, cls._ncol)
2025
+
2026
+
2027
+ class Polygons(SectionDf):
2028
+ _section_name = "POLYGONS"
2029
+ _ncol = 3
2030
+ _headings = ["Subcatch", "X", "Y"]
2031
+ _index_col = "Subcatch"
2032
+
2033
+ @classmethod
2034
+ def from_section_text(cls, text: str):
2035
+ return super()._from_section_text(text, cls._ncol)
2036
+
2037
+
2038
+ class Symbols(SectionDf):
2039
+ _section_name = "SYMBOLS"
2040
+ _ncol = 3
2041
+ _headings = ["Gage", "X", "Y"]
2042
+ _index_col = "Gage"
2043
+
2044
+ @classmethod
2045
+ def from_section_text(cls, text: str):
2046
+ return super()._from_section_text(text, cls._ncol)
2047
+
2048
+
2049
+ class Labels(SectionDf):
2050
+ _section_name = "LABELS"
2051
+ _ncol = 8
2052
+ _headings = [
2053
+ "Xcoord",
2054
+ "Ycoord",
2055
+ "Label",
2056
+ "Anchor",
2057
+ "Font",
2058
+ "Size",
2059
+ "Bold",
2060
+ "Italic",
2061
+ ]
2062
+
2063
+ @classmethod
2064
+ def from_section_text(cls, text: str):
2065
+ return super()._from_section_text(text, cls._ncol)
2066
+
2067
+
2068
+ class Tags(SectionDf):
2069
+ _section_name = "TAGS"
2070
+ _ncol = 3
2071
+ _headings = ["Element", "Name", "Tag"]
2072
+ _index_col = ["Element", "Name"]
2073
+
2074
+ @classmethod
2075
+ def from_section_text(cls, text: str):
2076
+ return super()._from_section_text(text, cls._ncol)
2077
+
2078
+
2079
+ class Profile(SectionText):
2080
+ _section_name = "PROFILE"
2081
+
2082
+
2083
+ class LID_Control(SectionDf):
2084
+ _section_name = "LID_CONTROLS"
2085
+ _ncol = 9
2086
+ _headings = ["Name", "Type"]
2087
+ _index_col = "Name"
2088
+
2089
+ @classmethod
2090
+ def from_section_text(cls, text: str):
2091
+ return super()._from_section_text(text, cls._ncol)
2092
+
2093
+ @classmethod
2094
+ def _tabulate(cls, line: list[str | float]) -> TRow | list[TRow]:
2095
+ lid_type = line[1]
2096
+ if lid_type == "REMOVALS":
2097
+ out: list[TRow] = []
2098
+ name = line.pop(0)
2099
+ lid_type = line.pop(0)
2100
+ for chunk in range(0, len(line), 2):
2101
+ row: TRow = [""] * cls._ncol
2102
+ pollutant, removal = line[chunk : chunk + 2]
2103
+ row[0:4] = name, lid_type, pollutant, removal
2104
+ out.append(row)
2105
+ return out
2106
+ else:
2107
+ return super()._tabulate(line)
2108
+
2109
+
2110
+ class LID_Usage(SectionDf):
2111
+ _section_name = "LID_USAGE"
2112
+ _ncol = 11
2113
+ _headings = [
2114
+ "Subcatchment",
2115
+ "LIDProcess",
2116
+ "Number",
2117
+ "Area",
2118
+ "Width",
2119
+ "InitSat",
2120
+ "FromImp",
2121
+ "ToPerv",
2122
+ "RptFile",
2123
+ "DrainTo",
2124
+ "FromPerv",
2125
+ ]
2126
+
2127
+ _index_col = ["Subcatchment", "LIDProcess"]
2128
+
2129
+ @classmethod
2130
+ def from_section_text(cls, text: str):
2131
+ return super()._from_section_text(text, cls._ncol)
2132
+
2133
+
2134
+ class Adjustments(SectionDf):
2135
+ _section_name = "ADJUSTMENTS"
2136
+ _ncol = 15
2137
+ _headings = [
2138
+ "Parameter",
2139
+ "Subcatchment",
2140
+ "Pattern",
2141
+ "Jan",
2142
+ "Feb",
2143
+ "Mar",
2144
+ "Apr",
2145
+ "May",
2146
+ "Jun",
2147
+ "Jul",
2148
+ "Aug",
2149
+ "Sep",
2150
+ "Oct",
2151
+ "Nov",
2152
+ "Dec",
2153
+ ]
2154
+ _index_col = "Parameter"
2155
+
2156
+ @classmethod
2157
+ def from_section_text(cls, text: str):
2158
+ return super()._from_section_text(text, cls._ncol)
2159
+
2160
+ @classmethod
2161
+ def _tabulate(cls, line: list[str | float | int]) -> TRow | list[TRow]:
2162
+ out: TRow = [""] * cls._ncol
2163
+ out[0] = line.pop(0)
2164
+ if str(out[0]).lower() in ["n-perv", "dstore"]:
2165
+ out[1 : 1 + len(line)] = line
2166
+ else:
2167
+ start = cls._headings.index("Jan")
2168
+ out[start : start + len(line)] = line
2169
+ return out
2170
+
2171
+
2172
+ class Backdrop(SectionText):
2173
+ _section_name = "BACKDROP"
2174
+
2175
+
2176
+ # TODO: write custom to_string class
2177
+ # class Backdrop:
2178
+ # @classmethod
2179
+ # def __init__(self, text: str):
2180
+ # rows = text.split("\n")
2181
+ # data = []
2182
+ # line_comment = ""
2183
+ # for row in rows:
2184
+ # if not _is_data(row):
2185
+ # continue
2186
+
2187
+ # elif row.strip()[0] == ";":
2188
+ # print(row)
2189
+ # line_comment += row
2190
+ # continue
2191
+
2192
+ # line, comment = _strip_comment(row)
2193
+ # line_comment += comment
2194
+
2195
+ # split_data = [_coerce_numeric(val) for val in row.split()]
2196
+
2197
+ # if split_data[0].upper() == "DIMENSIONS":
2198
+ # self.dimensions = split_data[1:]
2199
+
2200
+ # elif split_data[0].upper() == "FILE":
2201
+ # self.file = split_data[1]
2202
+
2203
+ # def from_section_text(cls, text: str):
2204
+ # return cls(text)
2205
+
2206
+ # def __repr__(self) -> str:
2207
+ # return f"Backdrop(dimensions = {self.dimensions}, file = {self.file})"
2208
+
2209
+
2210
+ class Map(SectionText):
2211
+ _section_name = "MAP"
2212
+
2213
+
2214
+ # TODO: write custom to_string class
2215
+ # class Map:
2216
+ # @classmethod
2217
+ # def __init__(self, text: str):
2218
+ # rows = text.split("\n")
2219
+ # data = []
2220
+ # line_comment = ""
2221
+ # for row in rows:
2222
+ # if not _is_data(row):
2223
+ # continue
2224
+
2225
+ # elif row.strip()[0] == ";":
2226
+ # print(row)
2227
+ # line_comment += row
2228
+ # continue
2229
+
2230
+ # line, comment = _strip_comment(row)
2231
+ # line_comment += comment
2232
+
2233
+ # split_data = [_coerce_numeric(val) for val in row.split()]
2234
+
2235
+ # if split_data[0].upper() == "DIMENSIONS":
2236
+ # self.dimensions = split_data[1:]
2237
+
2238
+ # elif split_data[0].upper() == "UNITS":
2239
+ # self.units = split_data[1]
2240
+
2241
+ # @classmethod
2242
+ # def from_section_text(cls, text: str):
2243
+ # return cls(text)
2244
+
2245
+ # def __repr__(self) -> str:
2246
+ # return f"Map(dimensions = {self.dimensions}, units = {self.units})"
2247
+
2248
+
2249
+ _sections: dict[str, type[SectionBase]] = {
2250
+ "TITLE": Title,
2251
+ "OPTION": Option,
2252
+ "REPORT": Report,
2253
+ "EVENT": Event,
2254
+ "FILE": Files,
2255
+ "RAINGAGE": Raingage,
2256
+ "EVAP": Evap,
2257
+ "TEMPERATURE": Temperature,
2258
+ "ADJUSTMENT": Adjustments,
2259
+ "SUBCATCHMENT": Subcatchment,
2260
+ "SUBAREA": Subarea,
2261
+ "INFIL": Infil,
2262
+ "LID_CONTROL": LID_Control,
2263
+ "LID_USAGE": LID_Usage,
2264
+ "AQUIFER": Aquifer,
2265
+ "GROUNDWATER": Groundwater,
2266
+ "GWF": GWF,
2267
+ "SNOWPACK": Snowpack,
2268
+ "JUNC": Junc,
2269
+ "OUTFALL": Outfall,
2270
+ "DIVIDER": Divider,
2271
+ "STORAGE": Storage,
2272
+ "CONDUIT": Conduit,
2273
+ "PUMP": Pump,
2274
+ "ORIFICE": Orifice,
2275
+ "WEIR": Weir,
2276
+ "OUTLET": Outlet,
2277
+ "XSECT": Xsections,
2278
+ # TODO build parser for this table
2279
+ "TRANSECT": Transects,
2280
+ "STREETS": Street,
2281
+ "INLET_USAGE": Inlet_Usage,
2282
+ "INLET": Inlet,
2283
+ "LOSS": Losses,
2284
+ # TODO build parser for this table
2285
+ "CONTROL": Controls,
2286
+ "POLLUT": Pollutants,
2287
+ "LANDUSE": LandUse,
2288
+ "COVERAGE": Coverage,
2289
+ "LOADING": Loading,
2290
+ "BUILDUP": Buildup,
2291
+ "WASHOFF": Washoff,
2292
+ "TREATMENT": Treatment,
2293
+ "INFLOW": Inflow,
2294
+ "DWF": DWF,
2295
+ "RDII": RDII,
2296
+ "HYDROGRAPH": Hydrographs,
2297
+ "CURVE": Curves,
2298
+ "TIMESERIES": Timeseries,
2299
+ "PATTERN": Patterns,
2300
+ "MAP": Map,
2301
+ "POLYGON": Polygons,
2302
+ "COORDINATE": Coordinates,
2303
+ "VERTICES": Vertices,
2304
+ "LABEL": Labels,
2305
+ "SYMBOL": Symbols,
2306
+ "BACKDROP": Backdrop,
2307
+ "PROFILE": Profile,
2308
+ "TAG": Tags,
2309
+ }