spectra-plot 0.2.6__tar.gz → 0.3.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 (103) hide show
  1. {spectra_plot-0.2.6/spectra_plot.egg-info → spectra_plot-0.3.0}/PKG-INFO +3 -1
  2. spectra_plot-0.3.0/VERSION +1 -0
  3. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/pyproject.toml +4 -1
  4. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/__init__.py +10 -0
  5. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_codec.py +17 -11
  6. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_codec_fb.py +74 -0
  7. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_easy.py +345 -4
  8. spectra_plot-0.3.0/spectra/_embed.py +1678 -0
  9. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_launcher.py +30 -18
  10. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_transport.py +18 -0
  11. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/embed.py +148 -55
  12. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/topic.py +38 -10
  13. {spectra_plot-0.2.6 → spectra_plot-0.3.0/spectra_plot.egg-info}/PKG-INFO +3 -1
  14. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra_plot.egg-info/SOURCES.txt +1 -0
  15. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra_plot.egg-info/requires.txt +2 -0
  16. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_easy.py +3 -2
  17. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_easy_embed.py +2 -2
  18. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_embed.py +147 -2
  19. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_phase2.py +1 -1
  20. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_phase4.py +40 -117
  21. spectra_plot-0.3.0/tests/test_topic_publisher.py +127 -0
  22. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_windows_compat.py +4 -4
  23. spectra_plot-0.2.6/VERSION +0 -1
  24. spectra_plot-0.2.6/spectra/_embed.py +0 -504
  25. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/MANIFEST.in +0 -0
  26. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/setup.cfg +0 -0
  27. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_animation.py +0 -0
  28. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_axes.py +0 -0
  29. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_blob.py +0 -0
  30. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_cli.py +0 -0
  31. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_download.py +0 -0
  32. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_errors.py +0 -0
  33. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/__init__.py +0 -0
  34. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/__init__.py +0 -0
  35. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/AckStatePayload.py +0 -0
  36. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/CmdAssignFiguresPayload.py +0 -0
  37. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/CmdCloseWindowPayload.py +0 -0
  38. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/CmdRemoveFigurePayload.py +0 -0
  39. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/CmdSetActivePayload.py +0 -0
  40. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/DiffOp.py +0 -0
  41. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/DiffOpType.py +0 -0
  42. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/EvtFigureDestroyedPayload.py +0 -0
  43. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/EvtInputPayload.py +0 -0
  44. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/EvtTopicListChangedPayload.py +0 -0
  45. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/EvtWindowClosedPayload.py +0 -0
  46. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/HelloPayload.py +0 -0
  47. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/InputType.py +0 -0
  48. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqAddSeriesPayload.py +0 -0
  49. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqAppendDataPayload.py +0 -0
  50. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqCloseFigurePayload.py +0 -0
  51. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqCloseWindowPayload.py +0 -0
  52. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqCreateAxesPayload.py +0 -0
  53. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqCreateFigurePayload.py +0 -0
  54. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqCreateWindowPayload.py +0 -0
  55. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqDeclareTopicPayload.py +0 -0
  56. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqDestroyFigurePayload.py +0 -0
  57. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqDetachFigurePayload.py +0 -0
  58. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqListTopicsPayload.py +0 -0
  59. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqPublishTopicSamplesPayload.py +0 -0
  60. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqReconnectPayload.py +0 -0
  61. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqRemoveSeriesPayload.py +0 -0
  62. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqSetDataPayload.py +0 -0
  63. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqShowPayload.py +0 -0
  64. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqSubscribeTopicPayload.py +0 -0
  65. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqUnsubscribeTopicPayload.py +0 -0
  66. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqUpdateBatchPayload.py +0 -0
  67. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/ReqUpdatePropertyPayload.py +0 -0
  68. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespAxesCreatedPayload.py +0 -0
  69. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespErrPayload.py +0 -0
  70. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespFigureCreatedPayload.py +0 -0
  71. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespFigureListPayload.py +0 -0
  72. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespOkPayload.py +0 -0
  73. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespSeriesAddedPayload.py +0 -0
  74. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespSubscribeTopicPayload.py +0 -0
  75. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/RespTopicListPayload.py +0 -0
  76. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/SnapshotAxisState.py +0 -0
  77. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/SnapshotFigureState.py +0 -0
  78. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/SnapshotKnobState.py +0 -0
  79. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/SnapshotSeriesState.py +0 -0
  80. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/StateDiffPayload.py +0 -0
  81. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/StateSnapshotPayload.py +0 -0
  82. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/TopicInfoEntry.py +0 -0
  83. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/WelcomePayload.py +0 -0
  84. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_fb_generated/spectra/ipc/fb/__init__.py +0 -0
  85. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_figure.py +0 -0
  86. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_log.py +0 -0
  87. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_persistence.py +0 -0
  88. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_protocol.py +0 -0
  89. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_series.py +0 -0
  90. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/_session.py +0 -0
  91. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/backends/__init__.py +0 -0
  92. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/backends/_qt_compat.py +0 -0
  93. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra/backends/backend_qtagg.py +0 -0
  94. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra_plot.egg-info/dependency_links.txt +0 -0
  95. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra_plot.egg-info/entry_points.txt +0 -0
  96. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/spectra_plot.egg-info/top_level.txt +0 -0
  97. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_codec.py +0 -0
  98. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_cross_codec.py +0 -0
  99. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_download.py +0 -0
  100. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_packaging_metadata.py +0 -0
  101. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_phase3.py +0 -0
  102. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_phase5.py +0 -0
  103. {spectra_plot-0.2.6 → spectra_plot-0.3.0}/tests/test_qt_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spectra-plot
3
- Version: 0.2.6
3
+ Version: 0.3.0
4
4
  Summary: GPU-accelerated scientific plotting via IPC
5
5
  License: MIT
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -15,8 +15,10 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Topic :: Scientific/Engineering :: Visualization
16
16
  Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
+ Requires-Dist: flatbuffers>=2.0
18
19
  Provides-Extra: numpy
19
20
  Requires-Dist: numpy>=1.20; extra == "numpy"
20
21
  Provides-Extra: dev
21
22
  Requires-Dist: pytest>=7.0; extra == "dev"
22
23
  Requires-Dist: numpy>=1.20; extra == "dev"
24
+ Requires-Dist: flatbuffers>=2.0; extra == "dev"
@@ -0,0 +1 @@
1
+ 0.3.0
@@ -9,6 +9,9 @@ description = "GPU-accelerated scientific plotting via IPC"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.9"
12
+ dependencies = [
13
+ "flatbuffers>=2.0",
14
+ ]
12
15
  classifiers = [
13
16
  "Development Status :: 3 - Alpha",
14
17
  "Intended Audience :: Science/Research",
@@ -24,7 +27,7 @@ classifiers = [
24
27
 
25
28
  [project.optional-dependencies]
26
29
  numpy = ["numpy>=1.20"]
27
- dev = ["pytest>=7.0", "numpy>=1.20"]
30
+ dev = ["pytest>=7.0", "numpy>=1.20", "flatbuffers>=2.0"]
28
31
 
29
32
  [project.scripts]
30
33
  spectra-backend = "spectra._cli:backend_main"
@@ -8,6 +8,8 @@ Usage::
8
8
 
9
9
  sp.plot([1, 4, 9, 16]) # that's it. window opens.
10
10
  sp.plot(x, y, color="red", label="data") # with style
11
+ sp.hline(0) # horizontal reference line
12
+ sp.fplot(lambda x: x**2, -2, 2) # plot a function
11
13
  sp.scatter(x, y) # scatter plot
12
14
  sp.hist(data, bins=50) # histogram
13
15
  sp.plot3(x, y, z) # 3D line
@@ -52,6 +54,10 @@ from ._easy import (
52
54
  violin_plot,
53
55
  hline,
54
56
  vline,
57
+ fplot,
58
+ ihline,
59
+ ivline,
60
+ ifplot,
55
61
  plot3,
56
62
  scatter3,
57
63
  surf,
@@ -105,6 +111,10 @@ __all__ = [
105
111
  "violin_plot",
106
112
  "hline",
107
113
  "vline",
114
+ "fplot",
115
+ "ihline",
116
+ "ivline",
117
+ "ifplot",
108
118
  "plot3",
109
119
  "scatter3",
110
120
  "surf",
@@ -1,10 +1,8 @@
1
- """TLV (Tag-Length-Value) codec mirroring src/ipc/codec.hpp.
1
+ """IPC payload codec mirroring src/ipc/codec.hpp.
2
2
 
3
- Wire format per field: [tag: u8] [len: u32 LE] [data: len bytes]
4
-
5
- FlatBuffers payloads are prefixed with 0x01; legacy TLV payloads start with
6
- a raw tag byte (never 0x00 or 0x01 in practice, but 0x00 is reserved).
7
- Decode functions auto-detect the format and delegate to _codec_fb when needed.
3
+ All encode_* functions emit FlatBuffers (0x01 prefix) via _codec_fb.
4
+ PayloadEncoder remains for a few legacy write paths (chunked set-data, anim).
5
+ Decode functions auto-detect FlatBuffers vs legacy TLV and delegate to _codec_fb.
8
6
  """
9
7
 
10
8
  import struct
@@ -306,17 +304,25 @@ def encode_req_remove_series(figure_id: int, series_index: int) -> bytes:
306
304
  def encode_req_close_figure(figure_id: int) -> bytes:
307
305
  return fb_codec.encode_fb_req_close_figure(figure_id=figure_id)
308
306
 
307
+ def decode_req_update_batch(data: bytes) -> list:
308
+ """Decode REQ_UPDATE_BATCH. Returns a list of update dicts."""
309
+ if fb_codec._is_fb(data):
310
+ return fb_codec.decode_fb_req_update_batch(data)
311
+ updates = []
312
+ dec = PayloadDecoder(data)
313
+ while dec.next():
314
+ if dec.tag == P.TAG_BATCH_ITEM:
315
+ updates.append(decode_req_update_property(dec.as_blob()))
316
+ return updates
317
+
318
+
309
319
  def encode_req_update_batch(updates: list) -> bytes:
310
320
  """Encode REQ_UPDATE_BATCH — multiple property updates in one message.
311
321
 
312
322
  Each item in `updates` is a dict with keys matching encode_req_update_property kwargs:
313
323
  figure_id, axes_index, series_index, prop, f1..f4, bool_val, str_val
314
324
  """
315
- enc = PayloadEncoder()
316
- for upd in updates:
317
- item_bytes = encode_req_update_property(**upd)
318
- enc.put_blob(P.TAG_BATCH_ITEM, item_bytes)
319
- return enc.take()
325
+ return fb_codec.encode_fb_req_update_batch(updates)
320
326
 
321
327
 
322
328
  def encode_req_reconnect(session_id: int, session_token: str = "") -> bytes:
@@ -217,6 +217,38 @@ def encode_fb_req_update_property(
217
217
  return _finalize(builder)
218
218
 
219
219
 
220
+ def encode_fb_req_update_batch(updates: List[dict]) -> bytes:
221
+ """Encode REQ_UPDATE_BATCH with nested property updates."""
222
+ builder = flatbuffers.Builder(1024)
223
+ offsets = []
224
+ for upd in updates:
225
+ prop_off = builder.CreateString(upd.get("prop", ""))
226
+ str_val = upd.get("str_val", "")
227
+ str_off = builder.CreateString(str_val) if str_val else None
228
+ FBReqUpdProp.Start(builder)
229
+ FBReqUpdProp.AddFigureId(builder, upd.get("figure_id", 0))
230
+ FBReqUpdProp.AddAxesIndex(builder, upd.get("axes_index", 0))
231
+ FBReqUpdProp.AddSeriesIndex(builder, upd.get("series_index", 0))
232
+ FBReqUpdProp.AddProperty(builder, prop_off)
233
+ FBReqUpdProp.AddF1(builder, upd.get("f1", 0.0))
234
+ FBReqUpdProp.AddF2(builder, upd.get("f2", 0.0))
235
+ FBReqUpdProp.AddF3(builder, upd.get("f3", 0.0))
236
+ FBReqUpdProp.AddF4(builder, upd.get("f4", 0.0))
237
+ FBReqUpdProp.AddBoolVal(builder, upd.get("bool_val", False))
238
+ if str_off is not None:
239
+ FBReqUpdProp.AddStrVal(builder, str_off)
240
+ offsets.append(FBReqUpdProp.End(builder))
241
+ FBReqUpdBatch.StartUpdatesVector(builder, len(offsets))
242
+ for off in reversed(offsets):
243
+ builder.PrependUOffsetTRelative(off)
244
+ upds_off = builder.EndVector()
245
+ FBReqUpdBatch.Start(builder)
246
+ FBReqUpdBatch.AddUpdates(builder, upds_off)
247
+ root = builder.EndObject()
248
+ builder.Finish(root)
249
+ return _finalize(builder)
250
+
251
+
220
252
  def encode_fb_req_show(figure_id: int, window_id: int = 0) -> bytes:
221
253
  builder = flatbuffers.Builder(64)
222
254
  FBReqShow.Start(builder)
@@ -330,6 +362,30 @@ def decode_fb_evt_window_closed(data: bytes) -> Tuple[int, int, str]:
330
362
  )
331
363
 
332
364
 
365
+ def decode_fb_req_update_batch(data: bytes) -> List[dict]:
366
+ buf = _strip(data) if _is_fb(data) else data
367
+ fb = FBReqUpdBatch.ReqUpdateBatchPayload.GetRootAs(buf, 0)
368
+ updates = []
369
+ if fb.UpdatesLength():
370
+ for i in range(fb.UpdatesLength()):
371
+ upd = fb.Updates(i)
372
+ updates.append(
373
+ {
374
+ "figure_id": upd.FigureId(),
375
+ "axes_index": upd.AxesIndex(),
376
+ "series_index": upd.SeriesIndex(),
377
+ "prop": (upd.Property() or b"").decode("utf-8", errors="replace"),
378
+ "f1": upd.F1(),
379
+ "f2": upd.F2(),
380
+ "f3": upd.F3(),
381
+ "f4": upd.F4(),
382
+ "bool_val": upd.BoolVal(),
383
+ "str_val": (upd.StrVal() or b"").decode("utf-8", errors="replace"),
384
+ }
385
+ )
386
+ return updates
387
+
388
+
333
389
  def decode_fb_evt_figure_destroyed(data: bytes) -> Tuple[int, str]:
334
390
  buf = _strip(data) if _is_fb(data) else data
335
391
  fb = FBEvtFigDestroyed.EvtFigureDestroyedPayload.GetRootAs(buf, 0)
@@ -504,3 +560,21 @@ def decode_fb_req_update_property(data: bytes) -> dict:
504
560
  "str_val": str_val_raw.decode("utf-8") if str_val_raw else "",
505
561
  }
506
562
 
563
+
564
+ def decode_fb_snapshot_knobs(data: bytes) -> dict:
565
+ """Extract knob name → value from a STATE_SNAPSHOT / RESP_SNAPSHOT payload."""
566
+ from ._fb_generated.spectra.ipc.fb.StateSnapshotPayload import StateSnapshotPayload
567
+
568
+ raw = _strip(data)
569
+ snap = StateSnapshotPayload.GetRootAs(bytearray(raw), 0)
570
+ out: dict = {}
571
+ n = snap.KnobsLength()
572
+ for i in range(n):
573
+ k = snap.Knobs(i)
574
+ if k is None:
575
+ continue
576
+ name = k.Name()
577
+ if name:
578
+ out[name.decode("utf-8")] = float(k.Value())
579
+ return out
580
+
@@ -137,6 +137,10 @@ class _EasyState:
137
137
  self._shutting_down = False
138
138
  self._axes_bounds: dict = {} # id(axes) -> [xmin, xmax, ymin, ymax]
139
139
  self._subplot_cache: dict = {} # (figure_id, rows, cols, index) -> Axes
140
+ self._pending_knobs: List[dict] = []
141
+ self._interactive_bindings: List = []
142
+ self._interactive_started = False
143
+ self._knob_values: dict = {}
140
144
 
141
145
  def _ensure_session(self):
142
146
  """Lazily create the backend session."""
@@ -160,8 +164,10 @@ class _EasyState:
160
164
  """Show the current figure if it hasn't been shown yet."""
161
165
  fig = self._current_fig
162
166
  if fig is not None and id(fig) in self._pending_show:
167
+ self._flush_pending_knobs()
163
168
  self._pending_show.discard(id(fig))
164
169
  fig.show()
170
+ _ensure_interactive_loop()
165
171
 
166
172
  def _ensure_axes(self):
167
173
  """Lazily create axes on the current figure."""
@@ -178,6 +184,32 @@ class _EasyState:
178
184
  self._current_axes3d = fig.subplot3d(1, 1, 1)
179
185
  return self._current_axes3d
180
186
 
187
+ def _flush_pending_knobs(self) -> None:
188
+ """Push queued knob definitions to the backend before the window opens."""
189
+ if not self._pending_knobs:
190
+ return
191
+ from . import _protocol as P
192
+ from . import _codec as codec
193
+
194
+ session = self._ensure_session()
195
+ for spec in self._pending_knobs:
196
+ payload = codec.encode_req_update_property(
197
+ figure_id=spec["figure_id"],
198
+ axes_index=0,
199
+ series_index=0,
200
+ prop="add_knob",
201
+ f1=spec["value"],
202
+ f2=spec["min"],
203
+ f3=spec["max"],
204
+ str_val=spec["name"],
205
+ )
206
+ try:
207
+ session._request(P.REQ_UPDATE_PROPERTY, payload)
208
+ except Exception:
209
+ pass
210
+ self._knob_values[spec["name"]] = spec["value"]
211
+ self._pending_knobs.clear()
212
+
181
213
  def shutdown(self):
182
214
  """Clean shutdown — stop live threads, close session."""
183
215
  self._shutting_down = True
@@ -187,6 +219,10 @@ class _EasyState:
187
219
  t.join(timeout=2.0)
188
220
  self._live_threads.clear()
189
221
  self._live_stop_events.clear()
222
+ self._pending_knobs.clear()
223
+ self._interactive_bindings.clear()
224
+ self._interactive_started = False
225
+ self._knob_values.clear()
190
226
  if self._session is not None:
191
227
  try:
192
228
  self._session.close()
@@ -686,8 +722,22 @@ def violin_plot(
686
722
  return series
687
723
 
688
724
 
689
- def hline(y: float, color: Union[str, Tuple, List, None] = "gray", label: str = ""):
690
- """Draw a horizontal line across the plot."""
725
+ def hline(
726
+ y: float,
727
+ color: Union[str, Tuple, List, None] = "gray",
728
+ label: str = "",
729
+ *,
730
+ interactive: bool = False,
731
+ ymin: float = -10.0,
732
+ ymax: float = 10.0,
733
+ knob_name: str = "Y line",
734
+ ):
735
+ """Draw a horizontal reference line at y (e.g. ``hline(0)`` for the x-axis).
736
+
737
+ Set ``interactive=True`` (or use ``ihline``) to drag a slider that moves the line.
738
+ """
739
+ if interactive:
740
+ return ihline(y, ymin, ymax, color=color, label=label, knob_name=knob_name)
691
741
  ax = _state._ensure_axes()
692
742
  series = ax.line([-1e12, 1e12], [y, y], label=label)
693
743
  c = _parse_color(color)
@@ -697,8 +747,22 @@ def hline(y: float, color: Union[str, Tuple, List, None] = "gray", label: str =
697
747
  return series
698
748
 
699
749
 
700
- def vline(x: float, color: Union[str, Tuple, List, None] = "gray", label: str = ""):
701
- """Draw a vertical line across the plot."""
750
+ def vline(
751
+ x: float,
752
+ color: Union[str, Tuple, List, None] = "gray",
753
+ label: str = "",
754
+ *,
755
+ interactive: bool = False,
756
+ xmin: float = -10.0,
757
+ xmax: float = 10.0,
758
+ knob_name: str = "X line",
759
+ ):
760
+ """Draw a vertical reference line at x (e.g. ``vline(0)`` for the y-axis).
761
+
762
+ Set ``interactive=True`` (or use ``ivline``) to drag a slider that moves the line.
763
+ """
764
+ if interactive:
765
+ return ivline(x, xmin, xmax, color=color, label=label, knob_name=knob_name)
702
766
  ax = _state._ensure_axes()
703
767
  series = ax.line([x, x], [-1e12, 1e12], label=label)
704
768
  c = _parse_color(color)
@@ -708,6 +772,283 @@ def vline(x: float, color: Union[str, Tuple, List, None] = "gray", label: str =
708
772
  return series
709
773
 
710
774
 
775
+ def fplot(
776
+ func,
777
+ xmin: Optional[float] = None,
778
+ xmax: Optional[float] = None,
779
+ n: int = 200,
780
+ color: Union[str, Tuple, List, None] = None,
781
+ width: Optional[float] = None,
782
+ label: str = "",
783
+ *,
784
+ interactive: bool = False,
785
+ params: Optional[List[Tuple[str, float, float, float]]] = None,
786
+ **kwargs,
787
+ ):
788
+ """Plot y = func(x) over an x range.
789
+
790
+ Usage::
791
+
792
+ sp.fplot(lambda x: x ** 2, -2, 2)
793
+ sp.fplot(math.sin) # uses current axes x-range or [-1, 1]
794
+ sp.fplot(lambda x: x ** 2, color="red", label="parabola")
795
+ sp.ifplot(lambda x, a, b: a * x * x + b, -3, 3, params=[("a", 0.2, -1, 1)])
796
+
797
+ If *xmin* / *xmax* are omitted, uses accumulated axes bounds from prior plots
798
+ on the same axes, otherwise ``[-1, 1]``.
799
+
800
+ Set ``interactive=True`` with *params* ``[(name, value, min, max), ...]`` to
801
+ expose sliders for each coefficient (or use ``ifplot``).
802
+ """
803
+ if interactive:
804
+ return ifplot(func, xmin, xmax, n=n, color=color, width=width, label=label,
805
+ params=params or [], **kwargs)
806
+
807
+ ax = _state._ensure_axes()
808
+
809
+ if xmin is None or xmax is None:
810
+ ax_key = (ax._figure_id, ax._index)
811
+ if ax_key in _state._axes_bounds:
812
+ bounds = _state._axes_bounds[ax_key]
813
+ if xmin is None:
814
+ xmin = bounds[0]
815
+ if xmax is None:
816
+ xmax = bounds[1]
817
+ else:
818
+ if xmin is None:
819
+ xmin = -1.0
820
+ if xmax is None:
821
+ xmax = 1.0
822
+
823
+ if xmin == xmax:
824
+ xmax = xmin + 1.0
825
+
826
+ n = max(int(n), 2)
827
+ step = (xmax - xmin) / (n - 1)
828
+ xs = [xmin + i * step for i in range(n)]
829
+ ys = [float(func(x)) for x in xs]
830
+
831
+ series = ax.line(xs, ys, label=label)
832
+ _apply_series_style(series, color=color, width=width, **kwargs)
833
+ _auto_fit_axes(ax, xs, ys)
834
+ _state._show_if_pending()
835
+ return series
836
+
837
+
838
+ # ─── Interactive reference lines & function plots ─────────────────────────────
839
+
840
+ _REF_SPAN = 1e12
841
+
842
+
843
+ class _InteractiveHLine:
844
+ __slots__ = ("series", "knob_name")
845
+
846
+ def __init__(self, series, knob_name: str) -> None:
847
+ self.series = series
848
+ self.knob_name = knob_name
849
+
850
+ def apply(self, knobs: dict) -> None:
851
+ if self.knob_name not in knobs:
852
+ return
853
+ y = float(knobs[self.knob_name])
854
+ self.series.set_data([-_REF_SPAN, _REF_SPAN], [y, y])
855
+
856
+
857
+ class _InteractiveVLine:
858
+ __slots__ = ("series", "knob_name")
859
+
860
+ def __init__(self, series, knob_name: str) -> None:
861
+ self.series = series
862
+ self.knob_name = knob_name
863
+
864
+ def apply(self, knobs: dict) -> None:
865
+ if self.knob_name not in knobs:
866
+ return
867
+ x = float(knobs[self.knob_name])
868
+ self.series.set_data([x, x], [-_REF_SPAN, _REF_SPAN])
869
+
870
+
871
+ class _InteractiveFplot:
872
+ __slots__ = ("series", "func", "xs", "param_names")
873
+
874
+ def __init__(self, series, func, xs: List[float], param_names: List[str]) -> None:
875
+ self.series = series
876
+ self.func = func
877
+ self.xs = xs
878
+ self.param_names = param_names
879
+
880
+ def apply(self, knobs: dict) -> None:
881
+ pvals = [float(knobs.get(n, 0.0)) for n in self.param_names]
882
+ ys = [float(self.func(x, *pvals)) for x in self.xs]
883
+ self.series.set_data(self.xs, ys)
884
+
885
+
886
+ def _queue_knob(ax, name: str, value: float, vmin: float, vmax: float) -> None:
887
+ spec = {
888
+ "figure_id": ax._figure_id,
889
+ "name": name,
890
+ "value": float(value),
891
+ "min": float(vmin),
892
+ "max": float(vmax),
893
+ }
894
+ _state._knob_values[name] = float(value)
895
+ fig = _state._current_fig
896
+ if fig is not None and id(fig) not in _state._pending_show:
897
+ from . import _protocol as P
898
+ from . import _codec as codec
899
+
900
+ session = _state._ensure_session()
901
+ payload = codec.encode_req_update_property(
902
+ figure_id=spec["figure_id"],
903
+ axes_index=0,
904
+ series_index=0,
905
+ prop="add_knob",
906
+ f1=spec["value"],
907
+ f2=spec["min"],
908
+ f3=spec["max"],
909
+ str_val=spec["name"],
910
+ )
911
+ try:
912
+ session._request(P.REQ_UPDATE_PROPERTY, payload)
913
+ except Exception:
914
+ _state._pending_knobs.append(spec)
915
+ else:
916
+ _state._pending_knobs.append(spec)
917
+
918
+
919
+ def _poll_knob_values(session) -> dict:
920
+ from . import _protocol as P
921
+ from . import _codec as codec
922
+
923
+ try:
924
+ resp = session._request(P.REQ_GET_SNAPSHOT, b"")
925
+ payload = resp["payload"]
926
+ if payload and payload[0] == P.PAYLOAD_FORMAT_FLATBUFFERS:
927
+ from . import _codec_fb as fb_codec
928
+ return fb_codec.decode_fb_snapshot_knobs(payload)
929
+ except Exception:
930
+ pass
931
+ return dict(_state._knob_values)
932
+
933
+
934
+ def _ensure_interactive_loop() -> None:
935
+ if _state._interactive_started or not _state._interactive_bindings:
936
+ return
937
+ _state._interactive_started = True
938
+ from ._animation import ipc_sleep
939
+ from ._log import log
940
+
941
+ stop_event = threading.Event()
942
+ _state._live_stop_events.append(stop_event)
943
+
944
+ def _loop() -> None:
945
+ session = _state._ensure_session()
946
+ session._live_thread_count += 1
947
+ try:
948
+ while not stop_event.is_set():
949
+ ipc_sleep(0.05, session)
950
+ knobs = _poll_knob_values(session)
951
+ _state._knob_values.update(knobs)
952
+ for binding in _state._interactive_bindings:
953
+ try:
954
+ binding.apply(knobs)
955
+ except Exception as e:
956
+ log.warning("interactive update error: %s", e)
957
+ finally:
958
+ session._live_thread_count -= 1
959
+
960
+ thread = threading.Thread(target=_loop, daemon=True, name="spectra-interactive")
961
+ thread.start()
962
+ _state._live_threads.append(thread)
963
+
964
+
965
+ def ihline(
966
+ y: float,
967
+ ymin: float = -10.0,
968
+ ymax: float = 10.0,
969
+ color: Union[str, Tuple, List, None] = "gray",
970
+ label: str = "",
971
+ knob_name: str = "Y line",
972
+ ):
973
+ """Interactive horizontal line — drag the PARAMETERS slider to move y."""
974
+ series = hline(y, color=color, label=label)
975
+ ax = _state._ensure_axes()
976
+ _queue_knob(ax, knob_name, y, ymin, ymax)
977
+ _state._interactive_bindings.append(_InteractiveHLine(series, knob_name))
978
+ _state._show_if_pending()
979
+ return series
980
+
981
+
982
+ def ivline(
983
+ x: float,
984
+ xmin: float = -10.0,
985
+ xmax: float = 10.0,
986
+ color: Union[str, Tuple, List, None] = "gray",
987
+ label: str = "",
988
+ knob_name: str = "X line",
989
+ ):
990
+ """Interactive vertical line — drag the PARAMETERS slider to move x."""
991
+ series = vline(x, color=color, label=label)
992
+ ax = _state._ensure_axes()
993
+ _queue_knob(ax, knob_name, x, xmin, xmax)
994
+ _state._interactive_bindings.append(_InteractiveVLine(series, knob_name))
995
+ _state._show_if_pending()
996
+ return series
997
+
998
+
999
+ def ifplot(
1000
+ func,
1001
+ xmin: Optional[float] = None,
1002
+ xmax: Optional[float] = None,
1003
+ n: int = 200,
1004
+ color: Union[str, Tuple, List, None] = None,
1005
+ width: Optional[float] = None,
1006
+ label: str = "",
1007
+ params: Optional[List[Tuple[str, float, float, float]]] = None,
1008
+ **kwargs,
1009
+ ):
1010
+ """Interactive function plot — sliders retune each entry in *params*.
1011
+
1012
+ *func* signature: ``func(x, *param_values)`` matching *params* order::
1013
+
1014
+ sp.ifplot(lambda x, a, b: a * x * x + b, -3, 3,
1015
+ params=[("a", 0.2, -1, 1), ("b", 0, -2, 2)])
1016
+ """
1017
+ param_specs = params or []
1018
+ ax = _state._ensure_axes()
1019
+
1020
+ if xmin is None or xmax is None:
1021
+ ax_key = (ax._figure_id, ax._index)
1022
+ if ax_key in _state._axes_bounds:
1023
+ bounds = _state._axes_bounds[ax_key]
1024
+ xmin = xmin if xmin is not None else bounds[0]
1025
+ xmax = xmax if xmax is not None else bounds[1]
1026
+ else:
1027
+ xmin = xmin if xmin is not None else -1.0
1028
+ xmax = xmax if xmax is not None else 1.0
1029
+ if xmin == xmax:
1030
+ xmax = xmin + 1.0
1031
+
1032
+ n = max(int(n), 2)
1033
+ step = (xmax - xmin) / (n - 1)
1034
+ xs = [xmin + i * step for i in range(n)]
1035
+ pvals = [p[1] for p in param_specs]
1036
+ ys = [float(func(x, *pvals)) for x in xs]
1037
+
1038
+ series = ax.line(xs, ys, label=label)
1039
+ _apply_series_style(series, color=color, width=width, **kwargs)
1040
+ _auto_fit_axes(ax, xs, ys)
1041
+
1042
+ param_names: List[str] = []
1043
+ for name, value, vmin, vmax in param_specs:
1044
+ _queue_knob(ax, name, value, vmin, vmax)
1045
+ param_names.append(name)
1046
+
1047
+ _state._interactive_bindings.append(_InteractiveFplot(series, func, xs, param_names))
1048
+ _state._show_if_pending()
1049
+ return series
1050
+
1051
+
711
1052
  # ─── Auto-fit helper ─────────────────────────────────────────────────────────
712
1053
 
713
1054
  def _auto_fit_axes(ax, x: List[float], y: List[float]) -> None: