diff --git a/src/rtichoke/calibration/__init__.py b/src/rtichoke/calibration/__init__.py index 190e74e9..b04d51eb 100644 --- a/src/rtichoke/calibration/__init__.py +++ b/src/rtichoke/calibration/__init__.py @@ -2,6 +2,30 @@ Subpackage for Calibration """ -from .calibration import create_calibration_curve, create_calibration_curve_times +from . import calibration as _calibration +from ._interactive_aspect import enforce_square_calibration_panel + +_original_create_calibration_curve = _calibration.create_calibration_curve +_original_create_calibration_curve_times = _calibration.create_calibration_curve_times + + +def create_calibration_curve(*args, **kwargs): + """Create an interactive calibration plot with a square main panel.""" + return enforce_square_calibration_panel( + _original_create_calibration_curve(*args, **kwargs) + ) + + +def create_calibration_curve_times(*args, **kwargs): + """Create an interactive time-dependent calibration plot with a square main panel.""" + return enforce_square_calibration_panel( + _original_create_calibration_curve_times(*args, **kwargs) + ) + + +# Keep direct imports from rtichoke.calibration.calibration aligned with the +# public package entry points. +_calibration.create_calibration_curve = create_calibration_curve +_calibration.create_calibration_curve_times = create_calibration_curve_times __all__ = ["create_calibration_curve", "create_calibration_curve_times"] diff --git a/src/rtichoke/calibration/_interactive_aspect.py b/src/rtichoke/calibration/_interactive_aspect.py new file mode 100644 index 00000000..de8c44ef --- /dev/null +++ b/src/rtichoke/calibration/_interactive_aspect.py @@ -0,0 +1,27 @@ +"""Shared layout constraints for interactive calibration plots.""" + +from typing import Any + +from plotly.graph_objs._figure import Figure + + +def enforce_square_calibration_panel(fig: Figure) -> Figure: + """Keep the upper calibration panel on a 1:1 predicted/observed scale. + + Calibration figures include a histogram in a separate lower subplot. The + aspect-ratio constraint therefore belongs only to the upper calibration + panel, not to the full Plotly widget or to the histogram. + """ + fig.update_yaxes( + scaleanchor="x", + scaleratio=1, + constrain="domain", + row=1, + col=1, + ) + return fig + + +def shared_calibration_axis_layout(axis_range: list[float]) -> dict[str, Any]: + """Return the common zoom settings used by calibration x and y axes.""" + return {"range": axis_range, "fixedrange": False} diff --git a/tests/test_calibration_interactive_aspect.py b/tests/test_calibration_interactive_aspect.py new file mode 100644 index 00000000..30db43c6 --- /dev/null +++ b/tests/test_calibration_interactive_aspect.py @@ -0,0 +1,45 @@ +import numpy as np + +from rtichoke.calibration import create_calibration_curve, create_calibration_curve_times + + +def _assert_square_main_panel(fig): + assert fig.layout.yaxis.scaleanchor == "x" + assert fig.layout.yaxis.scaleratio == 1 + assert fig.layout.yaxis.constrain == "domain" + # The histogram has its own y axis and must remain unconstrained. + assert fig.layout.yaxis2.scaleanchor is None + + +def test_interactive_calibration_main_panel_is_square(): + probs = {"model": np.linspace(0.05, 0.95, 20)} + reals = np.array([0, 1] * 10) + + for calibration_type in ("discrete", "smooth"): + fig = create_calibration_curve( + probs, reals, calibration_type=calibration_type + ) + _assert_square_main_panel(fig) + assert list(fig.layout.xaxis.range) == list(fig.layout.yaxis.range) + + +def test_interactive_calibration_times_main_panel_is_square(): + probs = {"model": np.linspace(0.05, 0.95, 20)} + reals = np.array([0, 1] * 10) + times = np.arange(1.0, 21.0) + + fig = create_calibration_curve_times( + probs, + reals, + times, + fixed_time_horizons=[10.0, 15.0], + heuristics_sets=[ + { + "censoring_heuristic": "excluded", + "competing_heuristic": "excluded", + } + ], + ) + + _assert_square_main_panel(fig) + assert list(fig.layout.xaxis.range) == list(fig.layout.yaxis.range)