Source code for wetting_angle_kit.analysis.fitters.base

"""``SurfaceFitter`` ABC + ``FitOutput`` types + factory classmethods.

The factories use deferred imports of the concrete fitter classes to
avoid a circular dependency with the sibling ``_slicing`` / ``_whole``
modules (which inherit from :class:`SurfaceFitter`).
"""

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar, Literal

import numpy as np

from wetting_angle_kit.analysis.geometry import DropletGeometry
from wetting_angle_kit.analysis.interface.base import InterfaceData
from wetting_angle_kit.analysis.results import (
    BatchResult,
    SlicingBatchResult,
    WholeBatchResult,
)

#: Surface-representation kind the fitter consumes. Mirrors
#: :data:`wetting_angle_kit.analysis.interface.SurfaceKind` — the two
#: are kept in sync by the analyzer's compatibility check, which raises
#: if ``extractor.kind != fitter.kind``.
SurfaceKind = Literal["slicing", "whole"]


[docs] class FitOutput(ABC): """Frames-less per-batch fit output returned by :meth:`SurfaceFitter.fit`. The fitter computes the geometric fit and returns one of :class:`SlicingFitOutput` or :class:`WholeFitOutput`. The analyzer then calls :meth:`to_batch_result` with the batch's frame indices to produce the user-facing :class:`BatchResult` — keeping bookkeeping (frames) and computation (the fit) separate. """
[docs] @abstractmethod def to_batch_result(self, frames: list[int]) -> BatchResult: """Attach ``frames`` to this fit output and return a BatchResult."""
[docs] @dataclass(frozen=True, eq=False, kw_only=True) class SlicingFitOutput(FitOutput): """Output of :meth:`SurfaceFitter.slicing` for one batch. Carries the same payload as :class:`SlicingBatchResult` minus ``frames``. Field semantics are identical; see that class for documentation. """ angle: float z_wall: float rms_residual: float angle_std: float per_slice_angles: np.ndarray slice_surfaces: list[np.ndarray] slice_popts: np.ndarray n_slices_total: int n_slices_used: int
[docs] def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: return SlicingBatchResult( frames=frames, angle=self.angle, z_wall=self.z_wall, rms_residual=self.rms_residual, angle_std=self.angle_std, per_slice_angles=self.per_slice_angles, slice_surfaces=self.slice_surfaces, slice_popts=self.slice_popts, n_slices_total=self.n_slices_total, n_slices_used=self.n_slices_used, )
[docs] @dataclass(frozen=True, eq=False, kw_only=True) class WholeFitOutput(FitOutput): """Output of :meth:`SurfaceFitter.whole` for one batch. Carries the same payload as :class:`WholeBatchResult` minus ``frames``. Field semantics are identical; see that class for documentation. """ angle: float z_wall: float rms_residual: float angle_std: float | None interface_shell: np.ndarray popt: np.ndarray
[docs] def to_batch_result(self, frames: list[int]) -> WholeBatchResult: return WholeBatchResult( frames=frames, angle=self.angle, z_wall=self.z_wall, rms_residual=self.rms_residual, angle_std=self.angle_std, interface_shell=self.interface_shell, popt=self.popt, )
[docs] class SurfaceFitter(ABC): """Abstract base for contact-angle surface fitters. Concrete fitters are constructed through the classmethod factories :meth:`slicing` and :meth:`whole`. Direct subclassing is supported for custom strategies but the factories cover all built-in cases. """ #: Surface-representation kind this fitter consumes. Set by each #: concrete subclass; the analyzer matches this against the chosen #: :class:`InterfaceExtractor` at construction time. kind: ClassVar[SurfaceKind]
[docs] @abstractmethod def fit( self, interface_data: InterfaceData, z_wall: float, droplet_geometry: DropletGeometry, ) -> FitOutput: """Fit the contact angle for one batch. Parameters ---------- interface_data : InterfaceData Interface point set produced by the :class:`InterfaceExtractor`. Per-slice 2D points for ``kind="slicing"``; a 3D shell for ``kind="whole"``. z_wall : float Wall-plane z-coordinate from the :class:`WallDetector`. droplet_geometry : DropletGeometry Droplet symmetry; controls the geometric model (circle per slice / sphere / cylinder). Returns ------- FitOutput :class:`SlicingFitOutput` for slicing fitters, :class:`WholeFitOutput` for whole fitters. The analyzer attaches the batch's frame indices via :meth:`FitOutput.to_batch_result` to produce the user-facing :class:`BatchResult`. """
[docs] @abstractmethod def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: """Raise if this fitter cannot handle ``droplet_geometry``. Called by :class:`TrajectoryAnalyzer.__init__`. The kind compatibility (slicing vs whole) is enforced separately at the analyzer level by matching :attr:`SurfaceFitter.kind` against the extractor's chosen ``surface_kind``. """
[docs] @classmethod def slicing( cls, *, surface_filter_offset: float = 2.0, ) -> "SurfaceFitter": """Per-slice algebraic circle fits, averaged across slices. Each slice's 2D interface points are filtered to ``z > z_wall + surface_filter_offset`` (to exclude wall-adjacent density distortions), an algebraic Taubin circle is fit to the kept points, and the contact angle is the angle of intersection between that circle and the line ``z = z_wall``. The batch angle is the mean over slices; :attr:`BatchResult.angle_std` is the empirical std across slices. Parameters ---------- surface_filter_offset : float, default 2.0 Vertical offset above ``z_wall`` (Å) below which interface points are excluded from the circle fit. This is distinct from any offset baked into the :class:`WallDetector`: this offset is a fit-quality knob for the per-slice circle, and the wall detector's offset (if it uses one, e.g. :meth:`WallDetector.min_plus_offset`) defines where the wall plane sits. """ from wetting_angle_kit.analysis.fitters._slicing import _SlicingFitter return _SlicingFitter(surface_filter_offset=surface_filter_offset)
[docs] @classmethod def whole( cls, *, surface_filter_offset: float = 2.0, bootstrap_samples: int = 0, ) -> "SurfaceFitter": """Algebraic sphere or cylinder fit to the 3D interface shell. Spherical droplets get a sphere fit; cylindrical droplets get a circular-cylinder fit whose axis is parallel to ``y`` (internal frame, post axis-swap for ``cylinder_x``). The contact angle follows from the cap geometry: ``cos θ = (z_wall - z_center) / R``. Parameters ---------- surface_filter_offset : float, default 2.0 Vertical offset above ``z_wall`` (Å) below which shell points are excluded from the geometric fit. Same role as in :meth:`slicing`: distinct from the wall detector's offset. bootstrap_samples : int, default 0 If positive, the fit is repeated on this many bootstrap resamples of the filtered shell, and the resulting std of the angles is reported as :attr:`BatchResult.angle_std`. ``0`` disables bootstrap; the field is then ``None`` in the returned :class:`WholeBatchResult`. """ from wetting_angle_kit.analysis.fitters._whole import _WholeFitter return _WholeFitter( surface_filter_offset=surface_filter_offset, bootstrap_samples=bootstrap_samples, )