#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Shared axis formatting helpers for composition clustering plots."""
from __future__ import annotations
import math
from typing import Any, List, Optional, Sequence
import numpy as np
from matplotlib.ticker import FuncFormatter, Locator, MaxNLocator
CLUSTERING_3D_VIEW_ELEV = 15.0
CLUSTERING_3D_VIEW_AZIM = -135.0
def _clamp_fraction_limits(low: float, high: float) -> tuple[float, float]:
"""Clamp composition axis limits to the valid [0, 1] fraction range."""
low = max(0.0, min(1.0, float(low)))
high = max(0.0, min(1.0, float(high)))
if high < low:
low, high = high, low
return low, high
def _clamp_axis_limits(ax: Any, axis_name: str) -> None:
"""Ensure a single axis never displays values outside [0, 1]."""
get_lim = getattr(ax, f"get_{axis_name}lim")
set_lim = getattr(ax, f"set_{axis_name}lim")
lo, hi = get_lim()
data_lo, data_hi = _clamp_fraction_limits(lo, hi)
if lo > hi:
if lo != data_hi or hi != data_lo:
set_lim(data_hi, data_lo, emit=False)
elif lo != data_lo or hi != data_hi:
set_lim(data_lo, data_hi, emit=False)
[docs]
def clamp_composition_axis_limits(ax: Any, *, is_3d: bool = False) -> None:
"""Clamp all composition axes to [0, 1], preserving reversed display order."""
_clamp_axis_limits(ax, "x")
_clamp_axis_limits(ax, "y")
if is_3d:
_clamp_axis_limits(ax, "z")
[docs]
def composition_axis_ticks(lo: float, hi: float, *, nbins: int = 6) -> np.ndarray:
"""Return non-negative tick positions for a composition axis segment."""
data_lo, data_hi = _clamp_fraction_limits(min(lo, hi), max(lo, hi))
if data_hi <= data_lo:
return np.array([0.0])
# Keep ticks at integer-percent granularity at minimum (>= 1% step).
min_step = 0.01
span = data_hi - data_lo
target_step = span / max(1, nbins)
ticks = MaxNLocator(nbins=nbins).tick_values(data_lo, data_hi)
ticks = ticks[(ticks >= 0.0) & (ticks <= 1.0)]
if ticks.size >= 2:
diffs = np.diff(np.sort(ticks))
if diffs.size > 0 and float(np.nanmin(diffs)) < min_step:
ticks = np.array([], dtype=float)
if ticks.size == 0:
# Build "nice" ticks, never denser than 1% spacing.
nice_step = max(min_step, _nice_step(target_step))
start = math.ceil(data_lo / nice_step) * nice_step
ticks = np.arange(start, data_hi + (nice_step * 0.5), nice_step)
ticks = ticks[(ticks >= 0.0) & (ticks <= 1.0)]
if data_lo <= 0.0 and not np.any(np.isclose(ticks, 0.0)):
ticks = np.sort(np.unique(np.concatenate(([0.0], ticks))))
return ticks
def _nice_step(step: float) -> float:
"""Return a 1-2-5 scaled step size close to ``step``."""
if not np.isfinite(step) or step <= 0.0:
return 0.01
exponent = math.floor(math.log10(step))
fraction = step / (10.0 ** exponent)
if fraction <= 1.0:
nice_fraction = 1.0
elif fraction <= 2.0:
nice_fraction = 2.0
elif fraction <= 5.0:
nice_fraction = 5.0
else:
nice_fraction = 10.0
return nice_fraction * (10.0 ** exponent)
def _enforce_min_axis_span(
low: float,
high: float,
*,
min_span: float,
) -> tuple[float, float]:
"""Guarantee an axis span while keeping limits clamped to [0, 1]."""
low, high = _clamp_fraction_limits(low, high)
if high - low >= min_span:
return low, high
center = 0.5 * (low + high)
half = min_span * 0.5
low = max(0.0, center - half)
high = min(1.0, center + half)
if high - low < min_span:
if np.isclose(low, 0.0):
high = min(1.0, low + min_span)
elif np.isclose(high, 1.0):
low = max(0.0, high - min_span)
return _clamp_fraction_limits(low, high)
class _CompositionTickLocator(Locator):
"""Locator that only proposes tick positions within the [0, 1] range.
Unlike calling ``Axes.set_xticks``/``set_yticks``/``set_zticks``
reactively (whose ``_set_tick_locations`` implicitly *expands* the axis
view limits to keep every tick visible, via ``Axis.set_view_interval``),
a ``Locator`` subclass is queried fresh on every draw without ever
mutating the view limits. This lets the view zoom/pan completely freely
while the ticks/labels still never show anything outside 0-100%.
"""
def __init__(self, nbins: int = 6) -> None:
super().__init__()
self._nbins = nbins
def tick_values(self, vmin: float, vmax: float) -> np.ndarray:
return composition_axis_ticks(vmin, vmax, nbins=self._nbins)
def __call__(self) -> np.ndarray:
vmin, vmax = self.axis.get_view_interval()
return self.tick_values(vmin, vmax)
[docs]
def compute_zoom_limits(
values: np.ndarray,
margin_ratio: float = 0.08,
min_span: float = 0.10,
central_fraction: float = 0.90,
) -> tuple[float, float]:
"""Return axis limits that frame the central fraction of sample values."""
values = np.asarray(values, dtype=float)
values = values[np.isfinite(values)]
if values.size == 0:
return 0.0, 1.0
lower_q = max(0.0, (1.0 - central_fraction) / 2.0)
upper_q = min(1.0, 1.0 - lower_q)
v_min = float(np.quantile(values, lower_q))
v_max = float(np.quantile(values, upper_q))
span = max(v_max - v_min, min_span)
margin = span * margin_ratio
low = max(0.0, v_min - margin)
high = min(1.0, v_max + margin)
if high <= low:
center = 0.5 * (v_min + v_max)
half = max(min_span * 0.5, 0.02)
low = max(0.0, center - half)
high = min(1.0, center + half)
return low, high
[docs]
def expand_limits(
low: float,
high: float,
margin_ratio: float = 0.20,
) -> tuple[float, float]:
"""Expand axis limits by a fraction of their span, clamped to [0, 1]."""
span = high - low
margin = span * margin_ratio
return max(0.0, low - margin), min(1.0, high + margin)
[docs]
def apply_fixed_full_range_ticks(ax: Any, *, is_3d: bool = False) -> None:
"""Use fixed 10%-step ticks for the full 0-100% composition range."""
ticks = np.arange(0, 1, 0.1)
tick_labels = [f"{x * 100:.0f}" for x in ticks]
ax.set_xticks(ticks)
ax.set_xticklabels(tick_labels)
ax.set_yticks(ticks)
ax.set_yticklabels(tick_labels)
if is_3d:
ax.set_zticks(ticks)
ax.set_zticklabels(tick_labels)
[docs]
def gather_clustering_zoom_points(
els_comps_list: np.ndarray,
centroids: np.ndarray,
unused_compositions_list: Sequence,
elements: List[str],
ref_phases_df: Optional[Any] = None,
) -> np.ndarray:
"""Collect points used to compute data-driven clustering plot axis limits."""
n_dims = len(elements)
zoom_points = [np.asarray(els_comps_list, dtype=float).T]
if unused_compositions_list:
zoom_points.append(np.asarray(unused_compositions_list, dtype=float))
zoom_points.append(np.asarray(centroids, dtype=float))
if ref_phases_df is not None and unused_compositions_list:
ref_phases_df_zoom = ref_phases_df[elements]
unused_arr = np.asarray(unused_compositions_list, dtype=float)
threshold = 0.20
for _, row in ref_phases_df_zoom.iterrows():
ref_point = np.array(row.values, dtype=float)
dists = np.linalg.norm(unused_arr - ref_point, axis=1)
if np.any(dists < threshold):
zoom_points.append(ref_point.reshape(1, -1))
if not zoom_points:
return np.empty((0, n_dims))
return np.vstack([pts for pts in zoom_points if pts.size > 0])
[docs]
def point_within_composition_limits(
point: Sequence[float],
xlim: tuple[float, float],
ylim: tuple[float, float],
zlim: Optional[tuple[float, float]] = None,
) -> bool:
"""Return True when a composition-space point lies inside the given axis limits."""
x_lo, x_hi = _clamp_fraction_limits(min(xlim), max(xlim))
y_lo, y_hi = _clamp_fraction_limits(min(ylim), max(ylim))
coords = np.asarray(point, dtype=float)
if not (x_lo <= coords[0] <= x_hi and y_lo <= coords[1] <= y_hi):
return False
if zlim is None:
return True
z_lo, z_hi = _clamp_fraction_limits(min(zlim), max(zlim))
return z_lo <= coords[2] <= z_hi
[docs]
def compute_data_driven_axis_limits(
all_points: np.ndarray,
*,
is_3d: bool = False,
margin_ratio: float = 0.20,
min_span_fraction: float = 0.01,
) -> tuple[tuple[float, float], tuple[float, float], Optional[tuple[float, float]]]:
"""Return data-driven composition axis limits for clustering zoom plots."""
default = (0.0, 1.0)
if all_points.size == 0:
return default, default, default if is_3d else None
x_low, x_high = compute_zoom_limits(all_points[:, 0])
y_low, y_high = compute_zoom_limits(all_points[:, 1])
x_low, x_high = expand_limits(x_low, x_high, margin_ratio=margin_ratio)
y_low, y_high = expand_limits(y_low, y_high, margin_ratio=margin_ratio)
x_low, x_high = _clamp_fraction_limits(x_low, x_high)
y_low, y_high = _clamp_fraction_limits(y_low, y_high)
x_low, x_high = _enforce_min_axis_span(x_low, x_high, min_span=min_span_fraction)
y_low, y_high = _enforce_min_axis_span(y_low, y_high, min_span=min_span_fraction)
zlim = None
if is_3d:
z_low, z_high = compute_zoom_limits(all_points[:, 2])
z_low, z_high = expand_limits(z_low, z_high, margin_ratio=margin_ratio)
z_low, z_high = _clamp_fraction_limits(z_low, z_high)
zlim = _enforce_min_axis_span(z_low, z_high, min_span=min_span_fraction)
return (x_low, x_high), (y_low, y_high), zlim
[docs]
def apply_data_driven_axis_limits(
ax: Any,
all_points: np.ndarray,
*,
is_3d: bool = False,
reversed_xy: bool = False,
margin_ratio: float = 0.20,
) -> None:
"""Set axis limits from clustered data and apply adaptive tick formatting."""
xlim, ylim, zlim = compute_data_driven_axis_limits(
all_points,
is_3d=is_3d,
margin_ratio=margin_ratio,
)
x_low, x_high = xlim
y_low, y_high = ylim
if is_3d and reversed_xy:
ax.set_xlim(x_high, x_low)
ax.set_ylim(y_high, y_low)
else:
ax.set_xlim(x_low, x_high)
ax.set_ylim(y_low, y_high)
if is_3d and zlim is not None:
ax.set_zlim(*zlim)
clamp_composition_axis_limits(ax, is_3d=is_3d)
apply_dynamic_axis_formatting(ax, is_3d=is_3d)