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.
@@ -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
+ ```
@@ -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)