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: 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" },
]