From 7264f8f0cff6e678d51d3981a94d82f7ea514b14 Mon Sep 17 00:00:00 2001 From: Sean Colby Date: Fri, 1 May 2026 14:11:54 -0800 Subject: [PATCH 1/2] feat: unify HTML animation timings, add CDN export, fix mypy Dashboard / CLI changes: - Slider step frame/transition durations now match the play-button values (700 ms / 300 ms) so scrubbing and playback feel identical. - save_html() gains a use_cdn keyword arg; use_cdn=True emits include_plotlyjs='cdn' for a ~3 MB smaller file suitable for web-hosted viewing (requires internet connection). - CLI (moal simulate) now saves both dashboard_animation.html (self-contained) and dashboard_animation_cdn.html (CDN) by default. - Two new tests: CDN file is smaller and references cdn.plot.ly; slider and play-button timings match in the exported HTML. mypy fixes (0 errors, was 17): - Install pandas-stubs and scipy-stubs (already in dev deps, just missing). - Add rdkit and rdkit.* to [[tool.mypy.overrides]] ignore_missing_imports. - preprocessing.py: wrap Chem.MolToSmiles return in str() to fix no-any-return. - logging_config.py: remove stale type: ignore[attr-defined] on RDLogger call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- moal/cli.py | 3 +++ moal/dashboard.py | 13 +++++++++---- moal/logging_config.py | 2 +- moal/preprocessing.py | 2 +- pyproject.toml | 2 +- tests/test_dashboard.py | 38 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/moal/cli.py b/moal/cli.py index 1da7d9e..f147e24 100644 --- a/moal/cli.py +++ b/moal/cli.py @@ -258,6 +258,9 @@ def simulate(config: Path, output_dir: Path | None, verbose: bool) -> None: html_path = out_dir / "dashboard_animation.html" dashboard.save_html(html_path) + html_cdn_path = out_dir / "dashboard_animation_cdn.html" + dashboard.save_html(html_cdn_path, use_cdn=True) + gif_path = out_dir / "dashboard_animation.gif" dashboard.save_gif(gif_path) dashboard.close() diff --git a/moal/dashboard.py b/moal/dashboard.py index 27bd40c..b4aba92 100644 --- a/moal/dashboard.py +++ b/moal/dashboard.py @@ -487,7 +487,7 @@ def save_gif( except Exception as exc: logger.warning("Could not save dashboard GIF: %s", exc) - def save_html(self, path: str | Path) -> None: + def save_html(self, path: str | Path, *, use_cdn: bool = False) -> None: """Export the animated figure as a standalone HTML file. The exported file embeds an iteration slider and play/pause buttons so @@ -498,12 +498,17 @@ def save_html(self, path: str | Path) -> None: ---------- path : str or Path Destination file path (should end in ``.html``). + use_cdn : bool, optional + When ``True``, the Plotly JS bundle is loaded from the Plotly CDN + instead of being embedded in the file. This reduces the file size + from ~3 MB to a few KB but requires an internet connection to view. + Default is ``False`` (fully self-contained file). """ animated_fig = self._build_animated_figure() animated_fig.update_layout(width=self._export_width, height=self._export_height) animated_fig.write_html( str(path), - include_plotlyjs=True, + include_plotlyjs="cdn" if use_cdn else True, post_script=_PLAY_PAUSE_SCRIPT, auto_play=False, ) @@ -1151,10 +1156,10 @@ def _build_animated_figure(self) -> go.Figure: "args": [ [str(i + 1)], { - "frame": {"duration": 350, "redraw": False}, + "frame": {"duration": 700, "redraw": False}, "mode": "immediate", "transition": { - "duration": 220, + "duration": 300, "easing": _EASING, "ordering": "layout first", }, diff --git a/moal/logging_config.py b/moal/logging_config.py index 97c380c..cfe27a3 100644 --- a/moal/logging_config.py +++ b/moal/logging_config.py @@ -54,7 +54,7 @@ def _silence_rdkit() -> None: try: from rdkit import RDLogger - RDLogger.DisableLog("rdApp.*") # type: ignore[attr-defined] + RDLogger.DisableLog("rdApp.*") except ImportError: pass # Also silence the Python-side rdkit logger diff --git a/moal/preprocessing.py b/moal/preprocessing.py index da0f89f..364f2f2 100644 --- a/moal/preprocessing.py +++ b/moal/preprocessing.py @@ -58,7 +58,7 @@ def canonicalize(self, smiles: str) -> str | None: if mol is None or mol.GetNumAtoms() == 0: logger.warning("SMILES reduced to empty molecule after salt stripping: %s", smiles) return None - return Chem.MolToSmiles(mol, isomericSmiles=True) + return str(Chem.MolToSmiles(mol, isomericSmiles=True)) def process_batch(self, smiles_list: list[str]) -> tuple[list[str], list[str]]: """Canonicalize a batch of SMILES strings. diff --git a/pyproject.toml b/pyproject.toml index f2c1c7c..c840753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,5 +80,5 @@ warn_unused_configs = true no_implicit_reexport = true [[tool.mypy.overrides]] -module = ["chemprop.*", "plotly.*", "dash.*"] +module = ["chemprop.*", "plotly.*", "dash.*", "rdkit", "rdkit.*"] ignore_missing_imports = true diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 3f2324f..12467b0 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -554,6 +554,44 @@ def test_html_disables_plotly_builtin_autoplay(self, tmp_path): assert re.search(r"Plotly\.animate\('[^']+', null\);", content) is None assert "redraw: false" in content + def test_save_html_cdn_omits_embedded_plotlyjs(self, tmp_path): + """save_html(use_cdn=True) must load Plotly from CDN instead of embedding it.""" + db = LiveDashboard(n_iterations=2, n_compounds=20) + records = _make_records(4) + for _ in range(2): + db.update(records, activity_threshold=7.0, iter_drc_cost=5.0, iter_ps_cost=1.0) + + cdn_path = tmp_path / "dashboard_cdn.html" + embedded_path = tmp_path / "dashboard_embedded.html" + db.save_html(cdn_path, use_cdn=True) + db.save_html(embedded_path, use_cdn=False) + db.close() + + cdn_content = cdn_path.read_text() + + # CDN version must reference the Plotly CDN URL + assert "cdn.plot.ly" in cdn_content + # CDN version must be substantially smaller (no embedded JS bundle) + assert cdn_path.stat().st_size < embedded_path.stat().st_size + + def test_slider_and_playback_use_same_frame_duration(self, tmp_path): + """Slider step args and the injected play script must use the same frame/transition durations.""" + db = LiveDashboard(n_iterations=2, n_compounds=20) + records = _make_records(4) + for _ in range(2): + db.update(records, activity_threshold=7.0, iter_drc_cost=5.0, iter_ps_cost=1.0) + + html_path = tmp_path / "dashboard.html" + db.save_html(html_path) + db.close() + + content = html_path.read_text() + # Slider step JSON uses compact form; injected play script uses spaced form + assert '"duration":700' in content # slider step frame duration + assert "duration: 700" in content # injected JS play animation frame duration + assert '"duration":300' in content # slider step transition duration + assert "duration: 300" in content # injected JS play animation transition duration + def test_animated_frames_include_per_iteration_axis_layout(self): """Animated HTML frames must carry per-iteration axis updates, not just trace data.""" db = LiveDashboard(n_iterations=3, n_compounds=20) From 11e4119078070ef189ed9743d256a8cc76d694ad Mon Sep 17 00:00:00 2001 From: Sean Colby Date: Fri, 1 May 2026 14:37:51 -0800 Subject: [PATCH 2/2] fix: restore type: ignore[attr-defined] on RDLogger.DisableLog; drop rdkit from ignore_missing_imports DisableLog is an untyped C++ method in rdkit's stubs (rdkit ships py.typed but does not annotate all C++ extension methods). A targeted type: ignore[attr-defined] on that line is the correct fix. Removed rdkit/rdkit.* from the ignore_missing_imports override since both local and CI environments now use the typed PyPI rdkit wheel. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- moal/logging_config.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/moal/logging_config.py b/moal/logging_config.py index cfe27a3..97c380c 100644 --- a/moal/logging_config.py +++ b/moal/logging_config.py @@ -54,7 +54,7 @@ def _silence_rdkit() -> None: try: from rdkit import RDLogger - RDLogger.DisableLog("rdApp.*") + RDLogger.DisableLog("rdApp.*") # type: ignore[attr-defined] except ImportError: pass # Also silence the Python-side rdkit logger diff --git a/pyproject.toml b/pyproject.toml index c840753..f2c1c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,5 +80,5 @@ warn_unused_configs = true no_implicit_reexport = true [[tool.mypy.overrides]] -module = ["chemprop.*", "plotly.*", "dash.*", "rdkit", "rdkit.*"] +module = ["chemprop.*", "plotly.*", "dash.*"] ignore_missing_imports = true