"""Package the current project's completed meshes and readable results."""
from datetime import datetime, timezone
from io import BytesIO
import json
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
import yaml
from ... import configuration_project as projects
from ...io.cgns_stage import combine_rows
from . import workflow as flow
NO_MESH = "Generate a current 3D mesh, or a 2D mesh for a linear cascade, before downloading."
def _identity(reference):
return (reference.get("session"), reference.get("job")) if reference else None
def _mesh_inputs(row, stage):
"""Compare scientific inputs for either row role, excluding display settings."""
geometry = {k: v for k, v in row["geometry"].items() if k != "axial_interspace_mm"}
ignored = {"verbosity", "terminal_output"}
if stage == "reference":
ignored.update(("span_layers", "span_stretch"))
numerics = {k: v for k, v in row["mesh"]["numerics"].items() if k not in ignored}
cgns = {k: v for k, v in row["mesh"]["cgns"].items() if k != "enabled"}
return geometry, numerics, cgns
def _completed_mesh(project, row, stage, references, registry=None):
"""Resolve only selected, completed jobs whose inputs still match the row."""
reference = references.get(stage)
if not reference:
raise ValueError("not generated")
folder = flow.directory(reference)
if flow.read_json(folder / "status.json").get("state") != "complete":
raise ValueError("not completed successfully")
request = flow.read_json(folder / "request.json")
handle = request["handle"]
if (_identity(handle) != _identity(reference) or handle.get("row_id") != row["id"]
or handle.get("row_role") != row["role"] or handle.get("action") != stage):
raise ValueError("job does not belong to this row and stage")
snapshot = request["project"]
previous_row = projects.get_row(snapshot, row["id"])
if (previous_row["role"] != row["role"]
or snapshot["machine"]["geometry_type"] != project["machine"]["geometry_type"]
or _mesh_inputs(previous_row, stage) != _mesh_inputs(row, stage)
or request.get("source_revision", flow.SOURCE_REVISION) != flow.SOURCE_REVISION):
raise ValueError("out of date; regenerate for the current settings")
# Every row depends on its resolved interfaces and assembly placement;
# the stator outlet can change when only the rotor's gap changes. A coupled
# rotor additionally depends on the selected upstream MoC result. Compare
# effective stage keys rather than accepting matching row-local YAML alone.
effective = flow.resolved_configuration(project, row["id"], registry)
expected = flow.keys(effective, references.get("nozzle"))[stage]
if not flow.current(reference, expected):
raise ValueError("out of date; resolved geometry, interfaces or inlet conditions have changed")
# Recomputing a predecessor invalidates its descendants even when the
# numerical inputs are unchanged. Keep the selected computation chain.
for dependency in (("nozzle", "reference") if stage == "volume" else ("nozzle",)):
if _identity(request["references"].get(dependency)) != _identity(references.get(dependency)):
raise ValueError("out of date; a preceding stage has been recomputed")
filename = row["mesh"]["cgns"]["filename"]
name = Path(filename)
if name.name != filename or name.suffix.lower() != ".cgns":
raise ValueError("invalid CGNS filename")
if stage == "reference":
name = name.with_name(f"{name.stem}_2d.cgns")
path = folder / name
required = [path, folder / "activity.log", folder / (
"mesh_summary.json" if stage == "volume" else "reference_summary.json")]
if stage == "volume":
required.append(folder / "mesh_report.json")
if any(not item.is_file() for item in required) or path.stat().st_size == 0:
raise ValueError("mesh, report or activity log is missing")
return {"row": row, "stage": stage, "folder": folder, "path": path,
"file": f"meshes/{row['id']}/{name.name}", "request": request}
[docs]
def collect_meshes(project, registry):
"""List available deliverables and explain omissions without reading mesh bytes."""
projects.validate_project(project)
stages = ("reference", "volume") if project["machine"]["geometry_type"] == "linear" else ("volume",)
meshes, omitted = [], []
for row in projects.active_rows(project):
references = (registry or {}).get(row["id"], {})
for stage in stages:
try:
meshes.append(_completed_mesh(project, row, stage, references, registry))
except (OSError, ValueError, KeyError, TypeError) as exc:
reason = str(exc) if isinstance(exc, ValueError) else "saved job files are missing or unreadable"
dimension = "3D" if stage == "volume" else "2D"
omitted.append(f"{row['id']} ({row['role']}), {dimension}: {reason}.")
return meshes, omitted
[docs]
def stage_meshes(project, registry):
"""Require a complete stage; calculation scope never limits export scope."""
meshes, omitted = collect_meshes(project, registry)
active = {row["id"] for row in projects.active_rows(project)}
complete = {stage: [mesh for mesh in meshes if mesh["stage"] == stage]
for stage in ("volume", "reference")}
complete = {stage: items for stage, items in complete.items()
if {item["row"]["id"] for item in items} == active}
if "volume" not in complete and (project["machine"]["geometry_type"] != "linear"
or "reference" not in complete):
raise ValueError(NO_MESH + " Every active row must be current. " + " ".join(omitted))
for items in complete.values():
if len({item["row"]["mesh"]["cgns"]["length_scale"] for item in items}) != 1:
raise ValueError("All rows must use the same CGNS length_scale for a shared physical stage.")
return meshes, omitted, complete
def _timestamp(value):
return datetime.fromtimestamp(value, timezone.utc).isoformat(timespec="seconds")
def _logs(meshes):
"""Combine each included row's computation chain once, in execution order."""
jobs = {}
for mesh in meshes:
request, row = mesh["request"], mesh["row"]
references = [request["references"].get(stage) for stage in ("nozzle", "reference")]
references.append(request["handle"])
for reference in references:
if not reference or _identity(reference) in jobs:
continue
folder = flow.directory(reference)
saved = flow.read_json(folder / "request.json")["handle"]
if _identity(saved) != _identity(reference) or saved.get("row_id") != row["id"]:
raise ValueError("A computation log does not belong to its mesh row.")
jobs[_identity(reference)] = (saved, folder / "activity.log")
chunks = []
for handle, path in sorted(jobs.values(), key=lambda item: item[0].get("started", 0)):
title = f"{handle['row_id']} | {handle['action']} | {_timestamp(handle['started'])}"
chunks.append(f"{'=' * 72}\n{title}\n{'=' * 72}\n{path.read_text(encoding='utf-8', errors='replace').rstrip()}\n")
return "\n".join(chunks)
[docs]
def build_archive(project, registry):
"""Return the complete active stage plus settings and per-row diagnostics."""
meshes, omitted, stages = stage_meshes(project, registry)
combined = {}
for stage, items in stages.items():
name = "stage_2d.cgns" if stage == "reference" and "volume" in stages else "stage.cgns"
combined[name] = combine_rows([(item["row"]["id"], item["path"]) for item in items])
now = datetime.now(timezone.utc)
generated = now.isoformat(timespec="seconds")
report = {"created_utc": generated, "machine": project["machine"], "rows": {}, "interfaces": [],
"stage_files": {name: manifest for name, (_, manifest) in combined.items()}}
interfaces = {}
readme = ["TurboMoC project", f"Packaged: {generated}",
f"Machine: {project['machine']['geometry_type']}; design mode: {project['machine']['design_mode']}", "",
"CONTENTS", "project.yaml - final settings; load this file in TurboMoC to reproduce the design.",
"mesh_report.json - consolidated mesh statistics and quality results, grouped by row and dimension.",
"activity.log - computation logs for the included meshes and their preceding stages.",
"stage.cgns - the complete active machine, with a separate fluid zone for each row.",
"stage_2d.cgns - optional separate true 2D linear mesh when a 3D mesh is also available.",
"meshes/ - unchanged individual row exports for diagnostics.", "", "MESHES"]
for mesh in meshes:
row, stage, folder = mesh["row"], mesh["stage"], mesh["folder"]
dimension = "3d" if stage == "volume" else "2d"
summary = flow.read_json(folder / ("mesh_summary.json" if stage == "volume" else "reference_summary.json"))
entry = {"file": mesh["file"], "generated_utc": _timestamp(mesh["request"]["handle"]["started"]),
"coordinate_basis": "physical_cartesian" if stage == "volume" else "linear_physical",
"cgns_length_scale": row["mesh"]["cgns"]["length_scale"], "summary": summary}
if stage == "volume":
entry["quality"] = flow.read_json(folder / "mesh_report.json")
report["rows"].setdefault(row["id"], {"role": row["role"], "meshes": {}})["meshes"][dimension] = entry
effective = mesh["request"].get("config", {})
assembly = effective.get("assembly")
if stage == "volume" and assembly:
entry["coordinate_basis"] = "assembly_cartesian"
entry["assembly"] = assembly
for boundary, interface in assembly.get("interfaces", {}).items():
pair = interfaces.setdefault(interface["id"], {**interface, "exported_boundaries": []})
pair["exported_boundaries"].append({"row_id": row["id"], "boundary": boundary,
"file": mesh["file"]})
if row["role"] == "rotor" and "rotor_design" in effective:
# Project YAML retains manual fallback values. Record the actual
# effective inlet used for this mesh alongside its diagnostics.
report["rows"][row["id"]]["rotor_design_inputs"] = effective["rotor_design"]
report["rows"][row["id"]]["inlet_coupling"] = effective.get("rotor_source")
nodes = summary.get("volume_nodes" if stage == "volume" else "section_nodes", "unknown")
cells = sum(summary["cells"].values()) if stage == "volume" else summary.get("section_cells", "unknown")
readme.extend([f"{mesh['file']} ({row['role']}, {dimension.upper()})",
f" Generated: {entry['generated_utc']}", f" Nodes: {nodes}; cells: {cells}",
f" CGNS coordinate multiplier from geometry in mm: {entry['cgns_length_scale']:g}."])
if stage == "volume" and assembly:
placement = assembly["placement"]
readme.append(
f" Machine placement applied: Z offset {placement['streamwise_offset_mm']:g} mm; "
f"phase {placement['angular_phase_deg']:g} deg."
)
if stage == "volume":
quality = summary.get("quality", {})
if "min_sicn" in quality:
readme.append(f" Minimum SICN: {quality['min_sicn']:.6g}.")
boundaries = row["mesh"]["cgns"]["boundary_types"]
readme.append(" Boundaries: " + ", ".join(f"{name} ({kind})" for name, kind in boundaries.items()
if stage == "volume" or name not in ("hub", "shroud")) + ".")
report["interfaces"] = list(interfaces.values())
for interface in report["interfaces"]:
for side in interface["exported_boundaries"]:
stage_row = combined["stage.cgns"][1]["rows"][side["row_id"]]
side["stage_file"] = "stage.cgns"
side["zone"] = stage_row["zone"]
side["stage_boundary"] = stage_row["boundaries"][side["boundary"]]
if interfaces:
readme.extend(["", "SHARED PHYSICAL INTERFACES"])
for interface in interfaces.values():
coordinate = "Z" if interface["geometry_type"] == "axial" else "radius"
readme.append(f"{interface['id']}: {coordinate} = {interface['coordinate_mm']:g} mm; "
f"{interface['upstream_id']}/outlet to {interface['downstream_id']}/inlet.")
readme.extend(["", "COORDINATES AND USE",
"Geometry and dimensional mesh-report lengths use mm. CGNS unit metadata uses metres.",
"The standard CGNS multiplier is 0.001 (mm to m); each mesh's configured multiplier is listed above.",
"Each mesh is one periodic passage. Multirow 3D meshes use machine coordinates with the recorded placement applied; additional displayed blades are not exported.",
"Single-row meshes and linear 2D meshes retain their original physical coordinates.",
"Configure periodic_left/periodic_right pairing in the CFD solver (translational for linear, rotational for annular).",
"Stage zones, fluid element sections and boundary names are prefixed with their stable row IDs. Periodic donor-zone references are updated; local node/element indices are unchanged.",
"Fluent 24.2 qualifies imported names with the zone, e.g. stator_1_fluid.stator_1_outlet. It initially imports these boundary patches as walls; assign the required inlet/outlet, periodic and inter-row interface types in the solver.",
"Adjacent rows share a physical interface, not necessarily matching nodes or blade counts. The two interface sides stay separate; configure row-interface coupling in the CFD solver. No flow interface model is assigned by this archive.",
"2D CGNS files are included only for linear cascades; axial/radial exports contain physical 3D meshes.",
"Only completed meshes matching the final settings and selected computation chain are included.",
"The project YAML also retains any inactive rows; their meshes are not included."])
if omitted:
readme.extend(["", "UNAVAILABLE MESHES", *omitted])
stream = BytesIO()
with ZipFile(stream, "w", compression=ZIP_DEFLATED) as archive:
archive.writestr("project.yaml", yaml.safe_dump(project, sort_keys=False, allow_unicode=True))
archive.writestr("README.txt", "\n".join(readme) + "\n")
archive.writestr("mesh_report.json", json.dumps(report, indent=2, ensure_ascii=False) + "\n")
archive.writestr("activity.log", _logs(meshes))
for name, (data, _) in combined.items():
archive.writestr(name, data)
for mesh in meshes:
archive.write(mesh["path"], mesh["file"])
return stream.getvalue(), f"turbo_moc_project_{now:%Y-%m-%d_%H-%M-%S}.zip"