diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398926d..65b7885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,5 +39,5 @@ jobs: uses: codecov/codecov-action@v6.0.0 with: files: ./coverage.xml - fail_ci_if_error: true + fail_ci_if_error: false use_oidc: true diff --git a/README.md b/README.md index 12e1c6b..b8c2ac5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

StericRender

+

StericRender

# StericRender: Topographic Steric Mapping with Molecular Visualisation diff --git a/docs/logo.png b/docs/logo.png new file mode 100644 index 0000000..ee93de4 Binary files /dev/null and b/docs/logo.png differ diff --git a/docs/logo.svg b/docs/logo.svg deleted file mode 100644 index ed111c8..0000000 --- a/docs/logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/pyproject.toml b/pyproject.toml index 69215a0..ce34be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "stericrender" -version = "1.0.0" +version = "1.1.0" description = "Topographic steric maps and buried volume (%VBur) with publication-quality SVG output." readme = "README.md" requires-python = ">=3.10" @@ -28,8 +28,8 @@ validation = [ ] dev = [ "morfeus-ml>=0.8.0", - "ruff", - "ty", + "ruff>=0.15.12", + "ty>=0.0.33", "pytest", "pytest-cov", ] @@ -56,6 +56,9 @@ python = ".venv" [dependency-groups] dev = [ + "morfeus-ml>=0.8.0", "ruff>=0.15.12", "ty>=0.0.33", + "pytest", + "pytest-cov", ] diff --git a/scripts/run_examples.py b/scripts/run_examples.py index b1ac1be..325a92f 100644 --- a/scripts/run_examples.py +++ b/scripts/run_examples.py @@ -9,6 +9,7 @@ import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) from split_sambvca_si import split_multi_xyz diff --git a/scripts/validate_references.py b/scripts/validate_references.py deleted file mode 100644 index 72809f1..0000000 --- a/scripts/validate_references.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python -"""Run StericRender validation checks against analytic and optional references.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - -import numpy as np - -from stericrender.io import atoms_to_arrays, load_atoms -from stericrender.radii import radius_for_symbol -from stericrender.validation import analytic_centered_sphere_percent, morfeus_compare -from stericrender.volume import compute_buried_volume - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--sambvca-xyz", help="Optional SambVca SI XYZ file containing reference structures") - parser.add_argument("--output", default="validation_report.json") - args = parser.parse_args() - - report = { - "analytic": _analytic_cases(), - "morfeus_simple": _morfeus_simple_case(), - "sambvca_si": _sambvca_placeholder(args.sambvca_xyz), - } - Path(args.output).write_text(json.dumps(report, indent=2, sort_keys=True) + "\n") - print(json.dumps(report, indent=2, sort_keys=True)) - - -def _analytic_cases() -> list[dict]: - cases = [] - for atom_radius, sphere_radius in [(1.0, 2.0), (radius_for_symbol("C", "scaled-bondi"), 3.5)]: - result = compute_buried_volume( - np.array([[0.0, 0.0, 0.0]]), - np.array([atom_radius]), - sphere_radius=sphere_radius, - mesh=0.05, - ) - expected = analytic_centered_sphere_percent(atom_radius, sphere_radius) - cases.append( - { - "atom_radius": atom_radius, - "sphere_radius": sphere_radius, - "stericrender_percent": result.percent_buried, - "analytic_percent": expected, - "delta_percent": result.percent_buried - expected, - } - ) - return cases - - -def _morfeus_simple_case() -> dict: - try: - import morfeus # noqa: F401 - except ModuleNotFoundError: - return {"status": "skipped", "reason": "morfeus-ml is not installed"} - atoms = load_atoms(Path("examples/simple.xyz")) - symbols, positions = atoms_to_arrays(atoms) - comparison = morfeus_compare( - symbols, - positions, - metal_index=1, - excluded_atoms=[], - sphere_radius=3.5, - mesh=0.1, - density=0.001, - ) - return { - "status": "ok", - "stericrender_percent": comparison.stericrender_percent, - "morfeus_percent": comparison.morfeus_percent, - "delta_percent": comparison.delta_percent, - } - - -def _sambvca_placeholder(path: str | None) -> dict: - if not path: - return { - "status": "skipped", - "reason": "pass --sambvca-xyz with om6b00371_si_002.xyz from the SambVca 2 supporting information", - } - input_path = Path(path) - if not input_path.is_file(): - return {"status": "missing", "path": str(input_path)} - structures = _read_multi_xyz_headers(input_path) - return { - "status": "available", - "path": str(input_path), - "structures": structures, - "n_structures": len(structures), - "note": "Reference structures are present. Add per-system center/axis/exclusion presets to validate published %VBur values.", - } - - -def _read_multi_xyz_headers(path: Path) -> list[dict]: - lines = path.read_text(errors="replace").splitlines() - structures = [] - i = 0 - while i < len(lines): - line = lines[i].strip() - if not line: - i += 1 - continue - try: - n_atoms = int(line) - except ValueError: - i += 1 - continue - title = lines[i + 1].strip() if i + 1 < len(lines) else "" - structures.append({"index": len(structures) + 1, "n_atoms": n_atoms, "title": title}) - i += n_atoms + 2 - return structures - - -if __name__ == "__main__": - main() diff --git a/src/stericrender/cli.py b/src/stericrender/cli.py index 5f4a740..c5b42f9 100644 --- a/src/stericrender/cli.py +++ b/src/stericrender/cli.py @@ -193,7 +193,7 @@ def process_frame(args: argparse.Namespace, frame: StructureFrame, prefix: Path) steric_map = compute_steric_map( selected_positions, selected_radii, - sphere_radius=map_radius, + sphere_radius=sphere_radius, mesh=args.mesh, ) @@ -236,7 +236,7 @@ def process_frame(args: argparse.Namespace, frame: StructureFrame, prefix: Path) f"{prefix}_map.svg", steric_map, volume, - sphere_radius=map_radius, + sphere_radius=sphere_radius, color_range=color_range, palette=args.map_palette, show_colorbar=not args.no_colorbar, @@ -261,7 +261,8 @@ def process_frame(args: argparse.Namespace, frame: StructureFrame, prefix: Path) output_svg=f"{prefix}_overlay.svg", steric_map=steric_map, volume=volume, - sphere_radius=map_radius, + sphere_radius=sphere_radius, + map_radius=map_radius, render_config=args.render_config, canvas_size=args.overlay_canvas_size, zoom=args.zoom, diff --git a/src/stericrender/export.py b/src/stericrender/export.py index 1dc1f77..4dd14d4 100644 --- a/src/stericrender/export.py +++ b/src/stericrender/export.py @@ -117,8 +117,8 @@ def sy(y: float) -> float: lines.extend( [ f'\n', - f'\n', - f'\n', + f'\n', + f'\n', ] ) if show_quadrant_labels: diff --git a/src/stericrender/overlay.py b/src/stericrender/overlay.py index a680821..4673174 100644 --- a/src/stericrender/overlay.py +++ b/src/stericrender/overlay.py @@ -28,6 +28,7 @@ def write_xyzrender_overlay_svg( steric_map: StericMapResult, volume: BuriedVolumeResult, sphere_radius: float, + map_radius: float | None = None, render_config: str = "default", canvas_size: int = 800, zoom: float = 1.0, @@ -47,6 +48,10 @@ def write_xyzrender_overlay_svg( steric-map origin. The overlay layer then uses the same orthographic projection convention as xyzrender: x to the right, y upward, z ignored for the 2D topographic map. + + sphere_radius controls the drawn circle and crosshairs (the analysis sphere + boundary). map_radius controls the viewport span and map fill extent; it + defaults to sphere_radius when not supplied. """ try: from xyzrender import load, render @@ -54,23 +59,24 @@ def write_xyzrender_overlay_svg( except ModuleNotFoundError as exc: raise RuntimeError("xyzrender is required for overlay SVG output") from exc + display_radius = map_radius if map_radius is not None else sphere_radius cfg = build_config(render_config, canvas_size=canvas_size, orient=False, hy=include_hydrogens, bo=True) cfg.fixed_center = (0.0, 0.0) if zoom <= 0.0: raise ValueError("--zoom must be greater than 0") - cfg.fixed_span = 2.0 * sphere_radius * zoom + cfg.fixed_span = 2.0 * display_radius * zoom mol = load(oriented_xyz) molecule_svg = str(render(mol, config=cfg, orient=False, stereo=stereo, stereo_style=stereo_style)) width, height = _svg_size(molecule_svg, default=canvas_size) scale = (canvas_size - 2.0 * cfg.padding) / cfg.fixed_span - r = sphere_radius * scale + r_clip = display_radius * scale if zoom > 1.0: molecule_svg = _clip_molecule_to_viewport(molecule_svg) else: - molecule_svg = _clip_molecule_to_disk(molecule_svg, cx=width / 2.0, cy=height / 2.0, r=r) + molecule_svg = _clip_molecule_to_disk(molecule_svg, cx=width / 2.0, cy=height / 2.0, r=r_clip) footer_layout = _overlay_footer_layout( height=height, - sphere_radius=sphere_radius, + map_radius=sphere_radius, scale=scale, show_colorbar=show_colorbar, content_bottom=_molecule_content_bottom(molecule_svg), @@ -167,7 +173,7 @@ def py(y: float) -> float: if footer_layout is None: footer_layout = _overlay_footer_layout( height=height, - sphere_radius=sphere_radius, + map_radius=sphere_radius, scale=scale, show_colorbar=show_colorbar, ) @@ -202,13 +208,13 @@ def py(y: float) -> float: def _overlay_footer_layout( *, height: float, - sphere_radius: float, + map_radius: float, scale: float, show_colorbar: bool, content_bottom: float | None = None, ) -> _OverlayFooterLayout: """Place overlay annotations after the visible map/molecule content.""" - map_bottom = height / 2.0 + sphere_radius * scale + map_bottom = height / 2.0 + map_radius * scale footer_anchor = max(map_bottom, content_bottom if content_bottom is not None else map_bottom) label_y = footer_anchor + 40.0 if show_colorbar: diff --git a/src/stericrender/radii.py b/src/stericrender/radii.py index 152b2ba..e47926c 100644 --- a/src/stericrender/radii.py +++ b/src/stericrender/radii.py @@ -41,7 +41,7 @@ def radius_for_symbol(symbol: str, radii: str = "scaled-bondi", default: float = if radii == "scaled-bondi": return BONDI_RADII.get(normalized, default) * 1.17 if radii == "csd": - return CSD_RADII.get(normalized, default * 1.17) + return CSD_RADII.get(normalized, default) raise ValueError(f"Unknown radii set: {radii}") diff --git a/src/stericrender/visual.py b/src/stericrender/visual.py index 2867db9..3a53154 100644 --- a/src/stericrender/visual.py +++ b/src/stericrender/visual.py @@ -254,7 +254,7 @@ def colorbar_svg( ) lines.append( f'(A)\n' + f'font-family="Arial,sans-serif" font-size="{font_size}" fill="#1f2933">(Å)\n' ) lines.append("\n") return "".join(lines) diff --git a/tests/test_multi_xyz.py b/tests/test_multi_xyz.py index 00955b0..545a3b5 100644 --- a/tests/test_multi_xyz.py +++ b/tests/test_multi_xyz.py @@ -117,6 +117,7 @@ def test_cli_map_radius_independent_of_sphere_radius(tmp_path, monkeypatch): def fake_overlay_renderer(**kwargs): captured["sphere_radius"] = kwargs["sphere_radius"] + captured["map_radius"] = kwargs["map_radius"] monkeypatch.setattr(cli, "write_xyzrender_overlay_svg", fake_overlay_renderer) @@ -137,7 +138,9 @@ def fake_overlay_renderer(**kwargs): ) metadata = json.loads(output_prefix.with_suffix(".json").read_text())["metadata"] - assert captured["sphere_radius"] == 5.0 + # sphere_radius controls the circle and map; map_radius only widens the viewport + assert captured["sphere_radius"] == 3.5 + assert captured["map_radius"] == 5.0 assert metadata["sphere_radius"] == 3.5 assert metadata["map_radius"] == 5.0 diff --git a/tests/test_visual.py b/tests/test_visual.py index 0f9a453..2a56342 100644 --- a/tests/test_visual.py +++ b/tests/test_visual.py @@ -1,5 +1,4 @@ import sys -from pathlib import Path from types import SimpleNamespace import numpy as np @@ -108,14 +107,6 @@ def test_steric_map_fill_svg_skips_empty_cells(): assert "data:image/png" not in svg -def test_readme_steric_map_example_has_no_quadrant_labels(): - svg = Path("examples/images/sambvca/complex_04_meduphos_map.svg").read_text() - assert ">NE" not in svg - assert ">NW" not in svg - assert ">SW" not in svg - assert ">SE" not in svg - - def test_contour_segments_find_crossing(): x = np.array([0.0, 1.0]) y = np.array([0.0, 1.0]) @@ -248,7 +239,7 @@ def fake_render(*args, **kwargs): def test_overlay_footer_follows_zoomed_map_instead_of_viewport_bottom(): layout = _overlay_footer_layout( height=800.0, - sphere_radius=3.5, + map_radius=3.5, scale=67.857142857, show_colorbar=True, content_bottom=610.0, @@ -262,7 +253,7 @@ def test_overlay_footer_follows_zoomed_map_instead_of_viewport_bottom(): def test_overlay_footer_respects_molecule_content_below_zoomed_map(): layout = _overlay_footer_layout( height=800.0, - sphere_radius=3.5, + map_radius=3.5, scale=67.857142857, show_colorbar=True, content_bottom=720.0, diff --git a/uv.lock b/uv.lock index 44bcde5..f41fb36 100644 --- a/uv.lock +++ b/uv.lock @@ -1449,27 +1449,33 @@ validation = [ [package.dev-dependencies] dev = [ + { name = "morfeus-ml" }, + { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, ] [package.metadata] requires-dist = [ - { name = "morfeus-ml", specifier = ">=0.8.0", marker = "extra == 'dev'" }, - { name = "morfeus-ml", specifier = ">=0.8.0", marker = "extra == 'validation'" }, + { name = "morfeus-ml", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "morfeus-ml", marker = "extra == 'validation'", specifier = ">=0.8.0" }, { name = "numpy", specifier = ">=1.22" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'test'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "ty", marker = "extra == 'dev'" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.33" }, { name = "xyzrender" }, ] provides-extras = ["test", "validation", "dev"] [package.metadata.requires-dev] dev = [ + { name = "morfeus-ml", specifier = ">=0.8.0" }, + { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff", specifier = ">=0.15.12" }, { name = "ty", specifier = ">=0.0.33" }, ]