better-rtplot 0.4.15__tar.gz → 0.5.0__tar.gz

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 (39) hide show
  1. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/PKG-INFO +9 -6
  2. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/README.md +6 -4
  3. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/pyproject.toml +1 -1
  4. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/client.py +173 -9
  5. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/server_browser.py +273 -51
  6. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/index.html +98 -45
  7. better_rtplot-0.5.0/rtplot/static/ui-view.css +24 -0
  8. better_rtplot-0.5.0/rtplot/static/ui-view.js +138 -0
  9. better_rtplot-0.5.0/rtplot/ui_state.py +102 -0
  10. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/LICENSE +0 -0
  11. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/interactive_test.py +0 -0
  12. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/saved_plots/.gitignore +0 -0
  13. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/server.py +0 -0
  14. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/server_browser_gui.py +0 -0
  15. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/auto-render.min.js +0 -0
  16. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  17. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  18. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  19. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  20. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  21. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  22. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  23. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  24. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  25. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  26. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  27. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  28. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  29. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  30. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  31. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  32. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  33. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  34. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  35. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  36. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/katex.min.css +0 -0
  37. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/katex/katex.min.js +0 -0
  38. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/uPlot.iife.min.js +0 -0
  39. {better_rtplot-0.4.15 → better_rtplot-0.5.0}/rtplot/static/uPlot.min.css +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: better-rtplot
3
- Version: 0.4.15
3
+ Version: 0.5.0
4
4
  Summary:
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Author: jmontp
7
8
  Author-email: jmontp@umich.edu
8
9
  Requires-Python: >=3.9,<3.13
@@ -67,10 +68,11 @@ Sender and server don't have to be on the same machine — see the
67
68
  No Python needed on the viewing machine. On Windows the binary opens
68
69
  a small Tk status window showing the listening URL.
69
70
 
70
- To arrange several plots side by side, start the server with
71
- `./rtplot-server --columns 2` (or `--columns 3`). The Python server accepts
72
- the same option: `python -m rtplot.server_browser --columns 2`. See the
73
- [multi-column example](examples/05_multi_column/).
71
+ Set columns per row in your sender's layout with
72
+ `PlotRow([Plot(names=["a"]), Plot(names=["b"])], columns=2)`.
73
+ Start the server normally; each row can use a different column count.
74
+ See the [multi-column example](examples/05_multi_column/) for typed and
75
+ dict forms, including a layout with two, one, and three columns.
74
76
 
75
77
  **Client** — pip install in the env that runs your script:
76
78
 
@@ -130,6 +132,7 @@ A rising line now draws itself in the browser tab.
130
132
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
131
133
  Connect buttons retarget without restart.
132
134
  - **Config lives with the data.** The sender declares plot layout.
135
+ - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
133
136
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
134
137
  from your loop, no threads, no callbacks.
135
138
  - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
@@ -43,10 +43,11 @@ Sender and server don't have to be on the same machine — see the
43
43
  No Python needed on the viewing machine. On Windows the binary opens
44
44
  a small Tk status window showing the listening URL.
45
45
 
46
- To arrange several plots side by side, start the server with
47
- `./rtplot-server --columns 2` (or `--columns 3`). The Python server accepts
48
- the same option: `python -m rtplot.server_browser --columns 2`. See the
49
- [multi-column example](examples/05_multi_column/).
46
+ Set columns per row in your sender's layout with
47
+ `PlotRow([Plot(names=["a"]), Plot(names=["b"])], columns=2)`.
48
+ Start the server normally; each row can use a different column count.
49
+ See the [multi-column example](examples/05_multi_column/) for typed and
50
+ dict forms, including a layout with two, one, and three columns.
50
51
 
51
52
  **Client** — pip install in the env that runs your script:
52
53
 
@@ -106,6 +107,7 @@ A rising line now draws itself in the browser tab.
106
107
  - **Remote-friendly.** Sender or plot host can bind. Live Bind /
107
108
  Connect buttons retarget without restart.
108
109
  - **Config lives with the data.** The sender declares plot layout.
110
+ - **Sections and runtime state.** Optional collapsible sections, persistent status controls, and presentation updates that preserve streaming history. See the [section API](docs/sections.md) and [workflow example](examples/06_sections/README.md).
109
111
  - **Interactive controls.** Buttons, sliders, dials, displays — polled
110
112
  from your loop, no threads, no callbacks.
111
113
  - **LaTeX labels.** Use `$...$` or `\(...\)` in plot and control text;
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.4.15"
3
+ version = "0.5.0"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "MIT"
@@ -1,6 +1,9 @@
1
1
  import zmq
2
2
  import numpy as np
3
3
  import time
4
+ import uuid
5
+ import warnings
6
+ from .ui_state import META_KEY, declarations, merge_state
4
7
  from collections import OrderedDict, namedtuple
5
8
  from dataclasses import dataclass, field
6
9
  from typing import List, Optional, Tuple, Union
@@ -24,6 +27,7 @@ control_socket.setsockopt(zmq.RCVHWM, 1000)
24
27
  #Non-blocking local state for controls, drained on each poll_controls() call
25
28
  _control_values = {}
26
29
  _control_button_events = []
30
+ _last_control_poll = time.monotonic()
27
31
 
28
32
  # Cached payload of the most recent initialize_plots() call. The server
29
33
  # uses this to recover from its own crashes: when its tab loses the
@@ -91,6 +95,16 @@ SENDING_PLOT_UPDATE = "0"
91
95
  SENDING_DATA = "1"
92
96
  SENDING_DISPLAY = "4"
93
97
  SENDING_TEXT_INPUT = "5"
98
+ SENDING_UI_STATE = "6"
99
+ _session = uuid.uuid4().hex
100
+ _generation = 0
101
+ _ui_revision = 0
102
+ _ui_state = {"controls": {}, "sections": {}}
103
+ _view = {}
104
+ _control_registry = {}
105
+ _server_capabilities = set()
106
+ _source_epoch = None
107
+ _last_ui_publish = 0.0
94
108
 
95
109
  #Lightweight result type returned by poll_controls()
96
110
  ControlState = namedtuple("ControlState", ["values", "buttons"])
@@ -147,6 +161,7 @@ class Plot:
147
161
  yrange: Optional[Tuple[float, float]] = None
148
162
  xrange: Optional[int] = None
149
163
  height: Optional[float] = None
164
+ section: Optional[str] = None
150
165
 
151
166
  def to_dict(self):
152
167
  return _drop_none({
@@ -161,9 +176,33 @@ class Plot:
161
176
  "yrange": _range_to_list(self.yrange),
162
177
  "xrange": self.xrange,
163
178
  "height": self.height,
179
+ "section": self.section,
164
180
  })
165
181
 
166
182
 
183
+ @dataclass
184
+ class PlotRow:
185
+ """A group of plots with its own column count in the browser."""
186
+ plots: List[Union[Plot, dict]]
187
+ columns: int
188
+ section: Optional[str] = None
189
+
190
+ def to_dict(self):
191
+ if isinstance(self.columns, bool) or not isinstance(self.columns, int) or self.columns < 1:
192
+ raise ValueError("PlotRow.columns must be a positive integer")
193
+ if not self.plots:
194
+ raise ValueError("PlotRow.plots must contain at least one plot")
195
+ plots = [
196
+ p.to_dict() if hasattr(p, "to_dict") else p
197
+ for p in self.plots
198
+ ]
199
+ if any(not isinstance(p, dict) or "names" not in p
200
+ or any(k in p for k in ("plots", "controls", "non_plot_labels"))
201
+ for p in plots):
202
+ raise ValueError("PlotRow can contain only plot descriptions")
203
+ return _drop_none({"columns": self.columns, "plots": plots, "section": self.section})
204
+
205
+
167
206
  @dataclass
168
207
  class Button:
169
208
  id: str
@@ -272,11 +311,37 @@ class ControlsRow:
272
311
  """A row of control widgets, rendered in place of a plot."""
273
312
  controls: List[Union[Button, Slider, Dial, Display, Text, TextInput, dict]] = field(default_factory=list)
274
313
 
314
+ section: Optional[str] = None
315
+
275
316
  def to_dict(self):
276
- return {"controls": [
317
+ return _drop_none({"controls": [
277
318
  c.to_dict() if hasattr(c, "to_dict") else c
278
319
  for c in self.controls
279
- ]}
320
+ ], "section": self.section})
321
+
322
+
323
+ @dataclass
324
+ class Section:
325
+ id: str
326
+ title: str
327
+ description: str = ""
328
+ collapsible: bool = False
329
+ collapsed: bool = False
330
+ visible: bool = True
331
+
332
+ def to_dict(self):
333
+ return dict(vars(self))
334
+
335
+
336
+ @dataclass
337
+ class View:
338
+ sections: List[Union[Section, dict]]
339
+ persistent_section: Optional[str] = None
340
+
341
+ def to_dict(self):
342
+ return _drop_none({"sections": [s.to_dict() if hasattr(s, "to_dict") else s
343
+ for s in self.sections],
344
+ "persistent_section": self.persistent_section})
280
345
 
281
346
 
282
347
  def local_plot():
@@ -426,6 +491,8 @@ def send_array(A, flags=0, copy=True, track=False):
426
491
  shape = A.shape,
427
492
  )
428
493
 
494
+ _publish_ui_if_due()
495
+
429
496
  #Send category
430
497
  socket.send_string(SENDING_DATA)
431
498
  #Send json description
@@ -434,7 +501,7 @@ def send_array(A, flags=0, copy=True, track=False):
434
501
  socket.send(A, flags, copy=copy, track=track)
435
502
 
436
503
 
437
- def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
504
+ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0, *, view=None, ui_state=None):
438
505
  """Send a json description of desired plot and block until the
439
506
  server acknowledges it.
440
507
 
@@ -455,13 +522,21 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
455
522
  - list of str: one plot, one trace per name
456
523
  - list of list of str: one plot per sublist
457
524
  - list of dict: multiple plots, each with full styling
525
+ - PlotRow or {"plots": [...], "columns": N}: a row with N columns
458
526
  handshake_timeout:
459
527
  Max seconds to wait for the config_ack. On timeout we print a
460
528
  warning and return (so an old server without the handshake
461
529
  feature, or a server on a flaky network, doesn't block the
462
530
  caller forever). Set to 0 to skip the handshake entirely.
531
+ view:
532
+ Optional View or dictionary declaring sections and a persistent section.
533
+ Requires a browser server >= 0.5.0; unconfirmed support emits a warning.
534
+ ui_state:
535
+ Optional initial controls/sections presentation overrides. Subsequent
536
+ set_ui_state() patches preserve plots and values; None resets overrides.
463
537
  """
464
- global plot_desc_dict
538
+ global plot_desc_dict, _generation, _ui_revision, _ui_state, _view
539
+ global _control_registry, _server_capabilities, _source_epoch, _control_button_events, _last_control_poll
465
540
 
466
541
  #Process int inputs
467
542
  if isinstance(plot_descriptions,int):
@@ -511,7 +586,19 @@ def initialize_plots(plot_descriptions=1, handshake_timeout=2.0):
511
586
  else:
512
587
  raise TypeError("Incorrect usage of initialize_plots, verify github for usage")
513
588
 
589
+ _view, _control_registry = declarations(plot_desc_dict, view)
590
+ _ui_state = merge_state({"controls": {}, "sections": {}}, ui_state or {}, _control_registry, _view)
591
+ _generation += 1
592
+ _ui_revision = 0
593
+ _source_epoch = None
594
+ _server_capabilities = set()
595
+ _control_button_events = []
596
+ _last_control_poll = time.monotonic()
514
597
  _send_initialize_with_handshake(plot_desc_dict, handshake_timeout)
598
+ if (view is not None or ui_state is not None) and "ui_state_v1" not in _server_capabilities:
599
+ warnings.warn("Sections/UI state require rtplot server >= 0.5.0. This server did not "
600
+ "confirm support: layout may be flat and disabled states are NOT enforced.",
601
+ RuntimeWarning, stacklevel=2)
515
602
 
516
603
 
517
604
  def _send_initialize_with_handshake(cfg, timeout):
@@ -526,7 +613,7 @@ def _send_initialize_with_handshake(cfg, timeout):
526
613
 
527
614
  def _send_once():
528
615
  socket.send_string(SENDING_PLOT_UPDATE)
529
- socket.send_json(cfg)
616
+ socket.send_json(_wire_config(cfg))
530
617
 
531
618
  _send_once()
532
619
 
@@ -552,8 +639,9 @@ def _send_initialize_with_handshake(cfg, timeout):
552
639
  continue
553
640
  evtype = event.get("type")
554
641
  if evtype == "config_ack":
555
- return
556
- elif evtype == "button":
642
+ if _accept_ack(event):
643
+ return
644
+ elif evtype == "button" and _current_event(event):
557
645
  _control_button_events.append(event.get("id"))
558
646
  elif evtype == "slider":
559
647
  _control_values[event.get("id")] = float(event.get("value", 0.0))
@@ -574,6 +662,65 @@ def _send_initialize_with_handshake(cfg, timeout):
574
662
  )
575
663
 
576
664
 
665
+ def _wire_config(cfg):
666
+ # Old servers already skip non_plot_labels entries. Metadata adds no traces.
667
+ return {**cfg, META_KEY: {"non_plot_labels": [], "rtplot": {
668
+ "session": _session, "generation": _generation, "view": _view,
669
+ "revision": _ui_revision, "state": _ui_state,
670
+ }}}
671
+
672
+
673
+ def _accept_ack(event):
674
+ global _server_capabilities, _source_epoch, _control_button_events
675
+ if event.get("session") is not None and (event.get("session"), event.get("generation")) != (_session, _generation):
676
+ return False
677
+ _server_capabilities = set(event.get("capabilities", []))
678
+ epoch = event.get("source_epoch")
679
+ if epoch != _source_epoch:
680
+ _control_button_events = []
681
+ _source_epoch = epoch
682
+ return True
683
+
684
+
685
+ def _current_event(event):
686
+ if "ui_state_v1" not in _server_capabilities:
687
+ return True
688
+ return (event.get("session"), event.get("generation"), event.get("source_epoch")) == (_session, _generation, _source_epoch)
689
+
690
+
691
+ def _publish_ui_if_due(force=False):
692
+ global _last_ui_publish
693
+ if "ui_state_v1" not in _server_capabilities:
694
+ return
695
+ now = time.monotonic()
696
+ if not force and now - _last_ui_publish < 1.0:
697
+ return
698
+ socket.send_string(SENDING_UI_STATE, zmq.SNDMORE)
699
+ socket.send_json({"session": _session, "generation": _generation,
700
+ "revision": _ui_revision, "state": _ui_state})
701
+ _last_ui_publish = now
702
+
703
+
704
+ def set_ui_state(patch):
705
+ """Patch control/section presentation state without reinitializing plots.
706
+
707
+ None removes a property override (or all overrides for a target ID).
708
+ Returns whether the merged state changed. Call poll_controls regularly
709
+ while idle to maintain liveness and recover dropped presentation updates.
710
+ """
711
+ global _ui_state, _ui_revision
712
+ if "ui_state_v1" not in _server_capabilities:
713
+ raise RuntimeError("set_ui_state requires an acknowledged rtplot server >= 0.5.0; "
714
+ "disabled-state enforcement is unavailable")
715
+ merged = merge_state(_ui_state, patch, _control_registry, _view)
716
+ changed = merged != _ui_state
717
+ if changed:
718
+ _ui_state = merged
719
+ _ui_revision += 1
720
+ _publish_ui_if_due(force=changed)
721
+ return changed
722
+
723
+
577
724
  def set_display(display_id: str, value):
578
725
  """Push a single display box value to the browser.
579
726
 
@@ -623,7 +770,16 @@ def poll_controls():
623
770
  restarting their script. The request is consumed silently — the
624
771
  returned ControlState only ever contains control/button events.
625
772
  """
626
- global _control_button_events
773
+ global _control_button_events, _source_epoch, _last_control_poll
774
+ now = time.monotonic()
775
+ stale_poll = "ui_state_v1" in _server_capabilities and now - _last_control_poll > 5.0
776
+ _last_control_poll = now
777
+ if stale_poll:
778
+ # An idle/disconnected application's PULL socket can still hold actions
779
+ # sent before the server detected the outage. Drain them without replay.
780
+ _control_button_events = []
781
+ _source_epoch = None
782
+ _publish_ui_if_due(force=stale_poll)
627
783
  while True:
628
784
  try:
629
785
  event = control_socket.recv_json(flags=zmq.NOBLOCK)
@@ -632,6 +788,13 @@ def poll_controls():
632
788
  except zmq.ZMQError:
633
789
  break
634
790
  evtype = event.get("type")
791
+ if stale_poll and evtype in ("config_ack", "button", "slider", "text"):
792
+ continue
793
+ if evtype == "config_ack":
794
+ _accept_ack(event)
795
+ continue
796
+ if evtype in ("button", "slider", "text") and not _current_event(event):
797
+ continue
635
798
  if evtype == "button":
636
799
  _control_button_events.append(event.get("id"))
637
800
  elif evtype == "slider":
@@ -641,7 +804,8 @@ def poll_controls():
641
804
  elif evtype == "resend_config":
642
805
  if plot_desc_dict is not None:
643
806
  socket.send_string(SENDING_PLOT_UPDATE)
644
- socket.send_json(plot_desc_dict)
807
+ _control_button_events = []
808
+ socket.send_json(_wire_config(plot_desc_dict))
645
809
  # Surface a one-line confirmation so the user can see in
646
810
  # their script log that the recovery handshake worked.
647
811
  # If they never see this after clicking Reconnect, the