openflume 0.3.1__py3-none-any.whl

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.
openflume/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """OpenFLUME — thermal-fluid network solver, from Python.
2
+
3
+ Quick start::
4
+
5
+ import openflume as of
6
+
7
+ net = of.load("feed_system.fn") # or of.example("Tank blowdown")
8
+ errors = net.validate() # [] means runnable
9
+ result = net.solve() # steady or transient per settings.mode
10
+ print(result.converged, result.node("tank")["pressure"])
11
+
12
+ n2 = of.Fluid.real("Nitrogen")
13
+ n2.density(101325, 300.0) # kg/m³
14
+
15
+ Everything is SI (Pa, K, kg/s, m, J/kg). The solver core is the same
16
+ TypeScript code that runs the web app, executed in a bundled Node.js runtime
17
+ (see :class:`Session`). Models that embed JavaScript user components run that
18
+ code with the privileges of your Python process — only solve models you trust.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from importlib.metadata import PackageNotFoundError
24
+ from importlib.metadata import version as _dist_version
25
+
26
+ from ._bridge import Session, default_session, find_node, shutdown
27
+ from .errors import (
28
+ BridgeError,
29
+ ConfigDecodeError,
30
+ CoreError,
31
+ InvalidRequestError,
32
+ OpenFLUMEError,
33
+ ParseError,
34
+ UnknownExampleError,
35
+ UnsupportedPropertyError,
36
+ ValidationError,
37
+ )
38
+ from .fluids import Fluid, combustion_gas, combustion_propellants, fluids
39
+ from .network import Network, example, examples, load, solve, validate
40
+ from .results import SteadyResult, TransientResult
41
+
42
+ try:
43
+ __version__ = _dist_version("openflume")
44
+ except PackageNotFoundError: # running from a source tree without install
45
+ __version__ = "0+unknown"
46
+
47
+ __all__ = [
48
+ "__version__",
49
+ # models
50
+ "Network",
51
+ "load",
52
+ "example",
53
+ "examples",
54
+ "solve",
55
+ "validate",
56
+ # results
57
+ "SteadyResult",
58
+ "TransientResult",
59
+ # fluids
60
+ "Fluid",
61
+ "fluids",
62
+ "combustion_gas",
63
+ "combustion_propellants",
64
+ # runtime
65
+ "Session",
66
+ "default_session",
67
+ "shutdown",
68
+ "find_node",
69
+ # errors
70
+ "OpenFLUMEError",
71
+ "BridgeError",
72
+ "ConfigDecodeError",
73
+ "ValidationError",
74
+ "ParseError",
75
+ "CoreError",
76
+ "UnknownExampleError",
77
+ "InvalidRequestError",
78
+ "UnsupportedPropertyError",
79
+ ]
openflume/__main__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Command line: ``openflume info | validate MODEL | solve MODEL [-o OUT]``.
2
+
3
+ Also runnable as ``python -m openflume``. Results are written as JSON (the
4
+ core's raw result object); exit status is 0 on success, 1 on validation
5
+ failure or a non-converged solve, 2 on usage/IO errors.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from ._bridge import _encode_nonfinite, default_session, find_node
17
+ from .errors import OpenFLUMEError
18
+ from .network import Network
19
+
20
+
21
+ def _cmd_info(_: argparse.Namespace) -> int:
22
+ info = default_session().start().info
23
+ print(f"openflume {__version__}")
24
+ print(f"core {info.get('version')} (config schema v{info.get('configVersion')})")
25
+ print(f"node {info.get('node')} ({find_node()})")
26
+ return 0
27
+
28
+
29
+ def _cmd_validate(args: argparse.Namespace) -> int:
30
+ errors = Network.load(args.model).validate()
31
+ for e in errors:
32
+ print(e)
33
+ if not errors:
34
+ print("valid")
35
+ return 1 if errors else 0
36
+
37
+
38
+ def _cmd_solve(args: argparse.Namespace) -> int:
39
+ net = Network.load(args.model)
40
+
41
+ def progress(p: dict) -> None:
42
+ if p.get("kind") == "transient":
43
+ print(f"\r t = {p['time']:.6g} / {p['endTime']:.6g} s", end="", file=sys.stderr)
44
+ else:
45
+ print(f"\r iter {p['iteration']} residual {p['residual']:.3e}", end="", file=sys.stderr)
46
+
47
+ result = net.solve(args.mode, progress=None if args.quiet else progress)
48
+ if not args.quiet:
49
+ print(file=sys.stderr)
50
+ print(repr(result), file=sys.stderr)
51
+ text = json.dumps(_encode_nonfinite(result.raw), indent=None if args.compact else 2)
52
+ if args.output:
53
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
54
+ else:
55
+ print(text)
56
+ return 0 if result.converged else 1
57
+
58
+
59
+ def main(argv: list[str] | None = None) -> int:
60
+ parser = argparse.ArgumentParser(prog="openflume", description="OpenFLUME thermal-fluid network solver")
61
+ parser.add_argument("--version", action="version", version=f"openflume {__version__}")
62
+ sub = parser.add_subparsers(dest="command", required=True)
63
+
64
+ sub.add_parser("info", help="show package, core and Node.js versions").set_defaults(fn=_cmd_info)
65
+
66
+ p_val = sub.add_parser("validate", help="validate a .fn/.json model")
67
+ p_val.add_argument("model")
68
+ p_val.set_defaults(fn=_cmd_validate)
69
+
70
+ p_solve = sub.add_parser("solve", help="solve a .fn/.json model and print the JSON result")
71
+ p_solve.add_argument("model")
72
+ p_solve.add_argument("-o", "--output", help="write the JSON result here instead of stdout")
73
+ p_solve.add_argument("--mode", choices=["steady", "transient"], help="override settings.mode")
74
+ p_solve.add_argument("-q", "--quiet", action="store_true", help="no progress on stderr")
75
+ p_solve.add_argument("--compact", action="store_true", help="single-line JSON")
76
+ p_solve.set_defaults(fn=_cmd_solve)
77
+
78
+ args = parser.parse_args(argv)
79
+ try:
80
+ return int(args.fn(args))
81
+ except OpenFLUMEError as exc:
82
+ print(f"error: {exc}", file=sys.stderr)
83
+ return 1
84
+ except OSError as exc:
85
+ print(f"error: {exc}", file=sys.stderr)
86
+ return 2
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
openflume/_bridge.py ADDED
@@ -0,0 +1,279 @@
1
+ """Node.js subprocess bridge to the bundled OpenFLUME core.
2
+
3
+ One long-lived ``node`` process per :class:`Session`, speaking newline-delimited
4
+ JSON over stdio (protocol documented in python/bridge/server.ts). Requests are
5
+ serialized with a lock; the solvers are synchronous so there is no benefit to
6
+ pipelining. Use several sessions (e.g. one per worker process) for parallelism.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import atexit
12
+ import json
13
+ import logging
14
+ import math
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import threading
19
+ from collections import deque
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from .errors import BridgeError, from_payload
25
+
26
+ PROTOCOL_VERSION = 1
27
+ MIN_NODE_MAJOR = 22
28
+
29
+ _JS_DIR = Path(__file__).resolve().parent / "_js"
30
+ BRIDGE_SCRIPT = _JS_DIR / "openflume-bridge.mjs"
31
+
32
+ _log = logging.getLogger("openflume.node")
33
+
34
+ ProgressCallback = Callable[[dict[str, Any]], None]
35
+
36
+
37
+ # ── non-finite number codec (mirrors server.ts) ─────────────────────────────
38
+ _NUM_TAG = "$num"
39
+
40
+
41
+ def _encode_nonfinite(value: Any) -> Any:
42
+ if isinstance(value, float):
43
+ if math.isnan(value):
44
+ return {_NUM_TAG: "NaN"}
45
+ if math.isinf(value):
46
+ return {_NUM_TAG: "Infinity" if value > 0 else "-Infinity"}
47
+ return value
48
+ if isinstance(value, dict):
49
+ return {k: _encode_nonfinite(v) for k, v in value.items()}
50
+ if isinstance(value, (list, tuple)):
51
+ return [_encode_nonfinite(v) for v in value]
52
+ # numpy scalars / arrays, without importing numpy
53
+ tolist = getattr(value, "tolist", None)
54
+ if callable(tolist) and type(value).__module__.startswith("numpy"):
55
+ return _encode_nonfinite(tolist())
56
+ return value
57
+
58
+
59
+ def _decode_hook(obj: dict[str, Any]) -> Any:
60
+ if len(obj) == 1 and _NUM_TAG in obj:
61
+ return float(obj[_NUM_TAG])
62
+ return obj
63
+
64
+
65
+ def dumps(value: Any) -> str:
66
+ return json.dumps(_encode_nonfinite(value), allow_nan=False, separators=(",", ":"))
67
+
68
+
69
+ def loads(text: str) -> Any:
70
+ return json.loads(text, object_hook=_decode_hook)
71
+
72
+
73
+ # ── Node discovery ──────────────────────────────────────────────────────────
74
+ def _wheel_node() -> str | None:
75
+ try:
76
+ import nodejs_wheel # type: ignore[import-not-found]
77
+ except ImportError:
78
+ return None
79
+ root = Path(nodejs_wheel.__file__).resolve().parent
80
+ candidate = root / "node.exe" if os.name == "nt" else root / "bin" / "node"
81
+ return str(candidate) if candidate.is_file() else None
82
+
83
+
84
+ def find_node() -> str:
85
+ """Return the Node.js executable to use.
86
+
87
+ Order: ``$OPENFLUME_NODE``, the ``nodejs-wheel-binaries`` runtime installed
88
+ alongside openflume, then ``node`` on ``PATH``.
89
+ """
90
+ override = os.environ.get("OPENFLUME_NODE")
91
+ if override:
92
+ return override
93
+ found = _wheel_node() or shutil.which("node")
94
+ if not found:
95
+ raise BridgeError(
96
+ "openflume needs Node.js >= 22. Install the bundled runtime with "
97
+ "`pip install nodejs-wheel-binaries`, install Node.js on PATH, or set "
98
+ "OPENFLUME_NODE to a node executable."
99
+ )
100
+ return found
101
+
102
+
103
+ # ── session ─────────────────────────────────────────────────────────────────
104
+ class Session:
105
+ """A running instance of the OpenFLUME core.
106
+
107
+ Most code never needs this: module-level functions use a shared default
108
+ session that starts on first use and stops at interpreter exit. Create one
109
+ explicitly to pick a Node executable, or to isolate state (e.g. one per
110
+ worker process). Usable as a context manager.
111
+ """
112
+
113
+ def __init__(self, node: str | os.PathLike[str] | None = None) -> None:
114
+ self._node = os.fspath(node) if node is not None else None
115
+ self._proc: subprocess.Popen[str] | None = None
116
+ self._lock = threading.RLock()
117
+ self._next_id = 0
118
+ self._stderr_tail: deque[str] = deque(maxlen=50)
119
+ self.info: dict[str, Any] = {}
120
+
121
+ # lifecycle -----------------------------------------------------------------
122
+ def start(self) -> Session:
123
+ with self._lock:
124
+ if self._proc is not None and self._proc.poll() is None:
125
+ return self
126
+ if not BRIDGE_SCRIPT.is_file():
127
+ raise BridgeError(
128
+ f"bundled OpenFLUME core missing at {BRIDGE_SCRIPT}; this "
129
+ "installation is incomplete (source checkouts need "
130
+ "`npm run build:python`)."
131
+ )
132
+ node = self._node or find_node()
133
+ self._stderr_tail.clear()
134
+ try:
135
+ self._proc = subprocess.Popen(
136
+ [node, "--no-warnings", str(BRIDGE_SCRIPT)],
137
+ stdin=subprocess.PIPE,
138
+ stdout=subprocess.PIPE,
139
+ stderr=subprocess.PIPE,
140
+ text=True,
141
+ encoding="utf-8",
142
+ bufsize=1,
143
+ cwd=str(_JS_DIR),
144
+ # Own process group: a terminal Ctrl-C reaches only Python,
145
+ # which then kills Node (see _request), instead of Node
146
+ # dying first and surfacing as a BridgeError.
147
+ start_new_session=os.name != "nt",
148
+ creationflags=(
149
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
150
+ ),
151
+ )
152
+ except OSError as exc:
153
+ raise BridgeError(f"could not start Node.js ({node}): {exc}") from exc
154
+ threading.Thread(
155
+ target=self._pump_stderr, args=(self._proc,), name="openflume-node-stderr", daemon=True
156
+ ).start()
157
+ info = self._request("hello", {}, None)
158
+ if info.get("protocol") != PROTOCOL_VERSION:
159
+ self.close()
160
+ raise BridgeError(f"bridge protocol mismatch: expected {PROTOCOL_VERSION}, got {info.get('protocol')}")
161
+ major = int(str(info.get("node", "0")).split(".")[0] or 0)
162
+ if major < MIN_NODE_MAJOR:
163
+ self.close()
164
+ raise BridgeError(
165
+ f"Node.js {info.get('node')} at {node} is too old; openflume needs >= {MIN_NODE_MAJOR}."
166
+ )
167
+ self.info = info
168
+ return self
169
+
170
+ def close(self) -> None:
171
+ """Stop the Node process. The session restarts automatically on next use."""
172
+ with self._lock:
173
+ proc, self._proc = self._proc, None
174
+ if proc is None:
175
+ return
176
+ try:
177
+ if proc.stdin:
178
+ proc.stdin.close()
179
+ proc.wait(timeout=5)
180
+ except Exception:
181
+ proc.kill()
182
+ proc.wait()
183
+
184
+ def _kill(self) -> None:
185
+ proc, self._proc = self._proc, None
186
+ if proc is not None:
187
+ proc.kill()
188
+ proc.wait()
189
+
190
+ @property
191
+ def running(self) -> bool:
192
+ return self._proc is not None and self._proc.poll() is None
193
+
194
+ def __enter__(self) -> Session:
195
+ return self.start()
196
+
197
+ def __exit__(self, *exc: object) -> None:
198
+ self.close()
199
+
200
+ # I/O -----------------------------------------------------------------------
201
+ def _pump_stderr(self, proc: subprocess.Popen[str]) -> None:
202
+ assert proc.stderr is not None
203
+ for line in proc.stderr:
204
+ line = line.rstrip("\n")
205
+ self._stderr_tail.append(line)
206
+ _log.info("%s", line)
207
+
208
+ def _died(self) -> BridgeError:
209
+ code = self._proc.poll() if self._proc else None
210
+ tail = "\n".join(self._stderr_tail)
211
+ self._proc = None
212
+ msg = f"OpenFLUME Node process exited unexpectedly (exit code {code})"
213
+ return BridgeError(msg + (f":\n{tail}" if tail else ""))
214
+
215
+ def call(
216
+ self, method: str, params: dict[str, Any] | None = None, *, on_progress: ProgressCallback | None = None
217
+ ) -> Any:
218
+ """Invoke a bridge method and return its decoded result."""
219
+ with self._lock:
220
+ self.start()
221
+ return self._request(method, params or {}, on_progress)
222
+
223
+ def _request(self, method: str, params: dict[str, Any], on_progress: ProgressCallback | None) -> Any:
224
+ proc = self._proc
225
+ assert proc is not None and proc.stdin is not None and proc.stdout is not None
226
+ self._next_id += 1
227
+ req_id = self._next_id
228
+ try:
229
+ proc.stdin.write(dumps({"id": req_id, "method": method, "params": params}) + "\n")
230
+ proc.stdin.flush()
231
+ except (BrokenPipeError, OSError):
232
+ raise self._died() from None
233
+ try:
234
+ while True:
235
+ line = proc.stdout.readline()
236
+ if not line:
237
+ raise self._died()
238
+ msg = loads(line)
239
+ if msg.get("id") != req_id:
240
+ continue # stale reply from an interrupted request
241
+ if "event" in msg:
242
+ if on_progress is not None and msg["event"] == "progress":
243
+ on_progress(msg.get("data", {}))
244
+ continue
245
+ if "error" in msg:
246
+ raise from_payload(msg["error"])
247
+ return msg.get("result")
248
+ except KeyboardInterrupt:
249
+ # The solve is synchronous inside Node and cannot be cancelled
250
+ # cooperatively; kill it so the session is usable again.
251
+ self._kill()
252
+ raise
253
+ except BaseException:
254
+ if self._proc is not None and self._proc.poll() is not None:
255
+ self._proc = None
256
+ raise
257
+
258
+
259
+ _default: Session | None = None
260
+ _default_lock = threading.Lock()
261
+
262
+
263
+ def default_session() -> Session:
264
+ """The shared session used by module-level functions."""
265
+ global _default
266
+ with _default_lock:
267
+ if _default is None:
268
+ _default = Session()
269
+ return _default
270
+
271
+
272
+ def shutdown() -> None:
273
+ """Stop the shared default session (it restarts on next use)."""
274
+ with _default_lock:
275
+ if _default is not None:
276
+ _default.close()
277
+
278
+
279
+ atexit.register(shutdown)
Binary file