Skip to content

Commit 777f256

Browse files
Jammy2211claude
authored andcommitted
feat(quick_update): IPython.display.update_display for live Jupyter cells
`BackgroundQuickUpdate` now pushes the freshly-written `subplot_fit.png` into the active Jupyter / Colab cell via `IPython.display.update_display` with a stable `display_id`, so the cell that ran `search.fit(...)` shows a single self-updating image during the fit rather than just writing PNGs to disk. How it works: - After `analysis.perform_quick_update(paths, instance)` returns, the daemon worker checks `_is_ipython_kernel()` (try-imports IPython, inspects `get_ipython().config` for `IPKernelApp`). - If running in a kernel, locates `subplot_fit.png` (with `fit.png` / `subplot_tracer.png` fallbacks) under `paths.image_path` and calls `display(Image(filename=...), display_id="pyauto_fit_progress")` on the first iteration, `update_display(... same id)` on subsequent iterations. - Reads the PNG from disk (not a matplotlib `Figure`) — the worker is a daemon thread, and matplotlib Figure handling is not thread-safe. Outside a kernel (plain `python my_fit.py`), the display layer is silently skipped — script behaviour is unchanged. Users inside a kernel can opt out via `PYAUTO_DISABLE_IPYTHON_DISPLAY=1` (papermill, nbconvert pipelines, etc.). Any IPython-side exception is logged and swallowed so a display failure never takes the search down. New optional kwarg `BackgroundQuickUpdate(display_id="pyauto_fit_progress")` for users running two concurrent searches in the same kernel. Tests added: - `_is_ipython_kernel()` returns False in pytest (script-mode fallback). - `_push_to_ipython()` no-ops when no PNG exists yet. - With a mocked IPython.display module, first call uses `display(...)` with the configured display_id; subsequent calls use `update_display` with the same id. Phase C of z_features/fast_visualization.md. Closes #1289 (library half; autofit_workspace cookbook docs follow after this lands in a release). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fe8f6d9 commit 777f256

2 files changed

Lines changed: 226 additions & 2 deletions

File tree

autofit/non_linear/quick_update.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import copy
22
import logging
3+
import os
34
import threading
5+
from pathlib import Path
46

57
import numpy as np
68

@@ -26,6 +28,14 @@ def _convert_jax_to_numpy(instance):
2628
return instance
2729

2830

31+
# Filenames the worker will look for under `paths.image_path`, in priority
32+
# order, when pushing a quick-update frame to a Jupyter / Colab cell. The
33+
# first existing file wins. Imaging analyses write `subplot_fit.png`;
34+
# interferometer / dataset-model variants may write `fit.png` or
35+
# `subplot_tracer.png`.
36+
_DISPLAY_CANDIDATES = ("subplot_fit.png", "fit.png", "subplot_tracer.png")
37+
38+
2939
class BackgroundQuickUpdate:
3040
"""
3141
Runs ``analysis.perform_quick_update`` on a background daemon thread so
@@ -34,15 +44,38 @@ class BackgroundQuickUpdate:
3444
Uses a **latest-only** pattern: if a new best-fit arrives before the
3545
previous visualisation finishes, the stale request is silently replaced.
3646
47+
When the search is running inside a Jupyter / Colab kernel, the worker
48+
additionally pushes the freshly-written subplot PNG to the active cell
49+
via :func:`IPython.display.update_display` with a stable ``display_id``,
50+
so the cell that ran ``search.fit(...)`` shows a single self-updating
51+
image rather than just writing PNGs to disk. Outside a kernel (plain
52+
``python my_fit.py``) this layer is silently skipped — PNGs still land
53+
on disk and no IPython side effects fire. Users can opt out inside a
54+
kernel by setting ``PYAUTO_DISABLE_IPYTHON_DISPLAY=1`` (e.g. for
55+
papermill / automated nbconvert pipelines).
56+
3757
Parameters
3858
----------
3959
convert_jax
4060
If ``True``, JAX arrays on the model instance are converted to
4161
NumPy before handing them to the worker thread.
62+
display_id
63+
Stable identifier passed to ``IPython.display.update_display`` so
64+
every quick-update frame refreshes the same cell output element
65+
rather than appending a new one. Default
66+
``"pyauto_fit_progress"`` is fine for almost all uses; override
67+
only when running two concurrent searches in the same kernel and
68+
wanting each in its own cell output.
4269
"""
4370

44-
def __init__(self, convert_jax: bool = False):
71+
def __init__(
72+
self,
73+
convert_jax: bool = False,
74+
display_id: str = "pyauto_fit_progress",
75+
):
4576
self._convert_jax = convert_jax
77+
self._display_id = display_id
78+
self._display_initialised = False
4679

4780
self._lock = threading.Lock()
4881
self._pending = None
@@ -76,6 +109,84 @@ def shutdown(self, timeout: float = 10.0):
76109
self._has_work.set()
77110
self._thread.join(timeout=timeout)
78111

112+
@staticmethod
113+
def _is_ipython_kernel() -> bool:
114+
"""
115+
Return ``True`` if running inside a Jupyter / Colab IPython kernel,
116+
``False`` otherwise (script mode, REPL, IPython terminal).
117+
118+
Detection works by importing ``IPython.get_ipython`` and checking
119+
that the returned shell has ``IPKernelApp`` registered in its
120+
config — only kernel-based shells (Jupyter, Colab, JupyterLab) do.
121+
A plain IPython terminal returns a shell without the kernel app,
122+
so it does not match. ``ImportError`` (IPython not installed) and
123+
any other exception are swallowed: the worker stays decoupled from
124+
the optional IPython dependency.
125+
"""
126+
try:
127+
from IPython import get_ipython
128+
except ImportError:
129+
return False
130+
try:
131+
ipy = get_ipython()
132+
if ipy is None:
133+
return False
134+
return "IPKernelApp" in getattr(ipy, "config", {})
135+
except Exception:
136+
return False
137+
138+
def _resolve_display_image_path(self, paths):
139+
"""
140+
Return the first existing PNG under ``paths.image_path`` that
141+
matches one of the canonical quick-update filenames, or ``None``
142+
if none of them exist yet (e.g. the analysis ran with
143+
``PYAUTO_FAST_PLOTS=1`` and wrote no figures this iteration).
144+
"""
145+
image_path = getattr(paths, "image_path", None)
146+
if image_path is None:
147+
return None
148+
base = Path(image_path)
149+
for name in _DISPLAY_CANDIDATES:
150+
candidate = base / name
151+
if candidate.exists():
152+
return candidate
153+
return None
154+
155+
def _push_to_ipython(self, paths):
156+
"""
157+
Display or refresh the latest quick-update subplot in the active
158+
IPython cell using a stable ``display_id``.
159+
160+
The first call publishes the image via ``display(... display_id=...)``;
161+
subsequent calls update it in place via
162+
``update_display(... display_id=...)``. The image is read from disk
163+
as PNG bytes — we deliberately do **not** touch matplotlib Figure
164+
objects here because this method runs on the daemon worker thread
165+
and matplotlib Figures are not thread-safe.
166+
167+
Skipped entirely when ``PYAUTO_DISABLE_IPYTHON_DISPLAY=1`` is set
168+
— useful for papermill / nbconvert pipelines that want PNGs on
169+
disk but no inline display side effects.
170+
"""
171+
if os.environ.get("PYAUTO_DISABLE_IPYTHON_DISPLAY") == "1":
172+
return
173+
174+
png_path = self._resolve_display_image_path(paths)
175+
if png_path is None:
176+
return
177+
178+
try:
179+
from IPython.display import Image, display, update_display
180+
except ImportError:
181+
return
182+
183+
img = Image(filename=str(png_path))
184+
if self._display_initialised:
185+
update_display(img, display_id=self._display_id)
186+
else:
187+
display(img, display_id=self._display_id)
188+
self._display_initialised = True
189+
79190
def _process_pending(self):
80191
with self._lock:
81192
work = self._pending
@@ -89,11 +200,24 @@ def _process_pending(self):
89200
try:
90201
analysis.perform_quick_update(paths, instance)
91202
except NotImplementedError:
92-
pass
203+
return
93204
except Exception:
94205
logger.exception(
95206
"Background quick-update raised an exception (ignored)."
96207
)
208+
return
209+
210+
# If running inside a Jupyter / Colab kernel, push the freshly-
211+
# written subplot PNG to the active cell so the user sees the fit
212+
# update in place. Any failure here is logged-and-swallowed so a
213+
# display problem never takes the search down.
214+
if self._is_ipython_kernel():
215+
try:
216+
self._push_to_ipython(paths)
217+
except Exception:
218+
logger.exception(
219+
"IPython display update raised an exception (ignored)."
220+
)
97221

98222
def _worker(self):
99223
while True:

test_autofit/non_linear/test_quick_update.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,103 @@ def __init__(self):
161161
worker.shutdown()
162162

163163
assert isinstance(analysis.calls[0].param, np.ndarray)
164+
165+
166+
class TestIPythonDisplayLayer:
167+
"""
168+
Covers the Jupyter / Colab cell live-update wiring added on top of the
169+
existing background-thread / latest-only / log-and-swallow behaviour.
170+
171+
The display layer is gated on `_is_ipython_kernel()` so script-mode
172+
behaviour (PNG-on-disk, no IPython side effects) is preserved.
173+
"""
174+
175+
def test_is_ipython_kernel_false_in_pytest(self):
176+
"""
177+
Plain pytest does not run inside a Jupyter / Colab kernel, so the
178+
detection helper must return False. This is the script-mode
179+
fallback path users rely on when running `python my_fit.py`.
180+
"""
181+
worker = BackgroundQuickUpdate()
182+
try:
183+
assert worker._is_ipython_kernel() is False
184+
finally:
185+
worker.shutdown()
186+
187+
def test_push_to_ipython_no_op_when_png_missing(self, tmp_path):
188+
"""
189+
If `perform_quick_update` produced no PNGs (e.g. `PYAUTO_FAST_PLOTS=1`
190+
suppressed them, or an early-iteration scenario where the visualizer
191+
wrote nothing yet), `_push_to_ipython` must silently no-op rather
192+
than raising — the search must not be taken down by a missing file.
193+
"""
194+
class Paths:
195+
image_path = tmp_path # exists but contains no PNGs
196+
197+
worker = BackgroundQuickUpdate()
198+
try:
199+
# No exception expected, no display side effects.
200+
worker._push_to_ipython(Paths())
201+
assert worker._display_initialised is False
202+
finally:
203+
worker.shutdown()
204+
205+
def test_push_to_ipython_display_then_update_sequence(
206+
self, tmp_path, monkeypatch
207+
):
208+
"""
209+
With a fake IPython.display module installed in `sys.modules`, the
210+
first `_push_to_ipython` call must invoke `display(..., display_id=...)`
211+
and the second must invoke `update_display(..., display_id=...)` with
212+
the same id. This is the contract that lets the notebook cell
213+
refresh in place rather than appending stacked frames.
214+
"""
215+
import sys
216+
import types
217+
218+
png_path = tmp_path / "subplot_fit.png"
219+
png_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16) # fake PNG header
220+
221+
calls = []
222+
223+
def fake_display(obj, display_id=None):
224+
calls.append(("display", display_id))
225+
226+
def fake_update_display(obj, display_id=None):
227+
calls.append(("update_display", display_id))
228+
229+
class FakeImage:
230+
def __init__(self, filename=None):
231+
self.filename = filename
232+
233+
fake_display_module = types.ModuleType("IPython.display")
234+
fake_display_module.display = fake_display
235+
fake_display_module.update_display = fake_update_display
236+
fake_display_module.Image = FakeImage
237+
fake_ipython_module = types.ModuleType("IPython")
238+
fake_ipython_module.display = fake_display_module
239+
240+
monkeypatch.setitem(sys.modules, "IPython", fake_ipython_module)
241+
monkeypatch.setitem(sys.modules, "IPython.display", fake_display_module)
242+
monkeypatch.delenv("PYAUTO_DISABLE_IPYTHON_DISPLAY", raising=False)
243+
244+
class Paths:
245+
image_path = tmp_path
246+
247+
worker = BackgroundQuickUpdate(display_id="test-display-id")
248+
try:
249+
worker._push_to_ipython(Paths())
250+
worker._push_to_ipython(Paths())
251+
worker._push_to_ipython(Paths())
252+
finally:
253+
worker.shutdown()
254+
255+
# First call uses `display`, subsequent calls use `update_display`,
256+
# and the display_id is stable across all of them so the cell
257+
# output is replaced rather than appended.
258+
assert calls == [
259+
("display", "test-display-id"),
260+
("update_display", "test-display-id"),
261+
("update_display", "test-display-id"),
262+
]
263+
assert worker._display_initialised is True

0 commit comments

Comments
 (0)