#!/usr/bin/env python3 """Run the Jetson cooling simulation and report its junction temperature.""" import argparse from collections.abc import Iterator import csv import io import json import math import os from pathlib import Path import threading import time from typing import Any import zipfile # These imports support the shared runtime inserted at the INCLUDE marker below. # API responses contain dynamically shaped JSON values validated by the server. JsonObject = dict[str, Any] # ANCHOR: model_inputs # Keep commonly changed physical inputs together as ordinary Python values. JETSON_POWER_W = 90.0 NUM_FINS = 13 # ANCHOR_END: model_inputs THERMAL_TARGET_C = 80.0 JUNCTION_COMPONENT = "jetson" JUNCTION_LABEL = "Jetson" JUNCTION_LEGEND_LOCATION = "lower right" PLOT_BACKGROUND = "#22242b" PLOT_FOREGROUND = "#fbfbfc" # A 4:3 canvas keeps paired convergence plots readable when the guide places # junction temperature and residuals side by side. HISTORY_FIGURE_SIZE = (8.0, 6.0) HISTORY_TITLE_SIZE = 24 HISTORY_LABEL_SIZE = 21 HISTORY_TICK_SIZE = 17 HISTORY_LEGEND_SIZE = 15 def conducting_component( tag: str, origin: list[float], size: list[float], conductivity: float | list[float], density: float, specific_heat_capacity: float, ) -> JsonObject: """Build one zero-power conducting cuboid component.""" # A shared helper keeps the material definitions consistent across the assembly. return { "tag": tag, "bbox": {"origin": origin, "size": size}, "thermal_model": { "conducting": { "conductivity": conductivity, "density": density, "specific_heat_capacity": specific_heat_capacity, "power": 0.0, } }, } def airflow_domain() -> JsonObject: """Build the enclosure, fan inlet, and pressure outlet.""" # The circular inlet uses a five-point fan curve rather than a fixed velocity. return { "origin": [-0.11, -0.08, 0.0], "size": [0.22, 0.16, 0.08], "boundary_conditions": [ { "boundary": { "circle": { "center": [0.0, 0.04], "radius": 0.038, "side": "x_min", } }, "condition": { "fan_inlet": { "flow_rates": [0.0, 0.011375, 0.02275, 0.034125, 0.0455], "static_pressures": [156.0, 144.0, 114.0, 66.0, 0.0], "temperature": 20.0, } }, }, { "boundary": {"full": {"side": "x_max"}}, "condition": {"static_pressure_outlet": {"pressure": 0.0}}, }, ], } def pcb_component() -> JsonObject: """Build the anisotropic conducting PCB beneath the Jetson.""" # In-plane conductivity is higher than conductivity through the board thickness. return conducting_component( "pcb", [-0.06, -0.05, 0.0], [0.12, 0.1, 0.002], [20.0, 20.0, 0.5], 1850.0, 900.0, ) # ANCHOR: jetson_component def jetson_component(power: float) -> JsonObject: """Build the compact thermal model used to predict junction temperature.""" # Keep power as an ordinary Python input rather than burying it in raw JSON. return { "tag": "jetson", "bbox": { "origin": [-0.0435, -0.05, 0.002], "size": [0.087, 0.1, 0.0155], }, "thermal_model": { "two_resistor_ctm": { "case_side": "z_max", "power": power, "resistance_jb": 6.0, "resistance_jc": 0.18, "exit_tolerance": 0.05, } }, } # ANCHOR_END: jetson_component def heat_spreader_component() -> JsonObject: """Build the copper plate between the Jetson and heatsink.""" # The thin, highly conducting layer spreads the chip load across the heatsink base. return conducting_component( "heat-spreader", [-0.05, -0.05, 0.0175], [0.1, 0.1, 0.002], 1000.0, 8960.0, 385.0, ) def heatsink_components(num_fins: int = NUM_FINS) -> list[JsonObject]: """Build the aluminum base and requested number of streamwise fins.""" # Thin, evenly spaced fins add cooling area while leaving open airflow channels. components = [ conducting_component( "heatsink-base", [-0.05, -0.05, 0.0195], [0.1, 0.1, 0.004], 180.0, 2700.0, 900.0, ) ] fin_thickness = 0.0025 heatsink_width = 0.1 maximum_fins = int(heatsink_width / fin_thickness) if not 1 <= num_fins <= maximum_fins: raise ValueError(f"num_fins must be between 1 and {maximum_fins}") if num_fins == 1: fin_origins = [-fin_thickness / 2] else: available_width = heatsink_width - fin_thickness fin_pitch = available_width / (num_fins - 1) # Rounding removes insignificant floating-point noise from serialized coordinates. fin_origins = [round(-heatsink_width / 2 + index * fin_pitch, 10) for index in range(num_fins)] for index, y_origin in enumerate(fin_origins): # Each fin runs with the airflow so air can pass through the channels. components.append( conducting_component( f"heatsink-fin-{index + 1}", [-0.05, y_origin, 0.0235], [0.1, fin_thickness, 0.032], 180.0, 2700.0, 900.0, ) ) return components # ANCHOR: build_request def build_request( jetson_power: float = JETSON_POWER_W, num_fins: int = NUM_FINS, ) -> JsonObject: """Build the validated Jetson cooling simulation request.""" # Compose the physical parts first, then configure the shared solver controls. return { "dry_run": False, "domain": airflow_domain(), "cuboid_components": [ pcb_component(), jetson_component(power=jetson_power), heat_spreader_component(), *heatsink_components(num_fins=num_fins), ], "fluid_properties": {"air": {}}, "max_iterations": 180, "target_residual": 0.005, "convergence_window": 20, "numerics": "stable", "mesh_settings": { "max_cell_size": 0.01, "target_wall_distance": 150.0, }, "turbulence_model": "k_omega_sst", "boussinesq": True, "gravity": [0.0, 0.0, -9.81], "ctm_coupling": "implicit", "monitors": [ {"mass_balance": {"relative": True}}, {"energy_balance": {"relative": True}}, ], } # ANCHOR_END: build_request def requests_client() -> Any: """Load Requests only when the downloaded client starts network activity.""" # Documentation builds import build_request(), which does not need the HTTP dependency. try: import requests except ModuleNotFoundError as error: raise RuntimeError("Install the client requirements before running a simulation") from error return requests def api_url(path: str) -> str: """Resolve a path against the public Vanellus API.""" return f"https://api.vanellus.tech/{path.lstrip('/')}" def request_headers(api_key: str, accept: str) -> dict[str, str]: """Return authentication and response-format headers for one request.""" # Keep the API key in a header rather than placing it in a URL or request body. return {"X-API-Key": api_key, "Accept": accept} def require_success(response: Any, action: str) -> None: """Raise a concise error containing the API response body when a request fails.""" # The response body normally contains more useful validation detail than a traceback. if response.status_code != 200: raise RuntimeError(f"{action} failed with HTTP {response.status_code}: {response.text}") # ANCHOR: submit_request def submit_request(request: JsonObject, api_key: str) -> int: """Submit one simulation request and return its server-generated ID.""" requests = requests_client() response = requests.post( api_url("/simulations"), json=request, headers=request_headers(api_key, "application/json"), timeout=120, ) require_success(response, "Simulation submission") submission = response.json() simulation_id = int(submission["id"]) print(f"Submitted simulation {simulation_id}") # Accepted requests can still contain setup warnings. for warning in submission.get("warnings", []): print(f"API warning: {warning}") return simulation_id # ANCHOR_END: submit_request # ANCHOR: progress_updates def iter_sse_data(response: Any) -> Iterator[str]: """Yield complete data payloads from a server-sent-events response.""" data_lines: list[str] = [] # One SSE event ends at a blank line and may contain more than one data line. for raw_line in response.iter_lines(decode_unicode=True): if raw_line is None: continue if isinstance(raw_line, bytes): raw_line = raw_line.decode(response.encoding or "utf-8", errors="replace") line = raw_line.rstrip("\r\n") if not line: if data_lines: yield "\n".join(data_lines) data_lines.clear() elif line.startswith("data:"): data_lines.append(line.removeprefix("data:").removeprefix(" ")) if data_lines: yield "\n".join(data_lines) def format_progress_value(value: Any) -> str: """Format one live diagnostic, preserving the API's null marker for non-finite output.""" return "NaN" if value is None else f"{float(value):.3e}" def print_progress_updates( api_key: str, simulation_id: int, stop_event: threading.Event, ) -> None: """Print live residual updates until the run or the client thread stops.""" requests = requests_client() try: with requests.get( api_url(f"/simulations/{simulation_id}/progress_stream"), headers=request_headers(api_key, "text/event-stream"), stream=True, timeout=(10, None), ) as response: require_success(response, "Progress stream") for payload in iter_sse_data(response): if stop_event.is_set(): return update = json.loads(payload) residuals = update.get("residuals", {}) residual_text = ", ".join( f"{name}={format_progress_value(value)}" for name, value in sorted(residuals.items()) ) print(f"Iteration {update['iteration']}: {residual_text}", flush=True) except (requests.RequestException, RuntimeError, json.JSONDecodeError) as error: # Status polling remains authoritative if the optional stream disconnects. if not stop_event.is_set(): print(f"Progress stream stopped: {error}") # ANCHOR_END: progress_updates def get_status(api_key: str, simulation_id: int) -> JsonObject: """Fetch the current lifecycle and solver status for one simulation.""" requests = requests_client() response = requests.get( api_url(f"/simulations/{simulation_id}/status"), headers=request_headers(api_key, "application/json"), timeout=120, ) require_success(response, "Status request") return response.json() def cancel_simulation(api_key: str, simulation_id: int) -> None: """Request a recoverable stop at the next solver iteration boundary.""" requests = requests_client() # Cancellation preserves partial results, unlike an immediate kill request. response = requests.post( api_url(f"/simulations/{simulation_id}/cancel"), headers=request_headers(api_key, "application/json"), timeout=120, ) require_success(response, "Cancellation") print(f"Cancellation requested for simulation {simulation_id}") # ANCHOR: wait_for_completion def wait_for_completion( api_key: str, simulation_id: int, dry_run: bool = False, ) -> JsonObject: """Stream progress while polling the authoritative simulation status.""" stop_event = threading.Event() reported_cells = False progress_thread: threading.Thread | None = None # Dry runs have no iterative residual history, so they only need status polling. if not dry_run: progress_thread = threading.Thread( target=print_progress_updates, kwargs={ "api_key": api_key, "simulation_id": simulation_id, "stop_event": stop_event, }, daemon=True, ) progress_thread.start() try: while True: status = get_status(api_key=api_key, simulation_id=simulation_id) if status.get("num_cells") and not reported_cells: print(f"Mesh cells: {int(status['num_cells']):,}", flush=True) reported_cells = True if status.get("status") in {"diverged", "error", "killed"} or status.get("error"): raise RuntimeError(f"Simulation stopped without results: {status}") if status.get("completed"): return status time.sleep(2) except KeyboardInterrupt: # Ctrl-C asks the server to preserve a partial result at an iteration boundary. cancel_simulation(api_key=api_key, simulation_id=simulation_id) raise SystemExit(130) from None finally: stop_event.set() if progress_thread is not None: progress_thread.join(timeout=5) # ANCHOR_END: wait_for_completion def extract_zip_safely(archive_bytes: bytes, output_directory: Path) -> None: """Extract a result archive after rejecting paths outside the destination.""" output_directory.mkdir(parents=True, exist_ok=True) output_root = output_directory.resolve() # Validate every member before extracting any file from the downloaded archive. with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: for member in archive.infolist(): destination = (output_directory / member.filename).resolve() if destination != output_root and not destination.is_relative_to(output_root): raise RuntimeError(f"Unsafe path in result archive: {member.filename!r}") archive.extractall(output_directory) # ANCHOR: download_results def download_results( api_key: str, simulation_id: int, output_directory: Path, ) -> None: """Download and safely extract every available result artifact.""" requests = requests_client() response = requests.get( api_url(f"/simulations/{simulation_id}/download"), headers=request_headers(api_key, "application/zip"), timeout=None, ) require_success(response, "Results download") extract_zip_safely(response.content, output_directory) print(f"Extracted results to {output_directory}") # ANCHOR_END: download_results # Shared convergence plotting inserted into standalone tutorial clients at build time. def read_iteration_columns( output_directory: Path, *columns: str, prefix: str | None = None, ) -> list[dict[str, str]]: """Read only selected columns from the combined per-iteration result table.""" iteration_path = output_directory / "iteration_info.csv" with iteration_path.open(newline="", encoding="utf-8") as csv_file: reader = csv.DictReader(csv_file) fieldnames = reader.fieldnames or [] requested = {"iteration", *columns} missing = requested - set(fieldnames) if missing: raise RuntimeError(f"{iteration_path} does not contain {', '.join(sorted(missing))}") # Preserve file order while excluding unrelated monitors, diagnostics, and histories. selected = [name for name in fieldnames if name in requested or (prefix and name.startswith(prefix))] rows = [{name: row[name] for name in selected} for row in reader] if not rows: raise RuntimeError(f"{iteration_path} contains no iteration data") return rows def read_final_junction_temperature(output_directory: Path, component: str = JUNCTION_COMPONENT) -> float: """Read the final component temperature from the combined iteration history.""" column = f"junction_temperature_{component}" # Prefixing keeps junction histories distinct from monitors with similar names. temperatures = [ float(value) for row in read_iteration_columns(output_directory, column) if (value := row.get(column)) ] if not temperatures: raise RuntimeError(f"No junction-temperature result found for {component!r}") return temperatures[-1] def plotting_dependencies() -> tuple[Any, Any]: """Load plotting dependencies only when the downloaded client creates figures.""" # Documentation builds import build_request(), so they must not require optional plotting packages. try: import matplotlib.pyplot as pyplot from matplotlib.ticker import MaxNLocator except ModuleNotFoundError as error: raise RuntimeError("Install the client requirements before generating plots") from error return pyplot, MaxNLocator def style_history_plot(figure: Any, axes: Any, title: str, ylabel: str, max_n_locator: Any) -> None: """Apply the shared dark result style to one convergence plot.""" # Matching styles make plots from separate runs easy to compare side by side. figure.patch.set_facecolor(PLOT_BACKGROUND) axes.set_facecolor(PLOT_BACKGROUND) axes.set_title(title, color=PLOT_FOREGROUND, fontsize=HISTORY_TITLE_SIZE, weight="bold") axes.set_xlabel("Iteration", color=PLOT_FOREGROUND, fontsize=HISTORY_LABEL_SIZE) axes.set_ylabel(ylabel, color=PLOT_FOREGROUND, fontsize=HISTORY_LABEL_SIZE) axes.grid(color=PLOT_FOREGROUND, alpha=0.13, linewidth=0.8, which="both") axes.tick_params(colors=PLOT_FOREGROUND, labelsize=HISTORY_TICK_SIZE) # A small number of larger tick labels stays legible when two plots share a row. axes.xaxis.set_major_locator(max_n_locator(nbins=5, integer=True)) for spine in axes.spines.values(): spine.set_color("#777b84") def plot_junction_history( output_directory: Path, convergence_window: int, *, component: str = JUNCTION_COMPONENT, component_label: str = JUNCTION_LABEL, output_filename: str = "junction-temperatures.png", legend_location: str = JUNCTION_LEGEND_LOCATION, ) -> Path: """Plot one component's junction temperature and final convergence window.""" pyplot, max_n_locator = plotting_dependencies() column = f"junction_temperature_{component}" rows = read_iteration_columns(output_directory, column) samples = [(int(row["iteration"]), float(value)) for row in rows if (value := row.get(column))] if not samples: raise RuntimeError(f"Iteration history does not contain {column!r}") iterations = [iteration for iteration, _ in samples] temperatures = [temperature for _, temperature in samples] figure, axes = pyplot.subplots(figsize=HISTORY_FIGURE_SIZE, dpi=150) axes.plot( iterations, temperatures, color="#59a5ff", linewidth=2.5, label=f"{component_label} junction", ) # Shade the exact history used by the junction-temperature stopping criterion. first_window_iteration = max(iterations[0], iterations[-1] - convergence_window + 1) axes.axvspan( first_window_iteration, iterations[-1], color="#59a5ff", alpha=0.12, label="convergence window", ) axes.scatter(iterations[-1], temperatures[-1], color="#59a5ff", s=45, zorder=3) axes.annotate( f"{temperatures[-1]:.2f} °C", xy=(iterations[-1], temperatures[-1]), xytext=(-18, -24), textcoords="offset points", ha="right", va="top", color=PLOT_FOREGROUND, fontsize=16, arrowprops={"arrowstyle": "->", "color": "#59a5ff"}, ) style_history_plot( figure, axes, "Junction temperature convergence", "Junction temperature (°C)", max_n_locator, ) axes.yaxis.set_major_locator(max_n_locator(nbins=5)) legend = axes.legend(loc=legend_location, fontsize=HISTORY_LEGEND_SIZE, framealpha=0.9) legend.get_frame().set_facecolor("#30333b") legend.get_frame().set_edgecolor("#777b84") for text in legend.get_texts(): text.set_color(PLOT_FOREGROUND) figure.tight_layout(pad=1.5) output_path = output_directory / output_filename figure.savefig(output_path, facecolor=figure.get_facecolor()) pyplot.close(figure) return output_path def plot_residual_history( output_directory: Path, target_residual: float, *, output_filename: str = "residuals.png", ) -> Path: """Plot every nonzero solver residual on a logarithmic axis.""" pyplot, max_n_locator = plotting_dependencies() rows = read_iteration_columns(output_directory, prefix="residual_") fields = [name for name in rows[0] if name != "iteration"] if not fields: raise RuntimeError("The iteration history contains no residual columns") colors = ["#59a5ff", "#f28e72", "#8bd17c", "#b699e8", "#f1ce63", "#ff9da7", "#76b7b2"] figure, axes = pyplot.subplots(figsize=HISTORY_FIGURE_SIZE, dpi=150) for field_name, color in zip(fields, colors, strict=False): samples = [(int(row["iteration"]), float(value)) for row in rows if (value := row.get(field_name))] iterations = [iteration for iteration, _ in samples] values = [value for _, value in samples] # Logarithmic axes cannot display zero-valued residual samples. plot_values = [value if value > 0.0 else math.nan for value in values] axes.semilogy( iterations, plot_values, label=field_name.removeprefix("residual_").replace("_", " "), color=color, linewidth=2, ) axes.axhline( target_residual, color="#c6c8ce", linestyle="--", label=f"target {target_residual:g}", ) style_history_plot(figure, axes, "Solver residual history", "Normalized residual", max_n_locator) legend = axes.legend( loc="upper right", fontsize=HISTORY_LEGEND_SIZE, framealpha=0.9, ncols=2, columnspacing=1.0, handlelength=2.0, ) legend.get_frame().set_facecolor("#30333b") legend.get_frame().set_edgecolor("#777b84") for text in legend.get_texts(): text.set_color(PLOT_FOREGROUND) figure.tight_layout(pad=1.5) output_path = output_directory / output_filename figure.savefig(output_path, facecolor=figure.get_facecolor()) pyplot.close(figure) return output_path def plot_histories(output_directory: Path, request: JsonObject) -> None: """Create both convergence plots beside the downloaded iteration table.""" # Read plotting controls from the submitted request so changed cases stay consistent. plot_junction_history(output_directory, convergence_window=int(request["convergence_window"])) plot_residual_history(output_directory, target_residual=float(request["target_residual"])) print(f"Saved convergence plots to {output_directory}") def report_outcome(status: JsonObject, output_directory: Path) -> None: """Print the stopping reason and final Jetson junction temperature.""" final_state = str(status["status"]) print(f"Simulation finished with status {final_state}") if final_state not in {"residual_converged", "monitor_converged"}: raise RuntimeError(f"Simulation ended as {final_state}; inspect its diagnostic results") runtime_seconds = status.get("runtime_seconds") if final_state == "monitor_converged" and isinstance(runtime_seconds, (int, float)): # Confirm the engineering stopping criterion and its measured solver runtime. print(f"Junction-temperature convergence reached in {runtime_seconds:.1f} seconds") junction_temperature = read_final_junction_temperature(output_directory, "jetson") print(f"Jetson junction temperature: {junction_temperature:.1f} °C") if junction_temperature > THERMAL_TARGET_C: print(f"The Jetson is above the {THERMAL_TARGET_C:.0f} °C tutorial target.") else: print(f"The Jetson is below the {THERMAL_TARGET_C:.0f} °C tutorial target.") # ANCHOR: run_simulation def run_simulation( request: JsonObject, api_key: str, output_root: Path, output_name: str | None, ) -> None: """Submit, follow, download, and summarize the electronics simulation.""" # Reject an occupied custom folder before submitting a job that uses credits. if output_name is not None: planned_output = output_root / output_name if planned_output.exists() and (not planned_output.is_dir() or any(planned_output.iterdir())): raise FileExistsError(f"Output directory is not empty: {planned_output}") simulation_id = submit_request(request, api_key=api_key) status = wait_for_completion(api_key=api_key, simulation_id=simulation_id) output_directory = output_root / (output_name or f"simulation-{simulation_id}") download_results( api_key=api_key, simulation_id=simulation_id, output_directory=output_directory, ) # Preserve the exact client request next to the server-normalized request. (output_directory / "submitted-request.json").write_text( json.dumps(request, indent=2) + "\n", encoding="utf-8", ) plot_histories(output_directory, request) report_outcome(status, output_directory) # ANCHOR_END: run_simulation def parse_arguments() -> argparse.Namespace: """Parse physical inputs and the result location.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--jetson-power", type=float, default=JETSON_POWER_W, help=f"Jetson power in W (default: {JETSON_POWER_W:g})", ) parser.add_argument( "--num-fins", type=int, default=NUM_FINS, help=f"number of heatsink fins (default: {NUM_FINS})", ) parser.add_argument("--output", type=Path, default=Path("results"), help="parent result directory") parser.add_argument("--name", help="result folder name (default: simulation ID)") arguments = parser.parse_args() # Keep custom names inside the selected output directory. if arguments.name and (Path(arguments.name).name != arguments.name or arguments.name in {".", ".."}): parser.error("--name must be a folder name; use --output to choose its parent") return arguments def main() -> None: """Read credentials and run the configured first simulation.""" arguments = parse_arguments() # Read the API key from the environment so it never enters the source or result files. api_key = os.environ.get("VANELLUS_API_KEY") if not api_key: raise SystemExit("Set VANELLUS_API_KEY before running this script") request = build_request( jetson_power=arguments.jetson_power, num_fins=arguments.num_fins, ) run_simulation( request, api_key=api_key, output_root=arguments.output, output_name=arguments.name, ) if __name__ == "__main__": main()