simstadt 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simstadt-0.1.0/PKG-INFO +53 -0
- simstadt-0.1.0/README.md +40 -0
- simstadt-0.1.0/pyproject.toml +31 -0
- simstadt-0.1.0/src/simstadt/__init__.py +32 -0
- simstadt-0.1.0/src/simstadt/results.py +612 -0
- simstadt-0.1.0/src/simstadt/runner.py +265 -0
- simstadt-0.1.0/src/simstadt/utils.py +66 -0
- simstadt-0.1.0/src/simstadt/workflows.py +115 -0
simstadt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: simstadt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python library for using and testing SimStadt workflows.
|
|
5
|
+
Author: Eric Duminil
|
|
6
|
+
Author-email: Eric Duminil <eric.duminil@hft-stuttgart.de>
|
|
7
|
+
Requires-Dist: matplotlib>=3.7
|
|
8
|
+
Requires-Dist: numpy>=1.24
|
|
9
|
+
Requires-Dist: pandas>=2.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.0
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# simstadt
|
|
15
|
+
|
|
16
|
+
A Python library for running and testing [SimStadt](https://simstadt.hft-stuttgart.de/) workflows programmatically.
|
|
17
|
+
|
|
18
|
+
SimStadt is a city simulation tool for energy and urban analysis developed at HFT Stuttgart. This library wraps its CLI to execute workflows against CityGML files and parse the results into pandas DataFrames.
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- Python 3.10+
|
|
23
|
+
- [SimStadt](https://simstadt.hft-stuttgart.de/download/InstallFiles/SimStadt2_latest.zip) installed separately
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install simstadt
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from simstadt import heatdemand_simulation, photovoltaic_simulation
|
|
35
|
+
|
|
36
|
+
results = heatdemand_simulation("path/to/city.gml", "Wuerzburg-hour.csv")
|
|
37
|
+
print(results.dataframe)
|
|
38
|
+
print(results.kpis)
|
|
39
|
+
|
|
40
|
+
pv = photovoltaic_simulation("path/to/city.gml", "Wuerzburg-hour.csv")
|
|
41
|
+
print(pv.dataframe)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
SimStadt is located automatically via the `SIMSTADT_FOLDER` environment variable, or by searching `~/Desktop` for a `SimStadt2_0.*/` directory.
|
|
45
|
+
|
|
46
|
+
Workflow templates are resolved via `SIMSTADT_TEMPLATE_PATH`, or a `Template/` directory in the current working directory.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv sync
|
|
52
|
+
uv run pytest
|
|
53
|
+
```
|
simstadt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# simstadt
|
|
2
|
+
|
|
3
|
+
A Python library for running and testing [SimStadt](https://simstadt.hft-stuttgart.de/) workflows programmatically.
|
|
4
|
+
|
|
5
|
+
SimStadt is a city simulation tool for energy and urban analysis developed at HFT Stuttgart. This library wraps its CLI to execute workflows against CityGML files and parse the results into pandas DataFrames.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.10+
|
|
10
|
+
- [SimStadt](https://simstadt.hft-stuttgart.de/download/InstallFiles/SimStadt2_latest.zip) installed separately
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install simstadt
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from simstadt import heatdemand_simulation, photovoltaic_simulation
|
|
22
|
+
|
|
23
|
+
results = heatdemand_simulation("path/to/city.gml", "Wuerzburg-hour.csv")
|
|
24
|
+
print(results.dataframe)
|
|
25
|
+
print(results.kpis)
|
|
26
|
+
|
|
27
|
+
pv = photovoltaic_simulation("path/to/city.gml", "Wuerzburg-hour.csv")
|
|
28
|
+
print(pv.dataframe)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
SimStadt is located automatically via the `SIMSTADT_FOLDER` environment variable, or by searching `~/Desktop` for a `SimStadt2_0.*/` directory.
|
|
32
|
+
|
|
33
|
+
Workflow templates are resolved via `SIMSTADT_TEMPLATE_PATH`, or a `Template/` directory in the current working directory.
|
|
34
|
+
|
|
35
|
+
## Development
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
uv run pytest
|
|
40
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simstadt"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python library for using and testing SimStadt workflows."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Eric Duminil", email = "eric.duminil@hft-stuttgart.de" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"matplotlib>=3.7",
|
|
12
|
+
"numpy>=1.24",
|
|
13
|
+
"pandas>=2.0",
|
|
14
|
+
"python-dotenv>=1.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
simstadt = "simstadt:main"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8.0"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
25
|
+
markers = [
|
|
26
|
+
"live: tests that require a SimStadt installation and a live project path",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.10.9,<0.11.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from .results import (
|
|
2
|
+
GreenWaterResults,
|
|
3
|
+
HeatDemandResults,
|
|
4
|
+
KPI,
|
|
5
|
+
LoadProfileResults,
|
|
6
|
+
PhotovoltaicResults,
|
|
7
|
+
SimStadtResults,
|
|
8
|
+
SolarPotentialResults,
|
|
9
|
+
create_simstadt_results,
|
|
10
|
+
)
|
|
11
|
+
from .runner import run_workflow_with_citygml
|
|
12
|
+
from .workflows import greenwater_simulation, heatdemand_simulation, photovoltaic_simulation
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"KPI",
|
|
16
|
+
"SimStadtResults",
|
|
17
|
+
"HeatDemandResults",
|
|
18
|
+
"LoadProfileResults",
|
|
19
|
+
"PhotovoltaicResults",
|
|
20
|
+
"GreenWaterResults",
|
|
21
|
+
"SolarPotentialResults",
|
|
22
|
+
"create_simstadt_results",
|
|
23
|
+
"run_workflow_with_citygml",
|
|
24
|
+
"photovoltaic_simulation",
|
|
25
|
+
"heatdemand_simulation",
|
|
26
|
+
"greenwater_simulation",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
print("simstadt - Python library for SimStadt workflows.")
|
|
32
|
+
print("See https://simstadt.hft-stuttgart.de/ for SimStadt documentation.")
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Parse SimStadt output files into typed result objects.
|
|
2
|
+
|
|
3
|
+
Supports HeatDemand, Photovoltaic, and GreenWater workflows.
|
|
4
|
+
Each result type exposes: .dataframe, .kpis, .diagrams, .csv_path,
|
|
5
|
+
.to_json() / .from_json(), and .zip_content().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import zipfile
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import ClassVar, Dict, Sequence, Type
|
|
18
|
+
from xml.etree import ElementTree as et
|
|
19
|
+
|
|
20
|
+
import matplotlib.pyplot as plt
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class KPI:
|
|
28
|
+
"""Metric with name, value, unit and display precision."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
value: float
|
|
32
|
+
unit: str | None = None
|
|
33
|
+
precision: int = 2
|
|
34
|
+
NAME_AND_UNIT = re.compile(r"^(.*?) \[(.*?)\]$")
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
"""Move units embedded in column names (e.g. 'Area [m²]') into the unit field."""
|
|
38
|
+
if match := self.NAME_AND_UNIT.match(self.name):
|
|
39
|
+
self.name, self.unit = match.groups()
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict:
|
|
42
|
+
return asdict(self)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def rounded_value(self) -> float:
|
|
46
|
+
return round(self.value, self.precision)
|
|
47
|
+
|
|
48
|
+
def __str__(self):
|
|
49
|
+
s = f"{self.name:35s} : {self.value:.{self.precision}f}"
|
|
50
|
+
if self.unit:
|
|
51
|
+
s = f"{s} {self.unit}"
|
|
52
|
+
return s
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return str(self)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, data: dict) -> "KPI":
|
|
59
|
+
return cls(**data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Providers(str, Enum):
|
|
63
|
+
HEAT_DEMAND = "de.hftstuttgart.simstadtworkflows.energy.HeatDemandAnalysisWorkflowProvider"
|
|
64
|
+
HEAT_DEMAND_WITH_REFURBISHMENT = (
|
|
65
|
+
"de.hftstuttgart.simstadtworkflows.energy.HeatDemandAnalysisWithRefurbishmentStrategyWorkflowProvider"
|
|
66
|
+
)
|
|
67
|
+
HEAT_DEMAND_WITH_HISTORIC_REFURBISHMENT = (
|
|
68
|
+
"de.hftstuttgart.simstadtworkflows.energy.HeatDemandAnalysisWithHistoricAndFutureRefurbishmentWorkflowProvider"
|
|
69
|
+
)
|
|
70
|
+
HEAT_DEMAND_WITH_SHADOW = (
|
|
71
|
+
"de.hftstuttgart.simstadtworkflows.shadow.HeatDemandCalculationWithShadowProcessingProvider"
|
|
72
|
+
)
|
|
73
|
+
PHOTOVOLTAIC = "de.hftstuttgart.simstadtworkflows.energy.PhotovoltaicPotentialAnalysisWorkflowProvider"
|
|
74
|
+
PHOTOVOLTAIC_WITH_SHADOW = "de.hftstuttgart.simstadtworkflows.shadow.PVPotentialWithShadowProcessingProvider"
|
|
75
|
+
PHOTOVOLTAIC_FINANCE = (
|
|
76
|
+
"de.hftstuttgart.simstadtworkflows.economics.PhotovoltaicPotentialFinancialAnalysisWorkflowProvider"
|
|
77
|
+
)
|
|
78
|
+
GREEN_WATER = "de.hftstuttgart.simstadtworkflows.greenwater.GreenWaterWorkflowProvider"
|
|
79
|
+
LOAD_PROFILE = "de.hftstuttgart.simstadtworkflows.energy.LoadProfileProvider"
|
|
80
|
+
SOLAR_POTENTIAL = "de.hftstuttgart.simstadtworkflows.energy.SolarPotentialAnalysisWorkflowProvider"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def detect_decimal(csv_path: Path, line_number: int, check: str = "") -> str:
|
|
84
|
+
"""Detect whether '.' or ',' is used as decimal separator on the given line."""
|
|
85
|
+
with open(csv_path, encoding="utf-8") as csv:
|
|
86
|
+
for _ in range(line_number):
|
|
87
|
+
next(csv)
|
|
88
|
+
line = next(csv)
|
|
89
|
+
if check and (check not in line):
|
|
90
|
+
raise ValueError(f"Couldn't find '{check}' in '{line}'.")
|
|
91
|
+
possible_delimiters = [".", ","]
|
|
92
|
+
delimiter = next(d for d in possible_delimiters if d in line)
|
|
93
|
+
logger.debug("Decimal for %s is '%s'", csv_path.name, delimiter)
|
|
94
|
+
return delimiter
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_workflow_provider(output_files: list[Path]) -> str:
|
|
98
|
+
"""Determine the SimStadt workflow provider class name from a params.xml file."""
|
|
99
|
+
if len(output_files) == 0:
|
|
100
|
+
raise ValueError("No output files were provided!")
|
|
101
|
+
first_output_file = output_files[0]
|
|
102
|
+
workflow_path = next(d for d in first_output_file.parents if d.suffix == ".flow")
|
|
103
|
+
params_xml = workflow_path / "params.xml"
|
|
104
|
+
|
|
105
|
+
tree = et.parse(params_xml)
|
|
106
|
+
root = tree.getroot()
|
|
107
|
+
element = root.find(".//void[@property='workflowProvider']/object")
|
|
108
|
+
assert element is not None, "workflowProvider element not found"
|
|
109
|
+
return element.get("class", "UnknownWorkflowProvider")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass
|
|
113
|
+
class SimStadtResults(ABC):
|
|
114
|
+
"""Abstract base class for SimStadt workflow results.
|
|
115
|
+
|
|
116
|
+
Subclasses register themselves via __init_subclass__ so that
|
|
117
|
+
create_simstadt_results() can instantiate the correct type automatically.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
description: str
|
|
121
|
+
output_files: list[Path]
|
|
122
|
+
|
|
123
|
+
supported_providers: ClassVar[list[str]]
|
|
124
|
+
csv_identifier: ClassVar[str]
|
|
125
|
+
_registry: ClassVar[Dict[str, Type["SimStadtResults"]]] = {}
|
|
126
|
+
|
|
127
|
+
_df: pd.DataFrame | None = field(default=None, init=False, repr=False)
|
|
128
|
+
|
|
129
|
+
def __init_subclass__(cls, **kwargs):
|
|
130
|
+
super().__init_subclass__(**kwargs)
|
|
131
|
+
for provider in cls.supported_providers:
|
|
132
|
+
SimStadtResults._registry[provider] = cls
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def dataframe(self) -> pd.DataFrame:
|
|
140
|
+
if self._df is None:
|
|
141
|
+
self._df = self._parse_results()
|
|
142
|
+
return self._df
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def more_info(self) -> dict[str, list[KPI]]:
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def kpis(self) -> list[KPI]:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def kpi(self, name: str) -> KPI:
|
|
154
|
+
for kpi in self.kpis:
|
|
155
|
+
if kpi.name == name:
|
|
156
|
+
return kpi
|
|
157
|
+
raise ValueError(f"No '{name}' KPI found for {self}")
|
|
158
|
+
|
|
159
|
+
def _prepare_kpis(self, sums: Sequence, averages: Sequence) -> list[KPI]:
|
|
160
|
+
kpis = []
|
|
161
|
+
default_precision = 0
|
|
162
|
+
for item in sums:
|
|
163
|
+
name, unit, precision = (item + (default_precision,))[:3]
|
|
164
|
+
kpis.append(KPI(name, self.dataframe[name].sum(), unit, precision))
|
|
165
|
+
for item in averages:
|
|
166
|
+
name, unit, precision = (item + (default_precision,))[:3]
|
|
167
|
+
kpis.append(KPI(name, float(self.dataframe[name].mean()), unit, precision))
|
|
168
|
+
return kpis
|
|
169
|
+
|
|
170
|
+
def _params_xml(self) -> Path:
|
|
171
|
+
return self.workflow_path / "params.xml"
|
|
172
|
+
|
|
173
|
+
def _params(self) -> et.Element:
|
|
174
|
+
tree = et.parse(self._params_xml())
|
|
175
|
+
return tree.getroot()
|
|
176
|
+
|
|
177
|
+
def _params_element(self, xpath: str) -> et.Element:
|
|
178
|
+
element = self._params().find(xpath)
|
|
179
|
+
if element is None:
|
|
180
|
+
raise ValueError(f"'{xpath}' not found in XML")
|
|
181
|
+
return element
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def name(self) -> str:
|
|
185
|
+
return self._params_element(".//void[@property='name']/string").text or "Unknown"
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def short_name(self) -> str:
|
|
189
|
+
return self._params_element(".//void[@property='shortName']/string").text or "Unknown"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def workflow_provider(self) -> str:
|
|
193
|
+
return self._params_element(".//void[@property='workflowProvider']/object").get("class", "Unknown provider")
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def workflow_path(self) -> Path:
|
|
197
|
+
first_output_file = self.output_files[0]
|
|
198
|
+
return next(d for d in first_output_file.parents if d.suffix == ".flow")
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def citygml(self) -> str:
|
|
202
|
+
return self._params_element(".//void[@property='cityGmlFileNames']//string").text or "Unknown CityGML"
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def project_path(self) -> Path:
|
|
206
|
+
return self.workflow_path.parent
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def project(self) -> str:
|
|
210
|
+
return self.project_path.stem
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def repository(self) -> Path:
|
|
214
|
+
return self.project_path.parent
|
|
215
|
+
|
|
216
|
+
def relative_paths(self) -> list[str]:
|
|
217
|
+
return [f.relative_to(self.workflow_path).as_posix() for f in self.output_files]
|
|
218
|
+
|
|
219
|
+
def __str__(self) -> str:
|
|
220
|
+
return f"{self.description} ({self.class_name})"
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
files_description = "\n".join(f" - {f}" for f in self.relative_paths())
|
|
224
|
+
kpis = "\n".join(f" {k}" for k in self.kpis)
|
|
225
|
+
return (
|
|
226
|
+
f"{self.description} ({self.class_name}, {self.project}, "
|
|
227
|
+
f"{self.workflow_path.name}, {self.citygml})\n{files_description}\n{kpis}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def class_name(self) -> str:
|
|
232
|
+
return type(self).__name__
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def csv_path(self) -> Path:
|
|
236
|
+
csvs = self.get_all_by_extension(".csv")
|
|
237
|
+
relevant_csvs = [csv for csv in csvs if self.csv_identifier in csv.name]
|
|
238
|
+
if len(relevant_csvs) == 0:
|
|
239
|
+
raise ValueError("Workflow didn't seem to have returned any CSV file!")
|
|
240
|
+
if len(relevant_csvs) > 1:
|
|
241
|
+
logger.warning("Too many CSV results found for %s", self)
|
|
242
|
+
return relevant_csvs[-1]
|
|
243
|
+
|
|
244
|
+
def csv_content(self) -> bytes:
|
|
245
|
+
with open(self.csv_path, "rb") as f:
|
|
246
|
+
return f.read()
|
|
247
|
+
|
|
248
|
+
def get_all_by_extension(self, ext: str) -> list[Path]:
|
|
249
|
+
return [f for f in sorted(self.output_files) if f.suffix == ext]
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def diagrams(self) -> dict[str, Path]:
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
def log_content(self) -> bytes:
|
|
256
|
+
logs = self.get_all_by_extension(".log")
|
|
257
|
+
if len(logs) == 0:
|
|
258
|
+
return b""
|
|
259
|
+
with open(logs[-1], "rb") as f:
|
|
260
|
+
return f.read()
|
|
261
|
+
|
|
262
|
+
def zip_content(self) -> bytes:
|
|
263
|
+
zip_buffer = io.BytesIO()
|
|
264
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED, False) as zf:
|
|
265
|
+
for file_path in self.output_files:
|
|
266
|
+
zf.write(file_path, arcname=file_path.name)
|
|
267
|
+
zip_buffer.seek(0)
|
|
268
|
+
return zip_buffer.read()
|
|
269
|
+
|
|
270
|
+
def to_dict(self) -> dict:
|
|
271
|
+
data = {
|
|
272
|
+
"description": self.description,
|
|
273
|
+
"output_files": [str(path) for path in self.output_files],
|
|
274
|
+
"_type": self.class_name,
|
|
275
|
+
"_df_dict": self.dataframe.to_dict("records"),
|
|
276
|
+
}
|
|
277
|
+
if self.dataframe.attrs:
|
|
278
|
+
data["_df_attrs"] = self.dataframe.attrs
|
|
279
|
+
return data
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def from_dict(cls, data: dict) -> "SimStadtResults":
|
|
283
|
+
data.pop("_type", None)
|
|
284
|
+
df_dict = data.pop("_df_dict", None)
|
|
285
|
+
df_attrs = data.pop("_df_attrs", {})
|
|
286
|
+
data["output_files"] = [Path(p) for p in data["output_files"]]
|
|
287
|
+
|
|
288
|
+
if cls is SimStadtResults:
|
|
289
|
+
provider = get_workflow_provider(data["output_files"])
|
|
290
|
+
result = cls.create(provider, **data)
|
|
291
|
+
else:
|
|
292
|
+
result = cls(**data)
|
|
293
|
+
|
|
294
|
+
result._df = pd.DataFrame(df_dict)
|
|
295
|
+
result._df.attrs = df_attrs
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
def to_json(self) -> str:
|
|
299
|
+
return json.dumps(self.to_dict())
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def from_json(cls, json_str: str) -> "SimStadtResults":
|
|
303
|
+
return cls.from_dict(json.loads(json_str))
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def create(cls, provider: str, **kwargs) -> "SimStadtResults":
|
|
307
|
+
if provider not in cls._registry:
|
|
308
|
+
raise ValueError(f"Unknown workflow provider: {provider}")
|
|
309
|
+
return cls._registry[provider](**kwargs)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(repr=False)
|
|
313
|
+
class HeatDemandResults(SimStadtResults):
|
|
314
|
+
"""Results for Heat Demand (and Heat+Cool Demand) workflows."""
|
|
315
|
+
|
|
316
|
+
MAX_FLAT_ROOF_DIFFERENCE = 0.1 # [m]
|
|
317
|
+
TOTAL_DEMAND = "Total Yearly Heating + DHW demand"
|
|
318
|
+
|
|
319
|
+
supported_providers: ClassVar[list[str]] = [
|
|
320
|
+
Providers.HEAT_DEMAND,
|
|
321
|
+
Providers.HEAT_DEMAND_WITH_SHADOW,
|
|
322
|
+
Providers.HEAT_DEMAND_WITH_REFURBISHMENT,
|
|
323
|
+
Providers.HEAT_DEMAND_WITH_HISTORIC_REFURBISHMENT,
|
|
324
|
+
]
|
|
325
|
+
csv_identifier = "DIN18599"
|
|
326
|
+
|
|
327
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
328
|
+
csv_decimal = detect_decimal(self.csv_path, 5, "Latitude")
|
|
329
|
+
df = pd.read_csv(self.csv_path, skiprows=list(range(19)) + [20], sep=";", decimal=csv_decimal)
|
|
330
|
+
df["has_flat_roof"] = (
|
|
331
|
+
df["Ridge/mean Height"] - df["Eaves/mean Height"] < self.MAX_FLAT_ROOF_DIFFERENCE
|
|
332
|
+
).fillna(False)
|
|
333
|
+
df.attrs["Heating"] = "Yearly Heating demand" in df.columns
|
|
334
|
+
df.attrs["Cooling"] = "Yearly Cooling demand" in df.columns
|
|
335
|
+
return df
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def kpis(self) -> list[KPI]:
|
|
339
|
+
df = self.dataframe
|
|
340
|
+
custom_kpis = [KPI("Number of buildings", df.shape[0], precision=0)]
|
|
341
|
+
sums = [("Heated area", "m²"), ("Footprint area", "m²")]
|
|
342
|
+
|
|
343
|
+
if df.attrs["Heating"]:
|
|
344
|
+
specific_heat_demand = df[self.TOTAL_DEMAND].sum() / df["Heated area"].sum()
|
|
345
|
+
heated_buildings = int((df[self.TOTAL_DEMAND] > 10_000).sum())
|
|
346
|
+
custom_kpis.extend(
|
|
347
|
+
[
|
|
348
|
+
KPI("Number of heated buildings", heated_buildings, precision=0),
|
|
349
|
+
KPI("Specific Heating Demand", specific_heat_demand, "kWh / (m² · a)", precision=0),
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
sums.extend(
|
|
353
|
+
[
|
|
354
|
+
("Yearly Heating demand", "kWh / a"),
|
|
355
|
+
(self.TOTAL_DEMAND, "kWh / a"),
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if df.attrs["Cooling"]:
|
|
360
|
+
specific_cooling_demand = df["Yearly Cooling demand"].sum() / df["Heated area"].sum()
|
|
361
|
+
custom_kpis.append(KPI("Specific Cooling Demand", specific_cooling_demand, "kWh / (m² · a)", precision=0))
|
|
362
|
+
sums.append(("Yearly Cooling demand", "kWh / a"))
|
|
363
|
+
|
|
364
|
+
averages = [("Mean Uvalue", "W / (m² · K)", 1), ("Year of construction", None), ("Storey number", None)]
|
|
365
|
+
return custom_kpis + self._prepare_kpis(sums, averages)
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def diagrams(self) -> dict[str, Path]:
|
|
369
|
+
heat_png = self.workflow_path / "heating.png"
|
|
370
|
+
ax = self.monthly_df().plot.bar(
|
|
371
|
+
rot=0,
|
|
372
|
+
ylabel="[MWh]",
|
|
373
|
+
width=0.8,
|
|
374
|
+
color={"Monthly Heating Demand": "darkred", "Monthly Cooling Demand": "darkblue"},
|
|
375
|
+
)
|
|
376
|
+
plt.savefig(heat_png, bbox_inches="tight", dpi=300)
|
|
377
|
+
plt.close(ax.figure)
|
|
378
|
+
return {"Monthly demands": heat_png}
|
|
379
|
+
|
|
380
|
+
def monthly_df(self) -> pd.DataFrame:
|
|
381
|
+
months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
382
|
+
df = self.dataframe
|
|
383
|
+
monthly_df = pd.DataFrame([], index=pd.Index(months))
|
|
384
|
+
for mode in ["Heating", "Cooling"]:
|
|
385
|
+
search_str = f"{mode} demand"
|
|
386
|
+
found_cols = [
|
|
387
|
+
col for col in df.columns if search_str in col and "Yearly" not in col and "Specific" not in col
|
|
388
|
+
]
|
|
389
|
+
if found_cols:
|
|
390
|
+
monthly_df[f"Monthly {mode} Demand"] = (df[found_cols].sum() / 1000).values
|
|
391
|
+
return monthly_df
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass(repr=False)
|
|
395
|
+
class PhotovoltaicResults(SimStadtResults):
|
|
396
|
+
"""Results for Photovoltaic (PV) workflows."""
|
|
397
|
+
|
|
398
|
+
supported_providers: ClassVar[list[str]] = [
|
|
399
|
+
Providers.PHOTOVOLTAIC,
|
|
400
|
+
Providers.PHOTOVOLTAIC_FINANCE,
|
|
401
|
+
Providers.PHOTOVOLTAIC_WITH_SHADOW,
|
|
402
|
+
]
|
|
403
|
+
csv_identifier = "_pv_potential"
|
|
404
|
+
|
|
405
|
+
def _count_header_lines(self) -> int:
|
|
406
|
+
count = 0
|
|
407
|
+
with open(self.csv_path) as csv:
|
|
408
|
+
for line in csv:
|
|
409
|
+
if line.startswith("Building ID"):
|
|
410
|
+
return count
|
|
411
|
+
count += 1
|
|
412
|
+
raise ValueError(f"Header not found in {self.csv_path}")
|
|
413
|
+
|
|
414
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
415
|
+
csv_decimal = detect_decimal(self.csv_path, 4, "Latitude")
|
|
416
|
+
header_length = self._count_header_lines()
|
|
417
|
+
df = pd.read_csv(
|
|
418
|
+
self.csv_path,
|
|
419
|
+
skiprows=list(range(header_length)) + [header_length + 1],
|
|
420
|
+
sep=";",
|
|
421
|
+
decimal=csv_decimal,
|
|
422
|
+
)
|
|
423
|
+
df = df.rename(columns={"Area": "Roof area for PV"})
|
|
424
|
+
return df.dropna(axis=1, how="all")
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def kpis(self) -> list[KPI]:
|
|
428
|
+
sums = [("Roof area for PV", "m²"), ("PV potential nominal power", "kWp"), ("PV potential yield", "MWh / a")]
|
|
429
|
+
averages = [("Irradiance in module plane", "W / m²"), ("PV specific yield", "kWh / (kWp · a)")]
|
|
430
|
+
return self._prepare_kpis(sums, averages)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@dataclass(repr=False)
|
|
434
|
+
class LoadProfileResults(SimStadtResults):
|
|
435
|
+
"""Results for LoadProfile workflows.
|
|
436
|
+
|
|
437
|
+
The dataframe has one column per building (building GML ID as column name)
|
|
438
|
+
and 8760 rows of hourly energy demand [kWh/h].
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
supported_providers: ClassVar[list[str]] = [Providers.LOAD_PROFILE]
|
|
442
|
+
csv_identifier = "_load_profile_Hourly"
|
|
443
|
+
|
|
444
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
445
|
+
csv_decimal = detect_decimal(self.csv_path, 6, "Area")
|
|
446
|
+
df = pd.read_csv(self.csv_path, skiprows=range(1, 12), sep=";", decimal=csv_decimal)
|
|
447
|
+
# First column is the timestamp; remaining columns are per-building loads
|
|
448
|
+
return df.drop(columns=[df.columns[0]])
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def kpis(self) -> list[KPI]:
|
|
452
|
+
# TODO: Add sum, average. Add timestep too?
|
|
453
|
+
# TODO: Add people? Add total heated area?
|
|
454
|
+
df = self.dataframe
|
|
455
|
+
total = df.sum().sum()
|
|
456
|
+
return [
|
|
457
|
+
KPI("Number of buildings", df.shape[1], precision=0),
|
|
458
|
+
KPI("Total load", total, "kWh/a", precision=1),
|
|
459
|
+
KPI("Average load", total / 8760, "kWh/h", precision=1),
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@dataclass(repr=False)
|
|
464
|
+
class SolarPotentialResults(SimStadtResults):
|
|
465
|
+
"""Results for SolarPotential workflows.
|
|
466
|
+
|
|
467
|
+
The output is a .prn weather file with 8760 hourly rows and three columns:
|
|
468
|
+
GHI (W/m²), DHI (W/m²), Ta (°C).
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
supported_providers: ClassVar[list[str]] = [Providers.SOLAR_POTENTIAL]
|
|
472
|
+
csv_identifier = "_solar_potential" # no CSV output; csv_path will raise if called
|
|
473
|
+
|
|
474
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
475
|
+
prn_paths = self.get_all_by_extension(".prn")
|
|
476
|
+
if len(prn_paths) != 1:
|
|
477
|
+
raise ValueError(f"Expected exactly one .prn file, found {len(prn_paths)}")
|
|
478
|
+
return pd.read_csv(prn_paths[0], sep=r"\s+", names=["GHI", "DHI", "Ta"], header=None)
|
|
479
|
+
|
|
480
|
+
@property
|
|
481
|
+
def kpis(self) -> list[KPI]:
|
|
482
|
+
df = self.dataframe
|
|
483
|
+
return [
|
|
484
|
+
#TODO: Add averages too?
|
|
485
|
+
KPI("Annual GHI", df["GHI"].sum() / 1000, "kWh/m²", precision=1),
|
|
486
|
+
KPI("Annual DHI", df["DHI"].sum() / 1000, "kWh/m²", precision=1),
|
|
487
|
+
KPI("Mean temperature", float(df["Ta"].mean()), "°C", precision=1),
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@dataclass(repr=False)
|
|
492
|
+
class GreenWaterResults(SimStadtResults):
|
|
493
|
+
"""Results for GreenWater workflows (hourly time series with tree species breakdown)."""
|
|
494
|
+
|
|
495
|
+
csv_identifier = "_greenwater"
|
|
496
|
+
supported_providers: ClassVar[list[str]] = [Providers.GREEN_WATER]
|
|
497
|
+
|
|
498
|
+
def _parse_results(self) -> pd.DataFrame:
|
|
499
|
+
csv_decimal = detect_decimal(self.csv_path, 5, "tree area")
|
|
500
|
+
df = pd.read_csv(
|
|
501
|
+
self.csv_path,
|
|
502
|
+
sep=";",
|
|
503
|
+
header=[0, 1],
|
|
504
|
+
skiprows=list(range(8)) + list(range(10, 13)),
|
|
505
|
+
decimal=csv_decimal,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Flatten multi-level columns: species names are forward-filled across sub-columns
|
|
509
|
+
cols = df.columns.tolist()
|
|
510
|
+
new_cols = []
|
|
511
|
+
current_species = "All"
|
|
512
|
+
species = []
|
|
513
|
+
|
|
514
|
+
for col in cols:
|
|
515
|
+
level0, level1 = col
|
|
516
|
+
if pd.notna(level0) and not level0.startswith("Unnamed") and not level0.startswith("#"):
|
|
517
|
+
current_species = level0
|
|
518
|
+
species.append(current_species)
|
|
519
|
+
new_cols.append(level1 if current_species == "All" else f"{current_species} - {level1}")
|
|
520
|
+
|
|
521
|
+
df.columns = new_cols
|
|
522
|
+
|
|
523
|
+
# Parse header metadata (tree counts, areas per species)
|
|
524
|
+
with open(self.csv_path, encoding="utf-8") as csv:
|
|
525
|
+
line = ""
|
|
526
|
+
for _ in range(5):
|
|
527
|
+
line = next(csv)
|
|
528
|
+
tree_count = int(line.split(";")[1])
|
|
529
|
+
line = next(csv)
|
|
530
|
+
tree_area_header = re.split("[;,]", line.strip())
|
|
531
|
+
total_tree_area = float(tree_area_header[1].replace(",", "."))
|
|
532
|
+
if tree_count == 0:
|
|
533
|
+
raise ValueError(f"CityGML {self.citygml} does not have any tree!")
|
|
534
|
+
for _ in range(5):
|
|
535
|
+
line = next(csv)
|
|
536
|
+
species_count = [int(c) for c in re.split(";+", line.strip())[1:-1]]
|
|
537
|
+
line = next(csv)
|
|
538
|
+
species_area = [float(c.replace(",", ".")) for c in re.split(";+", line.strip())[1:-1]]
|
|
539
|
+
|
|
540
|
+
species.pop(0)
|
|
541
|
+
df.attrs = {
|
|
542
|
+
"species": {
|
|
543
|
+
name: {"count": count, "area": area}
|
|
544
|
+
for name, count, area in zip(species, species_count, species_area)
|
|
545
|
+
},
|
|
546
|
+
"tree count": tree_count,
|
|
547
|
+
"tree area": total_tree_area,
|
|
548
|
+
}
|
|
549
|
+
return df
|
|
550
|
+
|
|
551
|
+
def df_with_time(self) -> pd.DataFrame:
|
|
552
|
+
"""Return the dataframe with a DatetimeIndex (year 2005, hourly)."""
|
|
553
|
+
df = self.dataframe
|
|
554
|
+
times = pd.date_range(start="2005-01-01 00:00", freq="1h", periods=8760)
|
|
555
|
+
df = df.set_index(times)
|
|
556
|
+
return df.drop(columns=["#"])
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def kpis(self) -> list[KPI]:
|
|
560
|
+
df = self.dataframe
|
|
561
|
+
rain = df["Rain [mm]"].sum()
|
|
562
|
+
etc = df["Average ETc [mm]"].sum()
|
|
563
|
+
cooling = df["Evaporative cooling [kWh/h]"].sum() / 1000
|
|
564
|
+
irrigation_mm = df["Average irrigation [mm]"].sum()
|
|
565
|
+
tree_area = df.attrs["tree area"]
|
|
566
|
+
irrigation_m3 = irrigation_mm * tree_area / 1000
|
|
567
|
+
return [
|
|
568
|
+
KPI("Number of trees", df.attrs["tree count"], precision=0),
|
|
569
|
+
KPI("Projected tree area", tree_area, "m²", precision=1),
|
|
570
|
+
KPI("Rain", rain, "mm / a", precision=0),
|
|
571
|
+
KPI("Average ETc", etc, "mm / a", precision=0),
|
|
572
|
+
KPI("Irrigation", irrigation_m3, "m³ / a", precision=0),
|
|
573
|
+
KPI("Evaporative cooling", cooling, "MWh / a", precision=0),
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
@property
|
|
577
|
+
def more_info(self) -> dict[str, list[KPI]]:
|
|
578
|
+
df = self.dataframe
|
|
579
|
+
all_info = {}
|
|
580
|
+
for name, info in df.attrs["species"].items():
|
|
581
|
+
count = info["count"]
|
|
582
|
+
area = info["area"]
|
|
583
|
+
irrigation_mm = df[f"{name} - Average irrigation [mm]"].sum()
|
|
584
|
+
average_area = area / count
|
|
585
|
+
all_info[f"_{name}_"] = [
|
|
586
|
+
KPI("Number of trees", count, precision=0),
|
|
587
|
+
KPI("Average tree area", average_area, "m²", precision=1),
|
|
588
|
+
KPI("Average ETc", df[f"{name} - Average ETc [mm]"].sum(), "mm / a", precision=0),
|
|
589
|
+
KPI("Irrigation", irrigation_mm, "mm / a", precision=0),
|
|
590
|
+
KPI("Average irrigation", average_area * irrigation_mm / 1000, "m³ / a", precision=0),
|
|
591
|
+
]
|
|
592
|
+
return all_info
|
|
593
|
+
|
|
594
|
+
@property
|
|
595
|
+
def diagrams(self) -> dict[str, Path]:
|
|
596
|
+
rain_irrigation_png = self.workflow_path / "rain_and_irrigation.png"
|
|
597
|
+
df_months = self.df_with_time()[["Rain [mm]", "Average irrigation [mm]"]].resample("ME").sum()
|
|
598
|
+
fig, ax = plt.subplots()
|
|
599
|
+
df_months = df_months.rename(
|
|
600
|
+
columns={"Rain [mm]": "Niederschlag", "Average irrigation [mm]": "Künstliche Bewässerung"}
|
|
601
|
+
)
|
|
602
|
+
df_months.plot(kind="bar", stacked=True, ax=ax, ylabel="[mm]", figsize=(13, 6))
|
|
603
|
+
ax.set_xticklabels([x.strftime("%b") for x in df_months.index], rotation=0)
|
|
604
|
+
plt.savefig(rain_irrigation_png, bbox_inches="tight", dpi=300)
|
|
605
|
+
plt.close(fig)
|
|
606
|
+
return {"Rain and Irrigation": rain_irrigation_png}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def create_simstadt_results(description: str, output_files: list[Path]) -> SimStadtResults:
|
|
610
|
+
"""Factory: detect workflow type from params.xml and return the correct subclass."""
|
|
611
|
+
provider = get_workflow_provider(output_files)
|
|
612
|
+
return SimStadtResults.create(provider, description=description, output_files=sorted(output_files))
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Run SimStadt workflows from Python.
|
|
2
|
+
|
|
3
|
+
SimStadt is located via the SIMSTADT_FOLDER environment variable, or by globbing
|
|
4
|
+
~/Desktop for SimStadt2_0.*/.
|
|
5
|
+
|
|
6
|
+
Workflow templates (.flow directories) are located via the SIMSTADT_TEMPLATE_PATH
|
|
7
|
+
environment variable. If unset, a Template/ directory in the current working directory
|
|
8
|
+
is used as a fallback.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import platform
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from xml.etree import ElementTree as et
|
|
19
|
+
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
from .results import SimStadtResults, create_simstadt_results
|
|
23
|
+
from .utils import chdir, random_id
|
|
24
|
+
|
|
25
|
+
PARAMS = "params.xml"
|
|
26
|
+
SIMSTADT2_GLOB = "SimStadt2_0.*/"
|
|
27
|
+
|
|
28
|
+
load_dotenv()
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# SimStadt discovery
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def find_simstadt(parent_folder: Path, simstadt_glob: str = SIMSTADT2_GLOB) -> Path:
|
|
38
|
+
try:
|
|
39
|
+
found = next(parent_folder.glob(simstadt_glob))
|
|
40
|
+
log.info("# SimStadt found in %s\n", found)
|
|
41
|
+
return found
|
|
42
|
+
except StopIteration as e:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"SimStadt not found in {parent_folder}/{simstadt_glob}. "
|
|
45
|
+
"Set the SIMSTADT_FOLDER environment variable."
|
|
46
|
+
) from e
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_simstadt_folder() -> Path:
|
|
50
|
+
if "SIMSTADT_FOLDER" in os.environ:
|
|
51
|
+
return Path(os.environ["SIMSTADT_FOLDER"])
|
|
52
|
+
return find_simstadt(Path.home() / "Desktop")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_template_path() -> Path:
|
|
56
|
+
if "SIMSTADT_TEMPLATE_PATH" in os.environ:
|
|
57
|
+
return Path(os.environ["SIMSTADT_TEMPLATE_PATH"])
|
|
58
|
+
fallback = Path.cwd() / "Template"
|
|
59
|
+
if fallback.exists():
|
|
60
|
+
return fallback
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"No template path found. Set SIMSTADT_TEMPLATE_PATH or create a Template/ directory."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Low-level workflow helpers
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _simstadt_script() -> str:
|
|
71
|
+
return "SimStadt.bat" if platform.system().lower() == "windows" else "./SimStadt.sh"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _regionchooser_script() -> str:
|
|
75
|
+
return "RegionChooser.bat" if platform.system().lower() == "windows" else "./RegionChooser.sh"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_paths(repo_path: Path, workflow_path: Path) -> None:
|
|
79
|
+
if not repo_path.exists():
|
|
80
|
+
raise ValueError(f"Repository not found: {repo_path}")
|
|
81
|
+
if not workflow_path.exists():
|
|
82
|
+
raise ValueError(f"Workflow not found: {workflow_path}")
|
|
83
|
+
if not (workflow_path / PARAMS).exists():
|
|
84
|
+
raise ValueError(f"No params.xml in {workflow_path}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def prepare_workflow(workflow_path: Path, citygmls: list[str]) -> str:
|
|
88
|
+
"""Inject CityGML file names into the workflow's params.xml. Returns the workflow name."""
|
|
89
|
+
tree = et.parse(workflow_path / PARAMS)
|
|
90
|
+
root = tree.getroot()
|
|
91
|
+
|
|
92
|
+
node = root.find(".//void[@property='name']/string")
|
|
93
|
+
if node is None:
|
|
94
|
+
raise ValueError("Unknown workflow!")
|
|
95
|
+
name = node.text or "workflow"
|
|
96
|
+
log.info("# Preparing '%s' workflow (%s):", name, workflow_path.name)
|
|
97
|
+
|
|
98
|
+
citygml_node = root.find(".//void[@property='cityGmlFileNames']")
|
|
99
|
+
if citygml_node is not None:
|
|
100
|
+
for child in list(citygml_node):
|
|
101
|
+
citygml_node.remove(child)
|
|
102
|
+
else:
|
|
103
|
+
workflow_node = root.find(".//object[@class='eu.simstadt.workflows.CityGmlWorkflow']")
|
|
104
|
+
if workflow_node is None:
|
|
105
|
+
raise ValueError("Broken workflow!")
|
|
106
|
+
citygml_node = et.SubElement(workflow_node, "void")
|
|
107
|
+
citygml_node.set("property", "cityGmlFileNames")
|
|
108
|
+
|
|
109
|
+
for citygml in citygmls:
|
|
110
|
+
log.info(" * Adding %s", citygml)
|
|
111
|
+
add_node = et.SubElement(citygml_node, "void")
|
|
112
|
+
add_node.set("method", "add")
|
|
113
|
+
gml = et.SubElement(add_node, "string")
|
|
114
|
+
gml.text = citygml
|
|
115
|
+
|
|
116
|
+
tree.write(workflow_path / PARAMS)
|
|
117
|
+
return name
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def replace_params(workflow_path: Path, replaces: dict[str, str]) -> None:
|
|
121
|
+
"""Apply regex substitutions to all params.xml files under the workflow path."""
|
|
122
|
+
param_files = list(workflow_path.glob(f"**/{PARAMS}"))
|
|
123
|
+
replaced = False
|
|
124
|
+
for param_file in param_files:
|
|
125
|
+
with open(param_file, encoding="utf-8") as f:
|
|
126
|
+
content = f.read()
|
|
127
|
+
new_content = content
|
|
128
|
+
for old, new in replaces.items():
|
|
129
|
+
new_content = re.sub(old, new, new_content)
|
|
130
|
+
if new_content != content:
|
|
131
|
+
log.debug("Replacing parameters in %s", param_file)
|
|
132
|
+
replaced = True
|
|
133
|
+
with open(param_file, "w", encoding="utf-8") as f:
|
|
134
|
+
f.write(new_content)
|
|
135
|
+
if not replaced:
|
|
136
|
+
log.warning("No parameter has been replaced! %r", replaces)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def copy_workflow_from_template(
|
|
140
|
+
template_path: Path,
|
|
141
|
+
project_path: Path,
|
|
142
|
+
destination: str,
|
|
143
|
+
replaces: dict | None = None,
|
|
144
|
+
) -> Path:
|
|
145
|
+
template_path = template_path.with_suffix(".flow")
|
|
146
|
+
workflow_path = (project_path / destination).with_suffix(".flow")
|
|
147
|
+
shutil.copytree(template_path, workflow_path, dirs_exist_ok=True)
|
|
148
|
+
if replaces:
|
|
149
|
+
replace_params(workflow_path, replaces)
|
|
150
|
+
return workflow_path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_all_files(folder: Path) -> dict[Path, float]:
|
|
154
|
+
return {f: f.stat().st_mtime for f in folder.glob("**/*") if f.is_file() and f.name != PARAMS}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _compare_written_files(repo_path: Path, before: dict, after: dict) -> list[Path]:
|
|
158
|
+
modified = []
|
|
159
|
+
log.info("# Listing written files:")
|
|
160
|
+
for result_file, mtime in after.items():
|
|
161
|
+
if mtime > before.get(result_file, 0):
|
|
162
|
+
log.info(" * '%s'", result_file.relative_to(repo_path))
|
|
163
|
+
modified.append(result_file)
|
|
164
|
+
log.info("")
|
|
165
|
+
return modified
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Public execution API
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def get_simstadt_output(path: Path) -> str:
|
|
173
|
+
"""Run SimStadt with the given path and return stdout regardless of exit code.
|
|
174
|
+
|
|
175
|
+
Useful for extracting version information from the startup banner.
|
|
176
|
+
"""
|
|
177
|
+
with chdir(get_simstadt_folder()):
|
|
178
|
+
result = subprocess.run([_simstadt_script(), str(path)], text=True, capture_output=True, check=False)
|
|
179
|
+
return result.stdout
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_simstadt(workflow_path: Path, name: str) -> str:
|
|
183
|
+
"""Invoke the SimStadt CLI for the given workflow. Returns stdout."""
|
|
184
|
+
with chdir(get_simstadt_folder()):
|
|
185
|
+
log.info("Launching %s:", name)
|
|
186
|
+
result = subprocess.run(
|
|
187
|
+
[_simstadt_script(), str(workflow_path)], text=True, capture_output=True, check=False
|
|
188
|
+
)
|
|
189
|
+
if result.returncode != 0:
|
|
190
|
+
log.warning(" Workflow failed!\n%s", result.stdout)
|
|
191
|
+
raise ValueError("Workflow failed!")
|
|
192
|
+
log.debug(result.stdout)
|
|
193
|
+
log.info(" Workflow finished successfully!\n")
|
|
194
|
+
return result.stdout
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def run_regionchooser(*params: str) -> str:
|
|
198
|
+
"""Invoke the SimStadt RegionChooser CLI. Returns stdout."""
|
|
199
|
+
with chdir(get_simstadt_folder()):
|
|
200
|
+
result = subprocess.run(
|
|
201
|
+
[_regionchooser_script(), *params], text=True, capture_output=True, check=True
|
|
202
|
+
)
|
|
203
|
+
log.debug(result.stdout)
|
|
204
|
+
return result.stdout
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_workflow(workflow_path: Path, citygmls: list[str]) -> list[Path]:
|
|
208
|
+
"""Prepare and run a SimStadt workflow. Returns the list of files written by SimStadt."""
|
|
209
|
+
repo_path = workflow_path.parent.parent
|
|
210
|
+
check_paths(repo_path, workflow_path)
|
|
211
|
+
name = prepare_workflow(workflow_path, citygmls)
|
|
212
|
+
before = _get_all_files(workflow_path)
|
|
213
|
+
run_simstadt(workflow_path, name)
|
|
214
|
+
after = _get_all_files(workflow_path)
|
|
215
|
+
return _compare_written_files(repo_path, before, after)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_workflow_with_citygml(
|
|
219
|
+
template: str | Path,
|
|
220
|
+
citygml_path: Path,
|
|
221
|
+
replaces: dict | None = None,
|
|
222
|
+
description: str | None = None,
|
|
223
|
+
destination: str | None = None,
|
|
224
|
+
project_path: Path | None = None,
|
|
225
|
+
) -> SimStadtResults:
|
|
226
|
+
"""Copy a workflow template, inject parameters, run SimStadt, and return parsed results.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
template: Template name (resolved via SIMSTADT_TEMPLATE_PATH) or full Path.
|
|
230
|
+
citygml_path: Path to the CityGML input file.
|
|
231
|
+
replaces: Optional dict of XML-fragment regex replacements for params.xml.
|
|
232
|
+
description: Human-readable label for the result object.
|
|
233
|
+
destination: Output workflow folder name; defaults to a timestamped random id.
|
|
234
|
+
project_path: Directory where the workflow folder is created. Defaults to
|
|
235
|
+
citygml_path.parent. When set to a different directory, citygml_path is
|
|
236
|
+
passed as an absolute path so SimStadt can locate it.
|
|
237
|
+
"""
|
|
238
|
+
if isinstance(template, str):
|
|
239
|
+
template_path = get_template_path() / template
|
|
240
|
+
else:
|
|
241
|
+
template_path = Path(template)
|
|
242
|
+
template_name = template_path.stem
|
|
243
|
+
|
|
244
|
+
if destination is None:
|
|
245
|
+
destination = random_id() + "_" + template_name.rsplit("_", 1)[-1]
|
|
246
|
+
|
|
247
|
+
if project_path is None:
|
|
248
|
+
project_path = citygml_path.parent
|
|
249
|
+
citygml_ref = citygml_path.name
|
|
250
|
+
else:
|
|
251
|
+
repo_path = project_path.parent
|
|
252
|
+
try:
|
|
253
|
+
citygml_ref = str(citygml_path.relative_to(repo_path))
|
|
254
|
+
except ValueError as e:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"CityGML file {citygml_path} is not under the repository root {repo_path}. "
|
|
257
|
+
"Place the GML file inside the repository directory or omit project_path."
|
|
258
|
+
) from e
|
|
259
|
+
|
|
260
|
+
workflow_path = copy_workflow_from_template(template_path, project_path, destination, replaces)
|
|
261
|
+
output_files = run_workflow(workflow_path, [citygml_ref])
|
|
262
|
+
|
|
263
|
+
if description is None:
|
|
264
|
+
description = f"{template_name} for {citygml_path.name}"
|
|
265
|
+
return create_simstadt_results(description, output_files)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
import string
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from math import floor, log10
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def significant_digits(value: float, sig_digits: int = 4) -> float:
|
|
17
|
+
"""Round a number to a chosen number of significant digits."""
|
|
18
|
+
if value == 0:
|
|
19
|
+
return 0
|
|
20
|
+
order = floor(log10(abs(value)))
|
|
21
|
+
digits_after_point = sig_digits - order - 1
|
|
22
|
+
return round(value, digits_after_point)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_number(value, decimal_sep=",", thousand_sep=" "):
|
|
26
|
+
"""Format a number with thousand/decimal separators, removing trailing zeros."""
|
|
27
|
+
if value is None:
|
|
28
|
+
return ""
|
|
29
|
+
if not isinstance(value, (int, float)):
|
|
30
|
+
return value
|
|
31
|
+
return (
|
|
32
|
+
f"{value:,f}".rstrip("0").rstrip(".").replace(",", "X").replace(".", decimal_sep).replace("X", thousand_sep)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_random_seed(input_string: str) -> None:
|
|
37
|
+
"""Seed both random and numpy from a string so sampling is reproducible between runs."""
|
|
38
|
+
random.seed(input_string)
|
|
39
|
+
np.random.seed(random.randrange(1000))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def random_id(n: int = 4, time: bool = True) -> str:
|
|
43
|
+
"""Return a random string of n chars, optionally prefixed with YYYYMMDD_HHMM.
|
|
44
|
+
Uses a fresh Random() instance to avoid being affected by set_random_seed()."""
|
|
45
|
+
generator = random.Random()
|
|
46
|
+
generator.seed()
|
|
47
|
+
result = "".join(generator.choices(string.ascii_lowercase + string.digits, k=n))
|
|
48
|
+
if time:
|
|
49
|
+
result = datetime.datetime.now().strftime("%Y%m%d_%H%M_") + result
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def remove_random_id(text: str) -> str:
|
|
54
|
+
"""Strip a leading random_id prefix from a filename."""
|
|
55
|
+
return re.sub(r"^(20\d{6}_\d{4}_)?[a-z0-9]{4}_", "", text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def chdir(path: Path):
|
|
60
|
+
"""Context manager that temporarily changes the working directory."""
|
|
61
|
+
oldpwd = os.getcwd()
|
|
62
|
+
os.chdir(path)
|
|
63
|
+
try:
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
os.chdir(oldpwd)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""High-level simulation helpers for common SimStadt workflows.
|
|
2
|
+
|
|
3
|
+
Each function wraps run_workflow_with_citygml() with a pre-built parameter dict
|
|
4
|
+
for a specific workflow type (PV, heat demand, green water).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .results import SimStadtResults
|
|
10
|
+
from .runner import run_workflow_with_citygml
|
|
11
|
+
from .utils import remove_random_id, set_random_seed
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _set_weather_file(meteonorm_filename: str) -> dict[str, str]:
|
|
15
|
+
"""Build the params.xml replacement for the weather file."""
|
|
16
|
+
return {"<string>METEONORM_FILE</string>": f"<string>{Path(meteonorm_filename).name}</string>"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _set_calculation_mode(calculation_mode: str) -> dict[str, str]:
|
|
20
|
+
allowed = ["HEATING", "COOLING", "HEATING_AND_COOLING"]
|
|
21
|
+
if calculation_mode not in allowed:
|
|
22
|
+
raise ValueError(f"{calculation_mode!r} must be one of {allowed}")
|
|
23
|
+
return {"<string>CALCULATION_MODE</string>": f"<string>{calculation_mode}</string>"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _set_refurbishment(geg_ratio: float) -> dict[str, str]:
|
|
27
|
+
return {"<double>GEG_RATIO</double>": f"<double>{geg_ratio}</double>"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def photovoltaic_simulation(
|
|
31
|
+
citygml_path: Path | str,
|
|
32
|
+
meteonorm_filename: str,
|
|
33
|
+
roof_percentage: float = 100.0,
|
|
34
|
+
pv_share_flat: float = 40,
|
|
35
|
+
pv_share_tilted: float = 50,
|
|
36
|
+
min_roof_insolation: float = 1000,
|
|
37
|
+
hourly_pv: bool = False,
|
|
38
|
+
with_shadows: bool = True,
|
|
39
|
+
description: str = "Photovoltaic",
|
|
40
|
+
) -> SimStadtResults:
|
|
41
|
+
"""Run a PhotovoltaicPotential workflow.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
citygml_path: Path to the CityGML input file.
|
|
45
|
+
meteonorm_filename: Weather file name (basename only, e.g. 'Wuerzburg-hour.csv').
|
|
46
|
+
roof_percentage: Fraction of roof surfaces to include (sampled randomly but reproducibly).
|
|
47
|
+
pv_share_flat: PV coverage [%] on flat roofs.
|
|
48
|
+
pv_share_tilted: PV coverage [%] on tilted roofs.
|
|
49
|
+
min_roof_insolation: Minimum irradiance threshold [kWh/(m²·a)].
|
|
50
|
+
hourly_pv: Whether to request hourly PV output.
|
|
51
|
+
with_shadows: Use the shadow-aware workflow template.
|
|
52
|
+
description: Label stored on the result object.
|
|
53
|
+
"""
|
|
54
|
+
citygml_path = Path(citygml_path)
|
|
55
|
+
replaces = _set_weather_file(meteonorm_filename) | {
|
|
56
|
+
"<int>PV_SHARE_FLAT_ROOF</int>": f"<int>{pv_share_flat}</int>",
|
|
57
|
+
"<int>PV_SHARE_TILTED_ROOF</int>": f"<int>{pv_share_tilted}</int>",
|
|
58
|
+
"<int>MIN_ROOF_INSOLATION</int>": f"<int>{min_roof_insolation}</int>",
|
|
59
|
+
"<boolean>HOURLY_PV</boolean>": f"<boolean>{str(hourly_pv).lower()}</boolean>",
|
|
60
|
+
}
|
|
61
|
+
template = "105_PVWithShadow" if with_shadows else "103_PV"
|
|
62
|
+
pv_results = run_workflow_with_citygml(template, citygml_path, replaces=replaces, description=description)
|
|
63
|
+
|
|
64
|
+
set_random_seed(remove_random_id(citygml_path.name))
|
|
65
|
+
pv_results._df = pv_results.dataframe.sample(frac=roof_percentage / 100.0)
|
|
66
|
+
return pv_results
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def heatdemand_simulation(
|
|
70
|
+
citygml_path: Path | str,
|
|
71
|
+
meteonorm_filename: str,
|
|
72
|
+
with_shadows: bool = True,
|
|
73
|
+
calculation_mode: str = "HEATING",
|
|
74
|
+
refurbishment_ratio: float = 0.0,
|
|
75
|
+
description: str = "Heat demand",
|
|
76
|
+
) -> SimStadtResults:
|
|
77
|
+
"""Run a HeatDemand workflow.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
citygml_path: Path to the CityGML input file.
|
|
81
|
+
meteonorm_filename: Weather file name or full path (basename is used).
|
|
82
|
+
with_shadows: Use the shadow-aware workflow template.
|
|
83
|
+
calculation_mode: One of 'HEATING', 'COOLING', or 'HEATING_AND_COOLING'.
|
|
84
|
+
refurbishment_ratio: GEG refurbishment ratio (0.0 = no refurbishment, 1.0 = full).
|
|
85
|
+
description: Label stored on the result object.
|
|
86
|
+
"""
|
|
87
|
+
citygml_path = Path(citygml_path)
|
|
88
|
+
template = "104_HeatDemandWithShadow" if with_shadows else "101_HeatDemand"
|
|
89
|
+
replaces = (
|
|
90
|
+
_set_weather_file(meteonorm_filename)
|
|
91
|
+
| _set_calculation_mode(calculation_mode)
|
|
92
|
+
| _set_refurbishment(refurbishment_ratio)
|
|
93
|
+
)
|
|
94
|
+
return run_workflow_with_citygml(template, citygml_path, replaces=replaces, description=description)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def greenwater_simulation(
|
|
98
|
+
citygml_path: Path | str,
|
|
99
|
+
meteonorm_filename: str,
|
|
100
|
+
irrigation_ratio: float,
|
|
101
|
+
description: str = "Green water",
|
|
102
|
+
) -> SimStadtResults:
|
|
103
|
+
"""Run a GreenWater workflow.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
citygml_path: Path to the CityGML input file.
|
|
107
|
+
meteonorm_filename: Weather file name (basename only).
|
|
108
|
+
irrigation_ratio: 1.0 = always irrigate when needed; 0.0 = no irrigation.
|
|
109
|
+
description: Label stored on the result object.
|
|
110
|
+
"""
|
|
111
|
+
citygml_path = Path(citygml_path)
|
|
112
|
+
replaces = _set_weather_file(meteonorm_filename) | {
|
|
113
|
+
"<double>MAX_WATER_STRESS</double>": f"<double>{1 - irrigation_ratio}</double>",
|
|
114
|
+
}
|
|
115
|
+
return run_workflow_with_citygml("102_GreenWater", citygml_path, replaces=replaces, description=description)
|