simulsi 0.2.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.
- simulsi/__init__.py +70 -0
- simulsi/__main__.py +5 -0
- simulsi/_version.py +1 -0
- simulsi/analysis/__init__.py +24 -0
- simulsi/analysis/comparison.py +154 -0
- simulsi/analysis/report.py +46 -0
- simulsi/analysis/sensitivity.py +387 -0
- simulsi/benchmarks.py +128 -0
- simulsi/cli/__init__.py +3 -0
- simulsi/cli/main.py +486 -0
- simulsi/cli/templates.py +96 -0
- simulsi/config/__init__.py +19 -0
- simulsi/config/schema.py +263 -0
- simulsi/core/__init__.py +13 -0
- simulsi/core/checkpoint.py +159 -0
- simulsi/core/clock.py +87 -0
- simulsi/core/model.py +441 -0
- simulsi/core/simulation.py +670 -0
- simulsi/core/trace.py +79 -0
- simulsi/cost/__init__.py +3 -0
- simulsi/cost/model.py +193 -0
- simulsi/entities/__init__.py +3 -0
- simulsi/entities/entity.py +120 -0
- simulsi/errors.py +50 -0
- simulsi/events/__init__.py +3 -0
- simulsi/events/event.py +221 -0
- simulsi/experiments/__init__.py +17 -0
- simulsi/experiments/experiment.py +548 -0
- simulsi/experiments/montecarlo.py +261 -0
- simulsi/experiments/provenance.py +61 -0
- simulsi/introspection/__init__.py +21 -0
- simulsi/introspection/graph.py +210 -0
- simulsi/metrics/__init__.py +3 -0
- simulsi/metrics/collectors.py +348 -0
- simulsi/models/__init__.py +3 -0
- simulsi/models/queueing.py +73 -0
- simulsi/optimization/__init__.py +3 -0
- simulsi/optimization/objective.py +131 -0
- simulsi/processes/__init__.py +31 -0
- simulsi/processes/disruption.py +165 -0
- simulsi/processes/process.py +346 -0
- simulsi/py.typed +0 -0
- simulsi/queues/__init__.py +4 -0
- simulsi/queues/discipline.py +99 -0
- simulsi/queues/queue.py +256 -0
- simulsi/randomness/__init__.py +47 -0
- simulsi/randomness/distributions.py +449 -0
- simulsi/randomness/stream.py +117 -0
- simulsi/resources/__init__.py +3 -0
- simulsi/resources/resource.py +519 -0
- simulsi/scenarios/__init__.py +10 -0
- simulsi/scenarios/scenario.py +132 -0
- simulsi/serialization/__init__.py +23 -0
- simulsi/serialization/io.py +128 -0
- simulsi/statistics/__init__.py +37 -0
- simulsi/statistics/core.py +418 -0
- simulsi/validation/__init__.py +3 -0
- simulsi/validation/validate.py +168 -0
- simulsi/visualization/__init__.py +27 -0
- simulsi/visualization/plots.py +423 -0
- simulsi/web/__init__.py +1 -0
- simulsi/web/server.py +338 -0
- simulsi/web/static/assets/index-CNMcVqxw.css +2 -0
- simulsi/web/static/assets/index-DDtrYZla.js +9 -0
- simulsi/web/static/index.html +13 -0
- simulsi-0.2.0.dist-info/METADATA +206 -0
- simulsi-0.2.0.dist-info/RECORD +71 -0
- simulsi-0.2.0.dist-info/WHEEL +4 -0
- simulsi-0.2.0.dist-info/entry_points.txt +2 -0
- simulsi-0.2.0.dist-info/licenses/LICENSE +21 -0
- simulsi-0.2.0.dist-info/licenses/NOTICE +11 -0
simulsi/benchmarks.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Reproducible micro- and macro-benchmarks (used by ``simulsi benchmark``).
|
|
2
|
+
|
|
3
|
+
Numbers depend on the machine and Python version; record them with the
|
|
4
|
+
environment (see :func:`simulsi.experiments.provenance.environment`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import tracemalloc
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from simulsi.core.simulation import Simulation
|
|
16
|
+
from simulsi.experiments.experiment import Experiment
|
|
17
|
+
from simulsi.models.queueing import mmc
|
|
18
|
+
from simulsi.randomness.distributions import Exponential
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class BenchmarkResult:
|
|
23
|
+
name: str
|
|
24
|
+
size: int
|
|
25
|
+
events: int
|
|
26
|
+
seconds: float
|
|
27
|
+
peak_memory_mb: float | None = None
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def events_per_second(self) -> float:
|
|
31
|
+
return self.events / self.seconds if self.seconds > 0 else float("nan")
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
d = asdict(self)
|
|
35
|
+
d["events_per_second"] = self.events_per_second
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _timer_chain(n_events: int) -> Simulation:
|
|
40
|
+
"""Pure engine overhead: ``n_events`` callback events, 100 interleaved chains."""
|
|
41
|
+
sim = Simulation(seed=1, keep_values=False, record_series=False)
|
|
42
|
+
chains = 100
|
|
43
|
+
per_chain = n_events // chains
|
|
44
|
+
stream = sim.stream("delays")
|
|
45
|
+
|
|
46
|
+
def make(remaining: list[int]) -> Callable[[], None]:
|
|
47
|
+
def tick() -> None:
|
|
48
|
+
remaining[0] -= 1
|
|
49
|
+
if remaining[0] > 0:
|
|
50
|
+
sim.call_at(tick, delay=stream.exponential(1.0))
|
|
51
|
+
|
|
52
|
+
return tick
|
|
53
|
+
|
|
54
|
+
for _ in range(chains):
|
|
55
|
+
sim.call_at(make([per_chain]), delay=stream.exponential(1.0))
|
|
56
|
+
return sim
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _process_queue(n_customers: int) -> Simulation:
|
|
60
|
+
"""Process-based M/M/2 queue: generator processes, resource contention, metrics."""
|
|
61
|
+
sim = Simulation(seed=1, keep_values=False, record_series=False, keep_entity_history=False)
|
|
62
|
+
server = sim.resource("server", 2)
|
|
63
|
+
inter, svc = Exponential(rate=1.8), Exponential(rate=1.0)
|
|
64
|
+
a, s = sim.stream("a"), sim.stream("s")
|
|
65
|
+
|
|
66
|
+
def customer(sim: Simulation) -> Any:
|
|
67
|
+
yield from sim.use(server, svc.sample(s))
|
|
68
|
+
|
|
69
|
+
def source(sim: Simulation) -> Any:
|
|
70
|
+
for _ in range(n_customers):
|
|
71
|
+
yield inter.sample(a)
|
|
72
|
+
sim.process(customer(sim))
|
|
73
|
+
|
|
74
|
+
sim.process(source(sim))
|
|
75
|
+
return sim
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SCENARIOS: dict[str, Callable[[int], Simulation]] = {
|
|
79
|
+
"events": _timer_chain,
|
|
80
|
+
"processes": _process_queue,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_engine_benchmark(name: str, size: int, *, memory: bool = False) -> BenchmarkResult:
|
|
85
|
+
sim = SCENARIOS[name](size)
|
|
86
|
+
if memory:
|
|
87
|
+
tracemalloc.start()
|
|
88
|
+
t0 = time.perf_counter()
|
|
89
|
+
sim.run()
|
|
90
|
+
dt = time.perf_counter() - t0
|
|
91
|
+
peak = None
|
|
92
|
+
if memory:
|
|
93
|
+
_, peak_b = tracemalloc.get_traced_memory()
|
|
94
|
+
tracemalloc.stop()
|
|
95
|
+
peak = peak_b / 1e6
|
|
96
|
+
return BenchmarkResult(name, size, sim.events_processed, dt, peak)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_experiment_benchmark(
|
|
100
|
+
replications: int, workers: int, duration: float = 20_000.0
|
|
101
|
+
) -> BenchmarkResult:
|
|
102
|
+
m = mmc.with_options(duration=duration, warmup=0.0)
|
|
103
|
+
exp = Experiment(m, replications=replications, seed=0, workers=workers)
|
|
104
|
+
t0 = time.perf_counter()
|
|
105
|
+
res = exp.run()
|
|
106
|
+
dt = time.perf_counter() - t0
|
|
107
|
+
return BenchmarkResult(
|
|
108
|
+
f"experiment(workers={workers})", replications, sum(r.events for r in res.records), dt
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def default_suite(
|
|
113
|
+
sizes: tuple[int, ...] = (10_000, 100_000, 1_000_000),
|
|
114
|
+
*,
|
|
115
|
+
memory: bool = True,
|
|
116
|
+
workers: tuple[int, ...] = (1, 4),
|
|
117
|
+
replications: int = 16,
|
|
118
|
+
) -> list[BenchmarkResult]:
|
|
119
|
+
out = []
|
|
120
|
+
for name in SCENARIOS:
|
|
121
|
+
for n in sizes:
|
|
122
|
+
out.append(run_engine_benchmark(name, n))
|
|
123
|
+
if memory and n <= 100_000:
|
|
124
|
+
mem = run_engine_benchmark(name, n, memory=True)
|
|
125
|
+
out[-1].peak_memory_mb = mem.peak_memory_mb
|
|
126
|
+
for w in workers:
|
|
127
|
+
out.append(run_experiment_benchmark(replications, w))
|
|
128
|
+
return out
|
simulsi/cli/__init__.py
ADDED
simulsi/cli/main.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Command-line interface: ``simulsi <command>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from simulsi._version import __version__
|
|
16
|
+
from simulsi.errors import ConfigError, SimulsiError
|
|
17
|
+
|
|
18
|
+
EXIT_OK, EXIT_ERROR, EXIT_INVALID = 0, 1, 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print(text: str = "") -> None:
|
|
22
|
+
sys.stdout.write(text + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _err(text: str) -> None:
|
|
26
|
+
sys.stderr.write(f"error: {text}\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_params(items: Sequence[str] | None) -> dict[str, Any]:
|
|
30
|
+
"""``key=value`` pairs; values are parsed as YAML scalars (safe) so numbers stay numbers."""
|
|
31
|
+
out: dict[str, Any] = {}
|
|
32
|
+
for item in items or []:
|
|
33
|
+
key, sep, raw = item.partition("=")
|
|
34
|
+
if not sep or not key.strip():
|
|
35
|
+
raise ConfigError(f"--param expects key=value, got {item!r}")
|
|
36
|
+
try:
|
|
37
|
+
out[key.strip()] = yaml.safe_load(raw)
|
|
38
|
+
except yaml.YAMLError as exc:
|
|
39
|
+
raise ConfigError(f"cannot parse value for {key!r}: {exc}") from exc
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _metrics_table(metrics: dict[str, float], only: Sequence[str] | None = None) -> str:
|
|
44
|
+
from simulsi.analysis.report import format_table
|
|
45
|
+
|
|
46
|
+
keys = [k for k in (only or sorted(metrics)) if k in metrics]
|
|
47
|
+
return format_table([{"metric": k, "value": metrics[k]} for k in keys])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_config(target: str) -> bool:
|
|
51
|
+
return target.endswith((".yaml", ".yml"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- commands ----------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
58
|
+
from simulsi.cli import templates
|
|
59
|
+
|
|
60
|
+
d = Path(args.directory)
|
|
61
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
files = {
|
|
63
|
+
"model.py": templates.MODEL_PY,
|
|
64
|
+
"experiment.yaml": templates.EXPERIMENT_YAML,
|
|
65
|
+
"README.md": templates.README_MD.format(name=d.resolve().name),
|
|
66
|
+
}
|
|
67
|
+
existing = [f for f in files if (d / f).exists()]
|
|
68
|
+
if existing and not args.force:
|
|
69
|
+
_err(f"{', '.join(existing)} already exist in {d}; use --force to overwrite")
|
|
70
|
+
return EXIT_ERROR
|
|
71
|
+
for name, content in files.items():
|
|
72
|
+
(d / name).write_text(content, encoding="utf-8")
|
|
73
|
+
_print(f"created SimulSI project in {d}/ ({', '.join(files)})")
|
|
74
|
+
_print(
|
|
75
|
+
f"next: cd {d} && simulsi validate experiment.yaml && simulsi experiment experiment.yaml"
|
|
76
|
+
)
|
|
77
|
+
return EXIT_OK
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
81
|
+
from simulsi.config.schema import load_experiment, resolve_model
|
|
82
|
+
from simulsi.validation import validate_model
|
|
83
|
+
|
|
84
|
+
target: str = args.target
|
|
85
|
+
try:
|
|
86
|
+
if _is_config(target):
|
|
87
|
+
exp, _cfg = load_experiment(target, allow_outside=args.allow_outside)
|
|
88
|
+
_print(
|
|
89
|
+
f"configuration OK: {len(exp.scenarios)} scenario(s) x {exp.replications} replication(s)"
|
|
90
|
+
)
|
|
91
|
+
reports = [
|
|
92
|
+
(s.name, validate_model(exp.model, s.parameters, smoke_duration=args.smoke))
|
|
93
|
+
for s in exp.scenarios
|
|
94
|
+
]
|
|
95
|
+
else:
|
|
96
|
+
m = resolve_model(target)
|
|
97
|
+
reports = [
|
|
98
|
+
("model", validate_model(m, _parse_params(args.param), smoke_duration=args.smoke))
|
|
99
|
+
]
|
|
100
|
+
except (ConfigError, SimulsiError) as exc:
|
|
101
|
+
_err(str(exc))
|
|
102
|
+
return EXIT_INVALID
|
|
103
|
+
ok = True
|
|
104
|
+
for name, rep in reports:
|
|
105
|
+
ok = ok and rep.ok
|
|
106
|
+
_print(f"[{name}] {rep.format()}")
|
|
107
|
+
return EXIT_OK if ok else EXIT_INVALID
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
111
|
+
from simulsi.config.schema import load_config, resolve_model
|
|
112
|
+
|
|
113
|
+
params = _parse_params(args.param)
|
|
114
|
+
target: str = args.target
|
|
115
|
+
if _is_config(target):
|
|
116
|
+
cfg = load_config(target)
|
|
117
|
+
m = resolve_model(cfg.model, base_dir=Path(target).parent, allow_outside=args.allow_outside)
|
|
118
|
+
params = {**cfg.parameters, **params}
|
|
119
|
+
seed = args.seed if args.seed is not None else cfg.simulation.seed
|
|
120
|
+
duration = args.duration or cfg.simulation.duration
|
|
121
|
+
else:
|
|
122
|
+
m = resolve_model(target)
|
|
123
|
+
seed = args.seed if args.seed is not None else 0
|
|
124
|
+
duration = args.duration
|
|
125
|
+
result = m.simulate(params, seed=seed, duration=duration, trace=bool(args.trace))
|
|
126
|
+
if args.trace and result.log is not None:
|
|
127
|
+
p = result.log.export(args.trace)
|
|
128
|
+
sys.stderr.write(f"wrote {len(result.log)} log records to {p}\n")
|
|
129
|
+
if args.json:
|
|
130
|
+
from simulsi.serialization.io import to_jsonable
|
|
131
|
+
|
|
132
|
+
_print(json.dumps(to_jsonable(result.to_dict()), indent=2, allow_nan=False))
|
|
133
|
+
return EXIT_OK
|
|
134
|
+
_print(
|
|
135
|
+
f"{m.name}: seed={result.seed} t=[{result.start_time:g}, {result.end_time:g}] "
|
|
136
|
+
f"events={result.events_processed} wall={result.wall_time:.3f}s"
|
|
137
|
+
)
|
|
138
|
+
_print(_metrics_table(result.metrics, args.metric))
|
|
139
|
+
for w in result.warnings:
|
|
140
|
+
_print(f"warning: {w}")
|
|
141
|
+
return EXIT_OK
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_experiment(args: argparse.Namespace) -> int:
|
|
145
|
+
from simulsi.analysis.comparison import compare
|
|
146
|
+
from simulsi.config.schema import load_experiment
|
|
147
|
+
from simulsi.cost.model import CostModel
|
|
148
|
+
|
|
149
|
+
exp, cfg = load_experiment(args.config, allow_outside=args.allow_outside)
|
|
150
|
+
if args.replications:
|
|
151
|
+
exp.replications = args.replications
|
|
152
|
+
if args.workers:
|
|
153
|
+
exp.workers = args.workers
|
|
154
|
+
if args.seed is not None:
|
|
155
|
+
exp.seed = args.seed
|
|
156
|
+
total = len(exp.scenarios) * exp.replications
|
|
157
|
+
_print(
|
|
158
|
+
f"experiment {exp.name!r}: model={exp.model.name} scenarios={len(exp.scenarios)} "
|
|
159
|
+
f"replications={exp.replications} workers={exp.workers}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def progress(done: int, n: int) -> None:
|
|
163
|
+
if not args.quiet and (done == n or done % max(1, n // 20) == 0):
|
|
164
|
+
sys.stderr.write(f"\r {done}/{n} runs")
|
|
165
|
+
if done == n:
|
|
166
|
+
sys.stderr.write("\n")
|
|
167
|
+
|
|
168
|
+
result = exp.run(checkpoint=args.checkpoint, progress=progress)
|
|
169
|
+
if cfg.cost:
|
|
170
|
+
result.derive(CostModel.from_dict(cfg.cost).metrics)
|
|
171
|
+
metrics = cfg.metrics
|
|
172
|
+
if cfg.cost and metrics is not None:
|
|
173
|
+
metrics = [*metrics, "cost.total", "cost.profit"]
|
|
174
|
+
_print(result.format_summary(metrics, confidence=cfg.experiment.confidence))
|
|
175
|
+
if len(result.scenarios) > 1 and cfg.baseline in result.scenarios:
|
|
176
|
+
_print("")
|
|
177
|
+
_print(
|
|
178
|
+
f"Comparison with {cfg.baseline!r} ({cfg.experiment.confidence:.0%} CI; * = CI excludes 0):"
|
|
179
|
+
)
|
|
180
|
+
_print(
|
|
181
|
+
compare(
|
|
182
|
+
result, cfg.baseline, metrics=metrics, confidence=cfg.experiment.confidence
|
|
183
|
+
).format()
|
|
184
|
+
)
|
|
185
|
+
if result.errors:
|
|
186
|
+
_print(
|
|
187
|
+
f"\n{len(result.errors)} of {total} runs failed; first error: {result.errors[0].error}"
|
|
188
|
+
)
|
|
189
|
+
out = args.output or cfg.output.directory
|
|
190
|
+
if out:
|
|
191
|
+
if args.output:
|
|
192
|
+
directory = Path(args.output)
|
|
193
|
+
else:
|
|
194
|
+
from simulsi.serialization.io import safe_child
|
|
195
|
+
|
|
196
|
+
base = Path(args.config).resolve().parent
|
|
197
|
+
try:
|
|
198
|
+
directory = safe_child(base, out) if not args.allow_outside else base / out
|
|
199
|
+
except ValueError as exc:
|
|
200
|
+
raise ConfigError(
|
|
201
|
+
f"output.directory {out!r} is outside the configuration directory; "
|
|
202
|
+
"use -o to choose a location explicitly"
|
|
203
|
+
) from exc
|
|
204
|
+
result.save(directory, formats=cfg.output.formats)
|
|
205
|
+
_print(f"\nsaved results to {directory}/")
|
|
206
|
+
return EXIT_OK if not result.errors else EXIT_ERROR
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _load_result(path: str) -> Any:
|
|
210
|
+
from simulsi.experiments.experiment import ExperimentResult
|
|
211
|
+
|
|
212
|
+
return ExperimentResult.load(path)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_analyze(args: argparse.Namespace) -> int:
|
|
216
|
+
from simulsi.analysis.comparison import compare
|
|
217
|
+
from simulsi.analysis.report import format_table
|
|
218
|
+
|
|
219
|
+
result = _load_result(args.results)
|
|
220
|
+
md = result.metadata
|
|
221
|
+
metrics = args.metric or None
|
|
222
|
+
if args.json:
|
|
223
|
+
payload: dict[str, Any] = {
|
|
224
|
+
"metadata": md.to_dict(),
|
|
225
|
+
"summary": result.summary(metrics, confidence=args.confidence),
|
|
226
|
+
}
|
|
227
|
+
if args.baseline in result.scenarios and len(result.scenarios) > 1:
|
|
228
|
+
payload["comparison"] = compare(
|
|
229
|
+
result, args.baseline, metrics=metrics, confidence=args.confidence
|
|
230
|
+
).to_dicts()
|
|
231
|
+
from simulsi.serialization.io import to_jsonable
|
|
232
|
+
|
|
233
|
+
_print(json.dumps(to_jsonable(payload), indent=2, allow_nan=False))
|
|
234
|
+
return EXIT_OK
|
|
235
|
+
_print(
|
|
236
|
+
f"{md.experiment_id} model={md.model_name} v{md.model_version} seed={md.seed} "
|
|
237
|
+
f"replications={md.replications} created={md.timestamp}"
|
|
238
|
+
)
|
|
239
|
+
if md.git and md.git.get("commit"):
|
|
240
|
+
_print(f"git {md.git['commit'][:12]}{' (dirty)' if md.git.get('dirty') else ''}")
|
|
241
|
+
_print("")
|
|
242
|
+
_print(result.format_summary(metrics, confidence=args.confidence))
|
|
243
|
+
if args.baseline in result.scenarios and len(result.scenarios) > 1:
|
|
244
|
+
_print("")
|
|
245
|
+
_print(compare(result, args.baseline, metrics=metrics, confidence=args.confidence).format())
|
|
246
|
+
if args.precision:
|
|
247
|
+
rows = []
|
|
248
|
+
for sc in result.scenarios:
|
|
249
|
+
for m in metrics or result.metric_names:
|
|
250
|
+
adv = result.replication_advice(
|
|
251
|
+
m, sc, relative_precision=args.precision, confidence=args.confidence
|
|
252
|
+
)
|
|
253
|
+
rows.append(
|
|
254
|
+
{
|
|
255
|
+
"scenario": sc,
|
|
256
|
+
"metric": m,
|
|
257
|
+
"n": adv.n,
|
|
258
|
+
"rel_half_width": adv.relative_half_width,
|
|
259
|
+
"required_n": adv.required_n if adv.required_n is not None else math.nan,
|
|
260
|
+
"enough": "yes" if adv.sufficient else "no",
|
|
261
|
+
"note": adv.note,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
_print("")
|
|
265
|
+
_print(f"Replication analysis (target relative half-width {args.precision:g}):")
|
|
266
|
+
_print(format_table(rows))
|
|
267
|
+
return EXIT_OK
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_benchmark(args: argparse.Namespace) -> int:
|
|
271
|
+
from simulsi.analysis.report import format_table
|
|
272
|
+
from simulsi.benchmarks import default_suite
|
|
273
|
+
from simulsi.experiments.provenance import environment
|
|
274
|
+
|
|
275
|
+
sizes = tuple(args.sizes) if args.sizes else (10_000, 100_000, 1_000_000)
|
|
276
|
+
results = default_suite(
|
|
277
|
+
sizes,
|
|
278
|
+
memory=not args.no_memory,
|
|
279
|
+
workers=tuple(args.workers),
|
|
280
|
+
replications=args.replications,
|
|
281
|
+
)
|
|
282
|
+
rows = [r.to_dict() for r in results]
|
|
283
|
+
if args.json:
|
|
284
|
+
from simulsi.serialization.io import to_jsonable
|
|
285
|
+
|
|
286
|
+
_print(json.dumps(to_jsonable({"environment": environment(), "results": rows}), indent=2))
|
|
287
|
+
return EXIT_OK
|
|
288
|
+
_print(
|
|
289
|
+
format_table(
|
|
290
|
+
rows, ["name", "size", "events", "seconds", "events_per_second", "peak_memory_mb"]
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
env = environment()
|
|
294
|
+
_print(f"\npython {env['python']} on {env['platform']} ({env['cpu_count']} CPUs)")
|
|
295
|
+
return EXIT_OK
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_visualize(args: argparse.Namespace) -> int:
|
|
299
|
+
from simulsi.visualization import plots
|
|
300
|
+
|
|
301
|
+
out = Path(args.out)
|
|
302
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
ext = "html" if args.backend == "plotly" else args.format
|
|
304
|
+
written: list[Path] = []
|
|
305
|
+
|
|
306
|
+
def save(fig: Any, name: str) -> None:
|
|
307
|
+
p = out / f"{name}.{ext}"
|
|
308
|
+
plots.save_figure(fig, str(p))
|
|
309
|
+
written.append(p)
|
|
310
|
+
|
|
311
|
+
if args.target.endswith(".py") or args.target.startswith("builtin:"):
|
|
312
|
+
from simulsi.config.schema import resolve_model
|
|
313
|
+
|
|
314
|
+
m = resolve_model(args.target)
|
|
315
|
+
res = m.simulate(_parse_params(args.param), seed=args.seed, trace=True, record_series=True)
|
|
316
|
+
save(plots.plot_queue_length(res, backend=args.backend), "queue_length")
|
|
317
|
+
save(plots.plot_utilization(res, backend=args.backend), "utilization")
|
|
318
|
+
if res.log is not None and len(res.log):
|
|
319
|
+
save(plots.plot_timeline(res.log, backend=args.backend), "timeline")
|
|
320
|
+
save(
|
|
321
|
+
plots.plot_entity_trajectories(
|
|
322
|
+
res.log, backend=args.backend, end_time=res.end_time
|
|
323
|
+
),
|
|
324
|
+
"trajectories",
|
|
325
|
+
)
|
|
326
|
+
else:
|
|
327
|
+
from simulsi.analysis.comparison import compare
|
|
328
|
+
|
|
329
|
+
result = _load_result(args.target)
|
|
330
|
+
metrics = args.metric or result.metric_names[:3]
|
|
331
|
+
for m in metrics:
|
|
332
|
+
safe = m.replace("/", "_")
|
|
333
|
+
for sc in result.scenarios:
|
|
334
|
+
save(
|
|
335
|
+
plots.plot_distribution(
|
|
336
|
+
result.values(m, sc), title=f"{m} - {sc}", label=m, backend=args.backend
|
|
337
|
+
),
|
|
338
|
+
f"dist_{safe}_{sc}",
|
|
339
|
+
)
|
|
340
|
+
save(
|
|
341
|
+
plots.plot_convergence(
|
|
342
|
+
result.values(m, result.scenarios[0]),
|
|
343
|
+
title=f"Convergence: {m}",
|
|
344
|
+
backend=args.backend,
|
|
345
|
+
),
|
|
346
|
+
f"convergence_{safe}",
|
|
347
|
+
)
|
|
348
|
+
if args.baseline in result.scenarios and len(result.scenarios) > 1:
|
|
349
|
+
cmp = compare(result, args.baseline, metrics=metrics)
|
|
350
|
+
save(plots.plot_comparison(cmp, relative=True, backend=args.backend), "comparison")
|
|
351
|
+
for p in written:
|
|
352
|
+
_print(f"wrote {p}")
|
|
353
|
+
return EXIT_OK
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cmd_ui(args: argparse.Namespace) -> int:
|
|
357
|
+
from simulsi.config.schema import resolve_model
|
|
358
|
+
from simulsi.web.server import serve
|
|
359
|
+
|
|
360
|
+
models = {ref: resolve_model(ref) for ref in args.model or []}
|
|
361
|
+
return serve(
|
|
362
|
+
host=args.host,
|
|
363
|
+
port=args.port,
|
|
364
|
+
results=[Path(p) for p in args.results],
|
|
365
|
+
models=models,
|
|
366
|
+
open_browser=not args.no_browser,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# -- parser ----------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
374
|
+
p = argparse.ArgumentParser(
|
|
375
|
+
prog="simulsi", description="SimulSI - model the system, simulate the future."
|
|
376
|
+
)
|
|
377
|
+
p.add_argument("--version", action="version", version=f"simulsi {__version__}")
|
|
378
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
379
|
+
|
|
380
|
+
s = sub.add_parser("init", help="create a starter project (model.py + experiment.yaml)")
|
|
381
|
+
s.add_argument("directory", nargs="?", default=".")
|
|
382
|
+
s.add_argument("--force", action="store_true", help="overwrite existing files")
|
|
383
|
+
s.set_defaults(func=cmd_init)
|
|
384
|
+
|
|
385
|
+
model_help = (
|
|
386
|
+
"model reference: file.py[:attr], package.module:attr, builtin:mmc, or a config .yaml"
|
|
387
|
+
)
|
|
388
|
+
allow = {
|
|
389
|
+
"action": "store_true",
|
|
390
|
+
"help": "allow config files to reference model files outside their directory",
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
s = sub.add_parser("validate", help="validate a configuration or model (schema + smoke run)")
|
|
394
|
+
s.add_argument("target", help=model_help)
|
|
395
|
+
s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
|
|
396
|
+
s.add_argument(
|
|
397
|
+
"--smoke", type=float, default=None, help="smoke-run length (default: 10%% of duration)"
|
|
398
|
+
)
|
|
399
|
+
s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
|
|
400
|
+
s.set_defaults(func=cmd_validate)
|
|
401
|
+
|
|
402
|
+
s = sub.add_parser("run", help="run one replication and print its metrics")
|
|
403
|
+
s.add_argument("target", help=model_help)
|
|
404
|
+
s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
|
|
405
|
+
s.add_argument("--seed", type=int, default=None)
|
|
406
|
+
s.add_argument("--duration", type=float, default=None)
|
|
407
|
+
s.add_argument("--metric", "-m", action="append", help="only show these metrics")
|
|
408
|
+
s.add_argument("--trace", metavar="FILE", help="write the event log (.json/.csv/.parquet)")
|
|
409
|
+
s.add_argument("--json", action="store_true", help="print the full result as JSON")
|
|
410
|
+
s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
|
|
411
|
+
s.set_defaults(func=cmd_run)
|
|
412
|
+
|
|
413
|
+
s = sub.add_parser("experiment", help="run an experiment from a YAML configuration")
|
|
414
|
+
s.add_argument("config")
|
|
415
|
+
s.add_argument("--replications", "-r", type=int)
|
|
416
|
+
s.add_argument("--workers", "-w", type=int)
|
|
417
|
+
s.add_argument("--seed", type=int)
|
|
418
|
+
s.add_argument("--output", "-o", help="results directory (overrides output.directory)")
|
|
419
|
+
s.add_argument("--checkpoint", help="JSON-lines checkpoint file for resumable runs")
|
|
420
|
+
s.add_argument("--quiet", "-q", action="store_true")
|
|
421
|
+
s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
|
|
422
|
+
s.set_defaults(func=cmd_experiment)
|
|
423
|
+
|
|
424
|
+
s = sub.add_parser("analyze", help="summarise and compare saved experiment results")
|
|
425
|
+
s.add_argument("results", help="results directory or experiment.json")
|
|
426
|
+
s.add_argument("--metric", "-m", action="append")
|
|
427
|
+
s.add_argument("--baseline", default="baseline")
|
|
428
|
+
s.add_argument("--confidence", type=float, default=0.95)
|
|
429
|
+
s.add_argument(
|
|
430
|
+
"--precision",
|
|
431
|
+
type=float,
|
|
432
|
+
help="relative precision target for replication advice, e.g. 0.05",
|
|
433
|
+
)
|
|
434
|
+
s.add_argument("--json", action="store_true")
|
|
435
|
+
s.set_defaults(func=cmd_analyze)
|
|
436
|
+
|
|
437
|
+
s = sub.add_parser("benchmark", help="measure engine and experiment throughput on this machine")
|
|
438
|
+
s.add_argument("--sizes", type=int, nargs="+", help="event counts (default 10k 100k 1M)")
|
|
439
|
+
s.add_argument("--workers", type=int, nargs="+", default=[1, 4])
|
|
440
|
+
s.add_argument("--replications", type=int, default=16)
|
|
441
|
+
s.add_argument("--no-memory", action="store_true", help="skip tracemalloc runs")
|
|
442
|
+
s.add_argument("--json", action="store_true")
|
|
443
|
+
s.set_defaults(func=cmd_benchmark)
|
|
444
|
+
|
|
445
|
+
s = sub.add_parser("visualize", help="plot saved results, or run a model once and plot it")
|
|
446
|
+
s.add_argument("target", help="results directory/experiment.json, or a model reference")
|
|
447
|
+
s.add_argument("--out", "-o", default="plots")
|
|
448
|
+
s.add_argument("--metric", "-m", action="append")
|
|
449
|
+
s.add_argument("--baseline", default="baseline")
|
|
450
|
+
s.add_argument("--backend", choices=["matplotlib", "plotly"], default="matplotlib")
|
|
451
|
+
s.add_argument("--format", choices=["png", "svg", "pdf"], default="png")
|
|
452
|
+
s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
|
|
453
|
+
s.add_argument("--seed", type=int, default=0)
|
|
454
|
+
s.set_defaults(func=cmd_visualize)
|
|
455
|
+
|
|
456
|
+
s = sub.add_parser("ui", help="start the local web dashboard (127.0.0.1 only by default)")
|
|
457
|
+
s.add_argument("results", nargs="*", help="saved experiment results to preload")
|
|
458
|
+
s.add_argument(
|
|
459
|
+
"--model", action="append", metavar="REF", help="extra model the dashboard may run"
|
|
460
|
+
)
|
|
461
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
462
|
+
s.add_argument("--port", type=int, default=8642)
|
|
463
|
+
s.add_argument("--no-browser", action="store_true")
|
|
464
|
+
s.set_defaults(func=cmd_ui)
|
|
465
|
+
return p
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
469
|
+
parser = build_parser()
|
|
470
|
+
args = parser.parse_args(argv)
|
|
471
|
+
try:
|
|
472
|
+
code: int = args.func(args)
|
|
473
|
+
except (ConfigError, SimulsiError, FileNotFoundError, KeyError) as exc:
|
|
474
|
+
_err(str(exc) if not isinstance(exc, KeyError) else f"not found: {exc}")
|
|
475
|
+
return EXIT_INVALID
|
|
476
|
+
except ImportError as exc:
|
|
477
|
+
_err(str(exc))
|
|
478
|
+
return EXIT_ERROR
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
_err("interrupted")
|
|
481
|
+
return 130
|
|
482
|
+
return code
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
if __name__ == "__main__": # pragma: no cover
|
|
486
|
+
sys.exit(main())
|