"""Geometry modifiers shared by every string guide."""
from __future__ import annotations
from abc import ABC, abstractmethod
import numpy as np
import shapely
from shapely.geometry import LineString
from shapely.ops import substring
from .._random import as_rng, resolve_seed
from .._repr import _ArgumentRepr
from .._validation import _validate_integer, _validate_nonnegative, _validate_positive
from .base import _LogicalString, _segment_geometry
__all__ = ["StringModifier", "Breaks", "Meander"]
#: Meander draws retried before giving up on a self-intersecting guide.
_MAX_MEANDER_ATTEMPTS = 32
[docs]
class StringModifier(_ArgumentRepr, ABC):
"""Transform ordered logical strings without mutating the source guide."""
#: Whether the caller gave every random choice of the modifier a seed.
_pinned_seed = True
[docs]
@abstractmethod
def apply(self, strings: tuple[_LogicalString, ...]) -> tuple[_LogicalString, ...]:
"""Return new logical strings, leaving ``strings`` untouched."""
def _reseeded(self, seed: int) -> "StringModifier":
"""Return the modifier redrawn from ``seed`` unless its caller seeded it."""
if self._pinned_seed:
return self
return type(self)(**{**self._arguments(), "seed": seed})
[docs]
class Breaks(StringModifier):
"""Remove random internal sections from logical strings.
Adds ``n_breaks`` gaps, each ``size`` times the original arc length of its
string, with ``size`` in ``(0, 1)``. Without ``seed``, a
:class:`~oowl.layouts.String` layout draws new breaks for every farm.
"""
def __init__(
self,
n_breaks: int = 1,
*,
size: float = 0.05,
seed: int | np.random.Generator | None = None,
) -> None:
self.n_breaks = _validate_integer("n_breaks", n_breaks, minimum=0)
size = _validate_positive("size", size)
if size >= 1:
raise ValueError("size must be between 0 and 1")
self.size = size
self._pinned_seed = seed is not None
self.seed = resolve_seed(seed)
[docs]
def apply(self, strings: tuple[_LogicalString, ...]) -> tuple[_LogicalString, ...]:
"""Return new logical strings with ``n_breaks`` internal gaps removed.
Break locations are weighted by available length. Raises ``ValueError``
when no segment can hold another break.
"""
if self.n_breaks == 0:
return tuple(strings)
rng = as_rng(self.seed)
original_lengths = [logical.length for logical in strings]
segments = [list(logical.segments) for logical in strings]
for _ in range(self.n_breaks):
eligible = []
weights = []
for string_index, string_segments in enumerate(segments):
break_length = self.size * original_lengths[string_index]
available = sum(
segment.length
for segment in string_segments
if segment.length > break_length
)
if available > 0:
eligible.append(string_index)
weights.append(available)
if not eligible:
raise ValueError("breaks do not fit within the remaining guide")
weights = np.asarray(weights, dtype=float)
string_index = int(rng.choice(eligible, p=weights / weights.sum()))
break_length = self.size * original_lengths[string_index]
candidates = [
index
for index, segment in enumerate(segments[string_index])
if segment.length > break_length
]
lengths = np.asarray(
[segments[string_index][index].length for index in candidates]
)
segment_index = int(rng.choice(candidates, p=lengths / lengths.sum()))
segment = segments[string_index][segment_index]
start = float(rng.uniform(0, segment.length - break_length))
stop = start + break_length
replacements = []
if start > 0:
replacements.append(substring(segment, 0, start))
if stop < segment.length:
replacements.append(substring(segment, stop, segment.length))
segments[string_index][segment_index : segment_index + 1] = replacements
return tuple(
_LogicalString(original.guide_type, tuple(updated))
for original, updated in zip(strings, segments)
)
[docs]
class Meander(StringModifier):
"""Add smooth correlated lateral variation while fixing endpoints.
``roughness`` is the peak lateral offset as a fraction of segment arc
length, and ``0`` leaves the guide unchanged. Without ``seed``, a
:class:`~oowl.layouts.String` layout draws a new meander for every farm.
"""
def __init__(
self,
roughness: float = 0.03,
*,
seed: int | np.random.Generator | None = None,
) -> None:
self.roughness = _validate_nonnegative("roughness", roughness)
self._pinned_seed = seed is not None
self.seed = resolve_seed(seed)
[docs]
def apply(self, strings: tuple[_LogicalString, ...]) -> tuple[_LogicalString, ...]:
"""Return new logical strings displaced laterally, endpoints unchanged.
Each segment is offset along its normal by a smoothed random walk.
Candidates are retried a bounded number of times until the guide is
simple, else ``ValueError`` is raised.
"""
if self.roughness == 0:
if not _strings_are_simple(strings):
raise ValueError("Meander cannot produce a simple guide at roughness=0")
return tuple(strings)
rng = as_rng(self.seed)
for _ in range(_MAX_MEANDER_ATTEMPTS):
candidate = tuple(
_LogicalString(
logical.guide_type,
tuple(
_meander_segment(segment, self.roughness, rng)
for segment in logical.segments
),
)
for logical in strings
)
if _strings_are_simple(candidate):
return candidate
raise ValueError(
"Meander could not produce a simple guide "
f"after {_MAX_MEANDER_ATTEMPTS} attempts"
)
def _strings_are_simple(strings: tuple[_LogicalString, ...]) -> bool:
return bool(_segment_geometry(strings).is_simple)
def _meander_segment(segment: LineString, roughness: float, rng) -> LineString:
"""``segment`` offset along its normals by a smoothed random walk."""
n_points = max(257, len(segment.coords))
distances = np.linspace(0.0, segment.length, n_points)
coordinates = shapely.get_coordinates(
shapely.line_interpolate_point(segment, distances)
)
progress = np.linspace(0.0, 1.0, n_points)
# A random walk with its linear trend removed is zero at both ends. An
# 11-point moving average smooths it, and the detrend is repeated because
# smoothing moves the ends.
displacement = np.cumsum(rng.normal(size=n_points))
displacement -= displacement[0] + progress * (displacement[-1] - displacement[0])
window = 11
padded = np.pad(displacement, window // 2, mode="reflect")
displacement = np.convolve(padded, np.ones(window) / window, mode="valid")
displacement -= displacement[0] + progress * (displacement[-1] - displacement[0])
peak = float(np.max(np.abs(displacement)))
if peak > 0:
displacement *= roughness * segment.length / peak
tangents = np.gradient(coordinates, axis=0)
tangent_lengths = np.linalg.norm(tangents, axis=1)
if np.any(tangent_lengths == 0):
raise ValueError("string guide contains a zero-length section")
normals = np.column_stack([-tangents[:, 1], tangents[:, 0]])
normals /= tangent_lengths[:, None]
meandered = coordinates + displacement[:, None] * normals
meandered[[0, -1]] = coordinates[[0, -1]]
return LineString(meandered)