razaali10 commited on
Commit
f7a6add
·
verified ·
1 Parent(s): 63e9058

Upload 22 files

Browse files
pcswmm_ai/__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """PCSWMM AI SDK.
2
+
3
+ The PCSWMM application injects ``pcpy`` into its Script Editor and
4
+ JupyterLab runtimes. This package therefore uses dependency injection:
5
+
6
+ from pcswmm_ai import PCSWMMClient
7
+ client = PCSWMMClient(pcpy)
8
+
9
+ The package never imports ``pcpy`` directly.
10
+ """
11
+
12
+ from .client import PCSWMMClient
13
+ from .exceptions import (
14
+ PCSWMMError,
15
+ ProjectNotOpenError,
16
+ LayerNotFoundError,
17
+ ResultsNotAvailableError,
18
+ )
19
+
20
+ __all__ = [
21
+ "PCSWMMClient",
22
+ "PCSWMMError",
23
+ "ProjectNotOpenError",
24
+ "LayerNotFoundError",
25
+ "ResultsNotAvailableError",
26
+ ]
27
+
28
+ __version__ = "0.4.0"
pcswmm_ai/capabilities.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ModelCapabilityService:
7
+ """Detect model features without assuming a specific project type."""
8
+
9
+ def __init__(self, collections):
10
+ self._collections = collections
11
+
12
+ def counts(self) -> dict[str, int]:
13
+ summary = self._collections.summary()
14
+ return {
15
+ row["collection"]: int(row["count"] or 0)
16
+ for row in summary
17
+ }
18
+
19
+ def detect(self) -> dict[str, Any]:
20
+ counts = self.counts()
21
+
22
+ has_storage = counts.get("Storages", 0) > 0
23
+ has_outlet_structures = any(
24
+ counts.get(name, 0) > 0
25
+ for name in ("Orifices", "Weirs", "Outlets", "Pumps")
26
+ )
27
+ has_conveyance = any(
28
+ counts.get(name, 0) > 0
29
+ for name in ("Conduits", "Pumps", "Orifices", "Weirs", "Outlets")
30
+ )
31
+ has_subcatchments = counts.get("Subcatchments", 0) > 0
32
+ has_quality = counts.get("Pollutants", 0) > 0
33
+ has_lid = counts.get("LIDControls", 0) > 0
34
+ has_snow = counts.get("SnowPacks", 0) > 0
35
+ has_groundwater = counts.get("Aquifers", 0) > 0
36
+ has_controls = counts.get("Controls", 0) > 0
37
+
38
+ if has_storage and has_outlet_structures and counts.get("Nodes", 0) <= 5:
39
+ model_archetype = "detention_or_storage_model"
40
+ elif has_storage and has_conveyance:
41
+ model_archetype = "collection_system_with_storage"
42
+ elif has_conveyance and has_subcatchments:
43
+ model_archetype = "urban_drainage_network"
44
+ elif has_subcatchments and not has_conveyance:
45
+ model_archetype = "runoff_only_model"
46
+ else:
47
+ model_archetype = "general_swmm_model"
48
+
49
+ return {
50
+ "model_archetype": model_archetype,
51
+ "has_subcatchments": has_subcatchments,
52
+ "has_conveyance": has_conveyance,
53
+ "has_storage": has_storage,
54
+ "has_outlet_structures": has_outlet_structures,
55
+ "has_water_quality": has_quality,
56
+ "has_lid": has_lid,
57
+ "has_snowmelt": has_snow,
58
+ "has_groundwater": has_groundwater,
59
+ "has_controls": has_controls,
60
+ "counts": counts,
61
+ }
62
+
63
+ def applicable_review_modules(self) -> list[str]:
64
+ capability = self.detect()
65
+ modules = ["project", "inventory", "topology", "scenario_paths"]
66
+
67
+ if capability["has_subcatchments"]:
68
+ modules.append("hydrology")
69
+ if capability["has_conveyance"]:
70
+ modules.append("hydraulics")
71
+ if capability["has_storage"]:
72
+ modules.append("storage")
73
+ if capability["has_outlet_structures"]:
74
+ modules.append("outlet_structures")
75
+ if capability["has_water_quality"]:
76
+ modules.append("water_quality")
77
+ if capability["has_lid"]:
78
+ modules.append("lid")
79
+ if capability["has_groundwater"]:
80
+ modules.append("groundwater")
81
+ if capability["has_snowmelt"]:
82
+ modules.append("snowmelt")
83
+ if capability["has_controls"]:
84
+ modules.append("controls")
85
+
86
+ return modules
pcswmm_ai/client.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .capabilities import ModelCapabilityService
6
+ from .collections import CollectionService
7
+ from .engineering import EngineeringReviewService
8
+ from .hydraulics import HydraulicResultReviewService
9
+ from .hydrology import HydrologyReviewService
10
+ from .layers import LayerService
11
+ from .objects import ObjectService
12
+ from .pond import PondReviewService
13
+ from .project import ProjectService
14
+ from .qaqc import QAQCService
15
+ from .result_queries import ResultQueryService
16
+ from .results import ResultService
17
+ from .review_engine import GenericReviewEngine
18
+ from .scenarios import ScenarioReviewService
19
+ from .simulation import SimulationService
20
+ from .storage import StorageReviewService
21
+ from .topology import TopologyService
22
+
23
+
24
+ class PCSWMMClient:
25
+ """Generic facade over PCSWMM's injected ``pcpy`` object."""
26
+
27
+ def __init__(self, pcpy: Any):
28
+ if pcpy is None:
29
+ raise ValueError("The injected PCSWMM pcpy object is required.")
30
+
31
+ self.pcpy = pcpy
32
+ self.project = ProjectService(pcpy)
33
+ self.collections = CollectionService(pcpy, self.project)
34
+ self.layers = LayerService(pcpy, self.project)
35
+ self.objects = ObjectService(self.collections)
36
+ self.simulation = SimulationService(pcpy, self.project)
37
+ self.results = ResultService(pcpy, self.project)
38
+ self.result_queries = ResultQueryService(self.results)
39
+ self.qaqc = QAQCService(self.layers)
40
+ self.topology = TopologyService(self.objects)
41
+ self.engineering = EngineeringReviewService(
42
+ self.layers,
43
+ self.objects,
44
+ self.topology,
45
+ )
46
+ self.hydrology = HydrologyReviewService(
47
+ self.layers,
48
+ self.objects,
49
+ )
50
+ self.storage = StorageReviewService(self.objects)
51
+ self.pond = PondReviewService(self.objects)
52
+ self.scenarios = ScenarioReviewService(self.collections)
53
+ self.hydraulics = HydraulicResultReviewService(
54
+ self.collections,
55
+ self.result_queries,
56
+ )
57
+ self.capabilities = ModelCapabilityService(self.collections)
58
+ self.review_engine = GenericReviewEngine(
59
+ self.project,
60
+ self.collections,
61
+ self.capabilities,
62
+ self.topology,
63
+ self.engineering,
64
+ self.hydrology,
65
+ self.storage,
66
+ self.scenarios,
67
+ )
68
+
69
+ # Backward-compatible alias.
70
+ self.review_report = self.review_engine
71
+
72
+ def health(self) -> dict[str, Any]:
73
+ return {
74
+ "pcpy_type": str(type(self.pcpy)),
75
+ "project_open": self.project.is_open,
76
+ "project": self.project.summary() if self.project.is_open else None,
77
+ "capabilities": (
78
+ self.capabilities.detect()
79
+ if self.project.is_open
80
+ else None
81
+ ),
82
+ }
pcswmm_ai/collections.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .project import ProjectService
6
+ from .utils import dictionary_items, safe_count, public_names
7
+
8
+
9
+ class CollectionService:
10
+ def __init__(self, pcpy: Any, project: ProjectService):
11
+ self._pcpy = pcpy
12
+ self._project = project
13
+
14
+ def get(self, name: str):
15
+ self._project.require_open()
16
+ swmm = self._project.swmm
17
+
18
+ if not hasattr(swmm, name):
19
+ raise KeyError(f"SWMM collection not found: {name}")
20
+
21
+ return getattr(swmm, name)
22
+
23
+ def names(self) -> list[str]:
24
+ candidates = [
25
+ "Scenarios", "Nodes", "Links", "Subcatchments", "Junctions",
26
+ "Outfalls", "Dividers", "Storages", "Conduits", "Pumps",
27
+ "Orifices", "Weirs", "Outlets", "Raingages", "Curves",
28
+ "TimeSeries", "Controls", "Patterns", "Pollutants",
29
+ ]
30
+
31
+ available = []
32
+ for name in candidates:
33
+ try:
34
+ self.get(name)
35
+ available.append(name)
36
+ except Exception:
37
+ pass
38
+ return available
39
+
40
+ def summary(self) -> list[dict[str, Any]]:
41
+ rows = []
42
+ for name in self.names():
43
+ collection = self.get(name)
44
+ rows.append({
45
+ "collection": name,
46
+ "count": safe_count(collection),
47
+ "type": str(type(collection)),
48
+ })
49
+ return rows
50
+
51
+ def keys(self, name: str) -> list[str]:
52
+ collection = self.get(name)
53
+
54
+ try:
55
+ return [str(key) for key in list(collection.Keys)]
56
+ except Exception:
57
+ return [str(key) for key, _ in dictionary_items(collection)]
58
+
59
+ def items(self, name: str) -> list[tuple[str, Any]]:
60
+ return [(str(key), value) for key, value in dictionary_items(self.get(name))]
61
+
62
+ def inspect_object(self, collection_name: str, object_name: str) -> dict[str, Any]:
63
+ collection = self.get(collection_name)
64
+
65
+ try:
66
+ obj = collection[object_name]
67
+ except Exception as exc:
68
+ raise KeyError(
69
+ f"{object_name!r} was not found in {collection_name}."
70
+ ) from exc
71
+
72
+ values = {}
73
+ for name in public_names(obj):
74
+ try:
75
+ value = getattr(obj, name)
76
+ if callable(value):
77
+ continue
78
+ values[name] = value
79
+ except Exception:
80
+ pass
81
+
82
+ return {
83
+ "collection": collection_name,
84
+ "name": object_name,
85
+ "type": str(type(obj)),
86
+ "values": values,
87
+ "public_members": public_names(obj),
88
+ }
pcswmm_ai/engineering.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class EngineeringReviewService:
2
+ def __init__(self,layers,objects,topology): self.layers,self.objects,self.topology=layers,objects,topology
3
+ @staticmethod
4
+ def num(r,names):
5
+ for n in names:
6
+ try:
7
+ if r.get(n) not in (None,""): return float(r[n])
8
+ except Exception: pass
9
+ return None
10
+ def conduit_screening(self):
11
+ out=[]
12
+ for r in self.objects.collection_records("Conduits"):
13
+ L=self.num(r,("Length","Len")); D=self.num(r,("Geom1","Diameter","Height")); S=self.num(r,("Slope","PipeSlope"))
14
+ issues=[]
15
+ if L is not None and L<=0: issues.append("non-positive length")
16
+ if D is not None and D<=0: issues.append("non-positive diameter/height")
17
+ if S is not None and S<0: issues.append("negative slope")
18
+ if issues: out.append({"Name":r.get("Name"),"issues":issues,"Length":L,"DiameterOrHeight":D,"Slope":S})
19
+ return out
20
+ def subcatchment_screening(self):
21
+ try: rows=self.layers.records("Subcatchments")
22
+ except Exception: rows=self.objects.collection_records("Subcatchments")
23
+ out=[]
24
+ for r in rows:
25
+ A=self.num(r,("Area","TotalArea")); I=self.num(r,("PctImperv","PercentImpervious","%Imperv")); W=self.num(r,("Width",)); S=self.num(r,("Slope","%Slope")); O=r.get("Outlet")
26
+ issues=[]
27
+ if A is not None and A<=0: issues.append("non-positive area")
28
+ if I is not None and not 0<=I<=100: issues.append("imperviousness outside 0–100%")
29
+ if W is not None and W<=0: issues.append("non-positive width")
30
+ if S is not None and S<0: issues.append("negative slope")
31
+ if O in (None,""): issues.append("missing outlet")
32
+ if issues: out.append({"Name":r.get("Name"),"Outlet":O,"Area":A,"PctImperv":I,"Width":W,"Slope":S,"issues":issues})
33
+ return out
34
+ def model_screening_summary(self):
35
+ return {"orphan_link_count":len(self.topology.orphan_links()),"isolated_node_count":len(self.topology.isolated_nodes()),
36
+ "duplicate_link_pair_count":len(self.topology.duplicate_link_pairs()),"conduit_finding_count":len(self.conduit_screening()),
37
+ "subcatchment_finding_count":len(self.subcatchment_screening())}
pcswmm_ai/exceptions.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class PCSWMMError(RuntimeError):
2
+ """Base exception for the SDK."""
3
+
4
+
5
+ class ProjectNotOpenError(PCSWMMError):
6
+ """Raised when a PCSWMM project is required but is not open."""
7
+
8
+
9
+ class LayerNotFoundError(PCSWMMError):
10
+ """Raised when a requested PCSWMM map layer is unavailable."""
11
+
12
+
13
+ class ResultsNotAvailableError(PCSWMMError):
14
+ """Raised when simulation output or graph files are unavailable."""
pcswmm_ai/hydraulics.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .collections import CollectionService
6
+ from .result_queries import ResultQueryService
7
+
8
+
9
+ class HydraulicResultReviewService:
10
+ """Batch time-series summaries for common SWMM hydraulic outputs."""
11
+
12
+ def __init__(
13
+ self,
14
+ collections: CollectionService,
15
+ result_queries: ResultQueryService,
16
+ ):
17
+ self._collections = collections
18
+ self._result_queries = result_queries
19
+
20
+ def peak_link_results(
21
+ self,
22
+ variable: str,
23
+ units: str,
24
+ object_names: list[str] | None = None,
25
+ ) -> list[dict[str, Any]]:
26
+ names = object_names or self._collections.keys("Links")
27
+ return self._result_queries.peak_for_objects(
28
+ "Links", variable, units, names
29
+ )
30
+
31
+ def peak_node_results(
32
+ self,
33
+ variable: str,
34
+ units: str,
35
+ object_names: list[str] | None = None,
36
+ ) -> list[dict[str, Any]]:
37
+ names = object_names or self._collections.keys("Nodes")
38
+ return self._result_queries.peak_for_objects(
39
+ "Nodes", variable, units, names
40
+ )
41
+
42
+ def peak_subcatchment_results(
43
+ self,
44
+ variable: str,
45
+ units: str,
46
+ object_names: list[str] | None = None,
47
+ ) -> list[dict[str, Any]]:
48
+ names = object_names or self._collections.keys("Subcatchments")
49
+ return self._result_queries.peak_for_objects(
50
+ "Subcatchments", variable, units, names
51
+ )
pcswmm_ai/hydrology.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .layers import LayerService
6
+ from .objects import ObjectService
7
+
8
+
9
+ class HydrologyReviewService:
10
+ """Deterministic screening of subcatchment hydrologic parameters."""
11
+
12
+ def __init__(self, layers: LayerService, objects: ObjectService):
13
+ self._layers = layers
14
+ self._objects = objects
15
+
16
+ @staticmethod
17
+ def _number(record: dict[str, Any], names: tuple[str, ...]):
18
+ for name in names:
19
+ value = record.get(name)
20
+ if value in (None, ""):
21
+ continue
22
+ try:
23
+ return float(value)
24
+ except Exception:
25
+ continue
26
+ return None
27
+
28
+ def records(self) -> list[dict[str, Any]]:
29
+ try:
30
+ return self._layers.records("Subcatchments")
31
+ except Exception:
32
+ return self._objects.collection_records("Subcatchments")
33
+
34
+ def review(
35
+ self,
36
+ width_ratio_min: float = 0.1,
37
+ width_ratio_max: float = 20.0,
38
+ ) -> list[dict[str, Any]]:
39
+ """Screen common SWMM subcatchment inputs.
40
+
41
+ Width-ratio limits are screening values only and are intentionally
42
+ configurable. They are not municipal criteria.
43
+ """
44
+ findings = []
45
+
46
+ for row in self.records():
47
+ name = row.get("Name")
48
+ area = self._number(row, ("Area", "TotalArea"))
49
+ width = self._number(row, ("Width",))
50
+ slope = self._number(row, ("Slope", "%Slope"))
51
+ pct_imp = self._number(
52
+ row,
53
+ ("PctImperv", "PercentImpervious", "%Imperv", "Imperv"),
54
+ )
55
+ curve_number = self._number(row, ("CurveNumber", "CN"))
56
+ n_imp = self._number(row, ("ImpervN", "NImperv"))
57
+ n_perv = self._number(row, ("PervN", "NPerv"))
58
+ outlet = row.get("Outlet")
59
+
60
+ issues = []
61
+
62
+ if area is not None and area <= 0:
63
+ issues.append("non-positive area")
64
+ if width is not None and width <= 0:
65
+ issues.append("non-positive width")
66
+ if slope is not None and slope < 0:
67
+ issues.append("negative slope")
68
+ if pct_imp is not None and not 0 <= pct_imp <= 100:
69
+ issues.append("imperviousness outside 0–100%")
70
+ if curve_number is not None and not 0 <= curve_number <= 100:
71
+ issues.append("curve number outside 0–100")
72
+ if n_imp is not None and n_imp <= 0:
73
+ issues.append("non-positive impervious Manning n")
74
+ if n_perv is not None and n_perv <= 0:
75
+ issues.append("non-positive pervious Manning n")
76
+ if outlet in (None, ""):
77
+ issues.append("missing outlet")
78
+
79
+ width_area_ratio = None
80
+ if area and width is not None:
81
+ width_area_ratio = width / area
82
+ if not width_ratio_min <= width_area_ratio <= width_ratio_max:
83
+ issues.append("width-to-area ratio outside screening range")
84
+
85
+ if issues:
86
+ findings.append({
87
+ "Name": name,
88
+ "Area": area,
89
+ "Width": width,
90
+ "WidthAreaRatio": width_area_ratio,
91
+ "Slope": slope,
92
+ "PctImperv": pct_imp,
93
+ "CurveNumber": curve_number,
94
+ "Outlet": outlet,
95
+ "issues": issues,
96
+ })
97
+
98
+ return findings
99
+
100
+ def summary(self) -> dict[str, Any]:
101
+ rows = self.records()
102
+ findings = self.review()
103
+
104
+ total_area = 0.0
105
+ weighted_impervious_area = 0.0
106
+ impervious_available = False
107
+
108
+ for row in rows:
109
+ area = self._number(row, ("Area", "TotalArea"))
110
+ pct_imp = self._number(
111
+ row,
112
+ ("PctImperv", "PercentImpervious", "%Imperv", "Imperv"),
113
+ )
114
+ if area is not None:
115
+ total_area += area
116
+ if area is not None and pct_imp is not None:
117
+ weighted_impervious_area += area * pct_imp / 100.0
118
+ impervious_available = True
119
+
120
+ weighted_pct_imp = None
121
+ if total_area > 0 and impervious_available:
122
+ weighted_pct_imp = 100.0 * weighted_impervious_area / total_area
123
+
124
+ return {
125
+ "subcatchment_count": len(rows),
126
+ "total_area_model_units": total_area,
127
+ "weighted_percent_impervious": weighted_pct_imp,
128
+ "finding_count": len(findings),
129
+ }
pcswmm_ai/layers.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .exceptions import LayerNotFoundError
6
+ from .project import ProjectService
7
+
8
+
9
+ DEFAULT_LAYER_NAMES = [
10
+ "Rain Gages", "Subcatchments", "Junctions", "Outfalls",
11
+ "Dividers", "Storage Units", "Conduits", "Pumps",
12
+ "Orifices", "Weirs", "Outlets",
13
+ ]
14
+
15
+
16
+ class LayerService:
17
+ def __init__(self, pcpy: Any, project: ProjectService):
18
+ self._pcpy = pcpy
19
+ self._project = project
20
+
21
+ def get(self, layer_name: str):
22
+ self._project.require_open()
23
+ try:
24
+ return self._pcpy.Map.Layer[layer_name]
25
+ except Exception as exc:
26
+ raise LayerNotFoundError(
27
+ f"PCSWMM map layer not found: {layer_name}"
28
+ ) from exc
29
+
30
+ def names(self) -> list[str]:
31
+ self._project.require_open()
32
+
33
+ for attribute_name in ("Names", "keys", "Keys"):
34
+ try:
35
+ value = getattr(self._pcpy.Map.Layer, attribute_name)
36
+ value = value() if callable(value) else value
37
+ names = [str(item) for item in list(value)]
38
+ if names:
39
+ return sorted(set(names))
40
+ except Exception:
41
+ pass
42
+
43
+ names = []
44
+ for layer_name in DEFAULT_LAYER_NAMES:
45
+ try:
46
+ self.get(layer_name)
47
+ names.append(layer_name)
48
+ except LayerNotFoundError:
49
+ pass
50
+ return names
51
+
52
+ def attributes(self, layer_name: str) -> list[str]:
53
+ layer = self.get(layer_name)
54
+ try:
55
+ return [str(name) for name in list(layer.Attributes)]
56
+ except Exception:
57
+ return []
58
+
59
+ def entities(self, layer_name: str, query: str | None = None) -> list[Any]:
60
+ layer = self.get(layer_name)
61
+ if query:
62
+ return list(layer.get_entities(query))
63
+ return list(layer.get_entities())
64
+
65
+ def records(self, layer_name: str, query: str | None = None) -> list[dict[str, Any]]:
66
+ attributes = self.attributes(layer_name)
67
+ records = []
68
+
69
+ for entity in self.entities(layer_name, query=query):
70
+ row = {}
71
+ for attribute in attributes:
72
+ try:
73
+ row[attribute] = entity[attribute]
74
+ except Exception:
75
+ row[attribute] = None
76
+ records.append(row)
77
+
78
+ return records
pcswmm_ai/objects.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .utils import public_names
2
+ class ObjectService:
3
+ def __init__(self, collections): self._collections=collections
4
+ @staticmethod
5
+ def to_record(obj):
6
+ r={}
7
+ for n in public_names(obj):
8
+ try: v=getattr(obj,n)
9
+ except Exception: continue
10
+ if callable(v): continue
11
+ try: r[n]=v if isinstance(v,(str,int,float,bool)) or v is None else str(v)
12
+ except Exception: r[n]=None
13
+ return r
14
+ def collection_records(self,name):
15
+ out=[]
16
+ for key,obj in self._collections.items(name):
17
+ row={"Name":key,"ObjectType":name}; row.update(self.to_record(obj)); out.append(row)
18
+ return out
pcswmm_ai/pond.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .storage import StorageReviewService
2
+
3
+
4
+ class PondReviewService(StorageReviewService):
5
+ """Backward-compatible alias for StorageReviewService."""
pcswmm_ai/project.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from .exceptions import ProjectNotOpenError
7
+ from .utils import safe_count, safe_text
8
+
9
+
10
+ class ProjectService:
11
+ def __init__(self, pcpy: Any):
12
+ self._pcpy = pcpy
13
+
14
+ @property
15
+ def is_open(self) -> bool:
16
+ try:
17
+ _ = self._pcpy.SWMM
18
+ return True
19
+ except Exception:
20
+ return False
21
+
22
+ def require_open(self):
23
+ if not self.is_open:
24
+ raise ProjectNotOpenError(
25
+ "No active PCSWMM/SWMM5 project is open."
26
+ )
27
+
28
+ @property
29
+ def swmm(self):
30
+ self.require_open()
31
+ return self._pcpy.SWMM
32
+
33
+ def summary(self) -> dict[str, Any]:
34
+ self.require_open()
35
+ swmm = self.swmm
36
+
37
+ collection_names = {
38
+ "scenario_count": "Scenarios",
39
+ "node_count": "Nodes",
40
+ "link_count": "Links",
41
+ "junction_count": "Junctions",
42
+ "conduit_count": "Conduits",
43
+ "subcatchment_count": "Subcatchments",
44
+ "storage_count": "Storages",
45
+ "outfall_count": "Outfalls",
46
+ }
47
+
48
+ result = {
49
+ "captured_at": datetime.now().isoformat(timespec="seconds"),
50
+ "project_open": True,
51
+ "project_name": safe_text(swmm.ProjectName),
52
+ "project_folder": safe_text(swmm.ProjectFolder),
53
+ "model_file": safe_text(swmm.FilePath),
54
+ "swmm_version": safe_text(swmm.Version),
55
+ "run_status": safe_text(swmm.RunStatus),
56
+ }
57
+
58
+ for output_name, attribute_name in collection_names.items():
59
+ try:
60
+ result[output_name] = safe_count(getattr(swmm, attribute_name))
61
+ except Exception:
62
+ result[output_name] = None
63
+
64
+ try:
65
+ result["graph_file_count"] = safe_count(self._pcpy.Graph.Files)
66
+ except Exception:
67
+ result["graph_file_count"] = 0
68
+
69
+ return result
70
+
71
+ def open_project(self, path: str):
72
+ self._pcpy.open_project(path)
73
+ return self.summary()
pcswmm_ai/qaqc.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .layers import LayerService
6
+
7
+
8
+ class QAQCService:
9
+ """Deterministic, read-only screening utilities."""
10
+
11
+ def __init__(self, layers: LayerService):
12
+ self._layers = layers
13
+
14
+ def duplicate_names(self, layer_name: str, name_field: str = "Name") -> list[dict[str, Any]]:
15
+ records = self._layers.records(layer_name)
16
+ groups: dict[str, list[int]] = {}
17
+
18
+ for index, record in enumerate(records):
19
+ value = record.get(name_field)
20
+ if value is None:
21
+ continue
22
+ groups.setdefault(str(value), []).append(index)
23
+
24
+ return [
25
+ {"name": name, "record_indices": indices, "count": len(indices)}
26
+ for name, indices in groups.items()
27
+ if len(indices) > 1
28
+ ]
29
+
30
+ def null_attributes(self, layer_name: str, attributes: list[str]) -> list[dict[str, Any]]:
31
+ findings = []
32
+ records = self._layers.records(layer_name)
33
+
34
+ for index, record in enumerate(records):
35
+ missing = [
36
+ attribute for attribute in attributes
37
+ if record.get(attribute) in (None, "")
38
+ ]
39
+ if missing:
40
+ findings.append({
41
+ "record_index": index,
42
+ "name": record.get("Name"),
43
+ "missing_attributes": missing,
44
+ })
45
+
46
+ return findings
47
+
48
+ def numeric_threshold(
49
+ self,
50
+ layer_name: str,
51
+ attribute: str,
52
+ operator: str,
53
+ threshold: float,
54
+ ) -> list[dict[str, Any]]:
55
+ operations = {
56
+ ">": lambda value: value > threshold,
57
+ ">=": lambda value: value >= threshold,
58
+ "<": lambda value: value < threshold,
59
+ "<=": lambda value: value <= threshold,
60
+ "==": lambda value: value == threshold,
61
+ "!=": lambda value: value != threshold,
62
+ }
63
+ if operator not in operations:
64
+ raise ValueError(f"Unsupported operator: {operator}")
65
+
66
+ findings = []
67
+ for record in self._layers.records(layer_name):
68
+ try:
69
+ value = float(record[attribute])
70
+ except Exception:
71
+ continue
72
+
73
+ if operations[operator](value):
74
+ findings.append(record)
75
+
76
+ return findings
pcswmm_ai/result_queries.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ class ResultQueryService:
2
+ def __init__(self,results): self.results=results
3
+ def peak_for_objects(self,group,variable,units,names):
4
+ out=[]
5
+ for name in names:
6
+ try:
7
+ rec=self.results.timeseries(group,variable,units,name)
8
+ out.append({"object":name,"variable":variable,"units":units,**self.results.summarize(rec)})
9
+ except Exception as e: out.append({"object":name,"variable":variable,"units":units,"error":str(e)})
10
+ return out
pcswmm_ai/results.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .exceptions import ResultsNotAvailableError
6
+ from .project import ProjectService
7
+ from .utils import safe_count
8
+
9
+
10
+ class ResultService:
11
+ def __init__(self, pcpy: Any, project: ProjectService):
12
+ self._pcpy = pcpy
13
+ self._project = project
14
+
15
+ def graph_files(self) -> list[Any]:
16
+ self._project.require_open()
17
+ try:
18
+ return list(self._pcpy.Graph.Files)
19
+ except Exception:
20
+ return []
21
+
22
+ def require_graph_file(self, index: int = 0):
23
+ files = self.graph_files()
24
+ if not files:
25
+ raise ResultsNotAvailableError(
26
+ "No PCSWMM graph/output file is available. Run the model first."
27
+ )
28
+ try:
29
+ return files[index]
30
+ except IndexError as exc:
31
+ raise ResultsNotAvailableError(
32
+ f"Graph file index {index} is unavailable."
33
+ ) from exc
34
+
35
+ def timeseries(
36
+ self,
37
+ object_group: str,
38
+ result_variable: str,
39
+ units: str,
40
+ object_name: str,
41
+ graph_file_index: int = 0,
42
+ ) -> list[dict[str, Any]]:
43
+ graph_file = self.require_graph_file(graph_file_index)
44
+
45
+ data = graph_file.get_data(
46
+ object_group,
47
+ result_variable,
48
+ units,
49
+ object_name,
50
+ )
51
+
52
+ records = []
53
+ for index, item in enumerate(data):
54
+ row = {
55
+ "index": index,
56
+ "value": getattr(item, "Value", None),
57
+ }
58
+
59
+ for candidate in ("DateTime", "Time", "Date", "X"):
60
+ try:
61
+ value = getattr(item, candidate)
62
+ if value is not None:
63
+ row["time"] = value
64
+ break
65
+ except Exception:
66
+ pass
67
+
68
+ records.append(row)
69
+
70
+ return records
71
+
72
+ @staticmethod
73
+ def summarize(records: list[dict[str, Any]]) -> dict[str, Any]:
74
+ numeric = []
75
+ for record in records:
76
+ try:
77
+ numeric.append((float(record["value"]), record))
78
+ except Exception:
79
+ pass
80
+
81
+ if not numeric:
82
+ return {"count": len(records)}
83
+
84
+ minimum = min(numeric, key=lambda pair: pair[0])
85
+ maximum = max(numeric, key=lambda pair: pair[0])
86
+ mean = sum(pair[0] for pair in numeric) / len(numeric)
87
+
88
+ return {
89
+ "count": len(numeric),
90
+ "minimum": minimum[0],
91
+ "maximum": maximum[0],
92
+ "mean": mean,
93
+ "peak_time": maximum[1].get("time"),
94
+ }
pcswmm_ai/review_engine.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+
7
+ class GenericReviewEngine:
8
+ """Run only review modules applicable to the active model."""
9
+
10
+ def __init__(
11
+ self,
12
+ project,
13
+ collections,
14
+ capabilities,
15
+ topology,
16
+ engineering,
17
+ hydrology,
18
+ storage,
19
+ scenarios,
20
+ ):
21
+ self._project = project
22
+ self._collections = collections
23
+ self._capabilities = capabilities
24
+ self._topology = topology
25
+ self._engineering = engineering
26
+ self._hydrology = hydrology
27
+ self._storage = storage
28
+ self._scenarios = scenarios
29
+
30
+ def run(self) -> dict[str, Any]:
31
+ capability = self._capabilities.detect()
32
+
33
+ report = {
34
+ "generated_at": datetime.now().isoformat(timespec="seconds"),
35
+ "classification": (
36
+ "Automated screening draft; professional engineering review required"
37
+ ),
38
+ "project": self._project.summary(),
39
+ "capabilities": capability,
40
+ "applicable_modules": self._capabilities.applicable_review_modules(),
41
+ "inventory": self._collections.summary(),
42
+ "topology": {
43
+ "link_endpoints": self._topology.link_endpoints(),
44
+ "orphan_links": self._topology.orphan_links(),
45
+ "isolated_nodes": self._topology.isolated_nodes(),
46
+ "duplicate_link_pairs": self._topology.duplicate_link_pairs(),
47
+ },
48
+ "general_engineering": {
49
+ "summary": self._engineering.model_screening_summary(),
50
+ "conduit_findings": self._engineering.conduit_screening(),
51
+ "subcatchment_findings": self._engineering.subcatchment_screening(),
52
+ },
53
+ "scenario_paths": {
54
+ "summary": self._scenarios.summary(),
55
+ "path_status": self._scenarios.path_status(),
56
+ },
57
+ }
58
+
59
+ if capability["has_subcatchments"]:
60
+ report["hydrology"] = {
61
+ "summary": self._hydrology.summary(),
62
+ "findings": self._hydrology.review(),
63
+ }
64
+
65
+ if capability["has_storage"] or capability["has_outlet_structures"]:
66
+ report["storage_and_outlets"] = {
67
+ "summary": self._storage.summary(),
68
+ "storage_records": self._storage.storage_records(),
69
+ "outlet_structure_records": (
70
+ self._storage.outlet_structure_records()
71
+ ),
72
+ "findings": self._storage.review(),
73
+ }
74
+
75
+ return report
pcswmm_ai/review_report.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .review_engine import GenericReviewEngine
2
+
3
+
4
+ class ReviewReportService(GenericReviewEngine):
5
+ """Backward-compatible alias for GenericReviewEngine."""
6
+
7
+ def build(self):
8
+ return self.run()
pcswmm_ai/scenarios.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .collections import CollectionService
7
+
8
+
9
+ class ScenarioReviewService:
10
+ def __init__(self, collections: CollectionService):
11
+ self._collections = collections
12
+
13
+ def paths(self) -> list[str]:
14
+ return self._collections.keys("Scenarios")
15
+
16
+ def path_status(self) -> list[dict[str, Any]]:
17
+ rows = []
18
+ for raw_path in self.paths():
19
+ path = Path(raw_path)
20
+ rows.append({
21
+ "scenario_path": raw_path,
22
+ "exists": path.exists(),
23
+ "suffix": path.suffix.lower(),
24
+ "file_name": path.name,
25
+ "parent": str(path.parent),
26
+ })
27
+ return rows
28
+
29
+ def summary(self) -> dict[str, Any]:
30
+ rows = self.path_status()
31
+ missing = [row for row in rows if not row["exists"]]
32
+ return {
33
+ "scenario_count": len(rows),
34
+ "existing_path_count": len(rows) - len(missing),
35
+ "missing_path_count": len(missing),
36
+ }
pcswmm_ai/simulation.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from .project import ProjectService
7
+ from .utils import safe_count, safe_text
8
+
9
+
10
+ class SimulationService:
11
+ def __init__(self, pcpy: Any, project: ProjectService):
12
+ self._pcpy = pcpy
13
+ self._project = project
14
+
15
+ def run(self) -> dict[str, Any]:
16
+ self._project.require_open()
17
+
18
+ started = datetime.now()
19
+ self._pcpy.SWMM.run()
20
+ finished = datetime.now()
21
+
22
+ try:
23
+ graph_file_count = safe_count(self._pcpy.Graph.Files)
24
+ except Exception:
25
+ graph_file_count = 0
26
+
27
+ return {
28
+ "status": "completed",
29
+ "started_at": started.isoformat(timespec="seconds"),
30
+ "finished_at": finished.isoformat(timespec="seconds"),
31
+ "elapsed_seconds": round((finished - started).total_seconds(), 3),
32
+ "run_status": safe_text(self._pcpy.SWMM.RunStatus),
33
+ "graph_file_count": graph_file_count,
34
+ }
35
+
36
+ def refresh_output(self):
37
+ self._project.require_open()
38
+ self._pcpy.SWMM.refresh_output()
pcswmm_ai/storage.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class StorageReviewService:
7
+ """Generic review of SWMM storage nodes and outlet structures."""
8
+
9
+ def __init__(self, objects):
10
+ self._objects = objects
11
+
12
+ def storage_records(self) -> list[dict[str, Any]]:
13
+ return self._objects.collection_records("Storages")
14
+
15
+ def outlet_structure_records(self) -> list[dict[str, Any]]:
16
+ rows = []
17
+ for collection_name in ("Orifices", "Weirs", "Outlets", "Pumps"):
18
+ for row in self._objects.collection_records(collection_name):
19
+ item = dict(row)
20
+ item["StructureCollection"] = collection_name
21
+ rows.append(item)
22
+ return rows
23
+
24
+ @staticmethod
25
+ def _number(record: dict[str, Any], names: tuple[str, ...]):
26
+ for name in names:
27
+ value = record.get(name)
28
+ if value in (None, ""):
29
+ continue
30
+ try:
31
+ return float(value)
32
+ except Exception:
33
+ pass
34
+ return None
35
+
36
+ def review(self) -> list[dict[str, Any]]:
37
+ findings = []
38
+
39
+ for row in self.storage_records():
40
+ issues = []
41
+ depth = self._number(row, ("Depth", "MaxDepth"))
42
+ surcharge = self._number(row, ("SurchargeDepth",))
43
+ curve = row.get("Curve")
44
+ curve_type = row.get("StorageCurveType")
45
+
46
+ if depth is not None and depth <= 0:
47
+ issues.append("non-positive storage depth")
48
+ if surcharge is not None and surcharge < 0:
49
+ issues.append("negative surcharge depth")
50
+ if (
51
+ curve_type
52
+ and str(curve_type).upper() == "TABULAR"
53
+ and curve in (None, "", "*")
54
+ ):
55
+ issues.append("tabular storage without identified curve")
56
+
57
+ if issues:
58
+ findings.append({
59
+ "Object": row.get("Name"),
60
+ "Category": "Storage",
61
+ "Depth": depth,
62
+ "SurchargeDepth": surcharge,
63
+ "Curve": curve,
64
+ "CurveType": curve_type,
65
+ "issues": issues,
66
+ })
67
+
68
+ for row in self.outlet_structure_records():
69
+ issues = []
70
+ inlet = row.get("InletNode")
71
+ outlet = row.get("OutletNode")
72
+ coeff = self._number(row, ("DischargeCoeff", "Coefficient"))
73
+ height = self._number(row, ("Height", "Geom1"))
74
+ width = self._number(row, ("Width", "Geom2"))
75
+
76
+ if inlet in (None, ""):
77
+ issues.append("missing inlet node")
78
+ if outlet in (None, ""):
79
+ issues.append("missing outlet node")
80
+ if coeff is not None and coeff <= 0:
81
+ issues.append("non-positive discharge coefficient")
82
+ if height is not None and height <= 0:
83
+ issues.append("non-positive opening height")
84
+ if width is not None and width < 0:
85
+ issues.append("negative opening width")
86
+
87
+ if issues:
88
+ findings.append({
89
+ "Object": row.get("Name"),
90
+ "Category": row.get("StructureCollection"),
91
+ "InletNode": inlet,
92
+ "OutletNode": outlet,
93
+ "DischargeCoeff": coeff,
94
+ "Height": height,
95
+ "Width": width,
96
+ "issues": issues,
97
+ })
98
+
99
+ return findings
100
+
101
+ def summary(self) -> dict[str, Any]:
102
+ storages = self.storage_records()
103
+ structures = self.outlet_structure_records()
104
+ findings = self.review()
105
+
106
+ return {
107
+ "storage_count": len(storages),
108
+ "outlet_structure_count": len(structures),
109
+ "finding_count": len(findings),
110
+ }
pcswmm_ai/topology.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class TopologyService:
2
+ FROM_FIELDS=("FromNode","FromNodeName","InletNode","Node1","UpstreamNode","USNode","From")
3
+ TO_FIELDS=("ToNode","ToNodeName","OutletNode","Node2","DownstreamNode","DSNode","To")
4
+ def __init__(self,objects): self._objects=objects
5
+ @staticmethod
6
+ def _first(r,names):
7
+ for n in names:
8
+ v=r.get(n)
9
+ if v not in (None,""): return str(v)
10
+ return None
11
+ def link_endpoints(self):
12
+ return [{"link":r.get("Name"),"from_node":self._first(r,self.FROM_FIELDS),"to_node":self._first(r,self.TO_FIELDS)}
13
+ for r in self._objects.collection_records("Links")]
14
+ def orphan_links(self):
15
+ nodes={r.get("Name") for r in self._objects.collection_records("Nodes")}
16
+ out=[]
17
+ for r in self.link_endpoints():
18
+ miss=[n for n in (r["from_node"],r["to_node"]) if n and n not in nodes]
19
+ if miss: out.append({**r,"missing_nodes":miss})
20
+ return out
21
+ def isolated_nodes(self):
22
+ nodes={r.get("Name") for r in self._objects.collection_records("Nodes")}
23
+ connected=set()
24
+ for r in self.link_endpoints(): connected.update(n for n in (r["from_node"],r["to_node"]) if n)
25
+ return [{"node":n} for n in sorted(nodes-connected)]
26
+ def duplicate_link_pairs(self):
27
+ g={}
28
+ for r in self.link_endpoints(): g.setdefault((r["from_node"],r["to_node"]),[]).append(r["link"])
29
+ return [{"from_node":k[0],"to_node":k[1],"links":v,"count":len(v)} for k,v in g.items() if k!=(None,None) and len(v)>1]
pcswmm_ai/utils.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Any
5
+
6
+
7
+ def safe_text(value: Any, default: str | None = None) -> str | None:
8
+ try:
9
+ if value is None:
10
+ return default
11
+ return str(value)
12
+ except Exception:
13
+ return default
14
+
15
+
16
+ def safe_count(collection: Any) -> int | None:
17
+ """Return a collection count for Python or .NET-style collections."""
18
+ try:
19
+ return len(collection)
20
+ except Exception:
21
+ pass
22
+
23
+ try:
24
+ return int(collection.Count)
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def as_list(collection: Any) -> list[Any]:
30
+ """Convert common Python.NET collections to a Python list."""
31
+ if collection is None:
32
+ return []
33
+
34
+ try:
35
+ return list(collection)
36
+ except Exception:
37
+ pass
38
+
39
+ try:
40
+ return list(collection.Values)
41
+ except Exception:
42
+ return []
43
+
44
+
45
+ def dictionary_items(collection: Any) -> list[tuple[Any, Any]]:
46
+ """Return key/value pairs from PCSWMM's PythonDictionaryExt."""
47
+ if collection is None:
48
+ return []
49
+
50
+ for method_name in ("items", "iteritems"):
51
+ try:
52
+ method = getattr(collection, method_name)
53
+ return list(method())
54
+ except Exception:
55
+ pass
56
+
57
+ try:
58
+ return [(key, collection[key]) for key in list(collection.Keys)]
59
+ except Exception:
60
+ return []
61
+
62
+
63
+ def public_names(obj: Any) -> list[str]:
64
+ return sorted(name for name in dir(obj) if not name.startswith("_"))