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 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")
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ redacc = pyredacc.cli:app