Spaces:
Configuration error
Configuration error
Upload 22 files
Browse files- pcswmm_ai/__init__.py +28 -0
- pcswmm_ai/capabilities.py +86 -0
- pcswmm_ai/client.py +82 -0
- pcswmm_ai/collections.py +88 -0
- pcswmm_ai/engineering.py +37 -0
- pcswmm_ai/exceptions.py +14 -0
- pcswmm_ai/hydraulics.py +51 -0
- pcswmm_ai/hydrology.py +129 -0
- pcswmm_ai/layers.py +78 -0
- pcswmm_ai/objects.py +18 -0
- pcswmm_ai/pond.py +5 -0
- pcswmm_ai/project.py +73 -0
- pcswmm_ai/qaqc.py +76 -0
- pcswmm_ai/result_queries.py +10 -0
- pcswmm_ai/results.py +94 -0
- pcswmm_ai/review_engine.py +75 -0
- pcswmm_ai/review_report.py +8 -0
- pcswmm_ai/scenarios.py +36 -0
- pcswmm_ai/simulation.py +38 -0
- pcswmm_ai/storage.py +110 -0
- pcswmm_ai/topology.py +29 -0
- pcswmm_ai/utils.py +64 -0
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("_"))
|