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,33 @@
1
+ """A pointer into a raw metrics tree (``/metrics``, ``/metrics/children/0``,
2
+ ...), resolved to the node it names. UI-free: the builtins read a node
3
+ through it to build their ops, and the Test page reads one to tell whether
4
+ a form row's node moved and whether a criterion it is asked to reveal
5
+ exists. The shell imports no builtin, so the one reader lives here."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ def node_at(tree: dict, path: str) -> dict:
13
+ """The raw node at ``path``, a pointer whose first token names the tree
14
+ itself (``/metrics``, ``/metrics/children/0``, ...)."""
15
+ node: Any = tree
16
+ for token in [t for t in path.split("/") if t][1:]:
17
+ # A pointer alternates `children` (a key) and an index (into that
18
+ # list): consume each token as what it is.
19
+ node = node[int(token)] if token.isdigit() else node[token]
20
+ return node
21
+
22
+
23
+ def node_or_none(tree: dict | None, path: str) -> dict | None:
24
+ """The node at ``path``, or ``None`` when the pointer names none of
25
+ ``tree`` — a stale selection, or one naming a raw key (``/metrics/
26
+ combinator`` resolves to a word, which is no node)."""
27
+ if tree is None:
28
+ return None
29
+ try:
30
+ node = node_at(tree, path)
31
+ except (KeyError, IndexError, TypeError, ValueError):
32
+ return None
33
+ return node if isinstance(node, dict) else None
@@ -0,0 +1,61 @@
1
+ """What a tree node READS as. UI-free, so the Test page's rows and the
2
+ Edit tree capability's form titles say the same thing: a form's title
3
+ names its node the way the node's row does. Every function takes a
4
+ `tree_view` node (``kind``, ``label``, ``path``), never a raw spec node."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from ..reporting.tree_rows import leaves_of
9
+
10
+
11
+ def nodes_of(view: dict | None) -> list[dict]:
12
+ """Every node of a `tree_view`, combinators included, in tree order."""
13
+ if view is None:
14
+ return []
15
+ return [view] + [
16
+ node for child in view.get("children") or [] for node in nodes_of(child)
17
+ ]
18
+
19
+
20
+ def leaf_name(leaf: dict) -> str:
21
+ """``metric · label`` — what a criterion's row READS. The label is the
22
+ expression the model wrote for the variable (`nrmse · pipe.T[1]`), which
23
+ the tree view carries beside the `variable` a patch op targets; a
24
+ variable with no expression reads as its name, as it always did."""
25
+ return f"{leaf.get('metric')} · {leaf.get('label') or leaf.get('variable')}"
26
+
27
+
28
+ def node_text(node: dict) -> str:
29
+ if node.get("kind") == "leaf":
30
+ return leaf_name(node)
31
+ n = len(leaves_of(node))
32
+ text = f"{node.get('combinator')} · {n} {'criterion' if n == 1 else 'criteria'}"
33
+ if node.get("combinator") == "k-of-n" and node.get("k") is not None:
34
+ text += f" · k={node['k']}"
35
+ if node.get("combinator") == "weighted" and node.get("threshold") is not None:
36
+ text += f" · threshold {node['threshold']:g}"
37
+ return text
38
+
39
+
40
+ def count_text(node: dict) -> str:
41
+ """A combinator's row text while its kind is read from the dropdown
42
+ beside it: `node_text` without the kind — ``3 criteria``,
43
+ ``k=2 · 3 criteria``, ``threshold 0.5 · 3 criteria``."""
44
+ n = len(leaves_of(node))
45
+ parts = [f"{n} {'criterion' if n == 1 else 'criteria'}"]
46
+ if node.get("combinator") == "k-of-n" and node.get("k") is not None:
47
+ parts.insert(0, f"k={node['k']}")
48
+ if node.get("combinator") == "weighted" and node.get("threshold") is not None:
49
+ parts.insert(0, f"threshold {node['threshold']:g}")
50
+ return " · ".join(parts)
51
+
52
+
53
+ def parent_kinds(view: dict | None) -> dict[str, str | None]:
54
+ """Each node's path -> its parent's combinator (None for the root):
55
+ what a leaf's `+` reads, since the parent takes the child it adds."""
56
+ kinds: dict[str, str | None] = {}
57
+ for node in nodes_of(view):
58
+ kinds.setdefault(str(node.get("path")), None)
59
+ for child in node.get("children") or []:
60
+ kinds[str(child.get("path"))] = node.get("combinator")
61
+ return kinds
@@ -0,0 +1,64 @@
1
+ // A number field the server cannot overwrite while it is typed in: what the
2
+ // field shows is what was typed, until it loses focus; a server value is
3
+ // taken only while the field is not being typed in. (The passthrough is
4
+ // NiceGUI's own, elements/input.js.)
5
+ //
6
+ // And a PREVIEW (D149): a value the server shows in the field in place of
7
+ // what it holds, staging nothing — a handle held on the plot, the field
8
+ // following it. `previewed` is that value (null: none) and `revision` the
9
+ // number of the drag it is of. It is shown whatever the field's typing
10
+ // state, except in a field typed in SINCE that drag began: what a person
11
+ // types is never painted over.
12
+ //
13
+ // The server sets it by calling `preview(value, revision)` on this
14
+ // component — never by a prop. A prop write resends the element, and
15
+ // NiceGUI renders the page's whole tree again for any element it is sent:
16
+ // with a 30-row table open that was a 50 to 77 ms task in the browser for
17
+ // every move of a held handle. A method call changes this component's own
18
+ // state, and it alone is drawn again.
19
+ export default {
20
+ template: `
21
+ <q-input ref="qRef" v-bind="$attrs" :model-value="display" @update:model-value="typed" @blur="typing = false" @wheel="wheel">
22
+ <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
23
+ <slot :name="slot" v-bind="slotProps || {}" />
24
+ </template>
25
+ </q-input>
26
+ `,
27
+ inheritAttrs: false,
28
+ props: { modelValue: null },
29
+ emits: ["update:modelValue"],
30
+ data() {
31
+ return { shown: this.modelValue, typing: false, typedIn: null, previewed: null, revision: 0 };
32
+ },
33
+ computed: {
34
+ display() {
35
+ const previewing = this.previewed !== null && this.previewed !== undefined;
36
+ return previewing && this.typedIn !== this.revision ? this.previewed : this.shown;
37
+ },
38
+ },
39
+ watch: {
40
+ modelValue(value) {
41
+ if (!this.typing) this.shown = value;
42
+ },
43
+ },
44
+ methods: {
45
+ preview(value, revision) {
46
+ this.previewed = value;
47
+ this.revision = revision;
48
+ },
49
+ typed(value) {
50
+ this.typing = true;
51
+ this.typedIn = this.revision;
52
+ this.shown = value;
53
+ this.$emit("update:modelValue", value);
54
+ },
55
+ // A focused `type=number` input steps by 1 per wheel notch and keeps the
56
+ // page from scrolling: scrolling on with the pointer still over the
57
+ // field just typed in turned 0.3 into 3.3, staged. Left before the
58
+ // browser acts on the wheel, the input steps nothing and the page
59
+ // scrolls. (The arrow keys' step is untouched.)
60
+ wheel(event) {
61
+ if (event.target === document.activeElement) event.target.blur();
62
+ },
63
+ },
64
+ };
@@ -0,0 +1,76 @@
1
+ """The tool's number field: one that keeps what is typed in it.
2
+
3
+ NiceGUI resends a whole element — its ``model-value`` with it — on any write
4
+ of one of its props (a hint, a validation mark, ``update()``), and leaves the
5
+ value out only when it equals the raw string the browser last sent
6
+ (``"0.0" != "0"``, ``None != ""``). A stage that rescores takes longer than
7
+ the gap between two keys, so the answer to one keystroke landed under the
8
+ keys that followed: `0.25` typed over 0.3 read — and staged — `0.025`.
9
+
10
+ Imports ``nicegui`` at module level, as ``forms`` does: neither is on the
11
+ path of ``import dstf.authoring``, and a capability imports it inside the
12
+ function that draws.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from nicegui import ui
20
+ from nicegui.events import GenericEventArguments
21
+
22
+
23
+ class TypedNumber(ui.number, component="typed_number.js"):
24
+ """``ui.number`` whose field shows what was typed until it is left: a
25
+ value from the server is taken only while the field is not being typed
26
+ in (``typed_number.js``). Every number field of the tool is one. The
27
+ mouse wheel over a focused one scrolls the page and steps nothing: the
28
+ field is left first (a browser steps a focused number input by 1 per
29
+ notch).
30
+
31
+ A field's own VALUE is still never set from the server while it is
32
+ typed in: Discard and a released drag rebuild the editor, and a linked
33
+ twin's mirror lands in the field that is not typed in. What the server
34
+ does show in a field that may hold the focus is a PREVIEW
35
+ (:meth:`preview`, D149): beside the value, never in its place.
36
+ """
37
+
38
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
39
+ super().__init__(*args, **kwargs)
40
+ # `ui.number` names `q-input` itself, over the registered component.
41
+ component = type(self).component
42
+ assert component is not None # the class statement registered it
43
+ self.tag = component.tag
44
+ #: The preview the browser was last told to show, and of which drag.
45
+ self.previewed: float | None = None
46
+ self.preview_revision = 0
47
+
48
+ def preview(self, value: float | None, revision: int = 0) -> None:
49
+ """Show ``value`` in the field in place of what it holds — a handle
50
+ held on the plot, the field following it (D149) — or, with None,
51
+ what it holds again. The field's value does not change, so nothing
52
+ is handed to ``on_change`` and nothing is staged. ``revision`` is
53
+ the number of the drag the preview is of: the component shows a
54
+ preview whatever its typing state, but not in a field typed in
55
+ since THAT drag began (``typed_number.js``).
56
+
57
+ A METHOD CALL on the component, not a prop: a prop write resends
58
+ the element, and the browser then renders the page's whole tree
59
+ again — a long task per move of the handle on an editor of thirty
60
+ rows. ``previewed`` and ``preview_revision`` are what was last
61
+ sent."""
62
+ same = self.previewed == value
63
+ if same and (value is None or self.preview_revision == revision):
64
+ return
65
+ self.previewed, self.preview_revision = value, revision
66
+ self.run_method("preview", value, revision)
67
+
68
+ def _event_args_to_value(self, e: GenericEventArguments) -> float | None:
69
+ """What was typed, as the number it is — and a negative zero as
70
+ zero. `-0` is on the way to every negative number and is what a
71
+ refused `-0.5` backspaces to; a float of it is −0.0, which was
72
+ staged as it came and written `-0.0` by Save. Here, the one place a
73
+ typed text becomes a number for every field of the tool: a table's
74
+ cell and a tolerance's side take their value from this."""
75
+ value = super()._event_args_to_value(e)
76
+ return 0.0 if value == 0 else value
@@ -0,0 +1,141 @@
1
+ """What an editor refuses to stage because it can never score: a negative
2
+ tolerance — a number, or either side of a pair; a value's or a time's; the
3
+ criterion's or a table row's — a tube's constant width whose sides sum
4
+ below zero (a negative number, or a pair that leaves nothing between its
5
+ bounds) with no positive minimum width under it, and bounds out of order.
6
+ And one value that scores but says nothing: a tube's minimum width below
7
+ zero, a floor under no width.
8
+
9
+ The EDITOR's rule, raised from a capability's ``act`` before anything is
10
+ staged, the way a window that ends before it starts is refused. It is not
11
+ the scorer's and not the spec parser's: a spec written by hand that holds
12
+ such a value is read, staged and scored exactly as it always was."""
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from typing import TypeGuard
18
+
19
+
20
+ class Refused(ValueError):
21
+ """A value an editor will not stage, said as its reason. A person's own
22
+ — a negative tolerance on the way to a good one, bounds out of order,
23
+ a required cell left blank — so it is shown and never logged
24
+ (`errors.USER_ERRORS`): as a plain `ValueError`, which a bug's also
25
+ is, each keystroke of one logged a traceback."""
26
+
27
+
28
+ #: The keys that hold a tolerance, on a criterion and in a row of its tables.
29
+ TOLERANCES = ("tolerance", "time_tolerance")
30
+
31
+ #: The keys that hold a tube's constant width (``TubeConfig``).
32
+ WIDTHS = ("tube_abs", "tube_rel")
33
+
34
+ #: The key that holds a tube's minimum width: ONE number, a floor under
35
+ #: both sides.
36
+ MIN_WIDTH = "tube_min_width"
37
+
38
+ #: The metrics whose two bounds may not be EQUAL either: the scorer refuses
39
+ #: a scalar leaf with ``min_value >= max_value`` when it is built
40
+ #: (``modes._validate_scalar_config``), as its drag editor does
41
+ #: (``handles.edit_scalar_handle``). A range with equal bounds scores.
42
+ STRICT_BOUNDS = frozenset({"scalar"})
43
+
44
+ _SIDES = ("lower", "upper")
45
+
46
+
47
+ def _is_number(value: object) -> TypeGuard[int | float]:
48
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
49
+
50
+
51
+ def _num(value: float) -> str:
52
+ """A number in a refusal, to 12 significant digits, its minus as U+2212
53
+ (the report's sign). The sign only: an exponent's ``-`` stays."""
54
+ text = f"{float(value):.12g}"
55
+ return f"−{text[1:]}" if text.startswith("-") else text
56
+
57
+
58
+ def refuse_negative(
59
+ key: str,
60
+ value: object,
61
+ side_names: tuple[str, str] | None = None,
62
+ where: str = "",
63
+ ) -> None:
64
+ """Raise :class:`Refused` when the tolerance ``value`` held under ``key``
65
+ is negative: ``tolerance must not be negative; got −0.5`` for a number,
66
+ ``tolerance (lower side) must not be negative; got −0.5`` for a side of
67
+ a pair — named as its fields are where they have words for it
68
+ (``side_names``: a time tolerance's ``early`` / ``late``), ``where``
69
+ before it (``"row 1: "``). A zero is a tolerance. Anything that is no
70
+ number or pair of numbers is not this rule's to refuse."""
71
+ if isinstance(value, dict):
72
+ named = dict(zip(_SIDES, side_names or _SIDES))
73
+ sides = [(f"{key} ({named[s]} side)", value.get(s)) for s in _SIDES]
74
+ else:
75
+ sides = [(key, value)]
76
+ for what, side in sides:
77
+ if _is_number(side) and side < 0:
78
+ raise Refused(f"{where}{what} must not be negative; got {_num(side)}")
79
+
80
+
81
+ def refuse_negative_width(key: str, value: object, floor: object) -> None:
82
+ """Raise :class:`Refused` when the tube width ``value`` held under ``key``
83
+ puts the tube's upper bound under its lower one, so that nothing is
84
+ ever inside it (it was staged, and read "0.0% inside tube"): a negative
85
+ NUMBER — both sides — ``tube_abs must not be negative; got −0.3``, and
86
+ a PAIR of numbers whose sides sum below zero, ``tube_abs sides must not
87
+ sum below zero; got lower −0.1, upper −0.5``. The sum, not either
88
+ side: a pair with one side negative and a sum above zero is a band
89
+ shifted off its reference, which a run can lie in, and one whose sides
90
+ sum to exactly zero is a band of no width — a zero is a tolerance.
91
+
92
+ Only where ``floor``, the leaf's minimum width, is no positive number.
93
+ A positive one widens BOTH sides to itself
94
+ (``algorithms._tube_bounds``), so no width over it leaves nothing
95
+ inside: ``{"lower": -0.1, "upper": -0.5}`` over a floor of 0.2 is a
96
+ tube 0.2 either side of its reference. Such a leaf, written by hand,
97
+ was refused every edit — a window end, and the Min width that would
98
+ repair one with no floor — with the width's sentence. A floor that is
99
+ absent, zero, negative or no number floors nothing, and the width is
100
+ refused as it was: the floor cleared from such a leaf too."""
101
+ if _is_number(floor) and floor > 0:
102
+ return
103
+ if _is_number(value):
104
+ refuse_negative(key, value)
105
+ return
106
+ if not isinstance(value, dict):
107
+ return
108
+ lower, upper = value.get("lower"), value.get("upper")
109
+ if _is_number(lower) and _is_number(upper) and lower + upper < 0:
110
+ raise Refused(
111
+ f"{key} sides must not sum below zero; "
112
+ f"got lower {_num(lower)}, upper {_num(upper)}"
113
+ )
114
+
115
+
116
+ def refuse_negative_floor(key: str, value: object) -> None:
117
+ """Raise :class:`Refused` when the tube's minimum width ``value`` held
118
+ under ``key`` is a negative number: ``tube_min_width must not be
119
+ negative; got −0.3``. A floor below zero is under every width: it
120
+ floors nothing, and the criterion scores as if the field were empty —
121
+ which is what the field should then be. Only a NUMBER is this rule's:
122
+ the floor takes one, and anything else is the leaf's config error,
123
+ said by its row (``comparison.sides.parse_number``)."""
124
+ if _is_number(value):
125
+ refuse_negative(key, value)
126
+
127
+
128
+ def refuse_bounds(metric: str, params: Mapping[str, object]) -> None:
129
+ """Raise :class:`Refused` when ``params`` holds both bounds out of order:
130
+ ``min_value 9 exceeds max_value 2`` — and, for a metric of
131
+ :data:`STRICT_BOUNDS`, when they are equal."""
132
+ lo, hi = params.get("min_value"), params.get("max_value")
133
+ if not _is_number(lo) or not _is_number(hi):
134
+ return
135
+ if lo > hi:
136
+ raise Refused(f"min_value {_num(lo)} exceeds max_value {_num(hi)}")
137
+ if lo == hi and metric in STRICT_BOUNDS:
138
+ raise Refused(
139
+ f"min_value {_num(lo)} equals max_value {_num(hi)}; "
140
+ f"a {metric} leaf needs min_value < max_value"
141
+ )
@@ -0,0 +1,216 @@
1
+ """The Variables drawer's rows (the roadmap's 3c; DESIGN.md "The Variables
2
+ drawer"), as data: the page draws them and knows none of the rules.
3
+
4
+ Every variable of the last result, in groups, with arrays folded into one
5
+ row the way the plots fold them (`discovery.plots.derive_plots`: by
6
+ RESULT-FILE NAME, a UnitTests element never), filtered, and each row's
7
+ state against the plots: in the selected plot's context,
8
+ its subject, some plot's subject. Three groupings (the
9
+ drawer's group menu): by component — the tracked ones first, then by the
10
+ first dot component of the NAME, inside any ``der(...)`` ("(top level)"
11
+ for a name with none), the diagnostics last; flat, one group; and tracked
12
+ first, the rest in one group after them. Two orders: result-file order, or
13
+ A→Z by what a row reads as. Three reads (the names menu): the expression,
14
+ the result-file name, or both."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable, Collection, Mapping, Sequence
19
+ from dataclasses import dataclass, replace
20
+
21
+ from ..discovery.plots import ResolvedPlot
22
+ from ..figures.families import family_label, group_families, mask_quoted
23
+
24
+ TOP_LEVEL = "(top level)"
25
+ #: The one group of the flat grouping, and the second of "tracked first".
26
+ ALL, OTHER = "All variables", "Other variables"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class VarRow:
31
+ #: the family key (``pipe.T[]``; a lone variable's too when it has one,
32
+ #: ``$DER.x[]``), else the name. Not unique: the tracked ``pipe.T[1:2]``
33
+ #: keys ``pipe.T[]`` as the untracked rest of the array does — ``id`` is.
34
+ key: str
35
+ #: ``<group rank>/<group title>/<key>``, unique within the drawer. The
36
+ #: rank is in it because a title is not unique: the tracked
37
+ #: ``Tracked.a[1:2]`` and the untracked ``Tracked.a[3:4]`` beside it are
38
+ #: both "Tracked" and both key ``Tracked.a[]``. A tracked family in a
39
+ #: group of untracked ones too (Flat) keys ``tracked:<key>``: under Flat
40
+ #: both ``pipe.T[]`` rows are in "All variables".
41
+ id: str
42
+ members: tuple[str, ...] #: names, by index; one for a lone variable
43
+ text: str #: what it reads as: the family label, or the label
44
+ #: what it reads as by result-file names: the family label
45
+ #: (``pipe.T[1:2]``), or the name
46
+ file_text: str
47
+ name: str | None #: the muted name beside ``text``, when they differ
48
+ #: members the selected plot draws as CONTEXT — what its check adds or
49
+ #: takes off
50
+ on_selected: int
51
+ elsewhere: tuple[int, ...] #: other plots showing any member, 0-based
52
+ #: whether a member is the selected plot's SUBJECT: its check is on and
53
+ #: grey, since a plot cannot lose its subject
54
+ is_subject: bool
55
+ #: the plot (0-based) a member is the subject of, or None: a variable
56
+ #: with a plot of its own has its "+ new plot" grey
57
+ subject_of: int | None
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class VarGroup:
62
+ #: 0 tracked, 1 a prefix group, 2 top level, 3 diagnostics: a variable
63
+ #: named ``Tracked.a`` is in a prefix group titled "Tracked", never in
64
+ #: the tracked one
65
+ rank: int
66
+ title: str
67
+ rows: tuple[VarRow, ...]
68
+
69
+
70
+ def _group(name: str, tracked: set[str], diagnostics: set[str]) -> tuple[int, str]:
71
+ if name in tracked:
72
+ return 0, "Tracked"
73
+ if name in diagnostics:
74
+ return 3, "Diagnostics"
75
+ # a derivative is in the group of what it differentiates:
76
+ # `der(pipe.T[1])` is in "pipe", never "der(pipe"
77
+ while name.startswith("der("):
78
+ name = name[4:]
79
+ # found in the mask: `'a.b'.c` is in "'a.b'", never "'a"
80
+ masked = mask_quoted(name)
81
+ dot = masked.find(".")
82
+ if dot == -1:
83
+ return 2, TOP_LEVEL
84
+ # the component, not its index: `port[2, 1].C[1]` is in "port"
85
+ bracket = masked.find("[", 0, dot)
86
+ return 1, name[: dot if bracket == -1 else bracket]
87
+
88
+
89
+ def variable_groups(
90
+ *,
91
+ names: Sequence[str],
92
+ tracked: Sequence[str],
93
+ diagnostics: Collection[str],
94
+ labels: Mapping[str, str],
95
+ plots: Sequence[ResolvedPlot],
96
+ selected: int | None,
97
+ keep: Callable[[str], bool],
98
+ unit_tests: Collection[str] = (),
99
+ group: str = "component",
100
+ order: str = "file",
101
+ read: str = "both",
102
+ ) -> list[VarGroup]:
103
+ """``names`` in the drawer's groups, each row's state read off
104
+ ``plots`` and the ``selected`` one (None: no plot); ``keep`` is the
105
+ filter, asked of the name AND the label. Absent from either map, a name
106
+ reads as itself.
107
+
108
+ The rows fold as the plots do (the owner's ruling O3,
109
+ :func:`~dstf.discovery.plots.derive_plots`): by RESULT-FILE NAME, never
110
+ by label, and each of the ``unit_tests`` names is a row of its own
111
+ whatever its expression reads — three UnitTests variables whose
112
+ expressions are ``pipe.T[1:3]`` are three plots, and one row over them
113
+ named a plot there is not. A row READS ``labels`` (the plain label),
114
+ with the name muted beside it where they differ.
115
+
116
+ ``group`` (the drawer's group menu): ``component`` — tracked, then by
117
+ component, top level, diagnostics; ``flat`` — one group, "All
118
+ variables"; ``tracked`` — "Tracked", then "Other variables" with
119
+ everything else, diagnostics included. Families fold in every one, the
120
+ tracked names apart from the rest: under Flat the tracked
121
+ ``pipe.T[1:2]`` and the untracked rest of the array fold to the same
122
+ key, and one row over both hid which of them the test tracks.
123
+ ``order``: ``file`` keeps a group's rows in first-appearance
124
+ (result-file) order; ``name`` sorts them by what they are shown as —
125
+ ``file_text`` when ``read`` (the names menu) is ``file``, else
126
+ ``text``."""
127
+ tracked_set, diag, apart = set(tracked), set(diagnostics), set(unit_tests)
128
+ kept = [n for n in names if keep(n) or (n in labels and keep(labels[n]))]
129
+ by_group: dict[tuple[int, str], list[str]] = {}
130
+ for name in kept:
131
+ if group == "flat":
132
+ where = (1, ALL)
133
+ elif group == "tracked":
134
+ where = (0, "Tracked") if name in tracked_set else (1, OTHER)
135
+ else:
136
+ where = _group(name, tracked_set, diag)
137
+ by_group.setdefault(where, []).append(name)
138
+ # the plots indexed once: scanning every plot per name cost 395 ms over
139
+ # 6,093 names and 300 plots
140
+ index: dict[str, list[int]] = {}
141
+ subject_of: dict[str, int] = {}
142
+ for i, plot in enumerate(plots):
143
+ for name in plot.subject:
144
+ subject_of.setdefault(name, i)
145
+ for name in plot.series:
146
+ at = index.setdefault(name, [])
147
+ if not at or at[-1] != i:
148
+ at.append(i)
149
+ # the page's cards can lag the Session's plots by one sync
150
+ chosen = (
151
+ plots[selected] if selected is not None and 0 <= selected < len(plots) else None
152
+ )
153
+ context = set(chosen.context) if chosen is not None else set()
154
+ subject = set(chosen.subject) if chosen is not None else set()
155
+ showing = {n: tuple(index.get(n, ())) for n in kept}
156
+ position = {n: i for i, n in enumerate(kept)}
157
+ out: list[VarGroup] = []
158
+ for rank, title in sorted(by_group, key=lambda g: (g[0], g[1].casefold(), g[1])):
159
+ in_group = by_group[rank, title]
160
+ # Folded apart, then merged back in result-file order (a family's
161
+ # first appearance, `group_families`' own order).
162
+ families = sorted(
163
+ [(n, [n]) for n in in_group if n in apart]
164
+ + group_families(
165
+ [n for n in in_group if n in tracked_set and n not in apart]
166
+ )
167
+ + group_families(
168
+ [n for n in in_group if n not in tracked_set and n not in apart]
169
+ ),
170
+ key=lambda f: min(position[m] for m in f[1]),
171
+ )
172
+ rows = []
173
+ for key, members in families:
174
+ text = family_label(key, members, labels)
175
+ file_text = family_label(key, members)
176
+ lone = members[0] if len(members) == 1 else None
177
+ on = [m for m in members if m in context]
178
+ elsewhere = sorted(
179
+ {i for m in members for i in showing[m] if i != selected}
180
+ )
181
+ rows.append(
182
+ VarRow(
183
+ key=key,
184
+ id=f"{rank}/{title}/"
185
+ + ("tracked:" if rank != 0 and members[0] in tracked_set else "")
186
+ + key,
187
+ members=tuple(members),
188
+ text=text,
189
+ file_text=file_text,
190
+ name=lone if lone is not None and text != lone else None,
191
+ on_selected=len(on),
192
+ elsewhere=tuple(elsewhere),
193
+ is_subject=any(m in subject for m in members),
194
+ subject_of=next(
195
+ (subject_of[m] for m in members if m in subject_of), None
196
+ ),
197
+ )
198
+ )
199
+ if order == "name":
200
+ shown = (lambda r: r.file_text) if read == "file" else (lambda r: r.text)
201
+ rows.sort(key=lambda r: (shown(r).casefold(), shown(r)))
202
+ out.append(VarGroup(rank, title, tuple(rows)))
203
+ return out
204
+
205
+
206
+ def cap(groups: list[VarGroup], limit: int) -> tuple[list[VarGroup], int, int]:
207
+ """At most ``limit`` rows, groups in order: ``(groups, shown, total)``.
208
+ A result of thousands of names drew thousands of rows otherwise."""
209
+ total = sum(len(g.rows) for g in groups)
210
+ out, left = [], limit
211
+ for g in groups:
212
+ if left <= 0:
213
+ break
214
+ out.append(replace(g, rows=g.rows[:left]))
215
+ left -= len(out[-1].rows)
216
+ return out, min(total, limit), total