smolpy 0.1.0__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.
- smolpy/__init__.py +4 -0
- smolpy/cli.py +178 -0
- smolpy/dsl/__init__.py +7 -0
- smolpy/dsl/adapter.py +89 -0
- smolpy/dsl/hub.py +11 -0
- smolpy/dsl/link.py +27 -0
- smolpy/dsl/mqtt_broker.py +23 -0
- smolpy/dsl/network.py +168 -0
- smolpy/dsl/node.py +18 -0
- smolpy/dsl/observation.py +26 -0
- smolpy/dsl/switch.py +17 -0
- smolpy/sim/__init__.py +0 -0
- smolpy/sim/engine.py +580 -0
- smolpy/viz/__init__.py +0 -0
- smolpy/viz/dashboard.py +464 -0
- smolpy/viz/text_dashboard.py +134 -0
- smolpy-0.1.0.dist-info/METADATA +268 -0
- smolpy-0.1.0.dist-info/RECORD +20 -0
- smolpy-0.1.0.dist-info/WHEEL +4 -0
- smolpy-0.1.0.dist-info/entry_points.txt +2 -0
smolpy/__init__.py
ADDED
smolpy/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import runpy
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from rich.theme import Theme
|
|
14
|
+
|
|
15
|
+
_THEME = Theme(
|
|
16
|
+
{
|
|
17
|
+
"banner.title": "bold cyan",
|
|
18
|
+
"banner.sub": "dim white",
|
|
19
|
+
"info": "bold white",
|
|
20
|
+
"success": "bold green",
|
|
21
|
+
"error": "bold red",
|
|
22
|
+
"dim": "dim white",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
console = Console(theme=_THEME)
|
|
27
|
+
|
|
28
|
+
_BANNER = Text.assemble(
|
|
29
|
+
("██████╗ ██╗ ██╗███████╗███╗ ███╗ ██████╗ ██╗\n", "bold cyan"),
|
|
30
|
+
("██╔══██╗╚██╗ ██╔╝██╔════╝████╗ ████║██╔═══██╗██║\n", "bold cyan"),
|
|
31
|
+
("██████╔╝ ╚████╔╝ ███████╗██╔████╔██║██║ ██║██║\n", "bold cyan"),
|
|
32
|
+
("██╔═══╝ ╚██╔╝ ╚════██║██║╚██╔╝██║██║ ██║██║\n", "bold cyan"),
|
|
33
|
+
("██║ ██║ ███████║██║ ╚═╝ ██║╚██████╔╝███████╗\n", "bold cyan"),
|
|
34
|
+
("╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝\n", "bold cyan"),
|
|
35
|
+
(" Network Description Language & Discrete-Event Simulator", "dim white"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _print_banner() -> None:
|
|
40
|
+
console.print()
|
|
41
|
+
console.print(Panel(_BANNER, border_style="cyan", padding=(0, 2)))
|
|
42
|
+
console.print()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cmd_run(script: str, output: str | None, text_mode: bool, extra_args: list[str]) -> None:
|
|
46
|
+
_print_banner()
|
|
47
|
+
console.print(f" [info]Script[/] [dim]{script}[/]")
|
|
48
|
+
console.print()
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
|
|
52
|
+
if text_mode:
|
|
53
|
+
os.environ["SMOLPY_TEXT_MODE"] = "1"
|
|
54
|
+
|
|
55
|
+
start = time.perf_counter()
|
|
56
|
+
namespace: dict = {}
|
|
57
|
+
|
|
58
|
+
with Progress(
|
|
59
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
60
|
+
TextColumn("[bold white]{task.description}"),
|
|
61
|
+
TimeElapsedColumn(),
|
|
62
|
+
console=console,
|
|
63
|
+
transient=False,
|
|
64
|
+
) as progress:
|
|
65
|
+
task = progress.add_task("Running simulation…", total=None)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
sys.argv = [script] + extra_args
|
|
69
|
+
namespace = runpy.run_path(script, run_name="__main__")
|
|
70
|
+
progress.update(task, description="Simulation complete")
|
|
71
|
+
except Exception:
|
|
72
|
+
progress.stop()
|
|
73
|
+
console.print()
|
|
74
|
+
console.print(
|
|
75
|
+
Panel(
|
|
76
|
+
traceback.format_exc(),
|
|
77
|
+
title="[error]Error[/]",
|
|
78
|
+
border_style="red",
|
|
79
|
+
padding=(1, 2),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
elapsed = time.perf_counter() - start
|
|
85
|
+
console.print()
|
|
86
|
+
console.print(
|
|
87
|
+
Panel(
|
|
88
|
+
f"[success]Finished in {elapsed:.2f}s[/]",
|
|
89
|
+
border_style="green",
|
|
90
|
+
padding=(0, 2),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
console.print()
|
|
94
|
+
|
|
95
|
+
if output:
|
|
96
|
+
result = namespace.get("result")
|
|
97
|
+
if result is not None:
|
|
98
|
+
result.export(output)
|
|
99
|
+
console.print(f" Results exported → {output}")
|
|
100
|
+
console.print()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _cmd_demo() -> None:
|
|
104
|
+
"""Run a built-in 3-client saturation demo in text mode."""
|
|
105
|
+
from smolpy import Network
|
|
106
|
+
|
|
107
|
+
_print_banner()
|
|
108
|
+
console.print(" [info]Demo[/] 3 clients → switch → server (10 s, text mode)\n")
|
|
109
|
+
|
|
110
|
+
net = Network("demo")
|
|
111
|
+
sw = net.switch("core-sw", ports=8, mode="store-and-forward")
|
|
112
|
+
server = net.adapter("server", ip="10.0.0.100")
|
|
113
|
+
net.link(server, sw, speed=1_000, length=2)
|
|
114
|
+
|
|
115
|
+
fps = int(100_000_000 / (1_518 * 8))
|
|
116
|
+
for i in range(1, 4):
|
|
117
|
+
c = net.adapter(f"client-{i}", ip=f"10.0.0.{i}")
|
|
118
|
+
net.link(c, sw, speed=100, length=10)
|
|
119
|
+
c.sends(to=server, rate=fps, size=1_518, pattern="constant", delay_ms=(i - 1) * 2_000)
|
|
120
|
+
net.observe("bytes_sent", on=c, every=500)
|
|
121
|
+
|
|
122
|
+
net.observe("throughput", on=server, every=200)
|
|
123
|
+
net.observe("latency", on=server, every=200)
|
|
124
|
+
net.observe("queue_depth", on=sw, every=200)
|
|
125
|
+
net.observe("bytes_received", on=server, every=500)
|
|
126
|
+
|
|
127
|
+
result = net.simulate(duration=10_000, text=True)
|
|
128
|
+
result.report()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> None:
|
|
132
|
+
parser = argparse.ArgumentParser(
|
|
133
|
+
prog="smolpy",
|
|
134
|
+
description="SMOLPy — Network simulation DSL and discrete-event simulator",
|
|
135
|
+
)
|
|
136
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
137
|
+
|
|
138
|
+
run_cmd = sub.add_parser(
|
|
139
|
+
"run",
|
|
140
|
+
help="Execute a SMOLPy simulation script",
|
|
141
|
+
description="Load and run a Python script that uses the SMOLPy DSL.",
|
|
142
|
+
)
|
|
143
|
+
run_cmd.add_argument("script", help="Path to the .py script to run")
|
|
144
|
+
run_cmd.add_argument(
|
|
145
|
+
"--output",
|
|
146
|
+
"-o",
|
|
147
|
+
default=None,
|
|
148
|
+
metavar="FILE",
|
|
149
|
+
help="Write metric time-series to FILE (.csv or .json)",
|
|
150
|
+
)
|
|
151
|
+
run_cmd.add_argument(
|
|
152
|
+
"--text",
|
|
153
|
+
"-t",
|
|
154
|
+
action="store_true",
|
|
155
|
+
default=False,
|
|
156
|
+
help="Force text-mode dashboard even if the script uses live=True",
|
|
157
|
+
)
|
|
158
|
+
run_cmd.add_argument(
|
|
159
|
+
"args",
|
|
160
|
+
nargs=argparse.REMAINDER,
|
|
161
|
+
help="Extra arguments forwarded to the script via sys.argv",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
sub.add_parser(
|
|
165
|
+
"demo",
|
|
166
|
+
help="Run a built-in 3-client saturation demo (no script needed)",
|
|
167
|
+
description="Quick sanity-check: 3 clients ramping onto a shared switch, text-mode output.",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
|
|
172
|
+
if args.command == "run":
|
|
173
|
+
# REMAINDER captures flags that appear after the script name, so check both
|
|
174
|
+
text = args.text or "--text" in args.args or "-t" in args.args
|
|
175
|
+
extra = [a for a in args.args if a not in ("--text", "-t")]
|
|
176
|
+
_cmd_run(args.script, args.output, text, extra)
|
|
177
|
+
elif args.command == "demo":
|
|
178
|
+
_cmd_demo()
|
smolpy/dsl/__init__.py
ADDED
smolpy/dsl/adapter.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Literal
|
|
4
|
+
|
|
5
|
+
from smolpy.dsl.node import Node
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
TrafficPattern = Literal["constant", "poisson", "bursty"]
|
|
11
|
+
FrameSize = int | Literal["imix"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TrafficSpec:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
destination: Adapter,
|
|
18
|
+
rate: float,
|
|
19
|
+
size: FrameSize,
|
|
20
|
+
pattern: TrafficPattern,
|
|
21
|
+
delay_ms: float = 0.0,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.destination = destination
|
|
24
|
+
self.rate = rate # frames/s
|
|
25
|
+
self.size = size # bytes or "imix"
|
|
26
|
+
self.pattern = pattern
|
|
27
|
+
self.delay_ms = delay_ms
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MQTTSpec:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
broker, # MQTTBroker
|
|
34
|
+
topic: str,
|
|
35
|
+
rate_hz: float, # messages per second
|
|
36
|
+
payload_bytes: int,
|
|
37
|
+
qos: int,
|
|
38
|
+
delay_ms: float = 0.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.broker = broker
|
|
41
|
+
self.topic = topic
|
|
42
|
+
self.rate_hz = rate_hz
|
|
43
|
+
self.payload_bytes = payload_bytes
|
|
44
|
+
self.qos = qos
|
|
45
|
+
self.delay_ms = delay_ms
|
|
46
|
+
# Wire-level frame size:
|
|
47
|
+
# Ethernet+IPv4+TCP overhead = 54 B
|
|
48
|
+
# MQTT fixed header = 2 B, topic-length field = 2 B
|
|
49
|
+
# topic name = len(topic) B, packet-ID (QoS>0) = 2 B
|
|
50
|
+
ethernet_ip_tcp = 54
|
|
51
|
+
mqtt_overhead = 2 + 2 + len(topic) + (2 if qos > 0 else 0)
|
|
52
|
+
self.frame_size: int = ethernet_ip_tcp + mqtt_overhead + payload_bytes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Adapter(Node):
|
|
56
|
+
"""NIC — generates and receives Ethernet frames."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, name: str, ip: str, mac: str | None = None) -> None:
|
|
59
|
+
super().__init__(name)
|
|
60
|
+
self.ip = ip
|
|
61
|
+
self.mac = mac or _derive_mac(ip)
|
|
62
|
+
self.traffic_specs: list[TrafficSpec] = []
|
|
63
|
+
self.mqtt_specs: list[MQTTSpec] = []
|
|
64
|
+
|
|
65
|
+
def sends(
|
|
66
|
+
self,
|
|
67
|
+
to: Adapter,
|
|
68
|
+
rate: float,
|
|
69
|
+
size: FrameSize = 512,
|
|
70
|
+
pattern: TrafficPattern = "constant",
|
|
71
|
+
delay_ms: float = 0.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
self.traffic_specs.append(TrafficSpec(to, rate, size, pattern, delay_ms))
|
|
74
|
+
|
|
75
|
+
def publishes(
|
|
76
|
+
self,
|
|
77
|
+
to, # MQTTBroker
|
|
78
|
+
topic: str,
|
|
79
|
+
rate: float = 1.0, # messages / second
|
|
80
|
+
payload: int = 20, # payload bytes
|
|
81
|
+
qos: int = 0,
|
|
82
|
+
delay_ms: float = 0.0,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.mqtt_specs.append(MQTTSpec(to, topic, rate, payload, qos, delay_ms))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _derive_mac(ip: str) -> str:
|
|
88
|
+
octets = ip.split(".")[-3:]
|
|
89
|
+
return f"02:00:{':'.join(f'{int(o):02x}' for o in octets)}"
|
smolpy/dsl/hub.py
ADDED
smolpy/dsl/link.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from smolpy.dsl.node import Node
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Link:
|
|
12
|
+
"""Point-to-point cable between two nodes."""
|
|
13
|
+
|
|
14
|
+
endpoint_a: Node
|
|
15
|
+
endpoint_b: Node
|
|
16
|
+
speed: float # Mb/s
|
|
17
|
+
length: float # metres
|
|
18
|
+
duplex: bool = True
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def speed_bps(self) -> float:
|
|
22
|
+
return self.speed * 1_000_000
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def propagation_delay_us(self) -> float:
|
|
26
|
+
"""One-way propagation delay in microseconds."""
|
|
27
|
+
return (self.length / 2e8) * 1_000_000
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from smolpy.dsl.adapter import _derive_mac
|
|
4
|
+
from smolpy.dsl.node import Node
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MQTTBroker(Node):
|
|
8
|
+
"""Application-layer MQTT message broker.
|
|
9
|
+
|
|
10
|
+
Receives PUBLISH frames from client adapters and forwards copies
|
|
11
|
+
to all registered subscribers for each topic.
|
|
12
|
+
Supports QoS 0 (fire-and-forget) and QoS 1 (PUBACK acknowledgement).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, name: str, ip: str, mac: str | None = None) -> None:
|
|
16
|
+
super().__init__(name)
|
|
17
|
+
self.ip = ip
|
|
18
|
+
self.mac = mac or _derive_mac(ip)
|
|
19
|
+
self._routes: dict[str, list] = {} # topic → [Adapter, ...]
|
|
20
|
+
|
|
21
|
+
def routes(self, topic: str, to: list) -> None:
|
|
22
|
+
"""Register subscriber adapters for *topic*."""
|
|
23
|
+
self._routes[topic] = list(to)
|
smolpy/dsl/network.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import networkx as nx
|
|
4
|
+
|
|
5
|
+
from smolpy.dsl.adapter import Adapter
|
|
6
|
+
from smolpy.dsl.hub import Hub
|
|
7
|
+
from smolpy.dsl.link import Link
|
|
8
|
+
from smolpy.dsl.mqtt_broker import MQTTBroker
|
|
9
|
+
from smolpy.dsl.node import Node
|
|
10
|
+
from smolpy.dsl.observation import MetricName, Observation
|
|
11
|
+
from smolpy.dsl.switch import Switch, SwitchMode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SimulationResult:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
metrics: dict[str, list[tuple[float, float]]],
|
|
18
|
+
network: Network,
|
|
19
|
+
) -> None:
|
|
20
|
+
self.metrics = metrics
|
|
21
|
+
self._network = network
|
|
22
|
+
|
|
23
|
+
def export(self, path: str, format: str | None = None) -> None:
|
|
24
|
+
"""Write all metric time-series to *path*.
|
|
25
|
+
|
|
26
|
+
Format is inferred from the file extension when *format* is omitted.
|
|
27
|
+
Supported: ``"csv"`` (long format: time_ms, metric, value)
|
|
28
|
+
and ``"json"`` (dict of lists-of-pairs).
|
|
29
|
+
"""
|
|
30
|
+
import csv
|
|
31
|
+
import json
|
|
32
|
+
import pathlib
|
|
33
|
+
|
|
34
|
+
p = pathlib.Path(path)
|
|
35
|
+
fmt = (format or p.suffix.lstrip(".")).lower()
|
|
36
|
+
|
|
37
|
+
if fmt == "csv":
|
|
38
|
+
with open(p, "w", newline="", encoding="utf-8") as fh:
|
|
39
|
+
w = csv.writer(fh)
|
|
40
|
+
w.writerow(["time_ms", "metric", "value"])
|
|
41
|
+
for key in sorted(self.metrics):
|
|
42
|
+
for t, v in self.metrics[key]:
|
|
43
|
+
w.writerow([f"{t:.3f}", key, f"{v:.6f}"])
|
|
44
|
+
elif fmt == "json":
|
|
45
|
+
payload = {
|
|
46
|
+
"network": self._network.name,
|
|
47
|
+
"metrics": {
|
|
48
|
+
k: [[round(t, 3), round(v, 6)] for t, v in s] for k, s in self.metrics.items()
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
52
|
+
json.dump(payload, fh, indent=2)
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError(f"Unknown format {fmt!r}. Supported formats: 'csv', 'json'.")
|
|
55
|
+
|
|
56
|
+
def plot(self) -> None:
|
|
57
|
+
from smolpy.viz.dashboard import show
|
|
58
|
+
|
|
59
|
+
show(self)
|
|
60
|
+
|
|
61
|
+
def report(self) -> None:
|
|
62
|
+
if not self.metrics:
|
|
63
|
+
print("No metrics collected. Add net.observe(...) calls before simulating.")
|
|
64
|
+
return
|
|
65
|
+
_UNITS = {
|
|
66
|
+
"throughput": "Mb/s",
|
|
67
|
+
"latency": "µs",
|
|
68
|
+
"frame_loss": "%",
|
|
69
|
+
"collision_rate": "/s",
|
|
70
|
+
"queue_depth": "fr",
|
|
71
|
+
"utilization": "%",
|
|
72
|
+
"bytes_sent": "MB",
|
|
73
|
+
"bytes_received": "MB",
|
|
74
|
+
}
|
|
75
|
+
col = max(len(k) for k in self.metrics) + 2
|
|
76
|
+
header = f"{'Metric':<{col}} {'n':>6} {'avg':>10} {'min':>10} {'max':>10}"
|
|
77
|
+
print(f"\n{header}")
|
|
78
|
+
print("-" * len(header))
|
|
79
|
+
for key in sorted(self.metrics):
|
|
80
|
+
vals = [v for _, v in self.metrics[key]]
|
|
81
|
+
if not vals:
|
|
82
|
+
continue
|
|
83
|
+
unit = next((u for m, u in _UNITS.items() if m in key), "")
|
|
84
|
+
avg = sum(vals) / len(vals)
|
|
85
|
+
print(
|
|
86
|
+
f"{key:<{col}} {len(vals):>6} {avg:>9.3f}{unit:>5}"
|
|
87
|
+
f" {min(vals):>9.3f}{unit:>5} {max(vals):>9.3f}{unit:>5}"
|
|
88
|
+
)
|
|
89
|
+
print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Network:
|
|
93
|
+
"""Top-level container — describes topology, traffic, and observations."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, name: str) -> None:
|
|
96
|
+
self.name = name
|
|
97
|
+
self._graph: nx.Graph = nx.Graph()
|
|
98
|
+
self._nodes: dict[str, Node] = {}
|
|
99
|
+
self._links: list[Link] = []
|
|
100
|
+
self._observations: list[Observation] = []
|
|
101
|
+
|
|
102
|
+
# --- topology builders ---
|
|
103
|
+
|
|
104
|
+
def adapter(self, name: str, ip: str, mac: str | None = None) -> Adapter:
|
|
105
|
+
node = Adapter(name, ip=ip, mac=mac)
|
|
106
|
+
self._register(node)
|
|
107
|
+
return node
|
|
108
|
+
|
|
109
|
+
def switch(self, name: str, ports: int, mode: SwitchMode = "store-and-forward") -> Switch:
|
|
110
|
+
node = Switch(name, ports=ports, mode=mode)
|
|
111
|
+
self._register(node)
|
|
112
|
+
return node
|
|
113
|
+
|
|
114
|
+
def hub(self, name: str, ports: int) -> Hub:
|
|
115
|
+
node = Hub(name, ports=ports)
|
|
116
|
+
self._register(node)
|
|
117
|
+
return node
|
|
118
|
+
|
|
119
|
+
def mqtt_broker(self, name: str, ip: str, mac: str | None = None) -> MQTTBroker:
|
|
120
|
+
node = MQTTBroker(name, ip=ip, mac=mac)
|
|
121
|
+
self._register(node)
|
|
122
|
+
return node
|
|
123
|
+
|
|
124
|
+
def link(self, a: Node, b: Node, speed: float, length: float, duplex: bool = True) -> Link:
|
|
125
|
+
lnk = Link(endpoint_a=a, endpoint_b=b, speed=speed, length=length, duplex=duplex)
|
|
126
|
+
a.links.append(lnk)
|
|
127
|
+
b.links.append(lnk)
|
|
128
|
+
self._links.append(lnk)
|
|
129
|
+
self._graph.add_edge(a.name, b.name, link=lnk)
|
|
130
|
+
return lnk
|
|
131
|
+
|
|
132
|
+
# --- observation builder ---
|
|
133
|
+
|
|
134
|
+
def observe(self, metric: MetricName, on: Node, every: float) -> None:
|
|
135
|
+
self._observations.append(Observation(metric=metric, target=on, interval_ms=every))
|
|
136
|
+
|
|
137
|
+
# --- simulation control ---
|
|
138
|
+
|
|
139
|
+
def simulate(
|
|
140
|
+
self,
|
|
141
|
+
duration: float,
|
|
142
|
+
*,
|
|
143
|
+
live: bool = False,
|
|
144
|
+
text: bool = False,
|
|
145
|
+
) -> SimulationResult:
|
|
146
|
+
import os
|
|
147
|
+
|
|
148
|
+
if os.environ.get("SMOLPY_TEXT_MODE") == "1":
|
|
149
|
+
live, text = False, True
|
|
150
|
+
if live:
|
|
151
|
+
from smolpy.viz.dashboard import show_live
|
|
152
|
+
|
|
153
|
+
return show_live(self, duration)
|
|
154
|
+
if text:
|
|
155
|
+
from smolpy.viz.text_dashboard import show_text
|
|
156
|
+
|
|
157
|
+
return show_text(self, duration)
|
|
158
|
+
from smolpy.sim.engine import run_simulation
|
|
159
|
+
|
|
160
|
+
return run_simulation(self, duration_ms=duration)
|
|
161
|
+
|
|
162
|
+
# --- internals ---
|
|
163
|
+
|
|
164
|
+
def _register(self, node: Node) -> None:
|
|
165
|
+
if node.name in self._nodes:
|
|
166
|
+
raise ValueError(f"Node {node.name!r} already exists in network {self.name!r}")
|
|
167
|
+
self._nodes[node.name] = node
|
|
168
|
+
self._graph.add_node(node.name, node=node)
|
smolpy/dsl/node.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from smolpy.dsl.link import Link
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Node(ABC):
|
|
11
|
+
"""Base class for all network nodes (Adapter, Switch, Hub)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, name: str) -> None:
|
|
14
|
+
self.name = name
|
|
15
|
+
self.links: list[Link] = []
|
|
16
|
+
|
|
17
|
+
def __repr__(self) -> str:
|
|
18
|
+
return f"{self.__class__.__name__}({self.name!r})"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from smolpy.dsl.node import Node
|
|
8
|
+
|
|
9
|
+
MetricName = Literal[
|
|
10
|
+
"throughput",
|
|
11
|
+
"latency",
|
|
12
|
+
"frame_loss",
|
|
13
|
+
"collision_rate",
|
|
14
|
+
"queue_depth",
|
|
15
|
+
"utilization",
|
|
16
|
+
"bytes_sent",
|
|
17
|
+
"bytes_received",
|
|
18
|
+
"broker_queue",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Observation:
|
|
24
|
+
metric: MetricName
|
|
25
|
+
target: Node
|
|
26
|
+
interval_ms: float
|
smolpy/dsl/switch.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from smolpy.dsl.node import Node
|
|
6
|
+
|
|
7
|
+
SwitchMode = Literal["store-and-forward", "cut-through"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Switch(Node):
|
|
11
|
+
"""Layer-2 switch with MAC table and per-port queues."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, name: str, ports: int, mode: SwitchMode = "store-and-forward") -> None:
|
|
14
|
+
super().__init__(name)
|
|
15
|
+
self.ports = ports
|
|
16
|
+
self.mode = mode
|
|
17
|
+
self.mac_table: dict[str, int] = {} # mac -> port index
|
smolpy/sim/__init__.py
ADDED
|
File without changes
|