pythonfmu3 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/MANIFEST.in +3 -0
- {pythonfmu3-1.0.0/pythonfmu3.egg-info → pythonfmu3-1.1.0}/PKG-INFO +9 -1
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/README.md +8 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pyproject.toml +2 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/__main__.py +8 -0
- pythonfmu3-1.1.0/pythonfmu3/_version.py +1 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-windows/pythonfmu-export.dll +0 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/__init__.py +1 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/server.py +358 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/static/app.css +179 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/static/app.js +613 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/static/index.html +141 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/static/logo.svg +8 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/bouncing_ball.py +75 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/dahlquist.py +31 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/pid_controller.py +42 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/signal_generator.py +40 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/surrogate.py +37 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/thermostat.py +45 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/templates/van_der_pol.py +34 -0
- pythonfmu3-1.1.0/pythonfmu3/studio/worker.py +519 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_builder.py +1 -1
- pythonfmu3-1.1.0/pythonfmu3/tests/test_example_pipe_cht.py +200 -0
- pythonfmu3-1.1.0/pythonfmu3/tests/test_migration_doc.py +34 -0
- pythonfmu3-1.1.0/pythonfmu3/tests/test_studio.py +149 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0/pythonfmu3.egg-info}/PKG-INFO +9 -1
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/SOURCES.txt +17 -0
- pythonfmu3-1.0.0/pythonfmu3/_version.py +0 -1
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/LICENSE +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/__init__.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/builder.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/cosimulation.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/default_experiment.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/deploy.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/enums.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/fmi3slave.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/logmsg.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/modelexchange.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/osutil.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/.clang-format +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/CMakeLists.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/build_unix.sh +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/build_win.sh +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/generate_shim.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/CMakeLists.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_common.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_cs.cpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/cppfmu_cs.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/cppfmu/fmi_functions.cpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3FunctionTypes.h +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3Functions.h +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/fmi/fmi3PlatformTypes.h +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/IPyState.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.cpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PySlaveInstance.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/pythonfmu/PyState.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/fmi3_forwarders.cpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/shim_loader.cpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/pythonfmu-export/src/shim/shim_loader.hpp +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-backend.so +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/resources/binaries/x86_64-linux/libpythonfmu-export.so +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/__init__.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/conftest.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/csvdemo.csv +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/fmi3_host.c +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/data/hello.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/fmi3_ctypes_driver.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/PythonSlaveWithException.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/localmodule.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslaveMX.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslaveMXCS.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave_arraytypes.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/pythonslave_read_file.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/slaves/slavewithdep.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_deploy.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_docs.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_fmi3slave.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_integration.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_logger.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_multiple_fmus.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_non_python_host.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_osutil.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_variables.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/test_wrapper_ctypes.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/tests/utils.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/unit.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/variable_types.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3/variables.py +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/dependency_links.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/entry_points.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/requires.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/pythonfmu3.egg-info/top_level.txt +0 -0
- {pythonfmu3-1.0.0 → pythonfmu3-1.1.0}/setup.cfg +0 -0
|
@@ -7,5 +7,8 @@ recursive-include pythonfmu3/tests/slaves *
|
|
|
7
7
|
recursive-include pythonfmu3/resources *
|
|
8
8
|
recursive-include pythonfmu3/pythonfmu-export *
|
|
9
9
|
|
|
10
|
+
recursive-include pythonfmu3/studio/static *
|
|
11
|
+
recursive-include pythonfmu3/studio/templates *.py
|
|
12
|
+
|
|
10
13
|
prune pythonfmu3/pythonfmu-export/build
|
|
11
14
|
global-exclude __pycache__ *.py[cod]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pythonfmu3
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: A lightweight framework that enables the packaging of Python 3 code as FMI 3.0 co-simulation and model exchange FMUs.
|
|
5
5
|
Author: Stephen Smith
|
|
6
6
|
License-Expression: MIT
|
|
@@ -74,6 +74,14 @@ On Windows, `python3.dll` must be on the `PATH`. See
|
|
|
74
74
|
[Running FMUs outside Python](docs/install.md#running-fmus-outside-python-linux-and-macos)
|
|
75
75
|
for the full lookup order.
|
|
76
76
|
|
|
77
|
+
### PythonFMU3 Studio
|
|
78
|
+
|
|
79
|
+
`pythonfmu3 studio` opens a local web app to write, check, simulate, build and inspect
|
|
80
|
+
FMUs, with live plots, a model-description preview, an FMU inspector and templates. It
|
|
81
|
+
needs no extra dependencies. See the [Studio docs](docs/studio.md).
|
|
82
|
+
|
|
83
|
+

|
|
84
|
+
|
|
77
85
|
### How do I build an FMU from python code?
|
|
78
86
|
|
|
79
87
|
1. Install `pythonfmu3` package:
|
|
@@ -39,6 +39,14 @@ On Windows, `python3.dll` must be on the `PATH`. See
|
|
|
39
39
|
[Running FMUs outside Python](docs/install.md#running-fmus-outside-python-linux-and-macos)
|
|
40
40
|
for the full lookup order.
|
|
41
41
|
|
|
42
|
+
### PythonFMU3 Studio
|
|
43
|
+
|
|
44
|
+
`pythonfmu3 studio` opens a local web app to write, check, simulate, build and inspect
|
|
45
|
+
FMUs, with live plots, a model-description preview, an FMU inspector and templates. It
|
|
46
|
+
needs no extra dependencies. See the [Studio docs](docs/studio.md).
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
42
50
|
### How do I build an FMU from python code?
|
|
43
51
|
|
|
44
52
|
1. Install `pythonfmu3` package:
|
|
@@ -43,6 +43,14 @@ def cli_main():
|
|
|
43
43
|
)
|
|
44
44
|
deploy.create_command_parser(deploy_parser)
|
|
45
45
|
|
|
46
|
+
studio_parser = subparsers.add_parser(
|
|
47
|
+
"studio",
|
|
48
|
+
description="Open PythonFMU3 Studio, a local web app to edit, check, simulate, build and inspect FMUs.",
|
|
49
|
+
help="Open the PythonFMU3 Studio web app."
|
|
50
|
+
)
|
|
51
|
+
from .studio.server import create_command_parser as create_studio_parser
|
|
52
|
+
create_studio_parser(studio_parser)
|
|
53
|
+
|
|
46
54
|
options = vars(parser.parse_args())
|
|
47
55
|
execute = options.pop("execute")
|
|
48
56
|
execute(**options)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""PythonFMU3 Studio: a local web app for building and checking FMUs (``pythonfmu3 studio``)."""
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""PythonFMU3 Studio: a local web app to edit, check, simulate, build and inspect FMUs.
|
|
2
|
+
|
|
3
|
+
Start it with ``pythonfmu3 studio [folder]``. It uses only the standard library: a
|
|
4
|
+
threaded HTTP server on 127.0.0.1 serving the static app and a small JSON API. Model
|
|
5
|
+
code runs in a separate worker process per request (see worker.py).
|
|
6
|
+
|
|
7
|
+
Security: the server only listens on the loopback interface, only answers requests
|
|
8
|
+
whose Host header is the loopback address, and requires a random token (generated per
|
|
9
|
+
run and passed to the browser in the URL) on every API call, so other web pages open in
|
|
10
|
+
the browser can't drive it.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
import webbrowser
|
|
24
|
+
from http import HTTPStatus
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, Optional
|
|
28
|
+
from urllib.parse import parse_qs, quote, urlparse
|
|
29
|
+
|
|
30
|
+
from .._version import __version__
|
|
31
|
+
|
|
32
|
+
HERE = Path(__file__).parent
|
|
33
|
+
STATIC = HERE / "static"
|
|
34
|
+
TEMPLATES = HERE / "templates"
|
|
35
|
+
WORKER_TIMEOUT = 300
|
|
36
|
+
MAX_UPLOAD = 200 * 1024 * 1024
|
|
37
|
+
TEMPLATE_INFO = {
|
|
38
|
+
"pid_controller": ("PID controller", "Co-Simulation", "control",
|
|
39
|
+
"Setpoint and measurement in, actuator command out, with tunable gains and output limits.",
|
|
40
|
+
["inputs", "tunable parameters"]),
|
|
41
|
+
"bouncing_ball": ("Bouncing ball", "Model Exchange", "me",
|
|
42
|
+
"Free fall with a state event at each impact and a restitution coefficient.",
|
|
43
|
+
["events", "units"]),
|
|
44
|
+
"van_der_pol": ("Van der Pol", "Model Exchange", "me",
|
|
45
|
+
"A nonlinear oscillator with two continuous states, for testing solvers.",
|
|
46
|
+
["continuous states"]),
|
|
47
|
+
"dahlquist": ("Dahlquist", "Model Exchange + Co-Simulation", "me cs",
|
|
48
|
+
"dx/dt = -k·x in both interfaces: the smallest model, with an exact solution.",
|
|
49
|
+
["both interfaces"]),
|
|
50
|
+
"thermostat": ("Thermostat", "Co-Simulation", "control",
|
|
51
|
+
"A heated room with on/off control and hysteresis: Boolean output and a discrete state.",
|
|
52
|
+
["Boolean", "discrete"]),
|
|
53
|
+
"surrogate": ("Polynomial surrogate", "Co-Simulation", "ml",
|
|
54
|
+
"A fitted NumPy polynomial exposed as an FMU: the pattern for ML surrogate models.",
|
|
55
|
+
["numpy", "data-driven"]),
|
|
56
|
+
"signal_generator": ("Signal generator", "Co-Simulation", "cs",
|
|
57
|
+
"Sine, square and ramp signals selected by an Int32 mode: a test-bench source.",
|
|
58
|
+
["Int32 mode", "outputs"]),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class StudioState:
|
|
63
|
+
def __init__(self, workspace: Path):
|
|
64
|
+
self.workspace = workspace.resolve()
|
|
65
|
+
self.token = secrets.token_urlsafe(24)
|
|
66
|
+
self.uploads = Path(tempfile.mkdtemp(prefix="pythonfmu3_studio_"))
|
|
67
|
+
self.last_build: Dict[str, str] = {}
|
|
68
|
+
|
|
69
|
+
def resolve(self, relative: str, must_exist: bool = True) -> Path:
|
|
70
|
+
"""Resolve a workspace-relative path, refusing anything outside the workspace."""
|
|
71
|
+
if not relative or "\x00" in relative:
|
|
72
|
+
raise ValueError("empty path")
|
|
73
|
+
path = (self.workspace / relative).resolve()
|
|
74
|
+
if path != self.workspace and self.workspace not in path.parents:
|
|
75
|
+
raise PermissionError(f"{relative} is outside the workspace")
|
|
76
|
+
if must_exist and not path.exists():
|
|
77
|
+
raise FileNotFoundError(relative)
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
def rel(self, path: Path) -> str:
|
|
81
|
+
return path.resolve().relative_to(self.workspace).as_posix()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_worker(request: Dict[str, Any], cwd: Path) -> Dict[str, Any]:
|
|
85
|
+
env = dict(os.environ)
|
|
86
|
+
package_root = str(HERE.parent.parent)
|
|
87
|
+
env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
|
|
88
|
+
try:
|
|
89
|
+
proc = subprocess.run(
|
|
90
|
+
[sys.executable, "-m", "pythonfmu3.studio.worker"], input=json.dumps(request),
|
|
91
|
+
capture_output=True, text=True, timeout=WORKER_TIMEOUT, cwd=str(cwd), env=env,
|
|
92
|
+
)
|
|
93
|
+
except subprocess.TimeoutExpired:
|
|
94
|
+
return {"ok": False, "error": f"Timed out after {WORKER_TIMEOUT} s"}
|
|
95
|
+
try:
|
|
96
|
+
return json.loads(proc.stdout)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
return {"ok": False, "error": "The worker process failed", "traceback": (proc.stdout + proc.stderr)[-8000:]}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def environment() -> Dict[str, Any]:
|
|
102
|
+
from .worker import find_fmusim
|
|
103
|
+
|
|
104
|
+
def has(module):
|
|
105
|
+
import importlib.util
|
|
106
|
+
return importlib.util.find_spec(module) is not None
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"version": __version__,
|
|
110
|
+
"python": sys.version.split()[0],
|
|
111
|
+
"fmusim": find_fmusim(),
|
|
112
|
+
"fmpy": has("fmpy"),
|
|
113
|
+
"numpy": has("numpy"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Handler(BaseHTTPRequestHandler):
|
|
118
|
+
server_version = f"PythonFMU3Studio/{__version__}"
|
|
119
|
+
state: StudioState # set on the class by serve()
|
|
120
|
+
|
|
121
|
+
# -- plumbing ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def log_message(self, fmt, *args): # keep the terminal quiet
|
|
124
|
+
if os.environ.get("PYTHONFMU3_STUDIO_DEBUG"):
|
|
125
|
+
super().log_message(fmt, *args)
|
|
126
|
+
|
|
127
|
+
def _send(self, status: int, body: bytes, content_type: str, extra: Optional[Dict[str, str]] = None):
|
|
128
|
+
self.send_response(status)
|
|
129
|
+
self.send_header("Content-Type", content_type)
|
|
130
|
+
self.send_header("Content-Length", str(len(body)))
|
|
131
|
+
self.send_header("Cache-Control", "no-store")
|
|
132
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
133
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
134
|
+
for k, v in (extra or {}).items():
|
|
135
|
+
self.send_header(k, v)
|
|
136
|
+
self.end_headers()
|
|
137
|
+
self.wfile.write(body)
|
|
138
|
+
|
|
139
|
+
def _json(self, payload: Any, status: int = 200):
|
|
140
|
+
self._send(status, json.dumps(payload, default=str).encode("utf-8"), "application/json; charset=utf-8")
|
|
141
|
+
|
|
142
|
+
def _error(self, status: int, message: str, detail: str = ""):
|
|
143
|
+
self._json({"ok": False, "error": message, "traceback": detail}, status)
|
|
144
|
+
|
|
145
|
+
def _host_ok(self) -> bool:
|
|
146
|
+
host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]")
|
|
147
|
+
return host in ("127.0.0.1", "localhost", "::1")
|
|
148
|
+
|
|
149
|
+
def _token_ok(self, query: Dict[str, list]) -> bool:
|
|
150
|
+
supplied = self.headers.get("X-Studio-Token") or (query.get("token") or [""])[0]
|
|
151
|
+
return secrets.compare_digest(supplied, self.state.token)
|
|
152
|
+
|
|
153
|
+
def _body(self) -> bytes:
|
|
154
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
155
|
+
if length > MAX_UPLOAD:
|
|
156
|
+
raise ValueError("Upload too large")
|
|
157
|
+
return self.rfile.read(length) if length else b""
|
|
158
|
+
|
|
159
|
+
def _json_body(self) -> Dict[str, Any]:
|
|
160
|
+
data = self._body()
|
|
161
|
+
return json.loads(data or b"{}")
|
|
162
|
+
|
|
163
|
+
# -- routing ----------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def do_GET(self):
|
|
166
|
+
self._dispatch("GET")
|
|
167
|
+
|
|
168
|
+
def do_POST(self):
|
|
169
|
+
self._dispatch("POST")
|
|
170
|
+
|
|
171
|
+
def _dispatch(self, method: str):
|
|
172
|
+
if not self._host_ok():
|
|
173
|
+
return self._error(HTTPStatus.FORBIDDEN, "Invalid Host header")
|
|
174
|
+
url = urlparse(self.path)
|
|
175
|
+
query = parse_qs(url.query)
|
|
176
|
+
if method == "GET" and not url.path.startswith("/api/"):
|
|
177
|
+
return self._static(url.path)
|
|
178
|
+
if not self._token_ok(query):
|
|
179
|
+
return self._error(HTTPStatus.FORBIDDEN, "Missing or invalid token")
|
|
180
|
+
route = getattr(self, f"api_{method.lower()}_{url.path[5:].replace('/', '_')}", None)
|
|
181
|
+
if route is None:
|
|
182
|
+
return self._error(HTTPStatus.NOT_FOUND, f"No such endpoint: {url.path}")
|
|
183
|
+
try:
|
|
184
|
+
route(query)
|
|
185
|
+
except (FileNotFoundError, PermissionError, ValueError, KeyError) as exc:
|
|
186
|
+
self._error(HTTPStatus.BAD_REQUEST, f"{type(exc).__name__}: {exc}")
|
|
187
|
+
except Exception as exc: # pragma: no cover - unexpected
|
|
188
|
+
import traceback
|
|
189
|
+
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, f"{type(exc).__name__}: {exc}", traceback.format_exc())
|
|
190
|
+
|
|
191
|
+
def _static(self, path: str):
|
|
192
|
+
name = "index.html" if path in ("", "/") else path.lstrip("/")
|
|
193
|
+
target = (STATIC / name).resolve()
|
|
194
|
+
if STATIC.resolve() not in target.parents or not target.is_file():
|
|
195
|
+
return self._error(HTTPStatus.NOT_FOUND, "Not found")
|
|
196
|
+
ctype = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
|
|
197
|
+
if ctype.startswith("text/") or ctype in ("application/javascript",):
|
|
198
|
+
ctype += "; charset=utf-8"
|
|
199
|
+
csp = ("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; "
|
|
200
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
|
|
201
|
+
self._send(200, target.read_bytes(), ctype, {"Content-Security-Policy": csp})
|
|
202
|
+
|
|
203
|
+
# -- API: environment and files ---------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def api_get_env(self, query):
|
|
206
|
+
info = environment()
|
|
207
|
+
info["workspace"] = str(self.state.workspace)
|
|
208
|
+
self._json({"ok": True, "result": info})
|
|
209
|
+
|
|
210
|
+
def api_get_files(self, query):
|
|
211
|
+
files = []
|
|
212
|
+
skip = {".git", "__pycache__", ".venv", "venv", "node_modules"}
|
|
213
|
+
for path in sorted(self.state.workspace.rglob("*")):
|
|
214
|
+
rel = path.relative_to(self.state.workspace)
|
|
215
|
+
if any(part in skip or part.startswith(".") for part in rel.parts[:-1]):
|
|
216
|
+
continue
|
|
217
|
+
if path.is_file() and path.suffix in (".py", ".fmu"):
|
|
218
|
+
files.append({"path": rel.as_posix(), "kind": path.suffix[1:], "size": path.stat().st_size})
|
|
219
|
+
if len(files) >= 500:
|
|
220
|
+
break
|
|
221
|
+
self._json({"ok": True, "result": files})
|
|
222
|
+
|
|
223
|
+
def api_get_file(self, query):
|
|
224
|
+
path = self.state.resolve(query["path"][0])
|
|
225
|
+
if path.suffix != ".py":
|
|
226
|
+
raise ValueError("Only .py files can be opened in the editor")
|
|
227
|
+
self._json({"ok": True, "result": {"path": self.state.rel(path), "content": path.read_text(encoding="utf-8")}})
|
|
228
|
+
|
|
229
|
+
def api_post_file(self, query):
|
|
230
|
+
body = self._json_body()
|
|
231
|
+
path = self.state.resolve(body["path"], must_exist=False)
|
|
232
|
+
if path.suffix != ".py":
|
|
233
|
+
raise ValueError("Only .py files can be saved")
|
|
234
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
path.write_text(body["content"], encoding="utf-8")
|
|
236
|
+
self._json({"ok": True, "result": {"path": self.state.rel(path)}})
|
|
237
|
+
|
|
238
|
+
# -- API: model operations (worker process) ---------------------------------------------
|
|
239
|
+
|
|
240
|
+
def _model_request(self, command: str, body: Dict[str, Any]):
|
|
241
|
+
path = self.state.resolve(body["path"])
|
|
242
|
+
request = dict(body, command=command, path=str(path))
|
|
243
|
+
return path, run_worker(request, path.parent)
|
|
244
|
+
|
|
245
|
+
def api_post_analyze(self, query):
|
|
246
|
+
_, response = self._model_request("analyze", self._json_body())
|
|
247
|
+
self._json(response)
|
|
248
|
+
|
|
249
|
+
def api_post_simulate(self, query):
|
|
250
|
+
_, response = self._model_request("simulate", self._json_body())
|
|
251
|
+
self._json(response)
|
|
252
|
+
|
|
253
|
+
def api_post_build(self, query):
|
|
254
|
+
body = self._json_body()
|
|
255
|
+
path = self.state.resolve(body["path"])
|
|
256
|
+
response = run_worker({"command": "build", "path": str(path),
|
|
257
|
+
"dest": str(path.parent / "build")}, path.parent)
|
|
258
|
+
if response.get("ok"):
|
|
259
|
+
fmu = Path(response["result"]["fmu"])
|
|
260
|
+
response["result"]["fmu"] = self.state.rel(fmu)
|
|
261
|
+
self.state.last_build[self.state.rel(path)] = self.state.rel(fmu)
|
|
262
|
+
self._json(response)
|
|
263
|
+
|
|
264
|
+
def api_post_validate(self, query):
|
|
265
|
+
body = self._json_body()
|
|
266
|
+
fmu = self.state.resolve(body["fmu"])
|
|
267
|
+
self._json(run_worker({"command": "validate", "fmu": str(fmu)}, self.state.workspace))
|
|
268
|
+
|
|
269
|
+
def api_get_download(self, query):
|
|
270
|
+
path = self.state.resolve(query["path"][0])
|
|
271
|
+
if path.suffix != ".fmu":
|
|
272
|
+
raise ValueError("Only FMUs can be downloaded")
|
|
273
|
+
self._send(200, path.read_bytes(), "application/octet-stream",
|
|
274
|
+
{"Content-Disposition": f"attachment; filename*=UTF-8''{quote(path.name)}"})
|
|
275
|
+
|
|
276
|
+
def api_post_inspect(self, query):
|
|
277
|
+
if (self.headers.get("Content-Type") or "").startswith("application/json"):
|
|
278
|
+
body = self._json_body()
|
|
279
|
+
fmu = self.state.resolve(body["path"])
|
|
280
|
+
name = fmu.name
|
|
281
|
+
else:
|
|
282
|
+
name = re.sub(r"[^A-Za-z0-9._-]", "_", (query.get("name") or ["upload.fmu"])[0]) or "upload.fmu"
|
|
283
|
+
fmu = self.state.uploads / secrets.token_hex(8) / name
|
|
284
|
+
fmu.parent.mkdir(parents=True)
|
|
285
|
+
fmu.write_bytes(self._body())
|
|
286
|
+
response = run_worker({"command": "inspect", "path": str(fmu), "name": name}, self.state.workspace)
|
|
287
|
+
if response.get("ok") and environment()["fmusim"]:
|
|
288
|
+
response["result"]["validation"] = run_worker({"command": "validate", "fmu": str(fmu)},
|
|
289
|
+
self.state.workspace).get("result")
|
|
290
|
+
self._json(response)
|
|
291
|
+
|
|
292
|
+
# -- API: templates -----------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def api_get_templates(self, query):
|
|
295
|
+
items = []
|
|
296
|
+
for key, (title, iface, cats, desc, tags) in TEMPLATE_INFO.items():
|
|
297
|
+
if (TEMPLATES / f"{key}.py").exists():
|
|
298
|
+
items.append({"id": key, "title": title, "interface": iface, "categories": cats.split(),
|
|
299
|
+
"description": desc, "tags": tags})
|
|
300
|
+
self._json({"ok": True, "result": items})
|
|
301
|
+
|
|
302
|
+
def api_post_templates_use(self, query):
|
|
303
|
+
body = self._json_body()
|
|
304
|
+
key = body["id"]
|
|
305
|
+
if key not in TEMPLATE_INFO:
|
|
306
|
+
raise KeyError(key)
|
|
307
|
+
target = self.state.resolve(body.get("name") or f"{key}.py", must_exist=False)
|
|
308
|
+
if target.suffix != ".py":
|
|
309
|
+
raise ValueError("The file name must end in .py")
|
|
310
|
+
if target.exists():
|
|
311
|
+
raise ValueError(f"{self.state.rel(target)} already exists")
|
|
312
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
shutil.copyfile(TEMPLATES / f"{key}.py", target)
|
|
314
|
+
self._json({"ok": True, "result": {"path": self.state.rel(target)}})
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def serve(workspace: Path, port: int = 0, open_browser: bool = True) -> ThreadingHTTPServer:
|
|
318
|
+
state = StudioState(workspace)
|
|
319
|
+
handler = type("StudioHandler", (Handler,), {"state": state})
|
|
320
|
+
httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
321
|
+
httpd.studio_state = state
|
|
322
|
+
url = f"http://127.0.0.1:{httpd.server_address[1]}/?token={state.token}"
|
|
323
|
+
httpd.studio_url = url
|
|
324
|
+
if open_browser:
|
|
325
|
+
threading.Timer(0.3, lambda: webbrowser.open(url)).start()
|
|
326
|
+
return httpd
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main(folder: str = ".", port: int = 0, no_browser: bool = False, **_):
|
|
330
|
+
workspace = Path(folder).resolve()
|
|
331
|
+
if not workspace.is_dir():
|
|
332
|
+
raise SystemExit(f"{workspace} is not a folder")
|
|
333
|
+
httpd = serve(workspace, port, open_browser=not no_browser)
|
|
334
|
+
print(f"PythonFMU3 Studio {__version__}")
|
|
335
|
+
print(f" workspace: {workspace}")
|
|
336
|
+
print(f" open: {httpd.studio_url}")
|
|
337
|
+
print(" (Ctrl+C to stop)")
|
|
338
|
+
try:
|
|
339
|
+
httpd.serve_forever()
|
|
340
|
+
except KeyboardInterrupt:
|
|
341
|
+
pass
|
|
342
|
+
finally:
|
|
343
|
+
httpd.server_close()
|
|
344
|
+
shutil.rmtree(httpd.studio_state.uploads, ignore_errors=True)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def create_command_parser(parser: argparse.ArgumentParser):
|
|
348
|
+
parser.add_argument("folder", nargs="?", default=".", help="Workspace folder with your model scripts (default: current folder).")
|
|
349
|
+
parser.add_argument("-p", "--port", type=int, default=0, help="Port to listen on (default: a free port).")
|
|
350
|
+
parser.add_argument("--no-browser", action="store_true", help="Don't open a browser window.")
|
|
351
|
+
parser.set_defaults(execute=main)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
p = argparse.ArgumentParser(prog="pythonfmu3 studio")
|
|
356
|
+
create_command_parser(p)
|
|
357
|
+
args = vars(p.parse_args())
|
|
358
|
+
args.pop("execute")(**args)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--blue: #2F5D8A; --blue-dark: #1F4468; --blue-soft: #E8EEF5;
|
|
3
|
+
--gold: #E8B11C; --gold-soft: #FEF6DD; --gold-ink: #6B4E00;
|
|
4
|
+
--ink: #1F2933; --ink-2: #46515E; --muted: #5B6673;
|
|
5
|
+
--bg: #F4F6F9; --panel: #FFFFFF; --line: #E3E8EF; --line-2: #C9D2DE;
|
|
6
|
+
--ok-bg: #ECFDF3; --ok: #05603A; --warn-bg: #FEF0C7; --warn: #7A2E0E; --err-bg: #FEE4E2; --err: #912018;
|
|
7
|
+
--code-bg: #0F1720; --code-fg: #D6DEEB; --code-gutter: #6B7A8A;
|
|
8
|
+
--mono: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, Consolas, monospace;
|
|
9
|
+
--sans: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
10
|
+
}
|
|
11
|
+
* { box-sizing: border-box; }
|
|
12
|
+
html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 14px; }
|
|
13
|
+
body { min-height: 100vh; display: flex; flex-direction: column; }
|
|
14
|
+
h1 { font-size: 26px; margin: 0 0 6px; }
|
|
15
|
+
h2 { font-size: 16px; margin: 0; }
|
|
16
|
+
h3 { font-size: 13px; margin: 14px 0 8px; color: var(--ink-2); }
|
|
17
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
18
|
+
a { color: var(--blue); }
|
|
19
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
20
|
+
.muted { color: var(--muted); }
|
|
21
|
+
.small { font-size: 12px; }
|
|
22
|
+
.spacer, .grow { flex: 1 1 auto; }
|
|
23
|
+
.push { margin-left: auto; }
|
|
24
|
+
[hidden] { display: none !important; }
|
|
25
|
+
|
|
26
|
+
/* top bar */
|
|
27
|
+
.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; }
|
|
28
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; }
|
|
29
|
+
.accent { color: var(--blue); }
|
|
30
|
+
.tabs { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
31
|
+
.tab { min-height: 38px; padding: 0 14px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); font-weight: 500; cursor: pointer; }
|
|
32
|
+
.tab:hover { background: var(--bg); }
|
|
33
|
+
.tab[aria-current="page"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
|
|
34
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
35
|
+
.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; }
|
|
36
|
+
.file-picker select { font-family: var(--mono); font-size: 13px; max-width: 260px; }
|
|
37
|
+
.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; }
|
|
38
|
+
.btn:hover { border-color: var(--blue); }
|
|
39
|
+
.btn.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
|
40
|
+
.btn.primary:hover { background: var(--blue-dark); }
|
|
41
|
+
.btn.gold { background: var(--gold); border-color: var(--gold); color: var(--ink); }
|
|
42
|
+
.btn:disabled { opacity: 0.55; cursor: progress; }
|
|
43
|
+
.btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
44
|
+
:focus-visible { outline: 3px solid #7FB0DE; outline-offset: 2px; }
|
|
45
|
+
|
|
46
|
+
.banner { margin: 14px 20px 0; padding: 10px 14px; border-radius: 8px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
47
|
+
.banner.ok { background: #DCFAE6; color: var(--ok); }
|
|
48
|
+
.banner.error { background: var(--err-bg); color: var(--err); }
|
|
49
|
+
.banner.info { background: var(--blue-soft); color: var(--blue-dark); }
|
|
50
|
+
.banner pre { flex-basis: 100%; margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: 12px; max-height: 240px; overflow: auto; }
|
|
51
|
+
|
|
52
|
+
main { flex: 1 1 auto; padding: 16px 20px 20px; }
|
|
53
|
+
.view { display: flex; flex-direction: column; gap: 16px; }
|
|
54
|
+
.view.narrow { max-width: 1240px; margin: 0 auto; width: 100%; }
|
|
55
|
+
.row { display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch; }
|
|
56
|
+
.row.top { align-items: flex-start; }
|
|
57
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; }
|
|
58
|
+
.panel-head { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); }
|
|
59
|
+
|
|
60
|
+
/* code editor */
|
|
61
|
+
.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; }
|
|
62
|
+
.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); }
|
|
63
|
+
.code-head .muted { color: #8A99A8; font-family: var(--sans); }
|
|
64
|
+
.dirty { color: var(--gold); font-family: var(--sans); font-weight: 600; }
|
|
65
|
+
.code-body { position: relative; display: flex; flex: 1 1 auto; min-height: 0; }
|
|
66
|
+
.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; }
|
|
67
|
+
#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; }
|
|
68
|
+
|
|
69
|
+
/* preview */
|
|
70
|
+
.preview { flex: 1 1 420px; display: flex; flex-direction: column; height: min(72vh, 760px); min-height: 420px; }
|
|
71
|
+
.subtabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 8px; border-bottom: 1px solid var(--line); }
|
|
72
|
+
.subtab { min-height: 38px; padding: 0 12px; border: none; border-radius: 8px; background: transparent; color: var(--ink-2); cursor: pointer; }
|
|
73
|
+
.subtab[aria-selected="true"] { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
|
|
74
|
+
.badge { margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 12px; background: #DCFAE6; color: var(--ok); }
|
|
75
|
+
.badge.bad { background: var(--warn-bg); color: var(--warn); }
|
|
76
|
+
.preview-body { flex: 1 1 auto; overflow: auto; padding: 16px; min-height: 0; }
|
|
77
|
+
.xml { margin: 0; font: 12px/20px var(--mono); background: #FAFBFC; white-space: pre; }
|
|
78
|
+
.checks { display: flex; flex-direction: column; gap: 10px; }
|
|
79
|
+
.check-item { display: flex; gap: 10px; padding: 10px 12px; border-radius: 8px; }
|
|
80
|
+
.check-item strong { width: 56px; flex: none; font-size: 12px; }
|
|
81
|
+
.check-item .title { font-weight: 600; }
|
|
82
|
+
.check-item.ok { background: var(--ok-bg); color: var(--ok); }
|
|
83
|
+
.check-item.warning { background: var(--warn-bg); color: var(--warn); }
|
|
84
|
+
.check-item.error { background: var(--err-bg); color: var(--err); }
|
|
85
|
+
|
|
86
|
+
/* FMU block */
|
|
87
|
+
.block-wrap { display: flex; flex-direction: column; align-items: center; gap: 18px; padding: 12px 0; }
|
|
88
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
89
|
+
.chip-param { padding: 5px 10px; border-radius: 999px; background: var(--gold-soft); color: var(--gold-ink); font-size: 13px; font-weight: 500; }
|
|
90
|
+
.block-row { display: flex; align-items: center; justify-content: center; width: 100%; max-width: 520px; }
|
|
91
|
+
.ports { display: flex; flex-direction: column; gap: 12px; flex: 1 1 0; min-width: 0; }
|
|
92
|
+
.ports.in { align-items: flex-end; }
|
|
93
|
+
.ports.out { align-items: flex-start; }
|
|
94
|
+
.port { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }
|
|
95
|
+
.port i { width: 20px; height: 12px; border-radius: 3px; background: var(--gold); flex: none; }
|
|
96
|
+
.port span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
|
|
97
|
+
.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; }
|
|
98
|
+
.block b { font-size: 15px; word-break: break-word; }
|
|
99
|
+
.block small { color: #D6E2EE; }
|
|
100
|
+
|
|
101
|
+
/* tables */
|
|
102
|
+
.table-wrap { overflow-x: auto; }
|
|
103
|
+
.table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
|
104
|
+
.table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; padding: 10px 8px; }
|
|
105
|
+
.table td { padding: 8px; border-top: 1px solid #EEF1F5; vertical-align: top; }
|
|
106
|
+
.table th:first-child, .table td:first-child { padding-left: 16px; }
|
|
107
|
+
.mono { font-family: var(--mono); font-size: 13px; }
|
|
108
|
+
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: var(--blue-soft); color: var(--blue-dark); }
|
|
109
|
+
.caus-input { background: var(--gold-soft); color: var(--gold-ink); }
|
|
110
|
+
.caus-output { background: #E3F2EA; color: var(--ok); }
|
|
111
|
+
.caus-structuralParameter { background: #EDE7F6; color: #4A2C83; }
|
|
112
|
+
.caus-independent, .caus-local { background: #F2F4F7; color: var(--ink-2); }
|
|
113
|
+
.flag { font-size: 12px; padding: 3px 8px; border-radius: 6px; }
|
|
114
|
+
.flag.warning { background: var(--warn-bg); color: var(--warn); }
|
|
115
|
+
.flag.error { background: var(--err-bg); color: var(--err); }
|
|
116
|
+
.flag.ok { color: var(--ok); }
|
|
117
|
+
|
|
118
|
+
/* simulate */
|
|
119
|
+
.settings { flex: 1 1 320px; max-width: 100%; padding: 18px; display: flex; flex-direction: column; gap: 14px; overflow: visible; }
|
|
120
|
+
fieldset { border: none; margin: 0; padding: 0; }
|
|
121
|
+
legend { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 8px; }
|
|
122
|
+
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
123
|
+
.grid2 select, .grid2 input[type=number] { width: 100%; }
|
|
124
|
+
.grid2 label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; font-weight: 600; color: var(--ink-2); }
|
|
125
|
+
.grid2 label.check { flex-direction: row; align-items: center; gap: 8px; font-weight: 500; }
|
|
126
|
+
.chip-btn { min-height: 38px; padding: 0 12px; border-radius: 999px; border: 1px solid var(--line-2); background: #fff; cursor: pointer; }
|
|
127
|
+
.chip-btn[aria-pressed="true"] { background: var(--blue); border-color: var(--blue); color: #fff; font-weight: 600; }
|
|
128
|
+
.chip-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
129
|
+
.params { display: flex; flex-direction: column; gap: 12px; }
|
|
130
|
+
.param { display: flex; flex-direction: column; gap: 4px; }
|
|
131
|
+
.param-head { display: flex; align-items: center; gap: 8px; }
|
|
132
|
+
.param-head .mono { font-weight: 600; }
|
|
133
|
+
.param-head input { width: 110px; margin-left: auto; min-height: 32px; }
|
|
134
|
+
.param input[type=range] { width: 100%; accent-color: var(--blue); }
|
|
135
|
+
.results { flex: 999 1 600px; min-width: 0; display: flex; flex-direction: column; gap: 14px; }
|
|
136
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
|
137
|
+
.stat { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
|
138
|
+
.stat .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
139
|
+
.stat .v { font-size: 24px; font-weight: 700; margin-top: 4px; }
|
|
140
|
+
.series-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
141
|
+
.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; }
|
|
142
|
+
.series-picker input { accent-color: var(--blue); }
|
|
143
|
+
.charts { display: flex; flex-direction: column; gap: 14px; }
|
|
144
|
+
.chart { margin: 0; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px 8px; }
|
|
145
|
+
.chart figcaption { display: flex; gap: 10px; align-items: baseline; font-weight: 600; margin-bottom: 4px; }
|
|
146
|
+
.chart figcaption .mono { font-weight: 400; color: var(--muted); }
|
|
147
|
+
.chart svg { display: block; width: 100%; height: auto; }
|
|
148
|
+
.chart .readout { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--ink-2); font-weight: 400; }
|
|
149
|
+
.empty { padding: 40px 16px; text-align: center; color: var(--muted); background: #fff; border: 1px dashed var(--line-2); border-radius: 12px; }
|
|
150
|
+
.log summary { padding: 12px 16px; cursor: pointer; font-weight: 600; }
|
|
151
|
+
.log pre { margin: 0; padding: 0 16px 16px; font: 12px/18px var(--mono); white-space: pre-wrap; max-height: 300px; overflow: auto; }
|
|
152
|
+
|
|
153
|
+
/* inspector */
|
|
154
|
+
.drop { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; padding: 16px 18px; border: 2px dashed #B8C3D0; border-radius: 12px; background: #fff; }
|
|
155
|
+
.drop.over { border-color: var(--blue); background: var(--blue-soft); }
|
|
156
|
+
.drop .grow { display: flex; flex-direction: column; gap: 2px; min-width: 220px; }
|
|
157
|
+
.summary { display: flex; flex-direction: column; gap: 12px; padding: 18px; }
|
|
158
|
+
.summary dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px 24px; }
|
|
159
|
+
.summary dt { color: var(--muted); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
160
|
+
.summary dd { margin: 2px 0 0; word-break: break-word; }
|
|
161
|
+
.tag { padding: 4px 10px; border-radius: 999px; background: #F2F4F7; color: var(--ink-2); font-size: 13px; }
|
|
162
|
+
.tag.strong { background: var(--blue-soft); color: var(--blue-dark); font-weight: 600; }
|
|
163
|
+
|
|
164
|
+
/* templates */
|
|
165
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0; }
|
|
166
|
+
.search { flex: 1 1 260px; }
|
|
167
|
+
.search input { width: 100%; }
|
|
168
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
|
169
|
+
.card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 18px; }
|
|
170
|
+
.card h2 { font-size: 16px; }
|
|
171
|
+
.card p { margin: 0; color: var(--ink-2); flex: 1 1 auto; }
|
|
172
|
+
|
|
173
|
+
.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; }
|
|
174
|
+
|
|
175
|
+
@media (max-width: 640px) {
|
|
176
|
+
.topbar { padding: 10px 16px; }
|
|
177
|
+
main { padding: 12px 16px; }
|
|
178
|
+
.grid2 { grid-template-columns: minmax(0, 1fr); }
|
|
179
|
+
}
|