dynamic-systems-testing-framework 1.0.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.
- dstf/__init__.py +41 -0
- dstf/__main__.py +10 -0
- dstf/authoring/__init__.py +13 -0
- dstf/authoring/builtin/__init__.py +32 -0
- dstf/authoring/builtin/accept.py +205 -0
- dstf/authoring/builtin/edit_criteria.py +479 -0
- dstf/authoring/builtin/edit_family.py +228 -0
- dstf/authoring/builtin/edit_on_plot.py +1354 -0
- dstf/authoring/builtin/edit_tree.py +1399 -0
- dstf/authoring/builtin/enqueue.py +144 -0
- dstf/authoring/capabilities.py +463 -0
- dstf/authoring/errors.py +49 -0
- dstf/authoring/family_rows.py +526 -0
- dstf/authoring/forms.py +631 -0
- dstf/authoring/layout.py +104 -0
- dstf/authoring/live_rows.py +337 -0
- dstf/authoring/live_runs.py +385 -0
- dstf/authoring/needle.py +127 -0
- dstf/authoring/picker_rows.py +92 -0
- dstf/authoring/queue_rows.py +354 -0
- dstf/authoring/session.py +3016 -0
- dstf/authoring/shell/__init__.py +9 -0
- dstf/authoring/shell/actions.py +702 -0
- dstf/authoring/shell/app.py +53 -0
- dstf/authoring/shell/drawer_controls.py +190 -0
- dstf/authoring/shell/family_fold.py +468 -0
- dstf/authoring/shell/figure_hook.py +249 -0
- dstf/authoring/shell/grips.js +1000 -0
- dstf/authoring/shell/header.py +783 -0
- dstf/authoring/shell/live_view.py +93 -0
- dstf/authoring/shell/picker.py +267 -0
- dstf/authoring/shell/placing.py +122 -0
- dstf/authoring/shell/plot_card.py +1171 -0
- dstf/authoring/shell/queue_panel.py +433 -0
- dstf/authoring/shell/rail.py +593 -0
- dstf/authoring/shell/shell.py +528 -0
- dstf/authoring/shell/stats_card.py +501 -0
- dstf/authoring/shell/strip.py +204 -0
- dstf/authoring/shell/style.py +721 -0
- dstf/authoring/shell/suite.py +277 -0
- dstf/authoring/shell/suite_grid.js +414 -0
- dstf/authoring/shell/suite_grid.py +373 -0
- dstf/authoring/shell/tab_theme.py +110 -0
- dstf/authoring/shell/tabstate.py +146 -0
- dstf/authoring/shell/test_list.py +179 -0
- dstf/authoring/shell/test_page.py +1404 -0
- dstf/authoring/shell/tree_card.py +1171 -0
- dstf/authoring/shell/variables_drawer.py +589 -0
- dstf/authoring/shell/widgets.py +188 -0
- dstf/authoring/situation.py +287 -0
- dstf/authoring/spec_document.py +215 -0
- dstf/authoring/spec_preview.py +83 -0
- dstf/authoring/suite_rows.py +99 -0
- dstf/authoring/tree_move.py +516 -0
- dstf/authoring/tree_pointer.py +33 -0
- dstf/authoring/tree_text.py +61 -0
- dstf/authoring/typed_number.js +64 -0
- dstf/authoring/typed_number.py +76 -0
- dstf/authoring/unscorable.py +141 -0
- dstf/authoring/variable_rows.py +216 -0
- dstf/backends/__init__.py +283 -0
- dstf/backends/base.py +1117 -0
- dstf/backends/common/__init__.py +1 -0
- dstf/backends/common/json_driver.py +484 -0
- dstf/backends/common/live_mat.py +369 -0
- dstf/backends/common/mat_reader.py +632 -0
- dstf/backends/common/proc_kill.py +238 -0
- dstf/backends/common/proc_output.py +20 -0
- dstf/backends/common/result_files.py +109 -0
- dstf/backends/common/result_variables.py +329 -0
- dstf/backends/common/statistics.py +39 -0
- dstf/backends/cross_backend.py +193 -0
- dstf/backends/dymola/__init__.py +5 -0
- dstf/backends/dymola/backend.py +776 -0
- dstf/backends/dymola/interface_loader.py +241 -0
- dstf/backends/dymola/log_parser.py +396 -0
- dstf/backends/dymola/persistent_backend.py +949 -0
- dstf/backends/fmpy/__init__.py +11 -0
- dstf/backends/fmpy/backend.py +443 -0
- dstf/backends/julia/__init__.py +27 -0
- dstf/backends/julia/backend.py +156 -0
- dstf/backends/julia/persistent_backend.py +541 -0
- dstf/backends/julia/run_persistent.jl +167 -0
- dstf/backends/julia/run_test.jl +146 -0
- dstf/backends/openmodelica/__init__.py +21 -0
- dstf/backends/openmodelica/backend.py +368 -0
- dstf/backends/openmodelica/init_xml.py +76 -0
- dstf/backends/openmodelica/log_parser.py +119 -0
- dstf/backends/openmodelica/mos_generator.py +228 -0
- dstf/backends/openmodelica/persistent_backend.py +1045 -0
- dstf/backends/openmodelica/session_loader.py +63 -0
- dstf/backends/persistent.py +1811 -0
- dstf/backends/pool.py +602 -0
- dstf/backends/progress.py +395 -0
- dstf/backends/python/__init__.py +11 -0
- dstf/backends/python/backend.py +141 -0
- dstf/backends/python/run_test.py +141 -0
- dstf/backends/run_metadata.py +103 -0
- dstf/backends/types.py +310 -0
- dstf/cli/__init__.py +12 -0
- dstf/cli/author_cmd.py +61 -0
- dstf/cli/baseline_cmds.py +341 -0
- dstf/cli/common.py +231 -0
- dstf/cli/diag_cmds.py +350 -0
- dstf/cli/main.py +561 -0
- dstf/cli/review.py +398 -0
- dstf/cli/run_cmds.py +589 -0
- dstf/cli/spec_cmds.py +123 -0
- dstf/comparison/__init__.py +13 -0
- dstf/comparison/algorithms.py +2127 -0
- dstf/comparison/builtins.py +291 -0
- dstf/comparison/comparator.py +1189 -0
- dstf/comparison/geometry.py +164 -0
- dstf/comparison/handles.py +1194 -0
- dstf/comparison/metric_tree.py +332 -0
- dstf/comparison/mode_schema.py +176 -0
- dstf/comparison/modes.py +788 -0
- dstf/comparison/registry.py +230 -0
- dstf/comparison/seeds.py +281 -0
- dstf/comparison/sides.py +158 -0
- dstf/comparison/snap.py +62 -0
- dstf/comparison/strict_keys.py +246 -0
- dstf/comparison/summaries.py +354 -0
- dstf/comparison/tree_eval.py +868 -0
- dstf/comparison/tree_spec.py +678 -0
- dstf/comparison/types.py +194 -0
- dstf/comparison/validator.py +232 -0
- dstf/config.py +815 -0
- dstf/discovery/__init__.py +0 -0
- dstf/discovery/json_recognizer.py +702 -0
- dstf/discovery/library_state.py +248 -0
- dstf/discovery/mo_parser.py +794 -0
- dstf/discovery/patch_apply.py +308 -0
- dstf/discovery/patch_envelope.py +63 -0
- dstf/discovery/plots.py +318 -0
- dstf/discovery/recognizer.py +103 -0
- dstf/discovery/scan_cache.py +154 -0
- dstf/discovery/spec_parser.py +538 -0
- dstf/discovery/spec_shape.py +122 -0
- dstf/discovery/spec_update.py +493 -0
- dstf/discovery/test_registry.py +488 -0
- dstf/discovery/unit_tests.py +95 -0
- dstf/figures/__init__.py +14 -0
- dstf/figures/assemble.py +48 -0
- dstf/figures/decimate.py +150 -0
- dstf/figures/families.py +289 -0
- dstf/figures/palette.py +170 -0
- dstf/figures/plotly.py +1413 -0
- dstf/figures/view.py +233 -0
- dstf/pipeline.py +435 -0
- dstf/plugins.py +104 -0
- dstf/reporting/__init__.py +0 -0
- dstf/reporting/console_report.py +187 -0
- dstf/reporting/context_builder.py +979 -0
- dstf/reporting/dashboard_render.py +534 -0
- dstf/reporting/junit_report.py +192 -0
- dstf/reporting/overlay_loader.py +708 -0
- dstf/reporting/report_suite.py +1059 -0
- dstf/reporting/rows.py +458 -0
- dstf/reporting/schema_export.py +413 -0
- dstf/reporting/stats_rows.py +321 -0
- dstf/reporting/status.py +388 -0
- dstf/reporting/templates/axes.js +61 -0
- dstf/reporting/templates/dashboard.html +284 -0
- dstf/reporting/templates/dashboard.js +604 -0
- dstf/reporting/templates/filters.js +100 -0
- dstf/reporting/templates/interactive.html +420 -0
- dstf/reporting/templates/report.js +116 -0
- dstf/reporting/templates/vendor/plotly.min.js +8 -0
- dstf/reporting/theme.py +324 -0
- dstf/reporting/tree_rows.py +73 -0
- dstf/reporting/verdicts.py +124 -0
- dstf/storage/__init__.py +0 -0
- dstf/storage/accept.py +216 -0
- dstf/storage/baseline.py +43 -0
- dstf/storage/batch_manifest.py +204 -0
- dstf/storage/ref_index.py +295 -0
- dstf/storage/reference_store.py +661 -0
- dstf/storage/roles.py +576 -0
- dstf/storage/run_stats.py +85 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
- dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
dstf/figures/decimate.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Largest-Triangle-Three-Buckets (LTTB) decimation for figure rendering.
|
|
2
|
+
|
|
3
|
+
Used on every figure embed — the report page and the authoring tool share
|
|
4
|
+
the budget — while the full-resolution arrays stay on disk in
|
|
5
|
+
``comparison_data.json``. Decimation affects what the browser draws, not
|
|
6
|
+
pass/fail scoring, stored baselines, or downstream tooling.
|
|
7
|
+
|
|
8
|
+
LTTB preserves visual peaks/valleys far better than uniform striding
|
|
9
|
+
by selecting the point in each bucket that forms the largest triangle
|
|
10
|
+
with the previously kept point and the next bucket's centroid.
|
|
11
|
+
|
|
12
|
+
Reference: Sveinn Steinarsson, "Downsampling Time Series for Visual
|
|
13
|
+
Representation" (MSc thesis, 2013).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numpy.typing import NDArray
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def bucket_boundaries(
|
|
23
|
+
n_in: int, n_out: int
|
|
24
|
+
) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
|
|
25
|
+
"""Every LTTB bucket boundary at once, for ``3 <= n_out < n_in``.
|
|
26
|
+
|
|
27
|
+
Bucket ``i`` is ``[starts[i], ends[i])`` and the bucket after it — whose
|
|
28
|
+
centroid the triangle uses — is ``[ends[i], next_ends[i])``. Same floor
|
|
29
|
+
arithmetic the pre-hoist loop did, one array at a time.
|
|
30
|
+
|
|
31
|
+
``lttb``'s guard leaves ``3 <= n_out < n_in``, so
|
|
32
|
+
``bucket_size = (n_in - 2) / (n_out - 2)`` is strictly above 1 and
|
|
33
|
+
consecutive entries of ``b`` differ by at least one. Hence no bucket is
|
|
34
|
+
empty (``starts[i] < ends[i]``) and every next-bucket holds at least one
|
|
35
|
+
sample (``next_ends[i] - ends[i] >= 1``) — which is why ``lttb`` carries
|
|
36
|
+
no empty-bucket branch and no ``maximum(counts, 1)``.
|
|
37
|
+
|
|
38
|
+
``ends`` needs no clamp: its largest entry is ``b[n_out - 2]``, and
|
|
39
|
+
``(n_out - 2) * bucket_size`` is ``n_in - 2`` in exact arithmetic, so the
|
|
40
|
+
entry is ``n_in - 1`` when the float product rounds up to or past
|
|
41
|
+
``n_in - 2`` and ``n_in - 2`` when it rounds below — never above
|
|
42
|
+
``n_in - 1``. (Measured over every pair with ``4 <= n_in <= 1200``:
|
|
43
|
+
680,649 at ``n_in - 1``, 36,354 at ``n_in - 2``, none above.)
|
|
44
|
+
|
|
45
|
+
``next_ends`` is the one boundary that does overshoot, and its clamp is
|
|
46
|
+
load-bearing: ``b[n_out - 1] >= n_in`` whenever ``bucket_size > 1``, and
|
|
47
|
+
clamping it to exactly ``n_in`` is what makes ``reduceat``'s last segment
|
|
48
|
+
— which runs to the end of the array — agree with the last next-bucket.
|
|
49
|
+
|
|
50
|
+
Lives here, called by both ``lttb`` and the test that sweeps these
|
|
51
|
+
claims, so the guard reads production's arithmetic rather than a copy.
|
|
52
|
+
"""
|
|
53
|
+
bucket_size = (n_in - 2) / (n_out - 2)
|
|
54
|
+
b = np.floor(np.arange(n_out, dtype=np.float64) * bucket_size).astype(np.int64) + 1
|
|
55
|
+
return b[: n_out - 2], b[1 : n_out - 1], np.minimum(b[2:n_out], n_in)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def lttb(
|
|
59
|
+
time: NDArray[np.float64],
|
|
60
|
+
values: NDArray[np.float64],
|
|
61
|
+
n_out: int,
|
|
62
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
63
|
+
"""Decimate ``(time, values)`` to at most ``n_out`` points via LTTB.
|
|
64
|
+
|
|
65
|
+
Returns the original arrays unchanged when ``len(time) <= n_out`` or
|
|
66
|
+
when ``n_out < 3`` (the algorithm requires at least first, last, and
|
|
67
|
+
one interior bucket). Empty inputs return empty outputs.
|
|
68
|
+
|
|
69
|
+
Bucket means come from ``np.add.reduceat``, which sums a segment in a
|
|
70
|
+
different order than ``np.mean``; the two differ by round-off. That
|
|
71
|
+
changes a pick only where candidate areas tie up to round-off: collinear
|
|
72
|
+
runs (every area is round-off, the other sample lies on the same line)
|
|
73
|
+
and equal-area candidates on stepped or boolean signals over a uniform
|
|
74
|
+
grid (the tie breaks to the other plateau). Either pick is a valid LTTB
|
|
75
|
+
choice of equal area; the difference is in the drawing, not the data.
|
|
76
|
+
"""
|
|
77
|
+
n_in = len(time)
|
|
78
|
+
if n_in == 0:
|
|
79
|
+
return np.asarray([], dtype=np.float64), np.asarray([], dtype=np.float64)
|
|
80
|
+
if n_in != len(values):
|
|
81
|
+
raise ValueError(f"time and values length mismatch: {n_in} vs {len(values)}")
|
|
82
|
+
if n_out >= n_in or n_out < 3:
|
|
83
|
+
return np.asarray(time, dtype=np.float64), np.asarray(values, dtype=np.float64)
|
|
84
|
+
|
|
85
|
+
t = np.asarray(time, dtype=np.float64)
|
|
86
|
+
v = np.asarray(values, dtype=np.float64)
|
|
87
|
+
|
|
88
|
+
out_idx = np.empty(n_out, dtype=np.int64)
|
|
89
|
+
out_idx[0] = 0
|
|
90
|
+
out_idx[-1] = n_in - 1
|
|
91
|
+
|
|
92
|
+
starts, ends, next_ends = bucket_boundaries(n_in, n_out)
|
|
93
|
+
# Next-bucket means, one reduceat each. reduceat's last segment runs to
|
|
94
|
+
# the end of the array, which is where the last next-bucket ends
|
|
95
|
+
# (next_ends[-1] == n_in, since b[n_out - 1] >= n_in), so the two agree.
|
|
96
|
+
counts = next_ends - ends
|
|
97
|
+
avg_t = np.add.reduceat(t, ends) / counts
|
|
98
|
+
avg_v = np.add.reduceat(v, ends) / counts
|
|
99
|
+
|
|
100
|
+
a = 0
|
|
101
|
+
for i in range(n_out - 2):
|
|
102
|
+
bucket_start = int(starts[i])
|
|
103
|
+
bucket_end = int(ends[i])
|
|
104
|
+
ta, va = t[a], v[a]
|
|
105
|
+
seg_t = t[bucket_start:bucket_end]
|
|
106
|
+
seg_v = v[bucket_start:bucket_end]
|
|
107
|
+
area = np.abs((ta - avg_t[i]) * (seg_v - va) - (ta - seg_t) * (avg_v[i] - va))
|
|
108
|
+
chosen = bucket_start + int(np.argmax(area))
|
|
109
|
+
out_idx[i + 1] = chosen
|
|
110
|
+
a = chosen
|
|
111
|
+
|
|
112
|
+
return t[out_idx], v[out_idx]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def decimate_pair(
|
|
116
|
+
time: list | NDArray,
|
|
117
|
+
values: list | NDArray,
|
|
118
|
+
n_out: int,
|
|
119
|
+
) -> tuple[list, list]:
|
|
120
|
+
"""LTTB that accepts/returns plain Python lists (template-context friendly)."""
|
|
121
|
+
if time is None or values is None:
|
|
122
|
+
return time, values
|
|
123
|
+
if not len(time) or not len(values):
|
|
124
|
+
return list(time), list(values)
|
|
125
|
+
t_out, v_out = lttb(
|
|
126
|
+
np.asarray(time, dtype=np.float64), np.asarray(values, dtype=np.float64), n_out
|
|
127
|
+
)
|
|
128
|
+
return t_out.tolist(), v_out.tolist()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def decimate_band(
|
|
132
|
+
time: NDArray[np.float64],
|
|
133
|
+
lower: NDArray[np.float64],
|
|
134
|
+
upper: NDArray[np.float64],
|
|
135
|
+
n_out: int,
|
|
136
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
137
|
+
"""Decimate an envelope on ONE shared time axis.
|
|
138
|
+
|
|
139
|
+
LTTB picks the kept samples from ``upper``; ``lower`` is then read at
|
|
140
|
+
exactly those times. The kept times are grid nodes, so ``np.interp`` at
|
|
141
|
+
them returns the node values — no approximation, and the two edges can
|
|
142
|
+
never drift onto different x arrays.
|
|
143
|
+
"""
|
|
144
|
+
t2, u2 = lttb(
|
|
145
|
+
np.asarray(time, dtype=np.float64), np.asarray(upper, dtype=np.float64), n_out
|
|
146
|
+
)
|
|
147
|
+
l2 = np.interp(
|
|
148
|
+
t2, np.asarray(time, dtype=np.float64), np.asarray(lower, dtype=np.float64)
|
|
149
|
+
)
|
|
150
|
+
return t2, l2, u2
|
dstf/figures/families.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Array families (DESIGN.md "Array families on a plot"; brief §3.9).
|
|
2
|
+
|
|
3
|
+
Indexed names fold by their LAST integer index: ``pipe.T[3]`` and
|
|
4
|
+
``pipe.medium[3].T`` each belong to a family; ``x[2,3]`` does not.
|
|
5
|
+
``massPorts[4, 1].C[3]`` does, by its last index. One function, so the
|
|
6
|
+
figure's legend, the Variables drawer's folded row and the folded criteria
|
|
7
|
+
row agree on what a family is.
|
|
8
|
+
|
|
9
|
+
A family is a DISPLAY fold, so it folds over what a person reads — the
|
|
10
|
+
label, which for a UnitTests variable is the expression the model wrote.
|
|
11
|
+
Callers pass a ``{name: label}`` mapping; the members stay names, because
|
|
12
|
+
that is what everything else is keyed by.
|
|
13
|
+
|
|
14
|
+
Two rules, and the caller says which by what it passes. A RESULT-FILE NAME
|
|
15
|
+
(the label a variable has when it has no expression) folds by its last
|
|
16
|
+
single-integer index and nothing else — result files spell names in ways no
|
|
17
|
+
whitelist anticipates (``θ[1]``, ``pipe₊T[1]`` from Julia/MTK, ``T-wall[1]``,
|
|
18
|
+
``$DER.x[1]``), and every one of them was a family before expressions
|
|
19
|
+
existed. An EXPRESSION the model wrote is held to the strict shape below:
|
|
20
|
+
it is text a person typed, and ``sum(pipe.Qs[1])`` folded with
|
|
21
|
+
``sum(pipe.Qs[2])`` gives two quantities one legend entry, one colour ramp
|
|
22
|
+
and a label naming a third thing neither of them is."""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
_LAST_INDEX = re.compile(r"\[(\d+)\](?!.*\[\d+\])")
|
|
30
|
+
#: A multi-dimensional index (``x[2,3]``) is not one array position.
|
|
31
|
+
_MULTI_INDEX = re.compile(r"\[[^\]]*,[^\]]*\]")
|
|
32
|
+
#: A Modelica variable name as a person TYPES one: identifiers (letters in
|
|
33
|
+
#: any script, so ``θ[1]`` is one), dots and index brackets. A quoted
|
|
34
|
+
#: identifier (``a.'b c'[2]``) is collapsed to one letter first, so its
|
|
35
|
+
#: spaces and punctuation do not have to be allowed everywhere.
|
|
36
|
+
_PLAIN_NAME = re.compile(r"^[^\W\d][\w.\[\]]*$")
|
|
37
|
+
#: Modelica's Q-IDENT: a backslash escapes the next character, so
|
|
38
|
+
#: ``'it\'s'`` is one identifier, not ``'it\'`` and a stray ``s'``.
|
|
39
|
+
_QUOTED_ID = re.compile(r"'(?:[^'\\]|\\.)*'")
|
|
40
|
+
#: An index of integers only, one or several (`[3]`, `[4, 1]`): a position
|
|
41
|
+
#: in an array, however many dimensions. Collapsed before `_PLAIN_NAME`, so
|
|
42
|
+
#: an expression that indexes a port two-dimensionally is still a plain name
|
|
43
|
+
#: (`massPorts[4, 1].C[1]`). A slice (`[6, :]`) is not one: it stays and
|
|
44
|
+
#: refuses the name. No space just inside the brackets, as `_LAST_INDEX`
|
|
45
|
+
#: allows none: otherwise `x[1].C[ 2 ]` folded on its FIRST index.
|
|
46
|
+
_INT_INDEX = re.compile(r"\[\d+(?:\s*,\s*\d+)*\]")
|
|
47
|
+
#: ``der(...)``, and only that: a derivative IS an element of the array it
|
|
48
|
+
#: differentiates. Any OTHER call is a reduction over the array — ``sum(...)``
|
|
49
|
+
#: of two different slices is two quantities, not two elements of one.
|
|
50
|
+
#:
|
|
51
|
+
#: Indexed AFTER the call too (``der(x)[1]``): that is a plain indexed name
|
|
52
|
+
#: of the derivative of an array, so ``der(x)[1]`` and ``der(x)[2]`` are two
|
|
53
|
+
#: elements of one array and fold. ``der(x[1], 2)`` does NOT — a second
|
|
54
|
+
#: derivative is a call of two arguments, and the index inside it belongs to
|
|
55
|
+
#: the argument, not to what the call returns; it stays a family of one.
|
|
56
|
+
_DER_CALL = re.compile(r"^der\((.*)\)(?:\[\d+\])*$")
|
|
57
|
+
|
|
58
|
+
#: Four stops per hue, light to dark (Material 200 / 400 / 700 / 900). The
|
|
59
|
+
#: first is DESIGN.md's accent ramp; a second family on a plot takes the
|
|
60
|
+
#: next hue, and the list cycles. These are the default theme's: a figure
|
|
61
|
+
#: reads its theme's ramps (`palette.py`), and a dark theme's lighten the
|
|
62
|
+
#: four stops that sink into a dark plot.
|
|
63
|
+
HUES: tuple[tuple[str, str, str, str], ...] = (
|
|
64
|
+
("#90caf9", "#42a5f5", "#1976d2", "#0d47a1"),
|
|
65
|
+
("#a5d6a7", "#66bb6a", "#388e3c", "#1b5e20"),
|
|
66
|
+
("#ce93d8", "#ab47bc", "#7b1fa2", "#4a148c"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mask_quoted(text: str) -> str:
|
|
71
|
+
"""``text`` with every character inside a quoted identifier replaced by
|
|
72
|
+
a letter, quotes kept, and the SAME length — so a position found in the
|
|
73
|
+
mask slices ``text``. A quoted identifier is one name: the ``[5]`` of
|
|
74
|
+
``a.'x[5]'`` is no index and the dot of ``'a.b'.c`` no separator, and
|
|
75
|
+
a search that reads the mask cannot mistake them for either."""
|
|
76
|
+
return _QUOTED_ID.sub(lambda m: "'" + "q" * (len(m[0]) - 2) + "'", text)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _names_an_element(text: str) -> bool:
|
|
80
|
+
"""Does ``text`` spell a variable, rather than compute one?
|
|
81
|
+
|
|
82
|
+
A plain name with any number of ``der(...)`` wrappers and leading
|
|
83
|
+
minus signs around it, in any order — ``-der(x[2])`` and
|
|
84
|
+
``der(-x[2])`` are the same quantity and must answer the same. A
|
|
85
|
+
``der(...)`` may itself be indexed (``der(x)[1]``, the derivative of an
|
|
86
|
+
array taken element by element). Everything else —
|
|
87
|
+
``sum(core.Qs[6, :])``, ``cat(1, a, b)[2]``, ``abs(x[1])``,
|
|
88
|
+
``der(x[1], 2)`` — is an expression: its value is not an element of the
|
|
89
|
+
array whose index it happens to end with.
|
|
90
|
+
"""
|
|
91
|
+
while True:
|
|
92
|
+
if text.startswith("-"):
|
|
93
|
+
text = text[1:]
|
|
94
|
+
continue
|
|
95
|
+
inner = _DER_CALL.match(text)
|
|
96
|
+
if inner is None:
|
|
97
|
+
break
|
|
98
|
+
text = inner.group(1)
|
|
99
|
+
return bool(_PLAIN_NAME.match(_INT_INDEX.sub("[0]", _QUOTED_ID.sub("q", text))))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def family_of(text: str, *, expression: bool = False) -> tuple[str, int] | None:
|
|
103
|
+
"""``(family key, index)`` — the text with its last ``[integer]`` emptied
|
|
104
|
+
— or ``None`` when it names no element of an array.
|
|
105
|
+
|
|
106
|
+
``expression=False`` (the default) reads ``text`` as a RESULT-FILE
|
|
107
|
+
NAME: anything with a single-integer last index is a family, and a
|
|
108
|
+
multi-dimensional index (``x[2,3]``) is not. ``massPorts[4, 1].C[3]``
|
|
109
|
+
is, by its last index: a multi-dimensional index refuses a family only
|
|
110
|
+
AFTER the last single one. This is the only rule a name ever meets — a
|
|
111
|
+
result file spells names the caller does not get to whitelist.
|
|
112
|
+
|
|
113
|
+
``expression=True`` reads it as a label a person typed, and only a
|
|
114
|
+
plain indexed name qualifies (``pipe.T[3]``, ``der(pipe.T[3])``,
|
|
115
|
+
``der(pipe.T)[3]``, ``pipe.medium[3].T``, ``a.'b c'[2]``, ``-x[1]``).
|
|
116
|
+
``cat(1, a, b)[2]``, ``sum(core.Qs[6, :])``, ``sum(pipe.Qs[1])`` and
|
|
117
|
+
``der(x[1], 2)`` are computations, not elements of an array — the
|
|
118
|
+
second derivative's index belongs to the ARGUMENT of a two-argument
|
|
119
|
+
call, and nothing here can say the call returns an element of it.
|
|
120
|
+
"""
|
|
121
|
+
if expression and not _names_an_element(text):
|
|
122
|
+
return None
|
|
123
|
+
masked = mask_quoted(text) # `a.'x[5]'` has no index; the key is `text`'s
|
|
124
|
+
m = _LAST_INDEX.search(masked)
|
|
125
|
+
if m is None:
|
|
126
|
+
return None
|
|
127
|
+
# Multi-dimensional AFTER the last single index (`a[1].b[2,3]`): the
|
|
128
|
+
# element's own position is two-dimensional, so it is no member of an
|
|
129
|
+
# `a` array. BEFORE it (`massPorts[4, 1].C[3]`) it is where the array
|
|
130
|
+
# sits, and the element is C's 3rd (3c ruling).
|
|
131
|
+
if _MULTI_INDEX.search(masked, m.end()):
|
|
132
|
+
return None
|
|
133
|
+
return text[: m.start()] + "[]" + text[m.end() :], int(m.group(1))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _labelled(name: str, labels: Mapping[str, str] | None) -> str:
|
|
137
|
+
return (labels or {}).get(name, name)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _family_of_member(name: str, labels: Mapping[str, str] | None):
|
|
141
|
+
"""The family of what ``name`` READS as, under the rule its text earns:
|
|
142
|
+
a text that IS the name is a result-file name, anything else is a label
|
|
143
|
+
the model or the page composed."""
|
|
144
|
+
text = _labelled(name, labels)
|
|
145
|
+
return family_of(text, expression=text != name)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def group_families(
|
|
149
|
+
names: Sequence[str],
|
|
150
|
+
labels: Mapping[str, str] | None = None,
|
|
151
|
+
) -> list[tuple[str, list[str]]]:
|
|
152
|
+
"""``(key, members)`` in order of first appearance; members by index.
|
|
153
|
+
|
|
154
|
+
The fold is over each name's LABEL (``labels`` maps name → label;
|
|
155
|
+
without it a name is its own label), and the members are the NAMES. A
|
|
156
|
+
label with no family leaves its name its own group under its own name —
|
|
157
|
+
never under the label, or two variables sharing one expression would
|
|
158
|
+
become one group.
|
|
159
|
+
"""
|
|
160
|
+
groups: dict[str, list[tuple[int, str]]] = {}
|
|
161
|
+
for name in names:
|
|
162
|
+
fam = _family_of_member(name, labels)
|
|
163
|
+
key, index = fam if fam is not None else (name, 0)
|
|
164
|
+
groups.setdefault(key, []).append((index, name))
|
|
165
|
+
return [(key, [n for _, n in sorted(members)]) for key, members in groups.items()]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def slice_text(indices: Sequence[int]) -> str:
|
|
169
|
+
"""Modelica slice syntax, the end INCLUDED: an arithmetic run reads
|
|
170
|
+
``1:4`` or ``1:5:16``; anything else is a comma list in which each
|
|
171
|
+
unbroken run of three or more reads as a range (``1:6,8:20`` — a family
|
|
172
|
+
with a gap) and every other index singly (``1,2,4``, ``1,3,5:8``).
|
|
173
|
+
:func:`parse_slice` reads it back."""
|
|
174
|
+
idx = sorted(indices)
|
|
175
|
+
if len(idx) == 1:
|
|
176
|
+
return str(idx[0])
|
|
177
|
+
step = idx[1] - idx[0]
|
|
178
|
+
if step > 0 and all(b - a == step for a, b in zip(idx, idx[1:])):
|
|
179
|
+
return f"{idx[0]}:{idx[-1]}" if step == 1 else f"{idx[0]}:{step}:{idx[-1]}"
|
|
180
|
+
parts: list[str] = []
|
|
181
|
+
i = 0
|
|
182
|
+
while i < len(idx):
|
|
183
|
+
j = i
|
|
184
|
+
while j + 1 < len(idx) and idx[j + 1] - idx[j] == 1:
|
|
185
|
+
j += 1
|
|
186
|
+
if j - i >= 2:
|
|
187
|
+
parts.append(f"{idx[i]}:{idx[j]}")
|
|
188
|
+
i = j + 1
|
|
189
|
+
else:
|
|
190
|
+
parts.append(str(idx[i]))
|
|
191
|
+
i += 1
|
|
192
|
+
return ",".join(parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
#: The most indices one Indices text may name: a typed ``1:999999999`` is
|
|
196
|
+
#: refused before it is a list.
|
|
197
|
+
MAX_INDICES = 100_000
|
|
198
|
+
|
|
199
|
+
#: ASCII digits and nothing else Unicode calls one (``1``, ``٣``): `\d`
|
|
200
|
+
#: takes those, and an index typed with them is a slip of the keyboard.
|
|
201
|
+
_INT = re.compile(r"[+-]?[0-9]+")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def parse_slice(text: str) -> list[int]:
|
|
205
|
+
"""The indices ``text`` names, ascending — what an Indices field holds
|
|
206
|
+
(:func:`slice_text`'s syntax, read back): an index (``3``), a range
|
|
207
|
+
with its end INCLUDED (``1:5``), a stepped one (``1:5:20`` is 1, 6, 11,
|
|
208
|
+
16 — start:step:end, as Modelica writes it), and a comma list of those
|
|
209
|
+
in any order (``1:3,7,10:2:20``); spaces anywhere. The indices are the
|
|
210
|
+
result file's — any whole number from 0 — and which of them an array
|
|
211
|
+
HAS is its caller's to check: a zero-based array (``y[0]``) has a 0,
|
|
212
|
+
and a floor of 1 here refused the very text its field was filled with.
|
|
213
|
+
|
|
214
|
+
``ValueError`` with the sentence to show for anything else: nothing
|
|
215
|
+
written, a part that is no index or range, a negative index, a step
|
|
216
|
+
below 1, a range that ends before it starts, an index written twice."""
|
|
217
|
+
parts = [part.strip() for part in text.split(",")]
|
|
218
|
+
if not any(parts):
|
|
219
|
+
raise ValueError(
|
|
220
|
+
"Indices is empty: write an index (3), a range (1:5), a stepped "
|
|
221
|
+
"range (1:5:20) or a list of those (1:3,7)."
|
|
222
|
+
)
|
|
223
|
+
runs: list[range] = []
|
|
224
|
+
for part in parts:
|
|
225
|
+
numbers = [n.strip() for n in part.split(":")]
|
|
226
|
+
if not 1 <= len(numbers) <= 3 or not all(_INT.fullmatch(n) for n in numbers):
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"{part!r} is not an index or a range: write 3, 1:5 or 1:5:20."
|
|
229
|
+
)
|
|
230
|
+
values = [int(n) for n in numbers]
|
|
231
|
+
first, last = values[0], values[-1]
|
|
232
|
+
step = values[1] if len(values) == 3 else 1
|
|
233
|
+
if first < 0 or last < 0:
|
|
234
|
+
raise ValueError(f"{part}: an index is not negative.")
|
|
235
|
+
if step < 1:
|
|
236
|
+
raise ValueError(f"{part}: the step is at least 1.")
|
|
237
|
+
if last < first:
|
|
238
|
+
raise ValueError(f"{part} ends before it starts.")
|
|
239
|
+
runs.append(range(first, last + 1, step))
|
|
240
|
+
count = sum(len(run) for run in runs)
|
|
241
|
+
if count > MAX_INDICES:
|
|
242
|
+
raise ValueError(f"{text.strip()} names {count:,} indices: too many.")
|
|
243
|
+
seen: set[int] = set()
|
|
244
|
+
for run in runs:
|
|
245
|
+
for index in run:
|
|
246
|
+
if index in seen:
|
|
247
|
+
raise ValueError(f"{index} is written twice.")
|
|
248
|
+
seen.add(index)
|
|
249
|
+
return sorted(seen)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def family_label(
|
|
253
|
+
key: str,
|
|
254
|
+
members: Sequence[str],
|
|
255
|
+
labels: Mapping[str, str] | None = None,
|
|
256
|
+
) -> str:
|
|
257
|
+
"""``pipe.T[1:5:16]`` for a family of several; the one member's LABEL for
|
|
258
|
+
a family of one (``labels`` as in :func:`group_families`)."""
|
|
259
|
+
if len(members) == 1:
|
|
260
|
+
return _labelled(members[0], labels)
|
|
261
|
+
indices = [
|
|
262
|
+
fam[1] for m in members if (fam := _family_of_member(m, labels)) is not None
|
|
263
|
+
]
|
|
264
|
+
# the fold marker, never a `[]` inside a quoted identifier (`a.'b[]'.x[]`)
|
|
265
|
+
at = mask_quoted(key).find("[]")
|
|
266
|
+
if at == -1:
|
|
267
|
+
return key
|
|
268
|
+
return key[:at] + f"[{slice_text(indices)}]" + key[at + 2 :]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _rgb(hex_color: str) -> tuple[int, int, int]:
|
|
272
|
+
return int(hex_color[1:3], 16), int(hex_color[3:5], 16), int(hex_color[5:7], 16)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def ramp(hue: Sequence[str], n: int) -> list[str]:
|
|
276
|
+
"""``n`` colours along the hue's four stops, light to dark; one colour is
|
|
277
|
+
the third stop (the hue's name shade)."""
|
|
278
|
+
if n == 1:
|
|
279
|
+
return [hue[2]]
|
|
280
|
+
out = []
|
|
281
|
+
for i in range(n):
|
|
282
|
+
pos = i * (len(hue) - 1) / (n - 1)
|
|
283
|
+
lo = min(int(pos), len(hue) - 2)
|
|
284
|
+
frac = pos - lo
|
|
285
|
+
a, b = _rgb(hue[lo]), _rgb(hue[lo + 1])
|
|
286
|
+
out.append(
|
|
287
|
+
"#" + "".join(f"{round(x + (y - x) * frac):02x}" for x, y in zip(a, b))
|
|
288
|
+
)
|
|
289
|
+
return out
|
dstf/figures/palette.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""What a figure takes from a theme (D148): one named table per bundled
|
|
2
|
+
theme, read by name wherever a figure is built.
|
|
3
|
+
|
|
4
|
+
A figure's colours are literals, never CSS variables: the report's
|
|
5
|
+
plotly.js and the tool's read a figure dict, not a stylesheet. Most of
|
|
6
|
+
them carry meaning and are the same in every theme — the actual and the
|
|
7
|
+
reference, a pass and a fail region, the error series, a grip
|
|
8
|
+
(``plotly.py``). The ones HERE follow the theme:
|
|
9
|
+
|
|
10
|
+
* the figure's chrome — a reference event's guide line, an open
|
|
11
|
+
criterion's ring and handle markers, the grey of a context series, a
|
|
12
|
+
marker's outline (the plot's own ground) and the tint of a region
|
|
13
|
+
drawn over the plot's whole height;
|
|
14
|
+
* the colours that sink into a dark plot — the actual's event line, the
|
|
15
|
+
three overlays (soft check, another backend's run, a companion) and
|
|
16
|
+
the darkest stops of the array families' ramps;
|
|
17
|
+
* the layout template: the plot's ground, grid, axes, legend, hover label
|
|
18
|
+
and toolbar.
|
|
19
|
+
|
|
20
|
+
The default theme's template is ``None``: its plot is plotly.js's own
|
|
21
|
+
defaults and its figure carries no ``layout.template`` key at all. A dark
|
|
22
|
+
table's neutrals are its token table's (``reporting.theme``), as literals;
|
|
23
|
+
``tests/test_themes.py`` pins the two equal.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from types import MappingProxyType
|
|
31
|
+
|
|
32
|
+
from .families import HUES
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Palette:
|
|
37
|
+
#: A reference event's dashed guide line.
|
|
38
|
+
reference_event: str
|
|
39
|
+
#: An open criterion's ring and its handle markers, and the ring's
|
|
40
|
+
#: fill — the same colour at a quarter, so the ring takes the pointer
|
|
41
|
+
#: over its whole area.
|
|
42
|
+
ring: str
|
|
43
|
+
ring_fill: str
|
|
44
|
+
#: A context series: the theme's ``text-muted``.
|
|
45
|
+
context: str
|
|
46
|
+
#: A soft-check overlay.
|
|
47
|
+
soft_check: str
|
|
48
|
+
#: An event of the ACTUAL: a solid guide line, a shade under the
|
|
49
|
+
#: actual's own blue.
|
|
50
|
+
actual_event: str
|
|
51
|
+
#: The overlay of another backend's run of the same test.
|
|
52
|
+
sibling: str
|
|
53
|
+
#: A companion overlay.
|
|
54
|
+
companion: str
|
|
55
|
+
#: A marker's outline: the plot's ground, so a checkpoint stands off
|
|
56
|
+
#: the line it sits on.
|
|
57
|
+
marker_outline: str
|
|
58
|
+
#: The fill of a region with no bounds of its own, drawn over the
|
|
59
|
+
#: plot's whole height (an event's time tolerance): muted text at 9 %.
|
|
60
|
+
span_fill: str
|
|
61
|
+
#: The array families' ramps, light to dark (:func:`~.families.ramp`).
|
|
62
|
+
hues: tuple[tuple[str, str, str, str], ...]
|
|
63
|
+
#: ``layout.template.layout``, or ``None`` for plotly.js's own defaults.
|
|
64
|
+
#: Its ``xaxis`` / ``yaxis`` reach EVERY axis of that letter — the
|
|
65
|
+
#: spectrum's and the error axes' too; whatever a figure sets itself
|
|
66
|
+
#: (an error axis's red ticks) wins over it. A figure takes a COPY.
|
|
67
|
+
template: dict | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: The dark theme's neutrals, as its token table names them.
|
|
71
|
+
_DARK = MappingProxyType(
|
|
72
|
+
{
|
|
73
|
+
"surface": "#1e1e1e",
|
|
74
|
+
"surface-hover": "#2a2a2a",
|
|
75
|
+
"border": "#484848",
|
|
76
|
+
"border-hairline": "#303030",
|
|
77
|
+
"text": "#e4e4e4",
|
|
78
|
+
"text-secondary": "#b8b8b8",
|
|
79
|
+
"text-muted": "#9e9e9e",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dark_axis() -> dict:
|
|
85
|
+
"""One dict per axis letter: a copy of the template keeps whatever two
|
|
86
|
+
keys share, and a figure's x axis must not be its y axis."""
|
|
87
|
+
return {
|
|
88
|
+
"gridcolor": _DARK["border-hairline"],
|
|
89
|
+
"zerolinecolor": _DARK["border"],
|
|
90
|
+
"linecolor": _DARK["border"],
|
|
91
|
+
"tickcolor": _DARK["border"],
|
|
92
|
+
"tickfont": {"color": _DARK["text-secondary"]},
|
|
93
|
+
"title": {"font": {"color": _DARK["text-secondary"]}},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
PALETTES: Mapping[str, Palette] = MappingProxyType(
|
|
98
|
+
{
|
|
99
|
+
"default": Palette(
|
|
100
|
+
reference_event="#757575",
|
|
101
|
+
ring="#2e7d32",
|
|
102
|
+
ring_fill="rgba(46,125,50,0.25)",
|
|
103
|
+
context="#888888",
|
|
104
|
+
soft_check="#7B1FA2",
|
|
105
|
+
actual_event="#1976D2",
|
|
106
|
+
sibling="#1976D2",
|
|
107
|
+
companion="#388E3C",
|
|
108
|
+
marker_outline="white",
|
|
109
|
+
span_fill="rgba(117,117,117,0.09)",
|
|
110
|
+
hues=HUES,
|
|
111
|
+
),
|
|
112
|
+
"dark": Palette(
|
|
113
|
+
# Each of these on the dark plot (#1e1e1e), default -> dark:
|
|
114
|
+
# the event line 3.6 -> 7.0, the ring 3.3 -> 7.1, the soft
|
|
115
|
+
# check 2.0 -> 4.7 (WCAG contrast).
|
|
116
|
+
reference_event="#a8a8a8",
|
|
117
|
+
ring="#66bb6a",
|
|
118
|
+
ring_fill="rgba(102,187,106,0.25)",
|
|
119
|
+
context=_DARK["text-muted"],
|
|
120
|
+
soft_check="#ba68c8",
|
|
121
|
+
# Series a person reads against each other, so 4.5:1: the
|
|
122
|
+
# actual's event line and the sibling overlay 3.6 -> 7.5 (the
|
|
123
|
+
# dark accent's blue), the companion 4.0 -> 8.3 — a green
|
|
124
|
+
# apart from the ring's, as the default's is.
|
|
125
|
+
actual_event="#64b5f6",
|
|
126
|
+
sibling="#64b5f6",
|
|
127
|
+
companion="#81c784",
|
|
128
|
+
marker_outline=_DARK["surface"],
|
|
129
|
+
span_fill="rgba(158,158,158,0.09)",
|
|
130
|
+
# The four stops under 2.2:1 on the dark plot, each raised in
|
|
131
|
+
# lightness at its own hue and saturation until it clears 3:1
|
|
132
|
+
# and still stands darker than the stop before it. The eight
|
|
133
|
+
# others are the default theme's.
|
|
134
|
+
hues=(
|
|
135
|
+
(*HUES[0][:3], "#1264e2"),
|
|
136
|
+
(*HUES[1][:3], "#237b2a"),
|
|
137
|
+
(*HUES[2][:2], "#a631d8", "#8a40e4"),
|
|
138
|
+
),
|
|
139
|
+
template={
|
|
140
|
+
"paper_bgcolor": _DARK["surface"],
|
|
141
|
+
"plot_bgcolor": _DARK["surface"],
|
|
142
|
+
"font": {"color": _DARK["text"]},
|
|
143
|
+
"xaxis": _dark_axis(),
|
|
144
|
+
"yaxis": _dark_axis(),
|
|
145
|
+
"legend": {"font": {"color": _DARK["text"]}},
|
|
146
|
+
"hoverlabel": {
|
|
147
|
+
"bgcolor": _DARK["surface-hover"],
|
|
148
|
+
"bordercolor": _DARK["border"],
|
|
149
|
+
"font": {"color": _DARK["text"]},
|
|
150
|
+
},
|
|
151
|
+
"modebar": {
|
|
152
|
+
"bgcolor": "rgba(0,0,0,0)",
|
|
153
|
+
"color": _DARK["text-muted"],
|
|
154
|
+
"activecolor": _DARK["text"],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def palette_of(theme: str) -> Palette:
|
|
163
|
+
"""The palette of the bundled theme ``theme``; any other name is a
|
|
164
|
+
``ValueError`` that says the bundled ones."""
|
|
165
|
+
try:
|
|
166
|
+
return PALETTES[theme]
|
|
167
|
+
except KeyError:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"unknown theme {theme!r}: the bundled themes are {', '.join(PALETTES)}"
|
|
170
|
+
) from None
|