pyredacc 0.11.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.
- pyredacc/__init__.py +18 -0
- pyredacc/cli.py +164 -0
- pyredacc/compiler.py +189 -0
- pyredacc/executor.py +275 -0
- pyredacc/ode_adapter.py +171 -0
- pyredacc/registry.py +55 -0
- pyredacc/simulator.py +121 -0
- pyredacc/types.py +108 -0
- pyredacc-0.11.1.dist-info/METADATA +14 -0
- pyredacc-0.11.1.dist-info/RECORD +12 -0
- pyredacc-0.11.1.dist-info/WHEEL +4 -0
- pyredacc-0.11.1.dist-info/entry_points.txt +2 -0
pyredacc/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .types import Backend, CompilerResult, SympyODE
|
|
2
|
+
from .ode_adapter import ODEAdapter
|
|
3
|
+
from .compiler import Compiler
|
|
4
|
+
from .simulator import Simulator
|
|
5
|
+
from .executor import DockerExecutor, LocalExecutor
|
|
6
|
+
from .registry import DockerRegistry
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Backend",
|
|
10
|
+
"CompilerResult",
|
|
11
|
+
"ODEAdapter",
|
|
12
|
+
"Compiler",
|
|
13
|
+
"Simulator",
|
|
14
|
+
"DockerExecutor",
|
|
15
|
+
"LocalExecutor",
|
|
16
|
+
"DockerRegistry",
|
|
17
|
+
"SympyODE",
|
|
18
|
+
]
|
pyredacc/cli.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple CLI forwarding all given arguments to Docker containers. Allows users
|
|
3
|
+
to run both compiler and simulator from the CLI, freeing them from the burden
|
|
4
|
+
of updating their installation.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
from yaspin import yaspin
|
|
12
|
+
from yaspin.spinners import Spinners
|
|
13
|
+
|
|
14
|
+
from .compiler import Backend
|
|
15
|
+
from .registry import DockerRegistry
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Compile ODEs and run simulations for anabrid analog computers."
|
|
19
|
+
)
|
|
20
|
+
try:
|
|
21
|
+
DockerRegistry.from_env()
|
|
22
|
+
except:
|
|
23
|
+
print("Make sure to set CI_REGISTRY_USER and CI_REGISTRY_PASSWORD variables " +
|
|
24
|
+
"for access to the Docker registry.")
|
|
25
|
+
exit(1)
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def compile(
|
|
29
|
+
input: Annotated[
|
|
30
|
+
str,
|
|
31
|
+
typer.Argument(help="Path to an .ana analang source file")
|
|
32
|
+
],
|
|
33
|
+
backend: Annotated[
|
|
34
|
+
Backend,
|
|
35
|
+
typer.Option(
|
|
36
|
+
case_sensitive=False,
|
|
37
|
+
help="LANE for LUCIDAC, NETWORK for REDAC"
|
|
38
|
+
)
|
|
39
|
+
],
|
|
40
|
+
device: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
typer.Option(
|
|
43
|
+
help="Path to a device specification in .apb format"
|
|
44
|
+
)
|
|
45
|
+
],
|
|
46
|
+
output: Annotated[
|
|
47
|
+
str,
|
|
48
|
+
typer.Option(
|
|
49
|
+
help="Path to output artifact, format is .apb"
|
|
50
|
+
)
|
|
51
|
+
],
|
|
52
|
+
plugins_dir: Annotated[
|
|
53
|
+
str,
|
|
54
|
+
typer.Option(
|
|
55
|
+
help="Paths to directories containing plugins"
|
|
56
|
+
)
|
|
57
|
+
] = None,
|
|
58
|
+
pull: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option(
|
|
61
|
+
help="Whether to always pull the most recent compiler image before using"
|
|
62
|
+
)
|
|
63
|
+
] = True
|
|
64
|
+
):
|
|
65
|
+
"""Compile an ODE description to an analog hardware configuration."""
|
|
66
|
+
from .compiler import Compiler
|
|
67
|
+
|
|
68
|
+
compiler = Compiler(
|
|
69
|
+
backend=backend,
|
|
70
|
+
device=device,
|
|
71
|
+
plugins_dir=plugins_dir,
|
|
72
|
+
always_pull=pull
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with yaspin(Spinners.dots, text="Compiling...") as sp:
|
|
76
|
+
try:
|
|
77
|
+
result = compiler.run(input=input)
|
|
78
|
+
if result.exit_code == 0:
|
|
79
|
+
sp.ok("✔")
|
|
80
|
+
else:
|
|
81
|
+
sp.fail("✘")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
sp.fail("✘")
|
|
84
|
+
sys.stderr.write(f"{e}\n")
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
|
|
87
|
+
if result.stdout:
|
|
88
|
+
typer.echo(result.stdout.rstrip())
|
|
89
|
+
|
|
90
|
+
if result.exit_code != 0:
|
|
91
|
+
if result.stderr:
|
|
92
|
+
sys.stderr.write(result.stderr)
|
|
93
|
+
raise typer.Exit(code=result.exit_code)
|
|
94
|
+
|
|
95
|
+
with yaspin(Spinners.dots, text=f"Saving to {output}...") as sp:
|
|
96
|
+
try:
|
|
97
|
+
result.save(output)
|
|
98
|
+
sp.ok("✔")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
sp.fail("✘")
|
|
101
|
+
sys.stderr.write(f"{e}\n")
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def simulate(
|
|
107
|
+
port: Annotated[
|
|
108
|
+
int,
|
|
109
|
+
typer.Option(
|
|
110
|
+
help="Port to listen on"
|
|
111
|
+
)
|
|
112
|
+
] = 5732,
|
|
113
|
+
token: Annotated[
|
|
114
|
+
str,
|
|
115
|
+
typer.Option(
|
|
116
|
+
help="If set, requires authentication by the client"
|
|
117
|
+
)
|
|
118
|
+
] = None,
|
|
119
|
+
plugins_dir: Annotated[
|
|
120
|
+
str,
|
|
121
|
+
typer.Option(
|
|
122
|
+
help="Path to directory containing compiled plugins"
|
|
123
|
+
)
|
|
124
|
+
] = None,
|
|
125
|
+
pull: Annotated[
|
|
126
|
+
bool,
|
|
127
|
+
typer.Option(
|
|
128
|
+
help="Whether to always pull the most recent simulator image before using"
|
|
129
|
+
)
|
|
130
|
+
] = True
|
|
131
|
+
):
|
|
132
|
+
"""Run the simulator in the foreground until stopped with Ctrl+C."""
|
|
133
|
+
from .simulator import Simulator
|
|
134
|
+
|
|
135
|
+
sim = Simulator(
|
|
136
|
+
port=port,
|
|
137
|
+
plugins_dir=plugins_dir,
|
|
138
|
+
always_pull=pull
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
with yaspin(Spinners.dots, text="Starting simulator...") as sp:
|
|
142
|
+
try:
|
|
143
|
+
sim.start()
|
|
144
|
+
sp.ok("✔")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
sp.fail("✘")
|
|
147
|
+
sys.stderr.write(f"{e}\n")
|
|
148
|
+
raise typer.Exit(code=1)
|
|
149
|
+
|
|
150
|
+
typer.echo(f"Simulator listening on port {port}. Press Ctrl+C to stop.")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
time.sleep(1)
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
with yaspin(Spinners.dots, text="Stopping simulator...") as sp:
|
|
159
|
+
sim.stop()
|
|
160
|
+
sp.ok("✔")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
typer.run(app)
|
pyredacc/compiler.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .executor import DockerExecutor, LocalExecutor
|
|
8
|
+
from .ode_adapter import ODEAdapter
|
|
9
|
+
import pybrid.base.proto.main_pb2 as pb
|
|
10
|
+
from .types import Backend, CompilerResult, SympyODE
|
|
11
|
+
from pybrid.base.proto.io import ProtoIO
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Compiler:
|
|
17
|
+
"""Compile ODE systems to analog hardware configurations.
|
|
18
|
+
|
|
19
|
+
Accepts input as a file path, an analang text string, or a SympyODE
|
|
20
|
+
instance. Execution happens either inside a Docker container (default)
|
|
21
|
+
or via a local ``redacc`` binary when *executable* is provided.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
backend: Backend,
|
|
27
|
+
device: Optional[str] = None,
|
|
28
|
+
executable: Optional[str] = None,
|
|
29
|
+
image_name: str = "redacc-compiler",
|
|
30
|
+
image_tag: str = "latest",
|
|
31
|
+
plugins_dir: Optional[str] = None,
|
|
32
|
+
always_pull: bool = True,
|
|
33
|
+
):
|
|
34
|
+
self._backend = backend
|
|
35
|
+
self._device = device
|
|
36
|
+
|
|
37
|
+
if executable:
|
|
38
|
+
self._executor = LocalExecutor(executable=executable, plugins_dir=plugins_dir)
|
|
39
|
+
self._is_docker = False
|
|
40
|
+
else:
|
|
41
|
+
self._executor = DockerExecutor(
|
|
42
|
+
image_name=image_name,
|
|
43
|
+
image_tag=image_tag,
|
|
44
|
+
plugins_dir=plugins_dir,
|
|
45
|
+
always_pull=always_pull,
|
|
46
|
+
)
|
|
47
|
+
self._is_docker = True
|
|
48
|
+
|
|
49
|
+
def _build_args(
|
|
50
|
+
self,
|
|
51
|
+
input_path: str,
|
|
52
|
+
output_dir: str,
|
|
53
|
+
output_apb: bool = True,
|
|
54
|
+
container_output_dir: Optional[str] = None,
|
|
55
|
+
container_input_path: Optional[str] = None,
|
|
56
|
+
container_device_path: Optional[str] = None,
|
|
57
|
+
) -> List[str]:
|
|
58
|
+
args: List[str] = []
|
|
59
|
+
effective_output = container_output_dir or output_dir
|
|
60
|
+
effective_input = container_input_path or input_path
|
|
61
|
+
|
|
62
|
+
args.extend(["--backend", self._backend.name])
|
|
63
|
+
|
|
64
|
+
if self._device:
|
|
65
|
+
effective_device = container_device_path or self._device
|
|
66
|
+
args.extend(["--entity", effective_device])
|
|
67
|
+
|
|
68
|
+
if output_apb:
|
|
69
|
+
args.extend(["--output", os.path.join(effective_output, "config.apb")])
|
|
70
|
+
|
|
71
|
+
args.append(effective_input)
|
|
72
|
+
|
|
73
|
+
return args
|
|
74
|
+
|
|
75
|
+
def run(
|
|
76
|
+
self,
|
|
77
|
+
input: Union[str, SympyODE],
|
|
78
|
+
) -> CompilerResult:
|
|
79
|
+
"""Run the compiler on the given input.
|
|
80
|
+
|
|
81
|
+
*input* may be a file path (str), an analang text string, or a
|
|
82
|
+
``SympyODE`` instance. Strings that point to an existing file are
|
|
83
|
+
used directly; all other strings are treated as inline analang text.
|
|
84
|
+
"""
|
|
85
|
+
temp_input = None
|
|
86
|
+
work_dir = None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
if isinstance(input, dict):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
"Dict input is no longer supported; "
|
|
92
|
+
"use a file path, analang text, or a SympyODE instance."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if isinstance(input, SympyODE):
|
|
96
|
+
adapter = ODEAdapter()
|
|
97
|
+
input = adapter.translate_sympy(input)
|
|
98
|
+
|
|
99
|
+
if isinstance(input, str) and os.path.isfile(input):
|
|
100
|
+
input_path = input
|
|
101
|
+
if self._is_docker:
|
|
102
|
+
work_dir = tempfile.mkdtemp()
|
|
103
|
+
os.chmod(work_dir, 0o755)
|
|
104
|
+
copied_input = os.path.join(
|
|
105
|
+
work_dir, os.path.basename(input_path)
|
|
106
|
+
)
|
|
107
|
+
shutil.copy2(input_path, copied_input)
|
|
108
|
+
os.chmod(copied_input, 0o644)
|
|
109
|
+
input_path = copied_input
|
|
110
|
+
else:
|
|
111
|
+
analang_text = input
|
|
112
|
+
if self._is_docker:
|
|
113
|
+
work_dir = tempfile.mkdtemp()
|
|
114
|
+
os.chmod(work_dir, 0o755)
|
|
115
|
+
import uuid
|
|
116
|
+
tmp_filename = f"input_{uuid.uuid4().hex[:8]}.ana"
|
|
117
|
+
tmp_path = os.path.join(work_dir, tmp_filename)
|
|
118
|
+
with open(tmp_path, "w") as f:
|
|
119
|
+
f.write(analang_text)
|
|
120
|
+
f.flush()
|
|
121
|
+
os.fsync(f.fileno())
|
|
122
|
+
os.chmod(tmp_path, 0o644)
|
|
123
|
+
input_path = tmp_path
|
|
124
|
+
temp_input = tmp_path
|
|
125
|
+
else:
|
|
126
|
+
tf = tempfile.NamedTemporaryFile(
|
|
127
|
+
mode="w", suffix=".ana", delete=False
|
|
128
|
+
)
|
|
129
|
+
tf.write(analang_text)
|
|
130
|
+
tf.close()
|
|
131
|
+
input_path = tf.name
|
|
132
|
+
temp_input = tf.name
|
|
133
|
+
|
|
134
|
+
container_output_dir = None
|
|
135
|
+
container_input_path = None
|
|
136
|
+
container_device_path = None
|
|
137
|
+
volumes = None
|
|
138
|
+
if self._is_docker:
|
|
139
|
+
# Mount work_dir read-write for both input and output
|
|
140
|
+
# This avoids issues with /tmp mounts in Docker
|
|
141
|
+
container_output_dir = "/work"
|
|
142
|
+
container_input_path = f"/work/{os.path.basename(input_path)}"
|
|
143
|
+
if work_dir is None:
|
|
144
|
+
work_dir = tempfile.mkdtemp()
|
|
145
|
+
volumes = {work_dir: "/work"}
|
|
146
|
+
if self._device:
|
|
147
|
+
device_dir = os.path.dirname(os.path.abspath(self._device))
|
|
148
|
+
container_device_path = f"/device/{os.path.basename(self._device)}"
|
|
149
|
+
volumes[device_dir] = "/device"
|
|
150
|
+
|
|
151
|
+
# For LocalExecutor, use output_dir directly (original behavior)
|
|
152
|
+
# For Docker, use work_dir as the output source
|
|
153
|
+
output_dir = work_dir or tempfile.mkdtemp()
|
|
154
|
+
|
|
155
|
+
args = self._build_args(
|
|
156
|
+
input_path=input_path,
|
|
157
|
+
output_dir=output_dir,
|
|
158
|
+
output_apb=True,
|
|
159
|
+
container_output_dir=container_output_dir,
|
|
160
|
+
container_input_path=container_input_path,
|
|
161
|
+
container_device_path=container_device_path,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self._executor.ensure_available()
|
|
165
|
+
exit_code, stdout, stderr = self._executor.run(
|
|
166
|
+
args, input_file=None, output_dir=output_dir, volumes=volumes
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
module = None
|
|
170
|
+
|
|
171
|
+
apb_path = os.path.join(output_dir, "config.apb")
|
|
172
|
+
if os.path.isfile(apb_path):
|
|
173
|
+
with open(apb_path, "rb") as f:
|
|
174
|
+
file_msg = pb.File()
|
|
175
|
+
file_msg.ParseFromString(f.read())
|
|
176
|
+
if file_msg.HasField("module"):
|
|
177
|
+
module = file_msg.module
|
|
178
|
+
|
|
179
|
+
return CompilerResult(
|
|
180
|
+
exit_code=exit_code,
|
|
181
|
+
stdout=stdout,
|
|
182
|
+
stderr=stderr,
|
|
183
|
+
module=module,
|
|
184
|
+
)
|
|
185
|
+
finally:
|
|
186
|
+
if temp_input and os.path.isfile(temp_input):
|
|
187
|
+
os.unlink(temp_input)
|
|
188
|
+
if work_dir:
|
|
189
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
pyredacc/executor.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
from .registry import DockerRegistry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _path_in_volumes(path: str, volumes: Optional[Dict[str, str]]) -> bool:
|
|
11
|
+
"""Check if a path is covered by any volume mount."""
|
|
12
|
+
if not volumes:
|
|
13
|
+
return False
|
|
14
|
+
abs_path = os.path.abspath(path)
|
|
15
|
+
for host_path in volumes:
|
|
16
|
+
abs_host = os.path.abspath(host_path)
|
|
17
|
+
# Check if path is inside the host mount point
|
|
18
|
+
if abs_path.startswith(abs_host + os.sep) or abs_path == abs_host:
|
|
19
|
+
return True
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseExecutor(ABC):
|
|
24
|
+
"""Interface for running the redacc binary (Docker or local)."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def ensure_available(self) -> None:
|
|
28
|
+
"""Verify the execution backend is ready (image pulled / binary exists)."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def run(
|
|
33
|
+
self,
|
|
34
|
+
args: List[str],
|
|
35
|
+
input_file: Optional[str],
|
|
36
|
+
output_dir: Optional[str],
|
|
37
|
+
volumes: Optional[Dict[str, str]],
|
|
38
|
+
) -> Tuple[int, str, str]:
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def run_detached(
|
|
43
|
+
self,
|
|
44
|
+
args: List[str],
|
|
45
|
+
ports: Optional[Dict[str, int]] = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DockerExecutor(BaseExecutor):
|
|
51
|
+
"""Run redacc inside a Docker container pulled from the configured registry."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
image_name: str,
|
|
56
|
+
image_tag: str = "latest",
|
|
57
|
+
plugins_dir: Optional[str] = None,
|
|
58
|
+
always_pull: bool = True,
|
|
59
|
+
):
|
|
60
|
+
self._image_name = image_name
|
|
61
|
+
self._image_tag = image_tag
|
|
62
|
+
self._plugins_dir = plugins_dir
|
|
63
|
+
self._always_pull = always_pull
|
|
64
|
+
|
|
65
|
+
import docker
|
|
66
|
+
self._docker = docker
|
|
67
|
+
self._client = None
|
|
68
|
+
|
|
69
|
+
def _get_client(self):
|
|
70
|
+
if self._client is None:
|
|
71
|
+
try:
|
|
72
|
+
self._client = self._docker.from_env()
|
|
73
|
+
except self._docker.errors.DockerException as e:
|
|
74
|
+
raise ConnectionError(
|
|
75
|
+
f"Cannot connect to Docker daemon. Is Docker running? "
|
|
76
|
+
f"Original error: {e}"
|
|
77
|
+
) from e
|
|
78
|
+
return self._client
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def full_image_name(self) -> str:
|
|
82
|
+
registry_url = DockerRegistry._get().url
|
|
83
|
+
if registry_url:
|
|
84
|
+
return f"{registry_url}/{self._image_name}:{self._image_tag}"
|
|
85
|
+
return f"{self._image_name}:{self._image_tag}"
|
|
86
|
+
|
|
87
|
+
def _format_bytes(self, num_bytes: int) -> str:
|
|
88
|
+
"""Format bytes as human-readable string."""
|
|
89
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
90
|
+
if abs(num_bytes) < 1024.0:
|
|
91
|
+
return f"{num_bytes:.1f} {unit}"
|
|
92
|
+
num_bytes /= 1024.0
|
|
93
|
+
return f"{num_bytes:.1f} PB"
|
|
94
|
+
|
|
95
|
+
def _pull_with_progress(self, image_name: str) -> None:
|
|
96
|
+
"""Pull an image with progress feedback to stderr."""
|
|
97
|
+
repo, tag = image_name.rsplit(":", 1)
|
|
98
|
+
|
|
99
|
+
is_tty = sys.stderr.isatty()
|
|
100
|
+
layer_totals: Dict[str, int] = {}
|
|
101
|
+
layer_current: Dict[str, int] = {}
|
|
102
|
+
layers_complete = set()
|
|
103
|
+
|
|
104
|
+
events = self._get_client().api.pull(repo, tag=tag, stream=True, decode=True)
|
|
105
|
+
|
|
106
|
+
for event in events:
|
|
107
|
+
status = event.get("status", "")
|
|
108
|
+
layer_id = event.get("id", "")
|
|
109
|
+
|
|
110
|
+
if status.startswith("Status: Image is up to date"):
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if status.startswith("Status: Downloaded newer image"):
|
|
114
|
+
if is_tty:
|
|
115
|
+
sys.stderr.write("\n")
|
|
116
|
+
sys.stderr.write(f"Image {image_name}: {status}\n")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if status == "Already exists":
|
|
120
|
+
if layer_id:
|
|
121
|
+
layers_complete.add(layer_id)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
# Track layer totals from progressDetail
|
|
125
|
+
progress_detail = event.get("progressDetail", {})
|
|
126
|
+
if progress_detail:
|
|
127
|
+
current = progress_detail.get("current", 0)
|
|
128
|
+
total = progress_detail.get("total", 0)
|
|
129
|
+
if layer_id:
|
|
130
|
+
layer_totals[layer_id] = total
|
|
131
|
+
layer_current[layer_id] = current
|
|
132
|
+
|
|
133
|
+
# Calculate total progress
|
|
134
|
+
total_bytes = sum(layer_totals.values())
|
|
135
|
+
current_bytes = sum(layer_current[lid] for lid in layer_totals if lid not in layers_complete)
|
|
136
|
+
|
|
137
|
+
if is_tty and total_bytes > 0:
|
|
138
|
+
# Update progress on same line
|
|
139
|
+
sys.stderr.write(f"\rPulling {image_name}: {self._format_bytes(current_bytes)}/{self._format_bytes(total_bytes)}")
|
|
140
|
+
sys.stderr.flush()
|
|
141
|
+
elif not is_tty and total_bytes > 0:
|
|
142
|
+
# Non-TTY: print status updates
|
|
143
|
+
if status in ("Downloading", "Extracting"):
|
|
144
|
+
sys.stderr.write(f"Pulling {image_name}: {self._format_bytes(current_bytes)}/{self._format_bytes(total_bytes)}\n")
|
|
145
|
+
sys.stderr.flush()
|
|
146
|
+
|
|
147
|
+
# Mark layer as complete
|
|
148
|
+
if status == "Pull complete" and layer_id:
|
|
149
|
+
layers_complete.add(layer_id)
|
|
150
|
+
|
|
151
|
+
if is_tty and total_bytes > 0:
|
|
152
|
+
sys.stderr.write("\n")
|
|
153
|
+
sys.stderr.write(f"Image {image_name} pulled successfully\n")
|
|
154
|
+
|
|
155
|
+
def ensure_available(self) -> None:
|
|
156
|
+
DockerRegistry._get().login(self._get_client())
|
|
157
|
+
# Only check if image exists locally when not always pulling
|
|
158
|
+
if not self._always_pull:
|
|
159
|
+
try:
|
|
160
|
+
self._get_client().images.get(self.full_image_name)
|
|
161
|
+
# Image exists and we're not set to always pull
|
|
162
|
+
return
|
|
163
|
+
except self._docker.errors.ImageNotFound:
|
|
164
|
+
pass # Image doesn't exist, will pull below
|
|
165
|
+
|
|
166
|
+
# Pull the image with progress
|
|
167
|
+
self._pull_with_progress(self.full_image_name)
|
|
168
|
+
|
|
169
|
+
def run(
|
|
170
|
+
self,
|
|
171
|
+
args: List[str],
|
|
172
|
+
input_file: Optional[str],
|
|
173
|
+
output_dir: Optional[str],
|
|
174
|
+
volumes: Optional[Dict[str, str]],
|
|
175
|
+
) -> Tuple[int, str, str]:
|
|
176
|
+
bind_volumes = {}
|
|
177
|
+
if volumes:
|
|
178
|
+
for host_path, container_path in volumes.items():
|
|
179
|
+
bind_volumes[host_path] = {"bind": container_path, "mode": "rw"}
|
|
180
|
+
# Only mount input_file separately if not already covered by custom volumes
|
|
181
|
+
if input_file and not _path_in_volumes(input_file, volumes):
|
|
182
|
+
bind_volumes[input_file] = {"bind": f"/work/{os.path.basename(input_file)}", "mode": "ro"}
|
|
183
|
+
# Only mount output_dir to /output if it's not already covered by a custom volume
|
|
184
|
+
if output_dir and output_dir not in volumes:
|
|
185
|
+
bind_volumes[output_dir] = {"bind": "/output", "mode": "rw"}
|
|
186
|
+
if self._plugins_dir:
|
|
187
|
+
bind_volumes[self._plugins_dir] = {"bind": "/plugins", "mode": "ro"}
|
|
188
|
+
|
|
189
|
+
container = self._get_client().containers.run(
|
|
190
|
+
self.full_image_name,
|
|
191
|
+
command=args,
|
|
192
|
+
volumes=bind_volumes,
|
|
193
|
+
detach=True,
|
|
194
|
+
user="root", # Run as root to ensure write permissions on mounted volumes
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
result = container.wait()
|
|
199
|
+
exit_code = result.get("StatusCode", -1)
|
|
200
|
+
stdout = container.logs(stdout=True, stderr=False).decode("utf-8", errors="replace")
|
|
201
|
+
stderr = container.logs(stdout=False, stderr=True).decode("utf-8", errors="replace")
|
|
202
|
+
finally:
|
|
203
|
+
container.remove(force=True)
|
|
204
|
+
|
|
205
|
+
return exit_code, stdout, stderr
|
|
206
|
+
|
|
207
|
+
def run_detached(
|
|
208
|
+
self,
|
|
209
|
+
args: List[str],
|
|
210
|
+
ports: Optional[Dict[str, int]] = None,
|
|
211
|
+
) -> str:
|
|
212
|
+
bind_volumes = {}
|
|
213
|
+
if self._plugins_dir:
|
|
214
|
+
bind_volumes[self._plugins_dir] = {"bind": "/plugins", "mode": "ro"}
|
|
215
|
+
|
|
216
|
+
container = self._get_client().containers.run(
|
|
217
|
+
self.full_image_name,
|
|
218
|
+
command=args,
|
|
219
|
+
volumes=bind_volumes,
|
|
220
|
+
ports=ports or {},
|
|
221
|
+
detach=True,
|
|
222
|
+
environment={
|
|
223
|
+
"REDACC_SIM_PLUGINS_PATH": "/plugins"
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return container.id
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class LocalExecutor(BaseExecutor):
|
|
231
|
+
"""Run redacc as a local subprocess."""
|
|
232
|
+
|
|
233
|
+
def __init__(
|
|
234
|
+
self,
|
|
235
|
+
executable: str,
|
|
236
|
+
plugins_dir: Optional[str] = None,
|
|
237
|
+
):
|
|
238
|
+
self._executable = executable
|
|
239
|
+
self._plugins_dir = plugins_dir
|
|
240
|
+
|
|
241
|
+
def ensure_available(self) -> None:
|
|
242
|
+
if not os.path.isfile(self._executable):
|
|
243
|
+
raise FileNotFoundError(f"Executable not found: {self._executable}")
|
|
244
|
+
if not os.access(self._executable, os.X_OK):
|
|
245
|
+
raise PermissionError(f"Not executable: {self._executable}")
|
|
246
|
+
|
|
247
|
+
def run(
|
|
248
|
+
self,
|
|
249
|
+
args: List[str],
|
|
250
|
+
input_file: Optional[str],
|
|
251
|
+
output_dir: Optional[str],
|
|
252
|
+
volumes: Optional[Dict[str, str]],
|
|
253
|
+
) -> Tuple[int, str, str]:
|
|
254
|
+
cmd = [self._executable] + args
|
|
255
|
+
if input_file:
|
|
256
|
+
cmd.append(input_file)
|
|
257
|
+
|
|
258
|
+
result = subprocess.run(
|
|
259
|
+
cmd,
|
|
260
|
+
capture_output=True,
|
|
261
|
+
text=True,
|
|
262
|
+
cwd=output_dir,
|
|
263
|
+
env={
|
|
264
|
+
"REDACC_SIM_PLUGINS_PATH": self._plugins_dir
|
|
265
|
+
} if self._plugins_dir else {}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return result.returncode, result.stdout, result.stderr
|
|
269
|
+
|
|
270
|
+
def run_detached(
|
|
271
|
+
self,
|
|
272
|
+
args: List[str],
|
|
273
|
+
ports: Optional[Dict[str, int]] = None,
|
|
274
|
+
) -> str:
|
|
275
|
+
raise NotImplementedError("Background mode not supported for local execution")
|
pyredacc/ode_adapter.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
|
|
3
|
+
import sympy as sp
|
|
4
|
+
from sympy import Function, Derivative, Symbol, Eq, solve
|
|
5
|
+
from typing import Dict, List, Union
|
|
6
|
+
|
|
7
|
+
from .types import SympyODE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ODEAdapter:
|
|
11
|
+
"""Translate SymPy ODE expressions to the analang format used by redacc."""
|
|
12
|
+
|
|
13
|
+
class _CustomPrinter(sp.StrPrinter):
|
|
14
|
+
"""Format SymPy expressions using analang syntax."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, implicit_variable: str = "t"):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self.implicit_variable = implicit_variable
|
|
19
|
+
|
|
20
|
+
def _print_Function(self, expr):
|
|
21
|
+
return f"{expr.func.__name__}({self.implicit_variable})"
|
|
22
|
+
|
|
23
|
+
def _print_Derivative(self, expr):
|
|
24
|
+
function = expr.args[0]
|
|
25
|
+
variables = expr.args[1:]
|
|
26
|
+
order = 0
|
|
27
|
+
t_sym = Symbol(self.implicit_variable)
|
|
28
|
+
for v in variables:
|
|
29
|
+
if isinstance(v, sp.core.containers.Tuple):
|
|
30
|
+
var, count = v
|
|
31
|
+
if var == t_sym:
|
|
32
|
+
order += count
|
|
33
|
+
else:
|
|
34
|
+
if v == t_sym:
|
|
35
|
+
order += 1
|
|
36
|
+
func_name = function.func.__name__
|
|
37
|
+
return (
|
|
38
|
+
"diff["
|
|
39
|
+
+ func_name
|
|
40
|
+
+ ", "
|
|
41
|
+
+ ", ".join([self.implicit_variable] * order)
|
|
42
|
+
+ "]("
|
|
43
|
+
+ self.implicit_variable
|
|
44
|
+
+ ")"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _print_Abs(self, expr):
|
|
48
|
+
return "abs(%s)" % self._print(expr.args[0])
|
|
49
|
+
|
|
50
|
+
def __init__(self, implicit_variable: str = "t"):
|
|
51
|
+
self.implicit_variable = implicit_variable
|
|
52
|
+
|
|
53
|
+
def translate_sympy(self, sympy_ode: SympyODE) -> str:
|
|
54
|
+
"""Translate a SympyODE instance to analang text."""
|
|
55
|
+
ic_str = {
|
|
56
|
+
f.func.__name__ if hasattr(f, "func") else str(f): v
|
|
57
|
+
for f, v in sympy_ode.initial_conditions.items()
|
|
58
|
+
}
|
|
59
|
+
printer = ODEAdapter._CustomPrinter(self.implicit_variable)
|
|
60
|
+
probes_str = []
|
|
61
|
+
for p in sympy_ode.probes:
|
|
62
|
+
if isinstance(p, Derivative):
|
|
63
|
+
probes_str.append(printer.doprint(p))
|
|
64
|
+
elif hasattr(p, "func"):
|
|
65
|
+
probes_str.append(p.func.__name__)
|
|
66
|
+
else:
|
|
67
|
+
probes_str.append(str(p))
|
|
68
|
+
return self.translate(
|
|
69
|
+
odes=sympy_ode.odes,
|
|
70
|
+
initial_conditions=ic_str,
|
|
71
|
+
probes=probes_str,
|
|
72
|
+
name=sympy_ode.name or str(uuid.uuid4()),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def translate(
|
|
76
|
+
self,
|
|
77
|
+
odes: Union[List[sp.Eq], sp.Eq],
|
|
78
|
+
initial_conditions: Dict[str, float],
|
|
79
|
+
probes: List[str],
|
|
80
|
+
name: str = "unnamed",
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Build analang text from SymPy equations.
|
|
83
|
+
|
|
84
|
+
Raises ``TypeError`` if *odes* is a dict (no longer supported).
|
|
85
|
+
Raises ``ValueError`` if initial conditions or probes are incomplete.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(odes, dict):
|
|
88
|
+
raise TypeError(
|
|
89
|
+
"Dict input is no longer supported; "
|
|
90
|
+
"use SymPy equations or analang files."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if isinstance(odes, Eq):
|
|
94
|
+
odes = [odes]
|
|
95
|
+
|
|
96
|
+
functions = self._extract_functions(odes)
|
|
97
|
+
equations = self._build_equations(odes)
|
|
98
|
+
|
|
99
|
+
for f in functions:
|
|
100
|
+
if f not in initial_conditions:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Initial condition missing for function '{f}'"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
func_set = set(functions)
|
|
106
|
+
for p in probes:
|
|
107
|
+
if p.startswith("diff["):
|
|
108
|
+
base = p[5:p.index(",")].strip()
|
|
109
|
+
else:
|
|
110
|
+
base = p
|
|
111
|
+
if base not in func_set:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Probe '{p}' does not match any known function"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
lines = ["use display;", "use math;"]
|
|
117
|
+
|
|
118
|
+
for lhs, rhs in equations.items():
|
|
119
|
+
lines.append(f"let {lhs} = {rhs};")
|
|
120
|
+
|
|
121
|
+
for f, v in initial_conditions.items():
|
|
122
|
+
lines.append(
|
|
123
|
+
f"let {f}({self.implicit_variable}: 0) = {v};"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
for p in probes:
|
|
127
|
+
if p.startswith("diff["):
|
|
128
|
+
lines.append(f"probe {p};")
|
|
129
|
+
else:
|
|
130
|
+
lines.append(f"probe {p}({self.implicit_variable});")
|
|
131
|
+
|
|
132
|
+
return "\n".join(lines) + "\n"
|
|
133
|
+
|
|
134
|
+
def _extract_functions(self, odes: List[sp.Eq]) -> List[str]:
|
|
135
|
+
"""Extract the names of all undefined functions from the ODEs."""
|
|
136
|
+
functions = set()
|
|
137
|
+
for ode in odes:
|
|
138
|
+
for atom in ode.atoms(Function):
|
|
139
|
+
if not isinstance(atom, Derivative):
|
|
140
|
+
if isinstance(
|
|
141
|
+
atom.func, sp.core.function.UndefinedFunction
|
|
142
|
+
):
|
|
143
|
+
functions.add(atom.func.__name__)
|
|
144
|
+
return sorted(functions)
|
|
145
|
+
|
|
146
|
+
def _build_equations(self, odes: List[sp.Eq]) -> Dict[str, str]:
|
|
147
|
+
"""Solve each ODE for its highest derivative and return as string pairs."""
|
|
148
|
+
printer = ODEAdapter._CustomPrinter(self.implicit_variable)
|
|
149
|
+
eqs = {}
|
|
150
|
+
|
|
151
|
+
for ode in odes:
|
|
152
|
+
derivatives = list(ode.atoms(Derivative))
|
|
153
|
+
if not derivatives:
|
|
154
|
+
raise ValueError("No derivatives found in the ODE")
|
|
155
|
+
|
|
156
|
+
highest = max(derivatives, key=lambda d: d.derivative_count)
|
|
157
|
+
|
|
158
|
+
if isinstance(ode, Eq):
|
|
159
|
+
expr = ode.lhs - ode.rhs
|
|
160
|
+
else:
|
|
161
|
+
expr = ode
|
|
162
|
+
|
|
163
|
+
solution = solve(expr, highest, simplify=False)
|
|
164
|
+
if not solution:
|
|
165
|
+
raise ValueError("Could not solve for the highest derivative")
|
|
166
|
+
|
|
167
|
+
lhs_str = printer.doprint(highest)
|
|
168
|
+
rhs_str = printer.doprint(solution[0])
|
|
169
|
+
eqs[lhs_str] = rhs_str
|
|
170
|
+
|
|
171
|
+
return eqs
|
pyredacc/registry.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DockerRegistry:
|
|
5
|
+
_instance = None
|
|
6
|
+
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self._url: str = "lab.analogparadigm.com:5050/lucidac/software/softthat"
|
|
9
|
+
self._username: str | None = None
|
|
10
|
+
self._password: str | None = None
|
|
11
|
+
self._logged_in = False
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def configure(cls, username=None, password=None):
|
|
15
|
+
"""Set registry credentials. Call once before using Compiler/Simulator."""
|
|
16
|
+
inst = cls._get()
|
|
17
|
+
inst._username = username
|
|
18
|
+
inst._password = password
|
|
19
|
+
inst._logged_in = False
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def _get(cls):
|
|
23
|
+
"""Return the singleton instance, creating it if needed."""
|
|
24
|
+
if cls._instance is None:
|
|
25
|
+
cls._instance = cls()
|
|
26
|
+
return cls._instance
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(cls):
|
|
30
|
+
"""Configure from REDACC_REGISTRY_URL, CI_REGISTRY_USER, CI_REGISTRY_PASSWORD."""
|
|
31
|
+
cls.configure(
|
|
32
|
+
username=os.environ.get("CI_REGISTRY_USER"),
|
|
33
|
+
password=os.environ.get("CI_REGISTRY_PASSWORD"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def url(self) -> str | None:
|
|
38
|
+
return self._url
|
|
39
|
+
|
|
40
|
+
def login(self, client) -> None:
|
|
41
|
+
"""Login to registry via the docker client. No-op if already logged in or no URL."""
|
|
42
|
+
if self._logged_in or not self._url:
|
|
43
|
+
return
|
|
44
|
+
kwargs = {"registry": self._url}
|
|
45
|
+
if self._username:
|
|
46
|
+
kwargs["username"] = self._username
|
|
47
|
+
if self._password:
|
|
48
|
+
kwargs["password"] = self._password
|
|
49
|
+
client.login(**kwargs)
|
|
50
|
+
self._logged_in = True
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def _reset(cls):
|
|
54
|
+
"""Reset singleton state. For testing only."""
|
|
55
|
+
cls._instance = None
|
pyredacc/simulator.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from .executor import DockerExecutor, LocalExecutor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Simulator:
|
|
10
|
+
"""Lifecycle manager for the pybrid-compatible simulation server.
|
|
11
|
+
|
|
12
|
+
Launches the simulator as a background Docker container (default) or local
|
|
13
|
+
subprocess, exposing ``host`` and ``port`` for client connections. Use as
|
|
14
|
+
a context manager for automatic cleanup.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
executable: Optional[str] = None,
|
|
20
|
+
image_name: str = "redacc-simulator",
|
|
21
|
+
image_tag: str = "latest",
|
|
22
|
+
port: int = 5732,
|
|
23
|
+
plugins_dir: Optional[str] = None,
|
|
24
|
+
always_pull: bool = True,
|
|
25
|
+
debug: bool = False,
|
|
26
|
+
):
|
|
27
|
+
self._port = port
|
|
28
|
+
self._executable = executable
|
|
29
|
+
self._debug = debug
|
|
30
|
+
self._container_id: Optional[str] = None
|
|
31
|
+
self._process: Optional[subprocess.Popen] = None
|
|
32
|
+
|
|
33
|
+
if executable:
|
|
34
|
+
self._executor = LocalExecutor(executable=executable, plugins_dir=plugins_dir)
|
|
35
|
+
else:
|
|
36
|
+
self._executor = DockerExecutor(
|
|
37
|
+
image_name=image_name,
|
|
38
|
+
image_tag=image_tag,
|
|
39
|
+
plugins_dir=plugins_dir,
|
|
40
|
+
always_pull=always_pull,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def host(self) -> str:
|
|
45
|
+
return "127.0.0.1"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def port(self) -> int:
|
|
49
|
+
return self._port
|
|
50
|
+
|
|
51
|
+
def start(self) -> None:
|
|
52
|
+
"""Launch the simulator as a background process."""
|
|
53
|
+
self._executor.ensure_available()
|
|
54
|
+
args = ["--port", str(self._port)]
|
|
55
|
+
|
|
56
|
+
if self._executable:
|
|
57
|
+
self._process = subprocess.Popen(
|
|
58
|
+
[self._executable] + args,
|
|
59
|
+
stdout=subprocess.PIPE if self._debug else subprocess.DEVNULL,
|
|
60
|
+
stderr=subprocess.PIPE if self._debug else subprocess.DEVNULL,
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
self._container_id = self._executor.run_detached(
|
|
64
|
+
args,
|
|
65
|
+
ports={f"{self._port}/tcp": self._port},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
time.sleep(2) # allow the server to bind its listening socket
|
|
69
|
+
|
|
70
|
+
def _print_debug_output(self, stdout: str, stderr: str) -> None:
|
|
71
|
+
"""Print captured stdout/stderr to sys.stderr for debugging."""
|
|
72
|
+
if stdout:
|
|
73
|
+
sys.stderr.write(f"--- simulator stdout ---\n{stdout}\n")
|
|
74
|
+
if stderr:
|
|
75
|
+
sys.stderr.write(f"--- simulator stderr ---\n{stderr}\n")
|
|
76
|
+
|
|
77
|
+
def stop(self) -> None:
|
|
78
|
+
"""Stop the background simulator."""
|
|
79
|
+
if self._container_id is not None:
|
|
80
|
+
import docker
|
|
81
|
+
|
|
82
|
+
client = docker.from_env()
|
|
83
|
+
try:
|
|
84
|
+
container = client.containers.get(self._container_id)
|
|
85
|
+
if self._debug:
|
|
86
|
+
stdout = container.logs(stdout=True, stderr=False).decode(
|
|
87
|
+
"utf-8", errors="replace"
|
|
88
|
+
)
|
|
89
|
+
stderr = container.logs(stdout=False, stderr=True).decode(
|
|
90
|
+
"utf-8", errors="replace"
|
|
91
|
+
)
|
|
92
|
+
self._print_debug_output(stdout, stderr)
|
|
93
|
+
container.stop(timeout=5)
|
|
94
|
+
container.remove(force=True)
|
|
95
|
+
except docker.errors.NotFound:
|
|
96
|
+
pass
|
|
97
|
+
self._container_id = None
|
|
98
|
+
|
|
99
|
+
if self._process is not None:
|
|
100
|
+
self._process.terminate()
|
|
101
|
+
try:
|
|
102
|
+
self._process.wait(timeout=10)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
self._process.kill()
|
|
105
|
+
self._process.wait()
|
|
106
|
+
if self._debug:
|
|
107
|
+
stdout = self._process.stdout.read().decode(
|
|
108
|
+
"utf-8", errors="replace"
|
|
109
|
+
)
|
|
110
|
+
stderr = self._process.stderr.read().decode(
|
|
111
|
+
"utf-8", errors="replace"
|
|
112
|
+
)
|
|
113
|
+
self._print_debug_output(stdout, stderr)
|
|
114
|
+
self._process = None
|
|
115
|
+
|
|
116
|
+
def __enter__(self) -> "Simulator":
|
|
117
|
+
self.start()
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def __exit__(self, *exc) -> None:
|
|
121
|
+
self.stop()
|
pyredacc/types.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import pybrid.base.proto.main_pb2 as pb
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import sympy as sp
|
|
9
|
+
|
|
10
|
+
_HAS_SYMPY = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
_HAS_SYMPY = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Backend(Enum):
|
|
16
|
+
LANE = "lane"
|
|
17
|
+
NETWORK = "network"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SympyODE:
|
|
22
|
+
"""Represents an ODE system defined via SymPy expressions."""
|
|
23
|
+
|
|
24
|
+
odes: list
|
|
25
|
+
initial_conditions: dict
|
|
26
|
+
probes: list
|
|
27
|
+
name: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CompilerResult:
|
|
32
|
+
"""Result of a redacc compilation run.
|
|
33
|
+
|
|
34
|
+
After a successful compilation, ``module`` contains the compiled protobuf
|
|
35
|
+
``Module`` directly. Use the ``configuration`` and ``specification``
|
|
36
|
+
properties for convenient access to the most commonly needed fields.
|
|
37
|
+
|
|
38
|
+
Instances can be persisted via ``save()`` and restored via ``from_file()``;
|
|
39
|
+
the resulting ``.apb`` files are compatible with pybrid's ``ProtoIO``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
exit_code: int
|
|
43
|
+
stdout: str
|
|
44
|
+
stderr: str
|
|
45
|
+
module: pb.Module | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def configuration(self) -> list[pb.Item]:
|
|
49
|
+
"""Hardware configuration list, ready for pybrid's deserializer.
|
|
50
|
+
|
|
51
|
+
Filters out ``EntitySpecification`` entries from the module — those
|
|
52
|
+
represent device specifications, not hardware configuration.
|
|
53
|
+
"""
|
|
54
|
+
if self.module is None:
|
|
55
|
+
return []
|
|
56
|
+
return [c for c in self.module.items
|
|
57
|
+
if not c.HasField("entity_specification")]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def specification(self) -> list[pb.Entity]:
|
|
61
|
+
"""Device specification entities for pybrid's ``add_device()``.
|
|
62
|
+
|
|
63
|
+
Extracts the ``Entity`` from each ``EntitySpecification`` entry in
|
|
64
|
+
the module.
|
|
65
|
+
"""
|
|
66
|
+
if self.module is None:
|
|
67
|
+
return []
|
|
68
|
+
return [c.entity_specification.entity for c in self.module.items
|
|
69
|
+
if c.HasField("entity_specification")]
|
|
70
|
+
|
|
71
|
+
def save(self, path: str) -> None:
|
|
72
|
+
"""Save the compiled output to an ``.apb`` protobuf file.
|
|
73
|
+
|
|
74
|
+
Wraps the ``Module`` in a ``pb.File`` envelope for compatibility
|
|
75
|
+
with pybrid's ``ProtoIO.open_pb_file()``.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
path: Destination file path (should end in ``.apb``).
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If no compiled output is available.
|
|
82
|
+
"""
|
|
83
|
+
if self.module is None:
|
|
84
|
+
raise ValueError("No compiled output to save.")
|
|
85
|
+
file_msg = pb.File(module=self.module)
|
|
86
|
+
with open(path, "wb") as f:
|
|
87
|
+
f.write(file_msg.SerializeToString())
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_file(cls, path: str) -> "CompilerResult":
|
|
91
|
+
"""Load a ``CompilerResult`` from a previously saved ``.apb`` file.
|
|
92
|
+
|
|
93
|
+
Process metadata (``exit_code``, ``stdout``, ``stderr``) is not
|
|
94
|
+
stored in the protobuf format; they default to neutral values.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: Path to an ``.apb`` file containing a serialised ``pb.File``.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A ``CompilerResult`` with the loaded protobuf data.
|
|
101
|
+
"""
|
|
102
|
+
file_msg = pb.File()
|
|
103
|
+
with open(path, "rb") as f:
|
|
104
|
+
file_msg.ParseFromString(f.read())
|
|
105
|
+
module = file_msg.module if file_msg.HasField("module") else None
|
|
106
|
+
return cls(exit_code=0, stdout="", stderr="", module=module)
|
|
107
|
+
|
|
108
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyredacc
|
|
3
|
+
Version: 0.11.1
|
|
4
|
+
Summary: Python frontend for the redacc analog compiler pipeline
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: docker>=7.0
|
|
7
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
8
|
+
Requires-Dist: numpy>=2.4
|
|
9
|
+
Requires-Dist: protobuf>=5.0
|
|
10
|
+
Requires-Dist: pybrid-computing-native>=0.11.7
|
|
11
|
+
Requires-Dist: pybrid-computing>=0.11.7
|
|
12
|
+
Requires-Dist: sympy>=1.13
|
|
13
|
+
Requires-Dist: typer>=0.24.1
|
|
14
|
+
Requires-Dist: yaspin>=3.4.0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
pyredacc/__init__.py,sha256=HMoKxT_I5d3fbvBPv2l9JbLoFFujBftR0pdr8J9dJNQ,426
|
|
2
|
+
pyredacc/cli.py,sha256=A5K3VWozqoe1VdbMagp7k5ajM7JpqXu4uNXA5L1ah10,4105
|
|
3
|
+
pyredacc/compiler.py,sha256=v0wPCAvmt7EMR1vtC2JYcU1YFNeGa-0q66LEFyAYfm0,6887
|
|
4
|
+
pyredacc/executor.py,sha256=nzxgHNc23yd29CGz_CT_CC5LeZnZD-UKCuvp-le5nkg,9457
|
|
5
|
+
pyredacc/ode_adapter.py,sha256=uWToKfs3lWceB7AbiVeEBL328He2M_QXwQXFWNJqkRQ,5722
|
|
6
|
+
pyredacc/registry.py,sha256=W1QstaTFoeexrWiPEbev29yhOiIn46oDulP0Ou3YTsY,1686
|
|
7
|
+
pyredacc/simulator.py,sha256=L3CKZSjx4VXOizenVU-31kPQP2AQKNNqIfQCledy-G8,4050
|
|
8
|
+
pyredacc/types.py,sha256=HhwQdgQtFxxz5ck_7sDRbbmT6xqOEG4-5yWs1CGKY1Y,3289
|
|
9
|
+
pyredacc-0.11.1.dist-info/METADATA,sha256=AWgf-0xLyf3RbOD1Rzo4FyUdbvfJKUB2Pxx46-0zW4A,430
|
|
10
|
+
pyredacc-0.11.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
pyredacc-0.11.1.dist-info/entry_points.txt,sha256=v0mBhIfTLp-d3cr-Q8M_Iw9nOgqwuE-pHDkwic1y9eg,44
|
|
12
|
+
pyredacc-0.11.1.dist-info/RECORD,,
|