Commit 725dda03 authored by Eric Duminil's avatar Eric Duminil
Browse files

Merge branch 'experimental/csv_export'

parents 0be86308 add17c27
...@@ -61,6 +61,31 @@ If no project path is specified, workflows are run in a temporary repository und ...@@ -61,6 +61,31 @@ If no project path is specified, workflows are run in a temporary repository und
# Print detected SimStadt installation path and version # Print detected SimStadt installation path and version
simstadt simstadt
# See help
simstadt --help
usage: simstadt [-h] [--gui] [--csv-export] [--install] [-d DESCRIPTION] [--destination DESTINATION] [-p PROJECT_PATH] [-f] [-s PATH] [-v] [template] [citygml]
simstadt - Python library for SimStadt workflows.
positional arguments:
template Template name (from SIMSTADT_TEMPLATE_PATH) or path to a .flow directory.
citygml Path to the CityGML input file.
options:
-h, --help show this help message and exit
--gui Launch the SimStadt GUI.
--csv-export Export CSV from workflowsteps, when available.
--install Download and install the latest SimStadt release to ~/Desktop.
-d DESCRIPTION, --description DESCRIPTION
Human-readable label for the result.
--destination DESTINATION
Workflow folder name (default: timestamped random id).
-p PROJECT_PATH, --project-path PROJECT_PATH
Directory where the workflow folder is created.
-f, --files Show output files after the run.
-s PATH, --save PATH Save result DataFrame to a file (.csv or .json).
-v, --verbose Enable debug logging.
# Download and install the latest SimStadt release to ~/Desktop # Download and install the latest SimStadt release to ~/Desktop
simstadt --install simstadt --install
......
...@@ -65,6 +65,7 @@ def cmd_run(args: argparse.Namespace) -> None: ...@@ -65,6 +65,7 @@ def cmd_run(args: argparse.Namespace) -> None:
description=args.description, description=args.description,
destination=args.destination, destination=args.destination,
project_path=args.project_path, project_path=args.project_path,
csv_export=args.csv_export,
) )
except (FileNotFoundError, ValueError) as e: except (FileNotFoundError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
...@@ -93,6 +94,7 @@ def build_parser() -> argparse.ArgumentParser: ...@@ -93,6 +94,7 @@ def build_parser() -> argparse.ArgumentParser:
description="simstadt - Python library for SimStadt workflows.", description="simstadt - Python library for SimStadt workflows.",
) )
parser.add_argument("--gui", action="store_true", help="Launch the SimStadt GUI.") parser.add_argument("--gui", action="store_true", help="Launch the SimStadt GUI.")
parser.add_argument("--csv-export", action="store_true", help="Export CSV from workflowsteps, when available.")
parser.add_argument( parser.add_argument(
"--install", "--install",
action="store_true", action="store_true",
......
...@@ -189,9 +189,9 @@ class SimStadtResults(ABC): ...@@ -189,9 +189,9 @@ class SimStadtResults(ABC):
def get_all_by_extension(self, ext: str) -> list[Path]: def get_all_by_extension(self, ext: str) -> list[Path]:
return [f for f in sorted(self.output_files) if f.suffix == ext] return [f for f in sorted(self.output_files) if f.suffix == ext]
def get_unique_by_extension(self, ext: str, filter: str | None = None) -> Path: def get_unique_by_extension(self, ext: str, *filters: str) -> Path:
files_by_extension = self.get_all_by_extension(ext) files_by_extension = self.get_all_by_extension(ext)
if filter: for filter in filters:
files_by_extension = [f for f in files_by_extension if filter in f.name] files_by_extension = [f for f in files_by_extension if filter in f.name]
if len(files_by_extension) == 0: if len(files_by_extension) == 0:
raise ValueError(f"Workflow didn't seem to have returned any {ext} file!") raise ValueError(f"Workflow didn't seem to have returned any {ext} file!")
......
...@@ -69,8 +69,11 @@ def get_template_path() -> Path: ...@@ -69,8 +69,11 @@ def get_template_path() -> Path:
"No template path found. Set SIMSTADT_TEMPLATE_PATH or create a templates/ directory." "No template path found. Set SIMSTADT_TEMPLATE_PATH or create a templates/ directory."
) )
def get_version_and_date() -> tuple[str, str]: def get_version_and_date() -> tuple[str, str]:
SIMSTADT_VERSION_PATTERN = re.compile(f"Launching SimStadt {SIMSTADT_VERSION_FORMAT}") SIMSTADT_VERSION_PATTERN = re.compile(
f"Launching SimStadt {SIMSTADT_VERSION_FORMAT}"
)
output = get_simstadt_output(get_simstadt_folder()) output = get_simstadt_output(get_simstadt_folder())
m = SIMSTADT_VERSION_PATTERN.search(output) m = SIMSTADT_VERSION_PATTERN.search(output)
if not m: if not m:
...@@ -78,6 +81,7 @@ def get_version_and_date() -> tuple[str, str]: ...@@ -78,6 +81,7 @@ def get_version_and_date() -> tuple[str, str]:
version, _branch, _commit, date = m.groups() version, _branch, _commit, date = m.groups()
return version, date return version, date
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Low-level workflow helpers # Low-level workflow helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
...@@ -207,16 +211,22 @@ def get_simstadt_output(path: Path) -> str: ...@@ -207,16 +211,22 @@ def get_simstadt_output(path: Path) -> str:
) )
return result.stdout return result.stdout
# TODO: Show which workflow is currently run # TODO: Show which workflow is currently run
# TODO: Show absolute paths to listed files? # TODO: Show absolute paths to listed files?
def run_simstadt(workflow_path: Path, name: str) -> str:
def run_simstadt(workflow_path: Path, name: str, csv_export: bool = False) -> str:
"""Invoke the SimStadt CLI for the given workflow. Returns stdout.""" """Invoke the SimStadt CLI for the given workflow. Returns stdout."""
workflow_path = workflow_path.resolve() workflow_path = workflow_path.resolve()
with chdir(get_simstadt_folder()): with chdir(get_simstadt_folder()):
log.info("Launching %s:", name) log.info("Launching %s:", name)
# NOTE: --csv-export requires SimStadt version >= August 2026
params = [_simstadt_script(), str(workflow_path)]
if csv_export:
params.append("--csv-export")
result = subprocess.run( result = subprocess.run(
[_simstadt_script(), str(workflow_path)], params,
text=True, text=True,
capture_output=True, capture_output=True,
check=False, check=False,
...@@ -247,13 +257,17 @@ def run_regionchooser(*params: str) -> str: ...@@ -247,13 +257,17 @@ def run_regionchooser(*params: str) -> str:
return result.stdout return result.stdout
def run_workflow(workflow_path: Path, citygmls: list[str]) -> list[Path]: def run_workflow(
workflow_path: Path,
citygmls: list[str],
csv_export: bool = False,
) -> list[Path]:
"""Prepare and run a SimStadt workflow. Returns the list of files written by SimStadt.""" """Prepare and run a SimStadt workflow. Returns the list of files written by SimStadt."""
repo_path = workflow_path.parent.parent repo_path = workflow_path.parent.parent
_check_paths(repo_path, workflow_path) _check_paths(repo_path, workflow_path)
name = _prepare_workflow(workflow_path, citygmls) name = _prepare_workflow(workflow_path, citygmls)
before = _get_all_files(workflow_path) before = _get_all_files(workflow_path)
run_simstadt(workflow_path, name) run_simstadt(workflow_path, name, csv_export)
after = _get_all_files(workflow_path) after = _get_all_files(workflow_path)
return _compare_written_files(repo_path, before, after) return _compare_written_files(repo_path, before, after)
...@@ -273,7 +287,7 @@ def _resolve_project(citygml_path: Path, project_path: Path | None) -> tuple[Pat ...@@ -273,7 +287,7 @@ def _resolve_project(citygml_path: Path, project_path: Path | None) -> tuple[Pat
if not citygml_path.exists(): if not citygml_path.exists():
raise FileNotFoundError(f"{citygml_path} not found") raise FileNotFoundError(f"{citygml_path} not found")
if project_path is not None: if project_path is not None:
project_path = project_path.with_suffix('.proj') project_path = project_path.with_suffix(".proj")
project_path.mkdir(exist_ok=True, parents=True) project_path.mkdir(exist_ok=True, parents=True)
dest = project_path / citygml_path.name dest = project_path / citygml_path.name
if not dest.exists(): if not dest.exists():
...@@ -300,6 +314,7 @@ def run_workflow_with_citygml( ...@@ -300,6 +314,7 @@ def run_workflow_with_citygml(
description: str | None = None, description: str | None = None,
destination: str | None = None, destination: str | None = None,
project_path: Path | None = None, project_path: Path | None = None,
csv_export: bool = False,
) -> SimStadtResults: ) -> SimStadtResults:
"""Copy a workflow template, inject parameters, run SimStadt, and return parsed results. """Copy a workflow template, inject parameters, run SimStadt, and return parsed results.
...@@ -313,6 +328,7 @@ def run_workflow_with_citygml( ...@@ -313,6 +328,7 @@ def run_workflow_with_citygml(
- project_path given: copy CityGML there if not already present (takes priority). - project_path given: copy CityGML there if not already present (takes priority).
- CityGML inside a .proj folder: use parent as project, reference by name. - CityGML inside a .proj folder: use parent as project, reference by name.
- Neither: create a temporary repository under /tmp (not auto-cleaned). - Neither: create a temporary repository under /tmp (not auto-cleaned).
csv_export: If available, workflowsteps will export Buildings/Surfaces CSV files.
""" """
citygml_path = Path(citygml_path) citygml_path = Path(citygml_path)
...@@ -331,7 +347,7 @@ def run_workflow_with_citygml( ...@@ -331,7 +347,7 @@ def run_workflow_with_citygml(
workflow_path = copy_workflow_from_template( workflow_path = copy_workflow_from_template(
template_path, resolved_project_path, destination, replaces template_path, resolved_project_path, destination, replaces
) )
output_files = run_workflow(workflow_path, [citygml_filename]) output_files = run_workflow(workflow_path, [citygml_filename], csv_export)
if description is None: if description is None:
description = f"{template_name} for {citygml_path.name}" description = f"{template_name} for {citygml_path.name}"
......
...@@ -34,6 +34,7 @@ def test_project_path() -> Path: ...@@ -34,6 +34,7 @@ def test_project_path() -> Path:
# SimStadt mock (replaces subprocess call with fixture file copying) # SimStadt mock (replaces subprocess call with fixture file copying)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _find_fixture_flow(provider: str) -> Path | None: def _find_fixture_flow(provider: str) -> Path | None:
"""Find a .flow directory in test data that matches the given provider and has output files.""" """Find a .flow directory in test data that matches the given provider and has output files."""
for flow_dir in TEST_REPOSITORY.rglob("*.flow"): for flow_dir in TEST_REPOSITORY.rglob("*.flow"):
...@@ -43,7 +44,9 @@ def _find_fixture_flow(provider: str) -> Path | None: ...@@ -43,7 +44,9 @@ def _find_fixture_flow(provider: str) -> Path | None:
root = et.parse(params_xml).getroot() root = et.parse(params_xml).getroot()
elem = root.find(".//void[@property='workflowProvider']/object") elem = root.find(".//void[@property='workflowProvider']/object")
if elem is not None and elem.get("class") == provider: if elem is not None and elem.get("class") == provider:
non_params = [f for f in flow_dir.rglob("*") if f.is_file() and f.name != "params.xml"] non_params = [
f for f in flow_dir.rglob("*") if f.is_file() and f.name != "params.xml"
]
if non_params: if non_params:
return flow_dir return flow_dir
return None return None
...@@ -53,7 +56,7 @@ def _find_fixture_flow(provider: str) -> Path | None: ...@@ -53,7 +56,7 @@ def _find_fixture_flow(provider: str) -> Path | None:
def mock_simstadt(monkeypatch): def mock_simstadt(monkeypatch):
"""Mock run_simstadt by copying fixture output files instead of running SimStadt.""" """Mock run_simstadt by copying fixture output files instead of running SimStadt."""
def _mock(workflow_path: Path, name: str) -> str: def _mock(workflow_path: Path, name: str, csv_export: bool = False) -> str:
root = et.parse(workflow_path / "params.xml").getroot() root = et.parse(workflow_path / "params.xml").getroot()
elem = root.find(".//void[@property='workflowProvider']/object") elem = root.find(".//void[@property='workflowProvider']/object")
provider = elem.get("class") if elem is not None else None provider = elem.get("class") if elem is not None else None
...@@ -74,13 +77,13 @@ def mock_simstadt(monkeypatch): ...@@ -74,13 +77,13 @@ def mock_simstadt(monkeypatch):
# Live fixtures (require SimStadt to be installed) # Live fixtures (require SimStadt to be installed)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def simstadt_folder(): def simstadt_folder():
"""Skip the test if SimStadt is not installed.""" """Skip the test if SimStadt is not installed."""
from simstadt.runner import get_simstadt_folder from simstadt.runner import get_simstadt_folder
try: try:
return get_simstadt_folder() return get_simstadt_folder()
except ValueError as e: except ValueError as e:
pytest.skip(str(e)) pytest.skip(str(e))
...@@ -91,7 +91,7 @@ def test_run_files_flag(mock_simstadt, run_cli, capsys, tmp_path): ...@@ -91,7 +91,7 @@ def test_run_files_flag(mock_simstadt, run_cli, capsys, tmp_path):
assert "Mean Uvalue" in out assert "Mean Uvalue" in out
def _broken_simstadt(_workflow_path: Path, _name: str) -> str: def _broken_simstadt(_workflow_path: Path, _name: str, _csv_export: bool = False) -> str:
raise ValueError("BOOM!") raise ValueError("BOOM!")
......
...@@ -40,6 +40,30 @@ def test_heat_demand(): ...@@ -40,6 +40,30 @@ def test_heat_demand():
assert kpis["Year of construction"].value == pytest.approx(1950, abs=50) assert kpis["Year of construction"].value == pytest.approx(1950, abs=50)
assert kpis["Year of construction"].unit == None assert kpis["Year of construction"].unit == None
for step in ["Geometric", "Physics", "Usage"]:
with pytest.raises(ValueError, match="Workflow didn't seem to have returned any .csv file!"):
results.get_unique_by_extension('.csv', step)
@pytest.mark.integration
def test_heat_demand_with_csv():
results = run_workflow_with_citygml(
TEST_TEMPLATES / "01_HeatDemand",
TEST_GML,
project_path=TEST_PROJECT,
description="Heat demand – MiniBuchwald",
csv_export=True,
)
for step, suffixes in [
("Geometric", ["buildings", "surfaces"]),
("Physics", ["buildings", "surfaces"]),
("Usage", ["buildings"]),
]:
for suffix in suffixes:
found_csv = results.get_unique_by_extension('.csv', step, suffix)
assert found_csv.stat().st_size > 0
@pytest.mark.integration @pytest.mark.integration
def test_pv(): def test_pv():
...@@ -83,6 +107,7 @@ def test_load_profile(): ...@@ -83,6 +107,7 @@ def test_load_profile():
assert "Average load" in kpis assert "Average load" in kpis
assert kpis["Number of buildings"].value > 0 assert kpis["Number of buildings"].value > 0
@pytest.mark.integration @pytest.mark.integration
def test_energy_grid(): def test_energy_grid():
results = run_workflow_with_citygml( results = run_workflow_with_citygml(
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment