diff --git a/MANIFEST.in b/MANIFEST.in index b66ca12..66c3a8c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,5 +7,8 @@ recursive-include pythonfmu3/tests/slaves * recursive-include pythonfmu3/resources * recursive-include pythonfmu3/pythonfmu-export * +recursive-include pythonfmu3/studio/static * +recursive-include pythonfmu3/studio/templates *.py + prune pythonfmu3/pythonfmu-export/build global-exclude __pycache__ *.py[cod] diff --git a/README.md b/README.md index 765eebd..6a92f45 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ On Windows, `python3.dll` must be on the `PATH`. See [Running FMUs outside Python](docs/install.md#running-fmus-outside-python-linux-and-macos) for the full lookup order. +### PythonFMU3 Studio + +`pythonfmu3 studio` opens a local web app to write, check, simulate, build and inspect +FMUs, with live plots, a model-description preview, an FMU inspector and templates. It +needs no extra dependencies. See the [Studio docs](docs/studio.md). + +![PythonFMU3 Studio](docs/assets/studio-simulate.png) + ### How do I build an FMU from python code? 1. Install `pythonfmu3` package: diff --git a/docs/assets/studio-editor.png b/docs/assets/studio-editor.png new file mode 100644 index 0000000..17bbc74 Binary files /dev/null and b/docs/assets/studio-editor.png differ diff --git a/docs/assets/studio-inspector.png b/docs/assets/studio-inspector.png new file mode 100644 index 0000000..19aa91b Binary files /dev/null and b/docs/assets/studio-inspector.png differ diff --git a/docs/assets/studio-simulate.png b/docs/assets/studio-simulate.png new file mode 100644 index 0000000..45c9ccc Binary files /dev/null and b/docs/assets/studio-simulate.png differ diff --git a/docs/assets/studio-templates.png b/docs/assets/studio-templates.png new file mode 100644 index 0000000..e6c14fe Binary files /dev/null and b/docs/assets/studio-templates.png differ diff --git a/docs/changelog.md b/docs/changelog.md index 18e3e03..61363c1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,19 @@ # Changelog -## Unreleased +## 1.1.0 (2026-11-14) + +### Added +- **PythonFMU3 Studio** (`pythonfmu3 studio`): a local web app to write, check, simulate, + build and inspect FMUs, with no extra dependencies. It has an editor that checks the + model on every save, a Python-preview simulator with live sliders (plus fmusim and + FMPy), an FMU inspector for any FMI 2.0/3.0 FMU, and seven model templates. See + [PythonFMU3 Studio](studio.md). +- Example: 1D pipe flow for conjugate heat transfer co-simulation + (`examples/pipe_cht`), with the number of segments as a structural parameter. +- [Migrating from PythonFMU](migration.md): a guide for moving FMI 2.0 PythonFMU models + to PythonFMU3. + +## 1.0.0 (2026-11-12) ### Added - FMUs can be loaded by native (non-Python) FMI importers on Linux and macOS, such as diff --git a/docs/index.md b/docs/index.md index 25b9ce5..9fab301 100644 --- a/docs/index.md +++ b/docs/index.md @@ -151,6 +151,7 @@ Explore comprehensive examples and documentation: - **[Co-simulation Examples](usage.md)**: PID controller, units, arrays, step results - **[Model Exchange Examples](usageMX.md)**: Van der Pol oscillator, bouncing ball with events +- **[PythonFMU3 Studio](studio.md)**: the `pythonfmu3 studio` web app for editing, simulating and inspecting FMUs - **[Installation Guide](install.md)**: installing, building from source, and running FMUs outside Python - **[Dependencies](dependencies.md)**: third-party packages and which Python an FMU uses diff --git a/docs/studio.md b/docs/studio.md new file mode 100644 index 0000000..338f944 --- /dev/null +++ b/docs/studio.md @@ -0,0 +1,215 @@ +# PythonFMU3 Studio + +PythonFMU3 Studio is a local web app for writing, checking, simulating, building and +inspecting FMUs. It ships with PythonFMU3, needs no extra packages, and runs entirely on +your computer. + +![Simulating a model in PythonFMU3 Studio](assets/studio-simulate.png) + +## Getting started + +### Install + +From PyPI: + +```bash +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install pythonfmu3 numpy +``` + +From a clone of the repository (for development), install it in editable mode instead: + +```bash +cd PythonFMU3 +python3 -m venv .venv +source .venv/bin/activate +pip install -e . numpy +``` + +NumPy is only needed by models that use arrays and by the polynomial-surrogate +template. + +### Build the native library (source installs only) + +Building an FMU needs PythonFMU3's compiled library for your platform. PyPI packages +include it for Windows and Linux x86_64. In a clone, or on macOS and ARM, build it once +with CMake and a C++17 compiler: + +```bash +cmake -S pythonfmu3/pythonfmu-export -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --config Release +``` + +The library is written to `pythonfmu3/resources/binaries//`, for example +`aarch64-darwin` on Apple Silicon. Without it, the editor, the Python preview, the +Inspector and the templates still work, but built FMUs contain no binaries and can't be +simulated (see [Troubleshooting](#troubleshooting)). + +### Start the Studio + +```bash +pythonfmu3 studio # use the current folder as the workspace +pythonfmu3 studio my_models # or another folder +``` + +The command prints a link and opens it in your browser. Keep the terminal open while you +work; press Ctrl+C there to stop the Studio. + +### Optional tools + +| Tool | Enables | Install | +| --- | --- | --- | +| [fmusim](https://github.com/modelica/fmusim) | Full validation, and simulating the built FMU in a native (non-Python) importer | Download the release for your platform and put `fmusim` on your `PATH`, or set `PYTHONFMU3_FMUSIM` to its full path | +| [FMPy](https://github.com/CATIA-Systems/FMPy) | Simulating the built FMU with FMPy | `pip install fmpy` | + +The status bar at the bottom of the Studio shows which of these were found. + +## Editor + +![The editor, with the FMU block and the variables table](assets/studio-editor.png) + +- Choose a model from the file menu at the top. Every `.py` file in the workspace is + listed. Edit it and save with **Ctrl+S** (**Cmd+S** on macOS), or the **Save** button. +- Each save loads the model in a separate Python process and checks it. The right-hand + panel has three tabs: + - **FMU block:** inputs on the left, outputs on the right, parameters on top. + - **modelDescription.xml:** exactly what will go into the FMU. + - **Checks:** problems found in the model; the number next to the tab is how many. +- The **Variables** table lists every registered variable with its value reference, + type, causality, variability, unit and start value, and flags problems per variable. +- **Build FMU** writes the FMU to `build/` in the workspace and offers a download. +- **Validate** builds the FMU and checks it with fmusim, or runs basic archive checks if + fmusim isn't installed. + +### What the checks catch + +| Check | Why it matters | +| --- | --- | +| No independent variable | FMI 3.0 expects the time variable to be declared: register a `Float64` with `causality=Fmi3Causality.independent`. | +| Input or parameter not settable | `register_variable` only creates a setter if the attribute already exists, so importers can't set it. Create the attribute first, or pass `setter=`. | +| Integer, Boolean or String marked continuous | Only `Float64` variables can be continuous; use `discrete`, or `fixed`/`tunable` for parameters. | +| Name starting with `_` | PythonFMU3 strips leading underscores when it looks up the attribute, so `_x` reads `self.x`. | +| Class attribute ignored | `author`, `description`, `license`, `version` and `copyright` must be set in `__init__` (`self.author = ...`); class-level values don't reach the model description. | +| Model description can't be generated | Any error raised while building the model description, with its message. | + +If the model can't be loaded at all, for example because of a syntax error, the Checks +tab shows the Python traceback. + +## Simulate + +Choose a simulator, set the stop time and output interval, and press **Run**. + +| Simulator | What it does | +| --- | --- | +| Python preview | Runs your class directly: `do_step` at each output interval for Co-Simulation, and a fixed-step RK4 integrator with event location for Model Exchange. No FMU is built, so it's quick. | +| fmusim | Builds the FMU and runs it in fmusim, a native importer, the way most simulation tools would load it. | +| FMPy | Builds the FMU and runs it with FMPy. | + +- For models that support both interfaces, choose Co-Simulation or Model Exchange under + **Interface**. +- Every scalar parameter and input gets a number box and a slider. With **Re-run on + change** ticked, the Python preview re-runs as you drag. **Reset values** returns to + the model's start values. +- Tick the variables to plot. Hover a chart to read values; dashed lines mark events. +- **Download CSV** saves the result. +- Anything your model prints, and the simulator's log, appears under **Output and log**. + +The Python preview is meant for quick checks while you edit. Use fmusim or FMPy to +confirm the built FMU behaves the same in an importer. + +## Inspector + +![The Inspector showing a built FMU](assets/studio-inspector.png) + +Drop any `.fmu` file on the Inspector, use **Open FMU…**, or pick an FMU from the +workspace. It works with FMI 2.0 and 3.0 FMUs from any tool, and shows: + +- the model name, description, FMI version, interfaces, platforms with binaries, and + whether sources are included; +- who generated it and when, its capabilities, units and default experiment; +- every variable, filterable by inputs, outputs, parameters and other; +- archive checks, such as a missing binary for a listed platform, and the fmusim + validation result when fmusim is installed. + +Uploaded FMUs are only read, never run. + +## Templates + +![The template gallery](assets/studio-templates.png) + +Start a new model from a working template. **Use template** copies it into the workspace +under a new name, such as `thermostat.py` or `thermostat_2.py`, and opens it in the +editor. + +| Template | Interface | Shows | +| --- | --- | --- | +| PID controller | Co-Simulation | Inputs, tunable parameters, output limits and anti-windup | +| Bouncing ball | Model Exchange | State events and units | +| Van der Pol | Model Exchange | Two continuous states | +| Dahlquist | Both | The same model in both interfaces, with an exact solution | +| Thermostat | Co-Simulation | Boolean output, discrete state, units with an offset | +| Polynomial surrogate | Co-Simulation | A data-driven model with NumPy: the pattern for ML surrogates | +| Signal generator | Co-Simulation | An `Int32` mode parameter selecting the signal | + +## Command-line options + +``` +pythonfmu3 studio [folder] [--port PORT] [--no-browser] +``` + +| Option | Meaning | +| --- | --- | +| `folder` | The workspace (default: the current folder). Only files inside it are read or written. | +| `-p`, `--port` | Port to listen on (default: any free port). | +| `--no-browser` | Don't open a browser; open the printed link yourself. | + +## Security + +- The server listens only on `127.0.0.1`, so other computers can't reach it. +- Every request needs the access token from the printed link, and must be addressed to + `127.0.0.1` or `localhost`. That stops other web pages open in your browser from using + it. +- Only the workspace folder is read or written. Uploaded FMUs go to a temporary folder + that is deleted when the Studio stops. +- Model code runs in a separate Python process for each request, so an error or crash in + your model can't take the Studio down. + +The Studio runs the Python code in your models, just as building an FMU does. Only open +workspaces you trust, and don't expose the Studio over a network. + +## Troubleshooting + +### `command not found: pythonfmu3` + +PythonFMU3 isn't installed in the active Python environment. Activate your virtual +environment (`source .venv/bin/activate`) and install it (see [Install](#install)), or +run `python -m pythonfmu3 studio` from an environment where it is installed. + +### "Built … · no binaries" + +The FMU was built without PythonFMU3's native library for your platform, so simulators +can't load it. Build the library as described in +[Build the native library](#build-the-native-library-source-installs-only), then build +the FMU again. + +### fmusim or FMPy buttons are greyed out + +The tool wasn't found. Put `fmusim` on your `PATH` (or set `PYTHONFMU3_FMUSIM`) or +`pip install fmpy`, then restart the Studio. + +### "Missing access token" or "Missing or invalid token" + +Open the Studio with the full link printed in the terminal; the token changes every +time the Studio starts. + +### The model fails to load + +The Checks tab shows the Python traceback. Check that the script defines exactly one +class inheriting `Fmi3Slave` (or `Fmi3SlaveBase` with a mixin), and that any packages +it imports are installed in the same environment as PythonFMU3. + +### The page doesn't open automatically + +Copy the printed link into your browser, or start with `--no-browser` and open it +yourself. diff --git a/mkdocs.yml b/mkdocs.yml index 9e932c4..a6d7277 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ nav: - Co-simulation: usage.md - Model Exchange: usageMX.md - Dependencies: dependencies.md + - Studio: studio.md - Migrating from PythonFMU: migration.md - Changelog: changelog.md theme: readthedocs \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 229ea73..ec62b5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,8 @@ pythonfmu3 = [ "pythonfmu-export/**/*", "tests/data/*", "tests/slaves/*", + "studio/static/*", + "studio/templates/*.py", ] [tool.setuptools.exclude-package-data] diff --git a/pythonfmu3/__main__.py b/pythonfmu3/__main__.py index da58e8d..789a4b7 100644 --- a/pythonfmu3/__main__.py +++ b/pythonfmu3/__main__.py @@ -43,6 +43,14 @@ def default_execution(**kwargs): ) deploy.create_command_parser(deploy_parser) + studio_parser = subparsers.add_parser( + "studio", + description="Open PythonFMU3 Studio, a local web app to edit, check, simulate, build and inspect FMUs.", + help="Open the PythonFMU3 Studio web app." + ) + from .studio.server import create_command_parser as create_studio_parser + create_studio_parser(studio_parser) + options = vars(parser.parse_args()) execute = options.pop("execute") execute(**options) diff --git a/pythonfmu3/_version.py b/pythonfmu3/_version.py index 5becc17..6849410 100644 --- a/pythonfmu3/_version.py +++ b/pythonfmu3/_version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/pythonfmu3/studio/__init__.py b/pythonfmu3/studio/__init__.py new file mode 100644 index 0000000..f3de22e --- /dev/null +++ b/pythonfmu3/studio/__init__.py @@ -0,0 +1 @@ +"""PythonFMU3 Studio: a local web app for building and checking FMUs (``pythonfmu3 studio``).""" diff --git a/pythonfmu3/studio/server.py b/pythonfmu3/studio/server.py new file mode 100644 index 0000000..0bb330d --- /dev/null +++ b/pythonfmu3/studio/server.py @@ -0,0 +1,358 @@ +"""PythonFMU3 Studio: a local web app to edit, check, simulate, build and inspect FMUs. + +Start it with ``pythonfmu3 studio [folder]``. It uses only the standard library: a +threaded HTTP server on 127.0.0.1 serving the static app and a small JSON API. Model +code runs in a separate worker process per request (see worker.py). + +Security: the server only listens on the loopback interface, only answers requests +whose Host header is the loopback address, and requires a random token (generated per +run and passed to the browser in the URL) on every API call, so other web pages open in +the browser can't drive it. +""" +import argparse +import json +import mimetypes +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import threading +import webbrowser +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, quote, urlparse + +from .._version import __version__ + +HERE = Path(__file__).parent +STATIC = HERE / "static" +TEMPLATES = HERE / "templates" +WORKER_TIMEOUT = 300 +MAX_UPLOAD = 200 * 1024 * 1024 +TEMPLATE_INFO = { + "pid_controller": ("PID controller", "Co-Simulation", "control", + "Setpoint and measurement in, actuator command out, with tunable gains and output limits.", + ["inputs", "tunable parameters"]), + "bouncing_ball": ("Bouncing ball", "Model Exchange", "me", + "Free fall with a state event at each impact and a restitution coefficient.", + ["events", "units"]), + "van_der_pol": ("Van der Pol", "Model Exchange", "me", + "A nonlinear oscillator with two continuous states, for testing solvers.", + ["continuous states"]), + "dahlquist": ("Dahlquist", "Model Exchange + Co-Simulation", "me cs", + "dx/dt = -k·x in both interfaces: the smallest model, with an exact solution.", + ["both interfaces"]), + "thermostat": ("Thermostat", "Co-Simulation", "control", + "A heated room with on/off control and hysteresis: Boolean output and a discrete state.", + ["Boolean", "discrete"]), + "surrogate": ("Polynomial surrogate", "Co-Simulation", "ml", + "A fitted NumPy polynomial exposed as an FMU: the pattern for ML surrogate models.", + ["numpy", "data-driven"]), + "signal_generator": ("Signal generator", "Co-Simulation", "cs", + "Sine, square and ramp signals selected by an Int32 mode: a test-bench source.", + ["Int32 mode", "outputs"]), +} + + +class StudioState: + def __init__(self, workspace: Path): + self.workspace = workspace.resolve() + self.token = secrets.token_urlsafe(24) + self.uploads = Path(tempfile.mkdtemp(prefix="pythonfmu3_studio_")) + self.last_build: Dict[str, str] = {} + + def resolve(self, relative: str, must_exist: bool = True) -> Path: + """Resolve a workspace-relative path, refusing anything outside the workspace.""" + if not relative or "\x00" in relative: + raise ValueError("empty path") + path = (self.workspace / relative).resolve() + if path != self.workspace and self.workspace not in path.parents: + raise PermissionError(f"{relative} is outside the workspace") + if must_exist and not path.exists(): + raise FileNotFoundError(relative) + return path + + def rel(self, path: Path) -> str: + return path.resolve().relative_to(self.workspace).as_posix() + + +def run_worker(request: Dict[str, Any], cwd: Path) -> Dict[str, Any]: + env = dict(os.environ) + package_root = str(HERE.parent.parent) + env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "") + try: + proc = subprocess.run( + [sys.executable, "-m", "pythonfmu3.studio.worker"], input=json.dumps(request), + capture_output=True, text=True, timeout=WORKER_TIMEOUT, cwd=str(cwd), env=env, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "error": f"Timed out after {WORKER_TIMEOUT} s"} + try: + return json.loads(proc.stdout) + except json.JSONDecodeError: + return {"ok": False, "error": "The worker process failed", "traceback": (proc.stdout + proc.stderr)[-8000:]} + + +def environment() -> Dict[str, Any]: + from .worker import find_fmusim + + def has(module): + import importlib.util + return importlib.util.find_spec(module) is not None + + return { + "version": __version__, + "python": sys.version.split()[0], + "fmusim": find_fmusim(), + "fmpy": has("fmpy"), + "numpy": has("numpy"), + } + + +class Handler(BaseHTTPRequestHandler): + server_version = f"PythonFMU3Studio/{__version__}" + state: StudioState # set on the class by serve() + + # -- plumbing --------------------------------------------------------------------------- + + def log_message(self, fmt, *args): # keep the terminal quiet + if os.environ.get("PYTHONFMU3_STUDIO_DEBUG"): + super().log_message(fmt, *args) + + def _send(self, status: int, body: bytes, content_type: str, extra: Optional[Dict[str, str]] = None): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _json(self, payload: Any, status: int = 200): + self._send(status, json.dumps(payload, default=str).encode("utf-8"), "application/json; charset=utf-8") + + def _error(self, status: int, message: str, detail: str = ""): + self._json({"ok": False, "error": message, "traceback": detail}, status) + + def _host_ok(self) -> bool: + host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]") + return host in ("127.0.0.1", "localhost", "::1") + + def _token_ok(self, query: Dict[str, list]) -> bool: + supplied = self.headers.get("X-Studio-Token") or (query.get("token") or [""])[0] + return secrets.compare_digest(supplied, self.state.token) + + def _body(self) -> bytes: + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_UPLOAD: + raise ValueError("Upload too large") + return self.rfile.read(length) if length else b"" + + def _json_body(self) -> Dict[str, Any]: + data = self._body() + return json.loads(data or b"{}") + + # -- routing ---------------------------------------------------------------------------- + + def do_GET(self): + self._dispatch("GET") + + def do_POST(self): + self._dispatch("POST") + + def _dispatch(self, method: str): + if not self._host_ok(): + return self._error(HTTPStatus.FORBIDDEN, "Invalid Host header") + url = urlparse(self.path) + query = parse_qs(url.query) + if method == "GET" and not url.path.startswith("/api/"): + return self._static(url.path) + if not self._token_ok(query): + return self._error(HTTPStatus.FORBIDDEN, "Missing or invalid token") + route = getattr(self, f"api_{method.lower()}_{url.path[5:].replace('/', '_')}", None) + if route is None: + return self._error(HTTPStatus.NOT_FOUND, f"No such endpoint: {url.path}") + try: + route(query) + except (FileNotFoundError, PermissionError, ValueError, KeyError) as exc: + self._error(HTTPStatus.BAD_REQUEST, f"{type(exc).__name__}: {exc}") + except Exception as exc: # pragma: no cover - unexpected + import traceback + self._error(HTTPStatus.INTERNAL_SERVER_ERROR, f"{type(exc).__name__}: {exc}", traceback.format_exc()) + + def _static(self, path: str): + name = "index.html" if path in ("", "/") else path.lstrip("/") + target = (STATIC / name).resolve() + if STATIC.resolve() not in target.parents or not target.is_file(): + return self._error(HTTPStatus.NOT_FOUND, "Not found") + ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream" + if ctype.startswith("text/") or ctype in ("application/javascript",): + ctype += "; charset=utf-8" + csp = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'") + self._send(200, target.read_bytes(), ctype, {"Content-Security-Policy": csp}) + + # -- API: environment and files --------------------------------------------------------- + + def api_get_env(self, query): + info = environment() + info["workspace"] = str(self.state.workspace) + self._json({"ok": True, "result": info}) + + def api_get_files(self, query): + files = [] + skip = {".git", "__pycache__", ".venv", "venv", "node_modules"} + for path in sorted(self.state.workspace.rglob("*")): + rel = path.relative_to(self.state.workspace) + if any(part in skip or part.startswith(".") for part in rel.parts[:-1]): + continue + if path.is_file() and path.suffix in (".py", ".fmu"): + files.append({"path": rel.as_posix(), "kind": path.suffix[1:], "size": path.stat().st_size}) + if len(files) >= 500: + break + self._json({"ok": True, "result": files}) + + def api_get_file(self, query): + path = self.state.resolve(query["path"][0]) + if path.suffix != ".py": + raise ValueError("Only .py files can be opened in the editor") + self._json({"ok": True, "result": {"path": self.state.rel(path), "content": path.read_text(encoding="utf-8")}}) + + def api_post_file(self, query): + body = self._json_body() + path = self.state.resolve(body["path"], must_exist=False) + if path.suffix != ".py": + raise ValueError("Only .py files can be saved") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body["content"], encoding="utf-8") + self._json({"ok": True, "result": {"path": self.state.rel(path)}}) + + # -- API: model operations (worker process) --------------------------------------------- + + def _model_request(self, command: str, body: Dict[str, Any]): + path = self.state.resolve(body["path"]) + request = dict(body, command=command, path=str(path)) + return path, run_worker(request, path.parent) + + def api_post_analyze(self, query): + _, response = self._model_request("analyze", self._json_body()) + self._json(response) + + def api_post_simulate(self, query): + _, response = self._model_request("simulate", self._json_body()) + self._json(response) + + def api_post_build(self, query): + body = self._json_body() + path = self.state.resolve(body["path"]) + response = run_worker({"command": "build", "path": str(path), + "dest": str(path.parent / "build")}, path.parent) + if response.get("ok"): + fmu = Path(response["result"]["fmu"]) + response["result"]["fmu"] = self.state.rel(fmu) + self.state.last_build[self.state.rel(path)] = self.state.rel(fmu) + self._json(response) + + def api_post_validate(self, query): + body = self._json_body() + fmu = self.state.resolve(body["fmu"]) + self._json(run_worker({"command": "validate", "fmu": str(fmu)}, self.state.workspace)) + + def api_get_download(self, query): + path = self.state.resolve(query["path"][0]) + if path.suffix != ".fmu": + raise ValueError("Only FMUs can be downloaded") + self._send(200, path.read_bytes(), "application/octet-stream", + {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(path.name)}"}) + + def api_post_inspect(self, query): + if (self.headers.get("Content-Type") or "").startswith("application/json"): + body = self._json_body() + fmu = self.state.resolve(body["path"]) + name = fmu.name + else: + name = re.sub(r"[^A-Za-z0-9._-]", "_", (query.get("name") or ["upload.fmu"])[0]) or "upload.fmu" + fmu = self.state.uploads / secrets.token_hex(8) / name + fmu.parent.mkdir(parents=True) + fmu.write_bytes(self._body()) + response = run_worker({"command": "inspect", "path": str(fmu), "name": name}, self.state.workspace) + if response.get("ok") and environment()["fmusim"]: + response["result"]["validation"] = run_worker({"command": "validate", "fmu": str(fmu)}, + self.state.workspace).get("result") + self._json(response) + + # -- API: templates ----------------------------------------------------------------------- + + def api_get_templates(self, query): + items = [] + for key, (title, iface, cats, desc, tags) in TEMPLATE_INFO.items(): + if (TEMPLATES / f"{key}.py").exists(): + items.append({"id": key, "title": title, "interface": iface, "categories": cats.split(), + "description": desc, "tags": tags}) + self._json({"ok": True, "result": items}) + + def api_post_templates_use(self, query): + body = self._json_body() + key = body["id"] + if key not in TEMPLATE_INFO: + raise KeyError(key) + target = self.state.resolve(body.get("name") or f"{key}.py", must_exist=False) + if target.suffix != ".py": + raise ValueError("The file name must end in .py") + if target.exists(): + raise ValueError(f"{self.state.rel(target)} already exists") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(TEMPLATES / f"{key}.py", target) + self._json({"ok": True, "result": {"path": self.state.rel(target)}}) + + +def serve(workspace: Path, port: int = 0, open_browser: bool = True) -> ThreadingHTTPServer: + state = StudioState(workspace) + handler = type("StudioHandler", (Handler,), {"state": state}) + httpd = ThreadingHTTPServer(("127.0.0.1", port), handler) + httpd.studio_state = state + url = f"http://127.0.0.1:{httpd.server_address[1]}/?token={state.token}" + httpd.studio_url = url + if open_browser: + threading.Timer(0.3, lambda: webbrowser.open(url)).start() + return httpd + + +def main(folder: str = ".", port: int = 0, no_browser: bool = False, **_): + workspace = Path(folder).resolve() + if not workspace.is_dir(): + raise SystemExit(f"{workspace} is not a folder") + httpd = serve(workspace, port, open_browser=not no_browser) + print(f"PythonFMU3 Studio {__version__}") + print(f" workspace: {workspace}") + print(f" open: {httpd.studio_url}") + print(" (Ctrl+C to stop)") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() + shutil.rmtree(httpd.studio_state.uploads, ignore_errors=True) + + +def create_command_parser(parser: argparse.ArgumentParser): + parser.add_argument("folder", nargs="?", default=".", help="Workspace folder with your model scripts (default: current folder).") + parser.add_argument("-p", "--port", type=int, default=0, help="Port to listen on (default: a free port).") + parser.add_argument("--no-browser", action="store_true", help="Don't open a browser window.") + parser.set_defaults(execute=main) + + +if __name__ == "__main__": + p = argparse.ArgumentParser(prog="pythonfmu3 studio") + create_command_parser(p) + args = vars(p.parse_args()) + args.pop("execute")(**args) diff --git a/pythonfmu3/studio/static/app.css b/pythonfmu3/studio/static/app.css new file mode 100644 index 0000000..97f3576 --- /dev/null +++ b/pythonfmu3/studio/static/app.css @@ -0,0 +1,179 @@ +:root { + --blue: #2F5D8A; --blue-dark: #1F4468; --blue-soft: #E8EEF5; + --gold: #E8B11C; --gold-soft: #FEF6DD; --gold-ink: #6B4E00; + --ink: #1F2933; --ink-2: #46515E; --muted: #5B6673; + --bg: #F4F6F9; --panel: #FFFFFF; --line: #E3E8EF; --line-2: #C9D2DE; + --ok-bg: #ECFDF3; --ok: #05603A; --warn-bg: #FEF0C7; --warn: #7A2E0E; --err-bg: #FEE4E2; --err: #912018; + --code-bg: #0F1720; --code-fg: #D6DEEB; --code-gutter: #6B7A8A; + --mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace; + --sans: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; } +body { min-height: 100vh; display: flex; flex-direction: column; } +h1 { font-size: 26px; margin: 0 0 6px; } +h2 { font-size: 16px; margin: 0; } +h3 { font-size: 13px; margin: 14px 0 8px; color: var(--ink-2); } +button, input, select, textarea { font: inherit; color: inherit; } +a { color: var(--blue); } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.spacer, .grow { flex: 1 1 auto; } +.push { margin-left: auto; } +[hidden] { display: none !important; } + +/* top bar */ +.topbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; padding: 10px 20px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 5; } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; } +.accent { color: var(--blue); } +.tabs { display: flex; flex-wrap: wrap; gap: 4px; } +.tab { min-height: 38px; padding: 0 14px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); font-weight: 500; cursor: pointer; } +.tab:hover { background: var(--bg); } +.tab[aria-current="page"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; } +.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.file-picker select, select, input[type=number], input[type=text], input[type=search] { min-height: 38px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 8px; background: #fff; } +.file-picker select { font-family: var(--mono); font-size: 13px; max-width: 260px; } +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; padding: 0 14px; border-radius: 8px; border: 1px solid var(--line-2); background: #fff; color: var(--ink); font-weight: 600; cursor: pointer; text-decoration: none; } +.btn:hover { border-color: var(--blue); } +.btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; } +.btn.primary:hover { background: var(--blue-dark); } +.btn.gold { background: var(--gold); border-color: var(--gold); color: var(--ink); } +.btn:disabled { opacity: 0.55; cursor: progress; } +.btn-row { display: flex; flex-wrap: wrap; gap: 8px; } +:focus-visible { outline: 3px solid #7FB0DE; outline-offset: 2px; } + +.banner { margin: 14px 20px 0; padding: 10px 14px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.banner.ok { background: #DCFAE6; color: var(--ok); } +.banner.error { background: var(--err-bg); color: var(--err); } +.banner.info { background: var(--blue-soft); color: var(--blue-dark); } +.banner pre { flex-basis: 100%; margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: 12px; max-height: 240px; overflow: auto; } + +main { flex: 1 1 auto; padding: 16px 20px 20px; } +.view { display: flex; flex-direction: column; gap: 16px; } +.view.narrow { max-width: 1240px; margin: 0 auto; width: 100%; } +.row { display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch; } +.row.top { align-items: flex-start; } +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; } +.panel-head { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); } + +/* code editor */ +.code-panel { flex: 999 1 560px; display: flex; flex-direction: column; background: var(--code-bg); border-color: var(--code-bg); height: min(72vh, 760px); min-height: 420px; } +.code-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid #22303D; color: #A9B4C2; font-size: 13px; font-family: var(--mono); } +.code-head .muted { color: #8A99A8; font-family: var(--sans); } +.dirty { color: var(--gold); font-family: var(--sans); font-weight: 600; } +.code-body { position: relative; display: flex; flex: 1 1 auto; min-height: 0; } +.gutter { margin: 0; padding: 14px 12px 14px 0; width: 48px; flex: none; text-align: right; color: var(--code-gutter); font: 13px/21px var(--mono); overflow: hidden; user-select: none; } +#code { flex: 1 1 auto; min-width: 0; border: none; resize: none; margin: 0; padding: 14px 14px 14px 4px; background: transparent; color: var(--code-fg); font: 13px/21px var(--mono); tab-size: 4; outline: none; white-space: pre; overflow: auto; } + +/* preview */ +.preview { flex: 1 1 420px; display: flex; flex-direction: column; height: min(72vh, 760px); min-height: 420px; } +.subtabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); } +.subtab { min-height: 38px; padding: 0 12px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); cursor: pointer; } +.subtab[aria-selected="true"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; } +.badge { margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 12px; background: #DCFAE6; color: var(--ok); } +.badge.bad { background: var(--warn-bg); color: var(--warn); } +.preview-body { flex: 1 1 auto; overflow: auto; padding: 16px; min-height: 0; } +.xml { margin: 0; font: 12px/20px var(--mono); background: #FAFBFC; white-space: pre; } +.checks { display: flex; flex-direction: column; gap: 10px; } +.check-item { display: flex; gap: 10px; padding: 10px 12px; border-radius: 8px; } +.check-item strong { width: 56px; flex: none; font-size: 12px; } +.check-item .title { font-weight: 600; } +.check-item.ok { background: var(--ok-bg); color: var(--ok); } +.check-item.warning { background: var(--warn-bg); color: var(--warn); } +.check-item.error { background: var(--err-bg); color: var(--err); } + +/* FMU block */ +.block-wrap { display: flex; flex-direction: column; align-items: center; gap: 18px; padding: 12px 0; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip-param { padding: 5px 10px; border-radius: 999px; background: var(--gold-soft); color: var(--gold-ink); font-size: 13px; font-weight: 500; } +.block-row { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 520px; } +.ports { display: flex; flex-direction: column; gap: 12px; flex: 1 1 0; min-width: 0; } +.ports.in { align-items: flex-end; } +.ports.out { align-items: flex-start; } +.port { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); } +.port i { width: 20px; height: 12px; border-radius: 3px; background: var(--gold); flex: none; } +.port span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; } +.block { width: 150px; min-height: 150px; flex: none; border-radius: 22px; background: var(--blue); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 12px; text-align: center; } +.block b { font-size: 15px; word-break: break-word; } +.block small { color: #D6E2EE; } + +/* tables */ +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; min-width: 760px; } +.table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; padding: 10px 8px; } +.table td { padding: 8px; border-top: 1px solid #EEF1F5; vertical-align: top; } +.table th:first-child, .table td:first-child { padding-left: 16px; } +.mono { font-family: var(--mono); font-size: 13px; } +.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: var(--blue-soft); color: var(--blue-dark); } +.caus-input { background: var(--gold-soft); color: var(--gold-ink); } +.caus-output { background: #E3F2EA; color: var(--ok); } +.caus-structuralParameter { background: #EDE7F6; color: #4A2C83; } +.caus-independent, .caus-local { background: #F2F4F7; color: var(--ink-2); } +.flag { font-size: 12px; padding: 3px 8px; border-radius: 6px; } +.flag.warning { background: var(--warn-bg); color: var(--warn); } +.flag.error { background: var(--err-bg); color: var(--err); } +.flag.ok { color: var(--ok); } + +/* simulate */ +.settings { flex: 1 1 320px; max-width: 100%; padding: 18px; display: flex; flex-direction: column; gap: 14px; overflow: visible; } +fieldset { border: none; margin: 0; padding: 0; } +legend { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 8px; } +.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.grid2 select, .grid2 input[type=number] { width: 100%; } +.grid2 label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; font-weight: 600; color: var(--ink-2); } +.grid2 label.check { flex-direction: row; align-items: center; gap: 8px; font-weight: 500; } +.chip-btn { min-height: 38px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line-2); background: #fff; cursor: pointer; } +.chip-btn[aria-pressed="true"] { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; } +.chip-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.params { display: flex; flex-direction: column; gap: 12px; } +.param { display: flex; flex-direction: column; gap: 4px; } +.param-head { display: flex; align-items: center; gap: 8px; } +.param-head .mono { font-weight: 600; } +.param-head input { width: 110px; margin-left: auto; min-height: 32px; } +.param input[type=range] { width: 100%; accent-color: var(--blue); } +.results { flex: 999 1 600px; min-width: 0; display: flex; flex-direction: column; gap: 14px; } +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; } +.stat { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; } +.stat .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } +.stat .v { font-size: 24px; font-weight: 700; margin-top: 4px; } +.series-picker { display: flex; flex-wrap: wrap; gap: 6px; } +.series-picker label { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 999px; background: #fff; font-family: var(--mono); font-size: 12px; cursor: pointer; } +.series-picker input { accent-color: var(--blue); } +.charts { display: flex; flex-direction: column; gap: 14px; } +.chart { margin: 0; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px 8px; } +.chart figcaption { display: flex; gap: 10px; align-items: baseline; font-weight: 600; margin-bottom: 4px; } +.chart figcaption .mono { font-weight: 400; color: var(--muted); } +.chart svg { display: block; width: 100%; height: auto; } +.chart .readout { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--ink-2); font-weight: 400; } +.empty { padding: 40px 16px; text-align: center; color: var(--muted); background: #fff; border: 1px dashed var(--line-2); border-radius: 12px; } +.log summary { padding: 12px 16px; cursor: pointer; font-weight: 600; } +.log pre { margin: 0; padding: 0 16px 16px; font: 12px/18px var(--mono); white-space: pre-wrap; max-height: 300px; overflow: auto; } + +/* inspector */ +.drop { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; padding: 16px 18px; border: 2px dashed #B8C3D0; border-radius: 12px; background: #fff; } +.drop.over { border-color: var(--blue); background: var(--blue-soft); } +.drop .grow { display: flex; flex-direction: column; gap: 2px; min-width: 220px; } +.summary { display: flex; flex-direction: column; gap: 12px; padding: 18px; } +.summary dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 24px; } +.summary dt { color: var(--muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } +.summary dd { margin: 2px 0 0; word-break: break-word; } +.tag { padding: 4px 10px; border-radius: 999px; background: #F2F4F7; color: var(--ink-2); font-size: 13px; } +.tag.strong { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; } + +/* templates */ +.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0; } +.search { flex: 1 1 260px; } +.search input { width: 100%; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } +.card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 18px; } +.card h2 { font-size: 16px; } +.card p { margin: 0; color: var(--ink-2); flex: 1 1 auto; } + +.statusbar { display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 20px; border-top: 1px solid var(--line); background: #fff; color: var(--muted); font-size: 12px; } + +@media (max-width: 640px) { + .topbar { padding: 10px 16px; } + main { padding: 12px 16px; } + .grid2 { grid-template-columns: minmax(0, 1fr); } +} diff --git a/pythonfmu3/studio/static/app.js b/pythonfmu3/studio/static/app.js new file mode 100644 index 0000000..b3ac335 --- /dev/null +++ b/pythonfmu3/studio/static/app.js @@ -0,0 +1,613 @@ +"use strict"; +/* PythonFMU3 Studio front end: plain JavaScript, no dependencies. */ + +const TOKEN = new URLSearchParams(location.search).get("token") || ""; +const $ = (sel) => document.querySelector(sel); + +const S = { + env: null, + files: [], + path: null, // workspace-relative path of the open .py file + saved: "", // last saved content + analysis: null, + preview: "fmu", + view: "editor", + sim: { simulator: "python", values: {}, result: null, selected: null, running: false, pending: false }, + templates: [], + tplCat: "all", +}; + +/* ---------- helpers ---------- */ + +function el(tag, props, ...children) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props || {})) { + if (v === undefined || v === null || v === false) continue; + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else if (k.startsWith("on")) node.addEventListener(k.slice(2).toLowerCase(), v); + else if (k === "dataset") Object.assign(node.dataset, v); + else node.setAttribute(k, v === true ? "" : v); + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + node.append(c instanceof Node ? c : document.createTextNode(String(c))); + } + return node; +} + +const SVGNS = "http://www.w3.org/2000/svg"; +function svg(tag, attrs, ...children) { + const node = document.createElementNS(SVGNS, tag); + for (const [k, v] of Object.entries(attrs || {})) node.setAttribute(k, v); + for (const c of children.flat()) if (c) node.append(c instanceof Node ? c : document.createTextNode(String(c))); + return node; +} + +async function api(path, { method = "GET", body, raw, query } = {}) { + const url = new URL("/api/" + path, location.origin); + for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v); + const headers = { "X-Studio-Token": TOKEN }; + let payload; + if (raw !== undefined) { payload = raw; headers["Content-Type"] = "application/octet-stream"; } + else if (body !== undefined) { payload = JSON.stringify(body); headers["Content-Type"] = "application/json"; } + const res = await fetch(url, { method, headers, body: payload }); + let data; + try { data = await res.json(); } catch { data = { ok: false, error: `HTTP ${res.status}` }; } + return data; +} + +function banner(kind, message, detail, extra) { + const b = $("#banner"); + b.className = "banner " + kind; + b.replaceChildren(...[el("strong", { text: message }), extra, detail ? el("pre", { text: detail }) : null, + el("button", { type: "button", class: "btn push", onClick: () => (b.hidden = true) }, "Dismiss")].filter(Boolean)); + b.hidden = false; +} + +function busy(button, on) { + if (!button) return; + button.disabled = on; + if (on) { button.dataset.label = button.textContent; button.textContent = "Working…"; } + else if (button.dataset.label) button.textContent = button.dataset.label; +} + +const fmt = (x) => { + if (x === null || x === undefined || Number.isNaN(x)) return "–"; + if (typeof x !== "number") return String(x); + if (x === 0) return "0"; + const a = Math.abs(x); + return a >= 1e5 || a < 1e-3 ? x.toExponential(3) : Number(x.toPrecision(5)).toString(); +}; + +/* ---------- navigation ---------- */ + +function show(view) { + S.view = view; + for (const b of document.querySelectorAll(".tab")) { + if (b.dataset.view === view) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current"); + } + for (const v of ["editor", "simulate", "inspector", "templates"]) $("#view-" + v).hidden = v !== view; + if (view === "simulate") renderSimSettings(); + if (view === "templates" && !S.templates.length) loadTemplates(); + if (view === "inspector") refreshFmuSelect(); +} + +/* ---------- files and editor ---------- */ + +async function loadFiles(select) { + const r = await api("files"); + if (!r.ok) return banner("error", r.error); + S.files = r.result; + const sel = $("#file-select"); + const py = S.files.filter((f) => f.kind === "py"); + sel.replaceChildren(...(py.length ? py.map((f) => el("option", { value: f.path, text: f.path })) : + [el("option", { value: "", text: "No .py files – use a template" })])); + const target = select || S.path || (py[0] && py[0].path); + if (target) { sel.value = target; if (target !== S.path) await openFile(target); } + else if (!py.length) show("templates"); +} + +async function openFile(path) { + if (S.path && dirty() && !confirm(`Discard unsaved changes to ${S.path}?`)) { $("#file-select").value = S.path; return; } + const r = await api("file", { query: { path } }); + if (!r.ok) return banner("error", r.error); + S.path = r.result.path; + S.saved = r.result.content; + $("#code").value = r.result.content; + $("#code-name").textContent = S.path; + S.sim.values = {}; S.sim.result = null; S.sim.selected = null; + updateGutter(); updateDirty(); + await analyze(); +} + +const dirty = () => S.path && $("#code").value !== S.saved; +function updateDirty() { $("#dirty").hidden = !dirty(); } +function updateGutter() { + const n = $("#code").value.split("\n").length; + $("#gutter").textContent = Array.from({ length: n }, (_, i) => i + 1).join("\n"); +} + +async function save() { + if (!S.path) return; + const content = $("#code").value; + const r = await api("file", { method: "POST", body: { path: S.path, content } }); + if (!r.ok) return banner("error", "Could not save", r.error); + S.saved = content; updateDirty(); + await analyze(); +} + +async function analyze() { + if (!S.path) return; + const r = await api("analyze", { method: "POST", body: { path: S.path } }); + if (!r.ok) { + S.analysis = null; + renderAnalysis(r); + return; + } + S.analysis = r.result; + $("#banner").hidden = true; + renderAnalysis(); +} + +/* ---------- analysis rendering ---------- */ + +function renderAnalysis(failure) { + const a = S.analysis; + const block = $("#preview-fmu"), xml = $("#preview-xml"), checks = $("#preview-checks"), table = $("#var-table"); + if (!a) { + const msg = failure ? failure.error : "Open or create a model to see it here."; + block.replaceChildren(el("div", { class: "empty" }, msg)); + xml.textContent = ""; + checks.replaceChildren(el("div", { class: "check-item error" }, el("strong", { text: "ERROR" }), + el("div", {}, el("div", { class: "title", text: "The model could not be loaded" }), + el("pre", { class: "mono", style: "white-space:pre-wrap;margin:6px 0 0", text: failure ? (failure.traceback || failure.error) : "" })))); + table.replaceChildren(); + setBadge(1); + return; + } + const vars = a.variables; + const label = (v) => v.name + (v.size && v.size > 1 ? `[${v.size}]` : "") + (v.unit ? ` [${v.unit}]` : ""); + const params = vars.filter((v) => ["parameter", "structuralParameter"].includes(v.causality)); + const inputs = vars.filter((v) => v.causality === "input"); + const outputs = vars.filter((v) => v.causality === "output"); + const ifaceText = a.interfaces.map((i) => (i === "cs" ? "Co-Simulation" : "Model Exchange")).join(" + "); + block.replaceChildren(el("div", { class: "block-wrap" }, + el("div", { class: "chips" }, params.map((p) => el("span", { class: "chip-param", text: `${p.name} = ${fmt(p.start ?? p.value)}` }))), + el("div", { class: "block-row" }, + el("div", { class: "ports in" }, inputs.map((p) => el("div", { class: "port" }, el("span", { text: label(p), title: label(p) }), el("i")))), + el("div", { class: "block" }, blockIcon(), el("b", { text: a.modelName }), el("small", { text: ifaceText || "no interface" })), + el("div", { class: "ports out" }, outputs.map((p) => el("div", { class: "port" }, el("i"), el("span", { text: label(p), title: label(p) }))))), + el("p", { class: "muted small", style: "text-align:center;max-width:380px;margin:0" }, + `${vars.length} variables · ${inputs.length} inputs · ${outputs.length} outputs · ${params.length} parameters` + + (a.eventIndicators ? ` · ${a.eventIndicators} event indicator(s)` : "")))); + xml.textContent = a.xml; + + const issues = a.checks.filter((c) => c.level !== "ok"); + checks.replaceChildren(...a.checks.map((c) => el("div", { class: "check-item " + c.level }, + el("strong", { text: { ok: "PASS", warning: "WARN", error: "ERROR" }[c.level] }), + el("div", {}, el("div", { class: "title", text: c.title }), el("div", { text: c.detail }))))); + setBadge(issues.length); + + const byVar = {}; + for (const c of issues) if (c.variable) (byVar[c.variable] = byVar[c.variable] || []).push(c); + table.replaceChildren( + el("thead", {}, el("tr", {}, ["VR", "Name", "Type", "Causality", "Variability", "Initial", "Unit", "Start / value", "Check"].map((h) => el("th", { scope: "col", text: h })))), + el("tbody", {}, vars.map((v) => el("tr", {}, + el("td", { class: "mono muted", text: v.vr }), + el("td", { class: "mono", text: v.name + (v.dims.length ? ` [${v.dims.join(", ")}]` : "") }), + el("td", {}, el("span", { class: "pill", text: v.type })), + el("td", {}, el("span", { class: "pill caus-" + v.causality, text: v.causality })), + el("td", { text: v.variability || "(default)" }), + el("td", { class: "muted", text: v.initial || "–" }), + el("td", { text: v.unit || "–" }), + el("td", { class: "mono", text: short(v.start ?? v.value) }), + el("td", {}, (byVar[v.name] || []).length + ? (byVar[v.name]).map((c) => el("span", { class: "flag " + c.level, title: c.detail, text: c.title.replace(v.name + ": ", "") })) + : el("span", { class: "flag ok", text: "OK" })))))); +} + +function short(value) { + if (Array.isArray(value)) return value.length > 6 ? `[${value.slice(0, 6).map(fmt).join(", ")}, …] (${value.length})` : `[${value.map(fmt).join(", ")}]`; + return fmt(value); +} + +function setBadge(n) { + const b = $("#issue-badge"); + b.textContent = String(n); + b.classList.toggle("bad", n > 0); +} + +function blockIcon() { + return svg("svg", { width: "56", height: "34", viewBox: "14 18 36 28", "aria-hidden": "true" }, + svg("path", { d: "M17 42 L22 42 C27 42 28 21.5 33.5 21 C38 20.6 39 26.5 43 25.6 L47 25", fill: "none", stroke: "#fff", "stroke-width": "3.2", "stroke-linecap": "round", "stroke-linejoin": "round" })); +} + +function setPreview(tab) { + S.preview = tab; + for (const b of document.querySelectorAll(".subtab")) b.setAttribute("aria-selected", String(b.dataset.preview === tab)); + $("#preview-fmu").hidden = tab !== "fmu"; + $("#preview-xml").hidden = tab !== "xml"; + $("#preview-checks").hidden = tab !== "checks"; +} + +/* ---------- build and validate ---------- */ + +async function build(button) { + if (!S.path) return; + if (dirty()) await save(); + busy(button, true); + const r = await api("build", { method: "POST", body: { path: S.path } }); + busy(button, false); + if (!r.ok) { banner("error", "Build failed", r.traceback || r.error); return null; } + const res = r.result; + const link = el("a", { class: "btn", href: `/api/download?path=${encodeURIComponent(res.fmu)}&token=${encodeURIComponent(TOKEN)}`, download: "" }, "Download"); + banner("ok", `Built ${res.fmu}`, null, el("span", {}, `${(res.size / 1024 / 1024).toFixed(2)} MB · ${res.platforms.join(", ") || "no binaries"}`, " ", link)); + return res; +} + +async function validate(button) { + busy(button, true); + const built = await build(null); + if (!built) { busy(button, false); return; } + const r = await api("validate", { method: "POST", body: { fmu: built.fmu } }); + busy(button, false); + if (!r.ok) return banner("error", "Validation failed to run", r.error); + const v = r.result; + banner(v.passed ? "ok" : "error", `${v.passed ? "Valid" : "Invalid"}: ${built.fmu} (${v.tool})`, v.output); + setPreview("checks"); +} + +/* ---------- simulate ---------- */ + +function simulatorOptions() { + const env = S.env || {}; + return [ + { id: "python", label: "Python preview", ok: true, note: "Runs your class directly (Co-Simulation: do_step; Model Exchange: RK4 with event location). Fast enough to re-run while you drag." }, + { id: "fmusim", label: "fmusim", ok: !!env.fmusim, note: env.fmusim ? "Builds the FMU and runs it in fmusim, a native (non-Python) importer." : "Install fmusim (github.com/modelica/fmusim) or set PYTHONFMU3_FMUSIM to enable." }, + { id: "fmpy", label: "FMPy", ok: !!env.fmpy, note: env.fmpy ? "Builds the FMU and runs it with FMPy." : "pip install fmpy to enable." }, + ]; +} + +function renderSimSettings() { + const a = S.analysis; + const opts = simulatorOptions(); + $("#sim-choice").replaceChildren(...opts.map((o) => el("button", { + type: "button", class: "chip-btn", "aria-pressed": String(S.sim.simulator === o.id), disabled: !o.ok, title: o.note, + onClick: () => { S.sim.simulator = o.id; renderSimSettings(); }, + }, o.label))); + $("#sim-note").textContent = (opts.find((o) => o.id === S.sim.simulator) || opts[0]).note; + + const iface = $("#sim-interface"); + const ifs = a ? a.interfaces : []; + const current = iface.value; + iface.replaceChildren(...ifs.map((i) => el("option", { value: i, text: i === "cs" ? "Co-Simulation" : "Model Exchange" }))); + if (ifs.includes(current)) iface.value = current; + if (a && a.defaultExperiment && !S.sim.defaultsApplied) { + if (a.defaultExperiment.stopTime) $("#sim-stop").value = a.defaultExperiment.stopTime; + if (a.defaultExperiment.stepSize) $("#sim-step").value = a.defaultExperiment.stepSize; + S.sim.defaultsApplied = true; + } + + const box = $("#sim-params"); + if (!a) { box.replaceChildren(el("p", { class: "muted", text: "Open a model in the editor first." })); return; } + const tunable = a.variables.filter((v) => ["parameter", "input"].includes(v.causality) && v.variability !== "constant" && + ["Float64", "Int32", "Int64", "UInt64", "Boolean"].includes(v.type) && !(v.size > 1)); + if (!tunable.length) { box.replaceChildren(el("p", { class: "muted", text: "This model has no scalar parameters or inputs." })); return; } + box.replaceChildren(...tunable.map((v) => paramControl(v))); +} + +function paramControl(v) { + const start = typeof v.start === "number" ? v.start : (typeof v.value === "number" ? v.value : (v.value === true ? 1 : 0)); + const value = S.sim.values[v.name] ?? start; + const isInt = v.type !== "Float64"; + const span = Math.abs(start) > 0 ? Math.abs(start) : 1; + const lo = v.type === "Boolean" ? 0 : (start >= 0 && v.type === "UInt64" ? 0 : start - 2 * span); + const hi = v.type === "Boolean" ? 1 : start + 2 * span; + const step = isInt ? 1 : span / 100; + const id = "p-" + v.name.replace(/[^A-Za-z0-9_-]/g, "_"); + const num = el("input", { type: "number", id, value: String(value), step: isInt ? "1" : "any", "aria-label": v.name }); + const range = el("input", { type: "range", min: String(lo), max: String(hi), step: String(step), value: String(value), "aria-label": `${v.name} slider`, tabindex: "-1" }); + const set = (x, from) => { + const val = isInt ? Math.round(Number(x)) : Number(x); + if (!Number.isFinite(val)) return; + S.sim.values[v.name] = val; + if (from !== num) num.value = String(val); + if (from !== range) range.value = String(val); + if ($("#sim-live").checked && S.sim.simulator === "python") scheduleRun(); + }; + num.addEventListener("change", () => set(num.value, num)); + range.addEventListener("input", () => set(range.value, range)); + return el("div", { class: "param" }, + el("div", { class: "param-head" }, + el("label", { for: id, class: "mono", text: v.name }), + el("span", { class: "muted small", text: `${v.causality}${v.unit ? " · " + v.unit : ""}` }), num), + range); +} + +let runTimer = null; +function scheduleRun() { + clearTimeout(runTimer); + runTimer = setTimeout(() => runSimulation(), 220); +} + +async function runSimulation(button) { + if (!S.path || !S.analysis) { banner("info", "Open a model first"); return; } + if (S.sim.running) { S.sim.pending = true; return; } + if (dirty()) await save(); + S.sim.running = true; + busy(button, true); + const body = { + path: S.path, simulator: S.sim.simulator, interface: $("#sim-interface").value || undefined, + stopTime: Number($("#sim-stop").value) || 10, stepSize: Number($("#sim-step").value) || 0.01, + startValues: S.sim.values, + }; + const r = await api("simulate", { method: "POST", body }); + busy(button, false); + S.sim.running = false; + if (!r.ok) { + banner("error", "Simulation failed", r.traceback || r.error); + } else { + S.sim.result = r.result; + S.sim.stdout = r.stdout; + renderResults(); + } + if (S.sim.pending) { S.sim.pending = false; runSimulation(); } +} + +function renderResults() { + const res = S.sim.result; + if (!res) return; + const names = Object.keys(res.series); + if (!S.sim.selected || !S.sim.selected.some((n) => names.includes(n))) S.sim.selected = names.slice(0, 4); + $("#sim-series").replaceChildren(...names.map((n) => { + const box = el("input", { type: "checkbox" }); + box.checked = S.sim.selected.includes(n); + box.addEventListener("change", () => { + S.sim.selected = box.checked ? [...S.sim.selected, n] : S.sim.selected.filter((x) => x !== n); + renderCharts(); + }); + return el("label", {}, box, n); + })); + const t = res.time; + const stats = [ + ["Simulator", { python: "Python preview", fmusim: "fmusim", fmpy: "FMPy" }[res.simulator] || res.simulator], + ["Points", String(t.length)], + ["End time", fmt(t[t.length - 1]) + " s"], + ["Events", String(res.events.length)], + ]; + $("#sim-stats").replaceChildren(...stats.map(([k, v]) => el("div", { class: "stat" }, el("div", { class: "k", text: k }), el("div", { class: "v", text: v })))); + const log = [S.sim.stdout, res.log].filter(Boolean).join("\n"); + $("#sim-log-wrap").hidden = !log; + $("#sim-log").textContent = log; + renderCharts(); +} + +function renderCharts() { + const res = S.sim.result; + const box = $("#sim-charts"); + if (!res) { box.replaceChildren(el("div", { class: "empty", text: "Press Run to simulate." })); return; } + const unitOf = {}; + for (const v of (S.analysis ? S.analysis.variables : [])) unitOf[v.name] = v.unit; + box.replaceChildren(...S.sim.selected.filter((n) => res.series[n]).map((n) => chart(n, unitOf[n.replace(/\[\d+\]$/, "")], res.time, res.series[n], res.events))); +} + +function niceTicks(lo, hi, count) { + if (!(hi > lo)) { hi = lo + 1; lo = lo - 1; } + const raw = (hi - lo) / count; + const mag = Math.pow(10, Math.floor(Math.log10(raw))); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) || 10 * mag; + const start = Math.floor(lo / step) * step, end = Math.ceil(hi / step) * step; + const ticks = []; + for (let x = start; x <= end + step / 2; x += step) ticks.push(Number(x.toPrecision(12))); + return ticks; +} + +function chart(name, unit, t, y, events) { + const W = 800, H = 230, L = 58, R = 12, T = 12, B = 30; + const finite = y.filter((v) => v !== null && Number.isFinite(v)); + const yt = niceTicks(Math.min(...finite), Math.max(...finite), 4); + const xt = niceTicks(t[0], t[t.length - 1], 6); + const x0 = xt[0], x1 = xt[xt.length - 1], y0 = yt[0], y1 = yt[yt.length - 1]; + const X = (v) => L + ((v - x0) / (x1 - x0)) * (W - L - R); + const Y = (v) => T + (1 - (v - y0) / (y1 - y0)) * (H - T - B); + const pts = []; + t.forEach((tv, i) => { if (y[i] !== null && Number.isFinite(y[i])) pts.push(`${X(tv).toFixed(1)},${Y(y[i]).toFixed(1)}`); }); + const readout = el("span", { class: "readout" }); + const dot = svg("circle", { r: "4", fill: "#E8B11C", stroke: "#1F2933", "stroke-width": "1", visibility: "hidden" }); + const plot = svg("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": `${name} over time` }, + yt.map((v) => svg("line", { x1: L, x2: W - R, y1: Y(v), y2: Y(v), stroke: "#EEF1F5" })), + yt.map((v) => svg("text", { x: L - 8, y: Y(v) + 4, "text-anchor": "end", "font-size": "11", fill: "#5B6673" }, fmt(v))), + xt.map((v) => svg("text", { x: X(v), y: H - 10, "text-anchor": "middle", "font-size": "11", fill: "#5B6673" }, fmt(v))), + svg("line", { x1: L, x2: W - R, y1: H - B, y2: H - B, stroke: "#9AA6B4" }), + events.map((e) => svg("line", { x1: X(e.t), x2: X(e.t), y1: T, y2: H - B, stroke: "#E8B11C", "stroke-dasharray": "3 4" })), + svg("polyline", { points: pts.join(" "), fill: "none", stroke: "#2F5D8A", "stroke-width": "2", "stroke-linejoin": "round" }), + dot); + plot.addEventListener("mousemove", (ev) => { + const r = plot.getBoundingClientRect(); + const tx = x0 + (((ev.clientX - r.left) / r.width) * W - L) / (W - L - R) * (x1 - x0); + let best = 0; + for (let i = 1; i < t.length; i++) if (Math.abs(t[i] - tx) < Math.abs(t[best] - tx)) best = i; + if (y[best] === null) return; + dot.setAttribute("cx", X(t[best])); dot.setAttribute("cy", Y(y[best])); dot.setAttribute("visibility", "visible"); + readout.textContent = `t = ${fmt(t[best])} s · ${fmt(y[best])}${unit ? " " + unit : ""}`; + }); + plot.addEventListener("mouseleave", () => { dot.setAttribute("visibility", "hidden"); readout.textContent = ""; }); + return el("figure", { class: "chart" }, el("figcaption", {}, name, unit ? el("span", { class: "mono", text: `[${unit}]` }) : null, readout), plot); +} + +function downloadCsv() { + const res = S.sim.result; + if (!res) return; + const names = Object.keys(res.series); + const lines = [["time", ...names].map((n) => `"${n}"`).join(",")]; + res.time.forEach((t, i) => lines.push([t, ...names.map((n) => res.series[n][i] ?? "")].join(","))); + const a = el("a", { href: URL.createObjectURL(new Blob([lines.join("\n") + "\n"], { type: "text/csv" })), download: (S.analysis ? S.analysis.modelName : "result") + "_result.csv" }); + document.body.append(a); a.click(); a.remove(); +} + +/* ---------- inspector ---------- */ + +function refreshFmuSelect() { + const sel = $("#fmu-select"); + const fmus = S.files.filter((f) => f.kind === "fmu"); + sel.replaceChildren(el("option", { value: "", text: fmus.length ? "FMUs in this folder…" : "No FMUs in this folder yet" }), + ...fmus.map((f) => el("option", { value: f.path, text: f.path }))); +} + +async function inspectUpload(file) { + if (!file) return; + $("#inspect-out").replaceChildren(el("div", { class: "empty", text: `Reading ${file.name}…` })); + const r = await api("inspect", { method: "POST", raw: await file.arrayBuffer(), query: { name: file.name } }); + renderInspection(r); +} + +async function inspectPath(path) { + if (!path) return; + const r = await api("inspect", { method: "POST", body: { path } }); + renderInspection(r); +} + +let inspectFilter = "all"; +function renderInspection(r) { + const out = $("#inspect-out"); + if (!r.ok) { out.replaceChildren(el("div", { class: "check-item error" }, el("strong", { text: "ERROR" }), el("div", { text: r.error }))); return; } + const f = r.result; + S.inspected = f; + const caps = Object.entries(f.capabilities).map(([iface, c]) => `${iface}: ` + Object.entries(c).filter(([, v]) => v === "true").map(([k]) => k).join(", ")); + const validation = f.validation + ? el("div", { class: "check-item " + (f.validation.passed ? "ok" : "error") }, el("strong", { text: f.validation.passed ? "PASS" : "FAIL" }), el("div", {}, el("div", { class: "title", text: `${f.validation.tool} validate` }), el("pre", { class: "mono", style: "white-space:pre-wrap;margin:4px 0 0", text: f.validation.output }))) + : null; + out.replaceChildren( + el("div", { class: "row" }, + el("section", { class: "panel summary", style: "flex: 999 1 560px" }, + el("div", { style: "display:flex;flex-wrap:wrap;gap:12px;align-items:baseline" }, el("h1", { text: f.modelName || "(unnamed)" }), el("span", { class: "mono muted", text: `${f.fileName} · ${(f.size / 1024 / 1024).toFixed(2)} MB` })), + f.description ? el("p", { style: "margin:0", class: "muted", text: f.description }) : null, + el("div", { class: "chips" }, el("span", { class: "tag strong", text: "FMI " + f.fmiVersion }), f.interfaces.map((i) => el("span", { class: "tag strong", text: i })), + f.platforms.map((p) => el("span", { class: "tag", text: p })), f.sources ? el("span", { class: "tag", text: "sources" }) : null), + el("dl", {}, + [["Generated by", f.generationTool], ["Author", f.author], ["Variables", String(f.variables.length)], ["Units", String(f.units)], + ["Generated", f.generationDateAndTime], ["Capabilities", caps.join("; ") || "–"], + ["Default experiment", f.defaultExperiment ? Object.entries(f.defaultExperiment).map(([k, v]) => `${k}=${v}`).join(", ") : "–"]] + .map(([k, v]) => el("div", {}, el("dt", { text: k }), el("dd", { text: v || "–" }))))), + el("section", { class: "panel summary", style: "flex: 1 1 320px" }, el("h2", { text: "Checks" }), + f.problems.map((p) => el("div", { class: "check-item error" }, el("strong", { text: "ERROR" }), el("div", { text: p }))), + f.problems.length ? null : el("div", { class: "check-item ok" }, el("strong", { text: "PASS" }), el("div", { text: "Archive layout and binaries" })), + validation || el("p", { class: "muted small", text: "Install fmusim for a full schema validation." }))), + el("section", { class: "panel" }, el("div", { class: "panel-head" }, el("h2", { text: "Variables" }), el("div", { id: "insp-filters", class: "chips" })), + el("div", { class: "table-wrap" }, el("table", { id: "insp-table", class: "table" })))); + renderInspectTable(); +} + +function renderInspectTable() { + const f = S.inspected; + const group = (v) => v.causality === "input" ? "inputs" : v.causality === "output" ? "outputs" : /parameter/i.test(v.causality) ? "parameters" : "other"; + const defs = [["all", "All"], ["inputs", "Inputs"], ["outputs", "Outputs"], ["parameters", "Parameters"], ["other", "Other"]]; + $("#insp-filters").replaceChildren(...defs.map(([id, label]) => el("button", { + type: "button", class: "chip-btn", "aria-pressed": String(inspectFilter === id), + onClick: () => { inspectFilter = id; renderInspectTable(); }, + }, `${label} ${id === "all" ? f.variables.length : f.variables.filter((v) => group(v) === id).length}`))); + const rows = f.variables.filter((v) => inspectFilter === "all" || group(v) === inspectFilter); + $("#insp-table").replaceChildren( + el("thead", {}, el("tr", {}, ["VR", "Name", "Type", "Size", "Causality", "Variability", "Start", "Unit", "Description"].map((h) => el("th", { scope: "col", text: h })))), + el("tbody", {}, rows.map((v) => el("tr", {}, + el("td", { class: "mono muted", text: v.vr }), el("td", { class: "mono", text: v.name }), + el("td", {}, el("span", { class: "pill", text: v.type })), el("td", { class: "mono", text: v.dims.length ? `[${v.dims.join(", ")}]` : "1" }), + el("td", {}, el("span", { class: "pill caus-" + v.causality, text: v.causality })), el("td", { text: v.variability || "–" }), + el("td", { class: "mono", text: v.start.length > 40 ? v.start.slice(0, 40) + "…" : (v.start || "–") }), + el("td", { text: v.unit || "–" }), el("td", { class: "muted", text: v.description || "" }))))); +} + +/* ---------- templates ---------- */ + +async function loadTemplates() { + const r = await api("templates"); + if (!r.ok) return banner("error", r.error); + S.templates = r.result; + renderTemplates(); +} + +function renderTemplates() { + const cats = [["all", "All"], ["cs", "Co-Simulation"], ["me", "Model Exchange"], ["control", "Control"], ["ml", "Machine learning"]]; + $("#tpl-cats").replaceChildren(...cats.map(([id, label]) => el("button", { + type: "button", class: "chip-btn", "aria-pressed": String(S.tplCat === id), onClick: () => { S.tplCat = id; renderTemplates(); }, + }, label))); + const q = $("#tpl-search").value.trim().toLowerCase(); + const items = S.templates.filter((t) => { + const inCat = S.tplCat === "all" || t.categories.includes(S.tplCat) || + (S.tplCat === "cs" && t.interface.includes("Co-Simulation")) || (S.tplCat === "me" && t.interface.includes("Model Exchange")); + return inCat && (!q || (t.title + " " + t.description + " " + t.tags.join(" ")).toLowerCase().includes(q)); + }); + $("#tpl-grid").replaceChildren(...(items.length ? items.map((t) => el("article", { class: "card" }, + el("div", { style: "display:flex;gap:12px;align-items:center" }, + el("img", { src: "logo.svg", alt: "", width: "36", height: "36" }), + el("div", {}, el("h2", { text: t.title }), el("span", { class: "muted small", text: t.interface }))), + el("p", { text: t.description }), + el("div", { class: "chips" }, t.tags.map((g) => el("span", { class: "tag", text: g }))), + el("button", { type: "button", class: "btn", onClick: () => useTemplate(t) }, "Use template"), + )) : [el("div", { class: "empty", text: "No templates match." })])); +} + +async function useTemplate(t) { + const existing = new Set(S.files.map((f) => f.path)); + let name = `${t.id}.py`; + for (let i = 2; existing.has(name); i++) name = `${t.id}_${i}.py`; + const r = await api("templates/use", { method: "POST", body: { id: t.id, name } }); + if (!r.ok) return banner("error", "Could not create the file", r.error); + await loadFiles(r.result.path); + show("editor"); + banner("ok", `Created ${r.result.path} from the ${t.title} template`); +} + +/* ---------- startup ---------- */ + +async function init() { + if (!TOKEN) { banner("error", "Missing access token", "Open the Studio with the link printed by `pythonfmu3 studio`."); return; } + const env = await api("env"); + if (!env.ok) { banner("error", "Cannot reach the Studio server", env.error); return; } + S.env = env.result; + $("#status-env").textContent = `PythonFMU3 ${S.env.version} · Python ${S.env.python} · fmusim: ${S.env.fmusim ? "found" : "not found"} · FMPy: ${S.env.fmpy ? "yes" : "no"}`; + $("#status-ws").textContent = S.env.workspace; + + for (const b of document.querySelectorAll(".tab")) b.addEventListener("click", () => show(b.dataset.view)); + for (const b of document.querySelectorAll(".subtab")) b.addEventListener("click", () => setPreview(b.dataset.preview)); + $("#file-select").addEventListener("change", (e) => e.target.value && openFile(e.target.value)); + $("#btn-save").addEventListener("click", save); + $("#btn-build").addEventListener("click", (e) => build(e.currentTarget)); + $("#btn-validate").addEventListener("click", (e) => validate(e.currentTarget)); + $("#btn-simulate").addEventListener("click", () => { show("simulate"); runSimulation(); }); + $("#btn-run").addEventListener("click", (e) => runSimulation(e.currentTarget)); + $("#btn-reset").addEventListener("click", () => { S.sim.values = {}; renderSimSettings(); runSimulation(); }); + $("#btn-csv").addEventListener("click", downloadCsv); + $("#tpl-search").addEventListener("input", renderTemplates); + + const code = $("#code"); + code.addEventListener("input", () => { updateGutter(); updateDirty(); }); + code.addEventListener("scroll", () => { $("#gutter").scrollTop = code.scrollTop; }); + code.addEventListener("keydown", (e) => { + if (e.key === "Tab" && !e.shiftKey) { + e.preventDefault(); + const s = code.selectionStart; + code.setRangeText(" ", s, code.selectionEnd, "end"); + updateGutter(); updateDirty(); + } + }); + document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); save(); } + }); + window.addEventListener("beforeunload", (e) => { if (dirty()) { e.preventDefault(); e.returnValue = ""; } }); + + const drop = $("#drop"); + drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); }); + drop.addEventListener("dragleave", () => drop.classList.remove("over")); + drop.addEventListener("drop", (e) => { e.preventDefault(); drop.classList.remove("over"); inspectUpload(e.dataTransfer.files[0]); }); + $("#fmu-input").addEventListener("change", (e) => inspectUpload(e.target.files[0])); + $("#fmu-select").addEventListener("change", (e) => inspectPath(e.target.value)); + + setPreview("fmu"); + show("editor"); + await loadFiles(); + renderCharts(); +} + +init(); diff --git a/pythonfmu3/studio/static/index.html b/pythonfmu3/studio/static/index.html new file mode 100644 index 0000000..2fcecc1 --- /dev/null +++ b/pythonfmu3/studio/static/index.html @@ -0,0 +1,141 @@ + + + + + +PythonFMU3 Studio + + + + + +
+
+ + PythonFMU3 Studio +
+ +
+
+ + + + + +
+
+ + + +
+ +
+
+
+
+ No file open + + Ctrl+S saves and re-checks +
+
+ + + +
+
+
+
+ + + +
+
+ + +
+
+
+
+

Variables

+ Read from the model; value references follow registration order +
+
+
+
+ + + + + + + + + +
+ + + + diff --git a/pythonfmu3/studio/static/logo.svg b/pythonfmu3/studio/static/logo.svg new file mode 100644 index 0000000..a547890 --- /dev/null +++ b/pythonfmu3/studio/static/logo.svg @@ -0,0 +1,8 @@ + +PythonFMU3 + + + + + +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpmYWNkNTQ1Ny1hMGMzLTRhODItYmI0Zi05YzcyY2IxNGZmYjkAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaEP/Dr+zp0VDBK8qwfd1e/UAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpmYzc2YTUzZi0zMDUyLTRkNjctYmY1Yy05ZjI5NmVhNmVkNjJscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNovK8f2nuCoWSOUEwzlGwSVAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggJ6B4MbS0e8TRvd0V1G1/QPqXuYiZIGAfQP1sqd1jzVmkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaAykpoNvDxoTDChfshysHPQAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRMAAAAAAAAAAAAAAAAZGhhc2hYIPtW/QEpSHSDd1xWnRLj58AHhsTTMtaGXRb55cSf26F+ZG5hbWVuanVtYmYgbWFuaWZlc3RqZXhjbHVzaW9uc4GiZXN0YXJ0GQLvZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmZhY2Q1NDU3LWEwYzMtNGE4Mi1iYjRmLTljNzJjYjE0ZmZiOS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmM4Mjc4ODRkLWMyZTYtNDNkZi1iM2YwLWM4ZmQ5Zjg0YzFiZnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAnoHgxtLR7xNG93RXUbX9A+pe5iJkgYB9A/Wyp3WPNWaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggL8kGYPMfa2tmKAPSf4TiwyJmhNDOaFy+nBm5jDsqtUSiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggxoEy2od6ZFdB4BqbIpKbs2KPO720es/XMf20Zw0WU1B0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQG/TGZ7zliBXcRCSFHiPTut7HS8k5xYxYv3AoNUMasYUYLO9GzqmYcWm4XHzHrj+9eTikhBxKPr2v04/t9eS1a4= diff --git a/pythonfmu3/studio/templates/bouncing_ball.py b/pythonfmu3/studio/templates/bouncing_ball.py new file mode 100644 index 0000000..64d0112 --- /dev/null +++ b/pythonfmu3/studio/templates/bouncing_ball.py @@ -0,0 +1,75 @@ +from pythonfmu3 import Fmi3Causality, Fmi3Variability, Fmi3SlaveBase, ModelExchange, Fmi3Status, Float64, Fmi3Initial, Unit, Fmi3UpdateDiscreteStatesResult + +from typing import List + +import sys + +EVENT_EPS = 1e-12 + +class BouncingBall(Fmi3SlaveBase, ModelExchange): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.author = "PythonFMU3 Studio template" + self.description = "Bouncing Ball" + + self.time = 0.0 + self.h = 1.0 + self.v = 0.0 + self.derh = 0.0 + self.derv = 0.0 + self.g = -9.81 + self.e = 0.7 + self.v_min = 0.1 + + # define units + unit1 = Unit(name="m", m=1) + unit2 = Unit(name="m/s", m=1, s=-1) + unit3 = Unit(name="m/s2", m=1, s=-2) + self.register_units([unit1, unit2, unit3]) + + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + + self.register_variable(Float64("ball.h", causality=Fmi3Causality.output, start=1, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact, unit=unit1.name), + nested=False, has_event_indicator=True) + self.register_variable(Float64("ball.derh", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, derivative=1, unit=unit2.name), + nested=False) + self.register_variable(Float64("ball.v", causality=Fmi3Causality.output, start=0, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact, unit=unit2.name), + nested=False) + self.register_variable(Float64("ball.derv", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, derivative=3, unit=unit3.name), + nested=False) + + self.register_variable(Float64("g", causality=Fmi3Causality.parameter, variability=Fmi3Variability.fixed, unit=unit3.name)) + self.register_variable(Float64("e", causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable)) + self.register_variable(Float64("v_min", variability=Fmi3Variability.constant, start=0.1)) + + + + def get_continuous_state_derivatives(self) -> List[float]: + self.derh = self.v + self.derv = self.g + return [self.derh, self.derv] + + def get_event_indicators(self) -> List[float]: + z = [self.h] + if self.h > -EVENT_EPS and self.h <=0 and self.v > 0: + z[0] = -EVENT_EPS + + return z + + def update_discrete_states(self): + fdsr = Fmi3UpdateDiscreteStatesResult() + + if self.h <= 0 and self.v < 0: + self.h = sys.float_info.min + self.v = -self.v * self.e + + if self.v < self.v_min: + self.v = 0.0; + self.g = 0.0; + + fdsr.valuesOfContinuousStatesChanged = True + + return fdsr diff --git a/pythonfmu3/studio/templates/dahlquist.py b/pythonfmu3/studio/templates/dahlquist.py new file mode 100644 index 0000000..01d20f8 --- /dev/null +++ b/pythonfmu3/studio/templates/dahlquist.py @@ -0,0 +1,31 @@ +from pythonfmu3 import Fmi3Causality, CoSimulation, ModelExchange, Fmi3Variability, Fmi3SlaveBase, Fmi3Status, Float64, Fmi3Initial, Unit, Float64Type, Fmi3StepResult + +from typing import List + +class Dahlquist(Fmi3SlaveBase, ModelExchange, CoSimulation): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.author = "Stephen Smith" + self.description = "Dahlquist's test problem" + + self.time = 0.0 + self.k = 1.0 + self.x = 1.0 + self.derx = 0.0 + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Float64("x", causality=Fmi3Causality.output, start=1, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact)) + self.register_variable(Float64("derx", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, derivative=1)) + self.register_variable(Float64("k", causality=Fmi3Causality.parameter, variability=Fmi3Variability.fixed)) + + + def get_continuous_state_derivatives(self) -> List[float]: + self.derx = -self.k * self.x + return [self.derx] + + def do_step(self, current_time: float, step_size: float) -> Fmi3StepResult: + self.derx = -self.k * self.x + self.x += self.derx * step_size + return True \ No newline at end of file diff --git a/pythonfmu3/studio/templates/pid_controller.py b/pythonfmu3/studio/templates/pid_controller.py new file mode 100644 index 0000000..7d83830 --- /dev/null +++ b/pythonfmu3/studio/templates/pid_controller.py @@ -0,0 +1,42 @@ +"""PID controller with output limits and anti-windup (Co-Simulation).""" +from pythonfmu3 import Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64 + + +class PIDController(Fmi3Slave): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.author = "PythonFMU3 Studio template" + self.description = "PID controller with output limits and anti-windup" + + self.time = 0.0 + self.setpoint = 1.0 + self.measurement = 0.0 + self.output = 0.0 + self.Kp = 2.0 + self.Ki = 1.0 + self.Kd = 0.1 + self.u_min = -10.0 + self.u_max = 10.0 + self._integral = 0.0 + self._previous_error = None + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Float64("setpoint", causality=Fmi3Causality.input, start=1.0)) + self.register_variable(Float64("measurement", causality=Fmi3Causality.input, start=0.0)) + self.register_variable(Float64("output", causality=Fmi3Causality.output)) + for name in ("Kp", "Ki", "Kd", "u_min", "u_max"): + self.register_variable(Float64(name, causality=Fmi3Causality.parameter, + variability=Fmi3Variability.tunable, start=getattr(self, name))) + + def do_step(self, current_time, step_size): + error = self.setpoint - self.measurement + derivative = 0.0 if self._previous_error is None else (error - self._previous_error) / step_size + candidate = self._integral + error * step_size + u = self.Kp * error + self.Ki * candidate + self.Kd * derivative + if self.u_min < u < self.u_max: # anti-windup: integrate only when not saturated + self._integral = candidate + self.output = min(max(u, self.u_min), self.u_max) + self._previous_error = error + self.time = current_time + step_size + return True diff --git a/pythonfmu3/studio/templates/signal_generator.py b/pythonfmu3/studio/templates/signal_generator.py new file mode 100644 index 0000000..e1cfc9c --- /dev/null +++ b/pythonfmu3/studio/templates/signal_generator.py @@ -0,0 +1,40 @@ +"""Test-bench signal source: sine, square or ramp, selected by an integer mode.""" +import math + +from pythonfmu3 import Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64, Int32 + + +class SignalGenerator(Fmi3Slave): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.author = "PythonFMU3 Studio template" + self.description = "Sine (mode 0), square (1) or ramp (2) signal" + + self.time = 0.0 + self.mode = 0 + self.amplitude = 1.0 + self.frequency = 0.5 + self.offset = 0.0 + self.y = 0.0 + + tunable = dict(causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable) + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Int32("mode", start=0, **tunable)) + self.register_variable(Float64("amplitude", start=1.0, **tunable)) + self.register_variable(Float64("frequency", start=0.5, **tunable)) + self.register_variable(Float64("offset", start=0.0, **tunable)) + self.register_variable(Float64("y", causality=Fmi3Causality.output)) + + def do_step(self, current_time, step_size): + t = current_time + step_size + phase = (t * self.frequency) % 1.0 + if self.mode == 1: + wave = 1.0 if phase < 0.5 else -1.0 + elif self.mode == 2: + wave = 2.0 * phase - 1.0 + else: + wave = math.sin(2 * math.pi * phase) + self.y = self.offset + self.amplitude * wave + self.time = t + return True diff --git a/pythonfmu3/studio/templates/surrogate.py b/pythonfmu3/studio/templates/surrogate.py new file mode 100644 index 0000000..d8dcacb --- /dev/null +++ b/pythonfmu3/studio/templates/surrogate.py @@ -0,0 +1,37 @@ +"""A data-driven surrogate: a NumPy polynomial fitted to samples, exposed as an FMU. + +Replace the fit with your own model (scikit-learn, PyTorch, ...): load it in __init__ +(put the file next to this script and pass it as a project file when building) and call +it in do_step. +""" +import numpy as np + +from pythonfmu3 import Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64 + + +class Surrogate(Fmi3Slave): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.author = "PythonFMU3 Studio template" + self.description = "Polynomial surrogate of y = sin(x) * exp(-x / 5) on [0, 10]" + + x = np.linspace(0.0, 10.0, 200) # "training data" + self._coeffs = np.polyfit(x, np.sin(x) * np.exp(-x / 5.0), deg=9) + + self.time = 0.0 + self.x = 0.0 + self.y = float(np.polyval(self._coeffs, self.x)) + self.speed = 1.0 + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Float64("x", causality=Fmi3Causality.output)) + self.register_variable(Float64("y", causality=Fmi3Causality.output)) + self.register_variable(Float64("speed", causality=Fmi3Causality.parameter, + variability=Fmi3Variability.tunable, start=1.0)) + + def do_step(self, current_time, step_size): + self.time = current_time + step_size + self.x = (self.speed * self.time) % 10.0 # sweep the input range + self.y = float(np.polyval(self._coeffs, self.x)) + return True diff --git a/pythonfmu3/studio/templates/thermostat.py b/pythonfmu3/studio/templates/thermostat.py new file mode 100644 index 0000000..ac49550 --- /dev/null +++ b/pythonfmu3/studio/templates/thermostat.py @@ -0,0 +1,45 @@ +"""A heated room with an on/off thermostat and hysteresis (Co-Simulation).""" +from pythonfmu3 import Boolean, Fmi3Causality, Fmi3Slave, Fmi3Variability, Float64, Int32, Unit + + +class Thermostat(Fmi3Slave): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.author = "PythonFMU3 Studio template" + self.description = "Room temperature with an on/off heater and hysteresis" + + self.time = 0.0 + self.T_room = 15.0 # degC + self.T_outside = 5.0 # degC, input + self.T_set = 20.0 # degC + self.hysteresis = 1.0 # K + self.heater_power = 2000.0 # W + self.UA = 50.0 # W/K, losses to outside + self.C = 5e5 # J/K, room heat capacity + self.heater_on = False + self.switches = 0 + + self.register_units([Unit(name="degC", K=1, offset=273.15), Unit(name="W", kg=1, m=2, s=-3), + Unit(name="W/K", kg=1, m=2, s=-3, K=-1), Unit(name="J/K", kg=1, m=2, s=-2, K=-1)]) + tunable = dict(causality=Fmi3Causality.parameter, variability=Fmi3Variability.tunable) + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Float64("T_outside", causality=Fmi3Causality.input, start=5.0, unit="degC")) + self.register_variable(Float64("T_room", causality=Fmi3Causality.output, unit="degC")) + self.register_variable(Boolean("heater_on", causality=Fmi3Causality.output, variability=Fmi3Variability.discrete)) + self.register_variable(Int32("switches", causality=Fmi3Causality.output, variability=Fmi3Variability.discrete)) + self.register_variable(Float64("T_set", start=20.0, unit="degC", **tunable)) + self.register_variable(Float64("hysteresis", start=1.0, **tunable)) + self.register_variable(Float64("heater_power", start=2000.0, unit="W", **tunable)) + self.register_variable(Float64("UA", start=50.0, unit="W/K", **tunable)) + self.register_variable(Float64("C", start=5e5, unit="J/K", **tunable)) + + def do_step(self, current_time, step_size): + if self.heater_on and self.T_room > self.T_set + self.hysteresis / 2: + self.heater_on, self.switches = False, self.switches + 1 + elif not self.heater_on and self.T_room < self.T_set - self.hysteresis / 2: + self.heater_on, self.switches = True, self.switches + 1 + q = (self.heater_power if self.heater_on else 0.0) - self.UA * (self.T_room - self.T_outside) + self.T_room += q * step_size / self.C + self.time = current_time + step_size + return True diff --git a/pythonfmu3/studio/templates/van_der_pol.py b/pythonfmu3/studio/templates/van_der_pol.py new file mode 100644 index 0000000..1aa52f6 --- /dev/null +++ b/pythonfmu3/studio/templates/van_der_pol.py @@ -0,0 +1,34 @@ +from pythonfmu3 import Fmi3Causality, ModelExchange, Fmi3Variability, Fmi3SlaveBase, Fmi3Status, Float64, Fmi3Initial, Unit, Float64Type, Fmi3StepResult + +from typing import List + +class VanDerPol(Fmi3SlaveBase, ModelExchange): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.author = "Stephen Smith" + self.description = "Van Der Pol oscillator problem for model exchange FMUs" + + self.time = 0.0 + self.mu = 1.0 + self.x0 = 2 + self.x1 = 0 + self.derx0 = 0.0 + self.derx1 = 0.0 + + self.register_variable(Float64("time", causality=Fmi3Causality.independent, variability=Fmi3Variability.continuous)) + self.register_variable(Float64("x0", causality=Fmi3Causality.output, start=2, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact)) + self.register_variable(Float64("x1", causality=Fmi3Causality.output, start=0, variability=Fmi3Variability.continuous, initial=Fmi3Initial.exact)) + self.register_variable(Float64("derx0", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, derivative=1)) + self.register_variable(Float64("derx1", causality=Fmi3Causality.local, variability=Fmi3Variability.continuous, derivative=2)) + self.register_variable(Float64("mu", causality=Fmi3Causality.parameter, variability=Fmi3Variability.fixed)) + + + def get_continuous_state_derivatives(self) -> List[float]: + self.derx0 = self.x1 + self.derx1 = self.mu * ((1 - self.x0**2) * self.x1) - self.x0 + return [self.derx0, self.derx1] + + def get_nominals_of_continuous_states(self, nStates: int) -> List[float]: + return [1.0, 1.0] \ No newline at end of file diff --git a/pythonfmu3/studio/worker.py b/pythonfmu3/studio/worker.py new file mode 100644 index 0000000..9d6399e --- /dev/null +++ b/pythonfmu3/studio/worker.py @@ -0,0 +1,519 @@ +"""Studio worker: analyses, simulates and builds a model in a separate process. + +Each request runs in a fresh interpreter (``python -m pythonfmu3.studio.worker``) so that +user code is imported from scratch every time and cannot affect the server. The request +is read as JSON from stdin; the response is written as JSON to stdout. Anything the +model prints is captured and returned in the response's ``stdout`` field. +""" +import contextlib +import importlib.util +import io +import json +import math +import shutil +import subprocess +import sys +import tempfile +import traceback +import zipfile +from pathlib import Path +from typing import Any, Dict, List, Optional +from xml.dom.minidom import parseString +from xml.etree.ElementTree import tostring + +PARAMETER_CAUSALITIES = ("parameter", "structuralParameter") +METADATA = ("author", "description", "license", "version", "copyright") + + +# --- loading ----------------------------------------------------------------------------- + +def load_model_class(path: Path): + """Import the script in ``path`` and return its FMU class.""" + from pythonfmu3.builder import _find_slave_class + + module_name = f"studio_model_{path.stem}" + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + sys.path.insert(0, str(path.parent)) + try: + spec.loader.exec_module(module) + finally: + sys.path.remove(str(path.parent)) + return _find_slave_class(module, path) + + +def instantiate(cls, path: Path): + sys.path.insert(0, str(path.parent)) + try: + return cls(instance_name="studio", resources=str(path.parent)) + finally: + sys.path.remove(str(path.parent)) + + +def interfaces_of(instance) -> List[str]: + from pythonfmu3 import CoSimulation, ModelExchange + + result = [] + if isinstance(instance, CoSimulation): + result.append("cs") + if isinstance(instance, ModelExchange): + result.append("me") + return result + + +def jsonable(value: Any): + try: + import numpy as np + if isinstance(value, np.ndarray): + return [jsonable(v) for v in value.ravel().tolist()] + if isinstance(value, np.generic): + return value.item() + except ImportError: + pass + if hasattr(value, "value") and not isinstance(value, (int, float, str, bool)): + return jsonable(value.value) # ctypes values, enum members + if isinstance(value, (list, tuple)): + return [jsonable(v) for v in value] + if isinstance(value, float) and not math.isfinite(value): + return str(value) + if isinstance(value, (int, float, str, bool)) or value is None: + return value + return str(value) + + +def variable_info(instance, var) -> Dict[str, Any]: + dims = list(getattr(var, "dimensions", []) or []) + size = None + if dims: + try: + size = int(var.size(instance.vars)) + except Exception: + size = None + try: + value = jsonable(var.getter()) + except Exception as exc: # reported by the checks + value = f"" + return { + "vr": var.value_reference, + "name": var.name, + "type": type(var).__name__, + "causality": var.causality.name if var.causality else "local", + "variability": var.variability.name if var.variability else None, + "initial": var.initial.name if var.initial else None, + "unit": getattr(var, "unit", None), + "description": var.description, + "start": jsonable(getattr(var, "start", None)), + "value": value, + "dims": [d.start or f"vr {d.value_reference}" for d in dims], + "size": size, + "settable": var.setter is not None, + "derivative": getattr(var, "derivative", None), + } + + +# --- checks ------------------------------------------------------------------------------ + +def run_checks(cls, instance, variables: List[Dict[str, Any]], xml_ok: Optional[str]) -> List[Dict[str, str]]: + checks = [] + + def add(level, title, detail, name=None): + checks.append({"level": level, "title": title, "detail": detail, "variable": name}) + + if xml_ok: + add("error", "Model description", xml_ok) + + if not any(v["causality"] == "independent" for v in variables): + add("error", "Independent variable", + "FMI 3.0 expects time to be declared: register a Float64 with causality=Fmi3Causality.independent.") + + for v in variables: + if v["type"] != "Float64" and v["variability"] in (None, "continuous"): + add("warning", f"{v['name']}: variability", + f"{v['type']} variables can't be continuous. Use variability=Fmi3Variability.discrete " + "(or fixed/tunable for parameters).", v["name"]) + settable = v["causality"] in ("input",) + PARAMETER_CAUSALITIES and v["variability"] != "constant" + if settable and not v["settable"]: + add("error", f"{v['name']}: not settable", + "No setter was created, so importers can't set it. Create the attribute before " + "register_variable, or pass setter=.", v["name"]) + if v["name"].lstrip(".").startswith("_") or "._" in v["name"]: + add("warning", f"{v['name']}: leading underscore", + "PythonFMU3 strips leading underscores when it looks up the attribute " + f"(it reads self.{v['name'].lstrip('_')}). Rename it or pass getter=/setter=.", v["name"]) + if isinstance(v["value"], str) and v["value"].startswith(" Dict[str, Any]: + path = Path(req["path"]).resolve() + cls = load_model_class(path) + instance = instantiate(cls, path) + + xml_text, xml_error = "", None + try: + root = instance.to_xml() + xml_text = parseString(tostring(root, "UTF-8")).toprettyxml(indent=" ") + xml_text = "\n".join(line for line in xml_text.splitlines()[1:] if line.strip()) + except Exception as exc: + xml_error = f"{type(exc).__name__}: {exc}" + + variables = [variable_info(instance, v) for v in instance.vars.values()] + de = instance.default_experiment + return { + "className": cls.__name__, + "modelName": instance.modelName, + "description": instance.description, + "author": instance.author, + "interfaces": interfaces_of(instance), + "variables": variables, + "eventIndicators": len(getattr(instance, "event_indicators", [])), + "defaultExperiment": { + "startTime": getattr(de, "start_time", None), "stopTime": getattr(de, "stop_time", None), + "stepSize": getattr(de, "step_size", None), "tolerance": getattr(de, "tolerance", None), + } if de else None, + "xml": xml_text, + "checks": run_checks(cls, instance, variables, xml_error), + } + + +# --- simulate ---------------------------------------------------------------------------- + +def _set_start_values(instance, values: Dict[str, Any]): + by_name = {v.name: v for v in instance.vars.values()} + for name, value in (values or {}).items(): + var = by_name.get(name) + if var is None or var.setter is None: + continue + kind = type(var).__name__ + if kind in ("Int32", "Int64", "UInt64", "Enumeration"): + value = int(float(value)) + elif kind == "Boolean": + value = value in (True, 1, "1", "true", "True") + elif kind == "Float64" and not isinstance(value, list): + value = float(value) + var.setter(value) + + +def _recorded(instance, names: Optional[List[str]]): + keep = [] + for var in instance.vars.values(): + if names is not None: + if var.name in names: + keep.append(var) + elif var.causality is not None and var.causality.name in ("output", "local") \ + and type(var).__name__ in ("Float64", "Int32", "Int64", "UInt64", "Boolean", "Enumeration"): + keep.append(var) + return keep + + +def _sample(series, recorded): + for var in recorded: + value = jsonable(var.getter()) + if isinstance(value, list): + for i, item in enumerate(value[:16]): + series.setdefault(f"{var.name}[{i + 1}]", []).append(_num(item)) + else: + series.setdefault(var.name, []).append(_num(value)) + + +def _num(value): + if isinstance(value, bool): + return 1.0 if value else 0.0 + try: + return float(value) + except (TypeError, ValueError): + return None + + +def simulate_python(req: Dict[str, Any]) -> Dict[str, Any]: + path = Path(req["path"]).resolve() + cls = load_model_class(path) + instance = instantiate(cls, path) + interface = req.get("interface") or interfaces_of(instance)[0] + start, stop = float(req.get("startTime", 0.0)), float(req["stopTime"]) + step = float(req["stepSize"]) + _set_start_values(instance, req.get("startValues")) + instance.enter_initialization_mode() + instance.exit_initialization_mode() + recorded = _recorded(instance, req.get("outputs")) + times, series, events = [start], {}, [] + _sample(series, recorded) + + if interface == "cs": + n = max(1, int(round((stop - start) / step))) + t = start + for i in range(n): + result = instance.do_step(t, step) + t = round(start + (i + 1) * step, 12) + times.append(t) + _sample(series, recorded) + if result is False or getattr(result, "terminateSimulation", False): + events.append({"t": t, "kind": "terminate"}) + break + if getattr(result, "status", 0) not in (0,): + events.append({"t": t, "kind": f"status {int(result.status)}"}) + else: + times, series, events = _simulate_me(instance, recorded, start, stop, step) + return {"interface": interface, "time": times, "series": series, "events": events, "simulator": "python"} + + +def _simulate_me(instance, recorded, start, stop, step): + """Fixed-step RK4 with event location (bisection) on the event indicators.""" + n_states = instance.get_number_of_continuous_states() + n_ind = instance.get_number_of_event_indicators() + + def set_state(t, x): + instance.time = t + if n_states: + instance.set_continuous_states(list(x)) + + def deriv(t, x): + set_state(t, x) + return list(instance.get_continuous_state_derivatives()) if n_states else [] + + def indicators(t, x): + set_state(t, x) + return list(instance.get_event_indicators()) if n_ind else [] + + def rk4(t, x, h): + k1 = deriv(t, x) + k2 = deriv(t + h / 2, [xi + h / 2 * ki for xi, ki in zip(x, k1)]) + k3 = deriv(t + h / 2, [xi + h / 2 * ki for xi, ki in zip(x, k2)]) + k4 = deriv(t + h, [xi + h * ki for xi, ki in zip(x, k3)]) + return [xi + h / 6 * (a + 2 * b + 2 * c + d) for xi, a, b, c, d in zip(x, k1, k2, k3, k4)] + + def record(t, x): + set_state(t, x) + if n_states: + instance.get_continuous_state_derivatives() + times.append(t) + _sample(series, recorded) + + times, series, events = [], {}, [] + t = start + x = list(instance.get_continuous_states()) if n_states else [] + record(t, x) + z = indicators(t, x) + h_sub = step / 10.0 # internal steps per output interval + n_out = max(1, int(round((stop - start) / step))) + for k in range(1, n_out + 1): + t_out = round(start + k * step, 12) + while t < t_out - 1e-12: + h = min(h_sub, t_out - t) + x_new = rk4(t, x, h) + z_new = indicators(t + h, x_new) + crossed = [i for i in range(n_ind) if (z[i] > 0) != (z_new[i] > 0)] + if crossed: + lo, hi = 0.0, h # locate the crossing by bisection + for _ in range(50): + mid = (lo + hi) / 2 + z_mid = indicators(t + mid, rk4(t, x, mid)) + if any((z[i] > 0) != (z_mid[i] > 0) for i in crossed): + hi = mid + else: + lo = mid + t, x = t + hi, rk4(t, x, hi) + set_state(t, x) + for _ in range(100): + result = instance.update_discrete_states() + if not getattr(result, "discreteStatesNeedUpdate", False): + break + events.append({"t": t, "kind": "state event"}) + x = list(instance.get_continuous_states()) if n_states else [] + z = indicators(t, x) + if getattr(result, "terminateSimulation", False): + record(t, x) + return times, series, events + else: + t, x, z = t + h, x_new, z_new + record(t_out, x) + return times, series, events + + +def find_fmusim() -> Optional[str]: + import os + candidate = os.environ.get("PYTHONFMU3_FMUSIM") or shutil.which("fmusim") + return candidate if candidate and Path(candidate).exists() else None + + +def simulate_fmusim(req: Dict[str, Any]) -> Dict[str, Any]: + fmusim = find_fmusim() + if not fmusim: + raise RuntimeError("fmusim was not found on PATH (or in PYTHONFMU3_FMUSIM)") + path = Path(req["path"]).resolve() + with tempfile.TemporaryDirectory(prefix="studio_") as tmp: + fmu = _build(path, Path(tmp))["fmu"] + out = Path(tmp) / "result.csv" + cmd = [fmusim, "simulate", fmu, "--stop-time", str(req["stopTime"]), + "--output-interval", str(req["stepSize"]), "--output-file", str(out)] + if req.get("interface"): + cmd += ["--interface-type", req["interface"]] + for name, value in (req.get("startValues") or {}).items(): + cmd += ["--start-value", f"{name}={value}"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if proc.returncode != 0 or not out.exists(): + raise RuntimeError(f"fmusim failed:\n{proc.stdout}\n{proc.stderr}".strip()) + times, series = _read_csv(out) + return {"interface": req.get("interface"), "time": times, "series": series, "events": [], + "simulator": "fmusim", "log": proc.stderr[-4000:]} + + +def simulate_fmpy(req: Dict[str, Any]) -> Dict[str, Any]: + import fmpy + + path = Path(req["path"]).resolve() + with tempfile.TemporaryDirectory(prefix="studio_") as tmp: + fmu = _build(path, Path(tmp))["fmu"] + result = fmpy.simulate_fmu( + fmu, stop_time=float(req["stopTime"]), output_interval=float(req["stepSize"]), + fmi_type={"cs": "CoSimulation", "me": "ModelExchange"}.get(req.get("interface"), None), + start_values=req.get("startValues") or {}, + ) + times = [float(t) for t in result["time"]] + series = {name: [float(v) for v in result[name]] for name in result.dtype.names if name != "time"} + return {"interface": req.get("interface"), "time": times, "series": series, "events": [], "simulator": "fmpy"} + + +def _read_csv(path: Path): + import csv + with open(path, newline="") as f: + rows = list(csv.reader(f)) + header = [h.strip().strip('"') for h in rows[0]] + times, series = [], {h: [] for h in header[1:]} + for row in rows[1:]: + if not row: + continue + times.append(float(row[0])) + for h, cell in zip(header[1:], row[1:]): + series[h].append(_num(cell.split(" ")[0]) if cell else None) + return times, series + + +def simulate(req: Dict[str, Any]) -> Dict[str, Any]: + simulator = req.get("simulator", "python") + if simulator == "fmusim": + return simulate_fmusim(req) + if simulator == "fmpy": + return simulate_fmpy(req) + return simulate_python(req) + + +# --- build / validate / inspect ---------------------------------------------------------- + +def _build(path: Path, dest: Path) -> Dict[str, Any]: + from pythonfmu3 import FmuBuilder + + fmu = FmuBuilder.build_FMU(path, dest=dest) + info = inspect_fmu({"path": str(fmu)}) + return {"fmu": str(fmu), "size": fmu.stat().st_size, "platforms": info["platforms"]} + + +def build(req: Dict[str, Any]) -> Dict[str, Any]: + path = Path(req["path"]).resolve() + dest = Path(req.get("dest") or path.parent / "build").resolve() + dest.mkdir(parents=True, exist_ok=True) + return _build(path, dest) + + +def validate(req: Dict[str, Any]) -> Dict[str, Any]: + fmu = Path(req["fmu"]).resolve() + fmusim = find_fmusim() + if fmusim: + proc = subprocess.run([fmusim, "validate", str(fmu)], capture_output=True, text=True, timeout=120) + return {"tool": "fmusim", "passed": proc.returncode == 0, + "output": (proc.stdout + proc.stderr).strip()} + info = inspect_fmu({"path": str(fmu)}) + return {"tool": "built-in", "passed": not info["problems"], "output": "\n".join(info["problems"]) or + "ZIP archive and model description read without problems (install fmusim for a full check)."} + + +def inspect_fmu(req: Dict[str, Any]) -> Dict[str, Any]: + from xml.etree.ElementTree import fromstring + + problems: List[str] = [] + with zipfile.ZipFile(req["path"]) as z: + names = z.namelist() + if "modelDescription.xml" not in names: + raise ValueError("Not an FMU: modelDescription.xml is missing") + root = fromstring(z.read("modelDescription.xml")) + platforms = sorted({n.split("/")[1] for n in names if n.startswith("binaries/") and n.count("/") >= 2 and n.split("/")[1]}) + interfaces = [tag for tag in ("CoSimulation", "ModelExchange", "ScheduledExecution") if root.find(tag) is not None] + for tag in interfaces: + ident = root.find(tag).get("modelIdentifier") + for plat in platforms: + if not any(n.startswith(f"binaries/{plat}/{ident}.") for n in names): + problems.append(f"{tag}: no binary for {ident} in binaries/{plat}/") + variables = [] + mv = root.find("ModelVariables") + for i, v in enumerate(list(mv) if mv is not None else []): + dims = [d.get("start") or f"vr {d.get('valueReference')}" for d in v.findall("Dimension")] + variables.append({ + "vr": v.get("valueReference", str(i)), "name": v.get("name"), + "type": v.tag if v.tag != "ScalarVariable" else next((c.tag for c in v if c.tag != "Annotations"), "?"), + "causality": v.get("causality", "local"), "variability": v.get("variability", ""), + "start": v.get("start", ""), "unit": v.get("unit", ""), "description": v.get("description", ""), + "dims": dims, + }) + caps = {} + for tag in interfaces: + caps[tag] = {k: v for k, v in root.find(tag).attrib.items() if k != "modelIdentifier"} + units = root.find("UnitDefinitions") + de = root.find("DefaultExperiment") + return { + "fileName": Path(req.get("name") or req["path"]).name, + "size": Path(req["path"]).stat().st_size, + "fmiVersion": root.get("fmiVersion"), + "modelName": root.get("modelName"), + "description": root.get("description"), + "author": root.get("author"), + "generationTool": root.get("generationTool"), + "generationDateAndTime": root.get("generationDateAndTime"), + "token": root.get("instantiationToken") or root.get("guid"), + "interfaces": interfaces, + "capabilities": caps, + "platforms": platforms, + "sources": any(n.startswith("sources/") for n in names), + "units": len(list(units)) if units is not None else 0, + "defaultExperiment": dict(de.attrib) if de is not None else None, + "variables": variables, + "files": len(names), + "problems": problems, + } + + +COMMANDS = {"analyze": analyze, "simulate": simulate, "build": build, "validate": validate, "inspect": inspect_fmu} + + +def main(): + request = json.loads(sys.stdin.read() or "{}") + real_stdout = sys.stdout + captured = io.StringIO() + try: + with contextlib.redirect_stdout(captured): + result = COMMANDS[request["command"]](request) + response = {"ok": True, "result": result} + except Exception as exc: + response = {"ok": False, "error": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc()} + response["stdout"] = captured.getvalue()[-8000:] + real_stdout.write(json.dumps(response, default=str)) + real_stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/pythonfmu3/tests/test_builder.py b/pythonfmu3/tests/test_builder.py index 03c8d63..bbc102f 100644 --- a/pythonfmu3/tests/test_builder.py +++ b/pythonfmu3/tests/test_builder.py @@ -62,7 +62,7 @@ def test_zip_content(tmp_path): pkg_folder = Path(pythonfmu3.__path__[0]) for f in pkg_folder.rglob("*.py"): relative_f = f.relative_to(pkg_folder).as_posix() - if "test" not in relative_f and not relative_f.startswith("pythonfmu-export/"): + if "test" not in relative_f and "/" not in relative_f: # only top-level modules are embedded assert "/".join(("resources", "pythonfmu3", relative_f)) in names assert len(names) >= nfiles # Library + python script + XML + module name + sources diff --git a/pythonfmu3/tests/test_example_pipe_cht.py b/pythonfmu3/tests/test_example_pipe_cht.py index e650f4a..c0847a4 100644 --- a/pythonfmu3/tests/test_example_pipe_cht.py +++ b/pythonfmu3/tests/test_example_pipe_cht.py @@ -137,6 +137,9 @@ def test_fmu_matches_python_model(tmp_path): z.extractall(target) md = fromstring(z.read("modelDescription.xml")) vr = {v.get("name"): int(v.get("valueReference")) for v in md.find("ModelVariables")} + # the FMU may hold binaries for several platforms: load the one for this machine + from pythonfmu3.osutil import get_lib_extension, get_platform + library = target / "binaries" / get_platform() / f"Pipe1D.{get_lib_extension()}" n = 35 # not the default, set through Configuration Mode T_wall = np.linspace(300.0, 380.0, n) @@ -144,8 +147,7 @@ def test_fmu_matches_python_model(tmp_path): import ctypes as C, json, sys from pathlib import Path root = Path({str(target)!r}) - lib = C.CDLL(str(next(p for p in (root / "binaries").rglob("*.*") - if p.suffix in (".so", ".dylib", ".dll") and "backend" not in p.name))) + lib = C.CDLL({str(library)!r}) lib.fmi3InstantiateCoSimulation.restype = C.c_void_p lib.fmi3InstantiateCoSimulation.argtypes = [C.c_char_p, C.c_char_p, C.c_char_p, C.c_bool, C.c_bool, C.c_bool, C.c_bool, C.c_void_p, C.c_size_t, C.c_void_p, C.c_void_p, C.c_void_p] diff --git a/pythonfmu3/tests/test_studio.py b/pythonfmu3/tests/test_studio.py new file mode 100644 index 0000000..3ee2022 --- /dev/null +++ b/pythonfmu3/tests/test_studio.py @@ -0,0 +1,149 @@ +"""Tests for PythonFMU3 Studio (pythonfmu3 studio): the worker and the HTTP API.""" +import json +import shutil +import threading +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +import pytest + +from pythonfmu3.builder import FmuBuilder +from pythonfmu3.studio import server as studio +from pythonfmu3.studio.server import TEMPLATE_INFO, TEMPLATES, run_worker + +HERE = Path(__file__).parent + + +def worker(command, **kwargs): + response = run_worker(dict(kwargs, command=command), HERE) + assert response["ok"], response.get("traceback") or response.get("error") + return response["result"] + + +@pytest.mark.parametrize("template", sorted(TEMPLATE_INFO)) +def test_templates_are_clean_models(template): + result = worker("analyze", path=str(TEMPLATES / f"{template}.py")) + problems = [c for c in result["checks"] if c["level"] != "ok"] + assert not problems, problems + assert result["interfaces"] + + +def test_analyze_reports_variables_and_problems(tmp_path): + script = tmp_path / "bad.py" + script.write_text( + "from pythonfmu3 import Fmi3Slave, Fmi3Causality, Float64, Int32\n" + "class Bad(Fmi3Slave):\n" + " author = 'class level'\n" + " def __init__(self, **kwargs):\n" + " super().__init__(**kwargs)\n" + " self.register_variable(Float64('u', causality=Fmi3Causality.input))\n" # no attribute yet + " self.u = 1.0\n" + " self.n = 0\n" + " self.register_variable(Int32('n', causality=Fmi3Causality.output))\n" + " def do_step(self, t, dt):\n" + " return True\n" + ) + result = worker("analyze", path=str(script)) + titles = " | ".join(c["title"] for c in result["checks"]) + assert "Independent variable" in titles + assert "u: not settable" in titles + assert "n: variability" in titles + assert "Class attribute 'author' is ignored" in titles + assert [v["name"] for v in result["variables"]] == ["u", "n"] + assert "" in result["xml"] + + +def test_simulate_cosimulation_and_model_exchange(): + cs = worker("simulate", path=str(TEMPLATES / "dahlquist.py"), interface="cs", stopTime=1.0, stepSize=0.1) + assert cs["series"]["x"][-1] == pytest.approx(0.9 ** 10) # explicit Euler in do_step + me = worker("simulate", path=str(TEMPLATES / "dahlquist.py"), interface="me", stopTime=1.0, stepSize=0.1) + assert me["series"]["x"][-1] == pytest.approx(0.36787944, rel=1e-6) # exp(-1), RK4 + ball = worker("simulate", path=str(TEMPLATES / "bouncing_ball.py"), stopTime=1.0, stepSize=0.01) + assert ball["events"][0]["t"] == pytest.approx((2 / 9.81) ** 0.5, rel=1e-6) + + +def test_simulate_uses_start_values(): + result = worker("simulate", path=str(TEMPLATES / "signal_generator.py"), stopTime=1.0, stepSize=0.25, + startValues={"mode": 2, "amplitude": 3}) + assert max(result["series"]["y"]) <= 3.0 and min(result["series"]["y"]) >= -3.0 + assert result["series"]["y"][1] == pytest.approx(3 * (2 * 0.125 - 1)) + + +@pytest.mark.skipif(not FmuBuilder.has_binary(), reason="needs the compiled wrapper") +def test_build_and_inspect(tmp_path): + script = tmp_path / "pid.py" + shutil.copy(TEMPLATES / "pid_controller.py", script) + built = worker("build", path=str(script), dest=str(tmp_path / "out")) + assert Path(built["fmu"]).exists() and built["platforms"] + info = worker("inspect", path=built["fmu"]) + assert info["modelName"] == "PIDController" + assert info["fmiVersion"] == "3.0" and info["interfaces"] == ["CoSimulation"] + assert not info["problems"] + assert {v["name"] for v in info["variables"]} >= {"setpoint", "measurement", "output", "Kp"} + + +@pytest.fixture() +def app(tmp_path): + shutil.copy(TEMPLATES / "dahlquist.py", tmp_path / "model.py") + httpd = studio.serve(tmp_path, port=0, open_browser=False) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + yield httpd, tmp_path + httpd.shutdown() + httpd.server_close() + + +def call(httpd, path, body=None, token=True, host=None): + port = httpd.server_address[1] + req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", method="POST" if body is not None else "GET", + data=json.dumps(body).encode() if body is not None else None) + if token: + req.add_header("X-Studio-Token", httpd.studio_state.token) + if body is not None: + req.add_header("Content-Type", "application/json") + if host: + req.add_header("Host", host) + try: + with urllib.request.urlopen(req, timeout=120) as res: + return res.status, res.read() + except urllib.error.HTTPError as err: + return err.code, err.read() + + +def test_api_requires_token_and_local_host(app): + httpd, _ = app + assert call(httpd, "/api/env", token=False)[0] == 403 + assert call(httpd, "/api/env", host="attacker.example")[0] == 403 + status, body = call(httpd, "/api/env") + assert status == 200 and json.loads(body)["ok"] + + +def test_api_stays_inside_the_workspace(app): + httpd, _ = app + assert call(httpd, "/api/file?path=../outside.py")[0] == 400 + assert call(httpd, "/api/file", {"path": "../escape.py", "content": "x"})[0] == 400 + + +def test_api_round_trip(app): + httpd, ws = app + status, body = call(httpd, "/api/files") + assert [f["path"] for f in json.loads(body)["result"]] == ["model.py"] + status, body = call(httpd, "/api/file?path=model.py") + content = json.loads(body)["result"]["content"] + assert "class Dahlquist" in content + assert call(httpd, "/api/file", {"path": "model.py", "content": content.replace("self.k = 1.0", "self.k = 2.0")})[0] == 200 + status, body = call(httpd, "/api/analyze", {"path": "model.py"}) + k = next(v for v in json.loads(body)["result"]["variables"] if v["name"] == "k") + assert k["start"] == 2.0 + status, body = call(httpd, "/api/templates/use", {"id": "thermostat", "name": "room.py"}) + assert json.loads(body)["result"]["path"] == "room.py" and (ws / "room.py").exists() + assert call(httpd, "/api/templates/use", {"id": "thermostat", "name": "room.py"})[0] == 400 # no overwrite + + +def test_static_page_is_served(app): + httpd, _ = app + status, body = call(httpd, "/", token=False) + assert status == 200 and b"PythonFMU3 Studio" in body + assert call(httpd, "/../server.py", token=False)[0] == 404