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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
@@ -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
@@ -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
@@ -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