Source code for biotuner.harmonic_geometry.media.morphogenetic.crystallization

"""
Reiter cellular-automaton snowflake — chord-shaped crystal growth.

Belongs to the ``morphogenetic`` family of
:mod:`biotuner.harmonic_geometry.media`: pattern grows through a
nonlinear update rule on a hexagonal grid rather than being read off as
a snapshot of a wave. The Reiter (2005) snowflake CA produces realistic
dendritic ice morphology from a small set of scalar parameters; here we
extend it with chord-driven anisotropy, asymmetry, and noise so the same
input chord can crystallize into materially different snowflakes by
varying the medium's parameters.

Algorithm
---------
Each hex cell ``i`` carries a scalar water content ``u_i``; cells with
``u_i ≥ 1`` are *frozen*. A cell is *receptive* if it is frozen or has
a frozen neighbor. Each step splits ``u_i`` into a receptive part
``v_i`` and a non-receptive part ``w_i``, then updates:

- ``v_i(t+1) = v_i(t) + γ(i)`` for receptive cells (ice phase gains
  humidity — optionally modulated per-cell by ``anisotropy_bias`` and
  by ``noise_temperature``)
- ``w_i(t+1) = w_i(t) + (α/2) · (mean_neighbors(w) − w_i(t))`` (diffusion)
- ``u_i(t+1) = v_i(t+1) + w_i(t+1)``

This is the original Reiter rule, [1]_, with 6-fold neighbor topology
on the hex grid.

Diversity controls
------------------
- ``humidity`` (γ) — background gain per step. Higher gives plumper
  shapes; lower gives sharper dendrites.
- ``diffusion`` (α) — non-receptive smoothing. Higher smooths the
  pattern; lower gives crisp finger growth.
- ``target_fill`` — stop when the fraction of frozen cells reaches this
  value (default ``0.20``). Keeps crystals from saturating into a hex
  blob; without this, dendrites grow until they hit the boundary and
  all chords end up looking the same.
- ``anisotropy_sectors`` — base symmetry of the angular humidity bias
  (3, 4, 5, 6, 8, 12, ...). Chord-driven by default from
  :func:`~biotuner.harmonic_geometry.media.coupling.ratio_complexity`:
  low-complexity chords (consonant) give clean 6-fold snowflakes;
  high-complexity chords give 3-, 5-, or 8-fold dendritic shapes.
- ``anisotropy_strength`` — magnitude of the angular humidity boost.
- ``anisotropy_kernel_width`` — angular width (radians) of each
  Gaussian bump in the bias. Narrower bumps give sharper, more
  dendritic rays.
- ``asymmetry`` — deterministic per-sector phase wobble seeded by the
  chord's ratios. Breaks perfect ``anisotropy_sectors``-fold symmetry,
  giving each chord a distinct fingerprint.
- ``noise_temperature`` — per-step humidity jitter standard deviation.
  Chord-driven from
  :func:`~biotuner.harmonic_geometry.media.coupling.amplitude_entropy`;
  introduces stochastic dendrite-tip splitting.

Chord coupling defaults
-----------------------
With the explicit knob set to ``None``, each parameter is derived from
the chord:

- ``humidity``           ← ``spectral_spread``    in ``[1.0e-3, 8.0e-3]``
- ``diffusion``          ← ``consonance``         in ``[0.15, 0.85]`` (consonant → low)
- ``anisotropy_sectors`` ← ``ratio_complexity``   in ``{3, 4, 5, 6, 8, 12}``
- ``asymmetry``          ← chord-deterministic    in ``[0, 0.45]``
- ``noise_temperature``  ← ``amplitude_entropy``  in ``[0, 3.0e-4]``

The chord-deterministic asymmetry is seeded by the chord's ratios so two
chords with the same ``sectors`` value still produce distinguishable
patterns: each chord prints a different phase-wobble fingerprint.

References
----------
.. [1] Reiter, C. A. (2005). A local cellular model for snow crystal growth.
       Chaos, Solitons & Fractals, 23, 1111-1119.
"""

from __future__ import annotations

from typing import Any, Optional, Sequence

import numpy as np

from biotuner.harmonic_geometry.geometry_data import GeometryData
from biotuner.harmonic_geometry.inputs import HarmonicInput
from biotuner.harmonic_geometry.media import coupling as _coupling
from biotuner.harmonic_geometry.media import structure as _structure
from biotuner.harmonic_geometry.media.base import Medium

_VALID_OUTPUTS = ("water", "frozen", "boundary")
_VALID_SEED_STRATEGIES = ("single", "polygon")

# Hard cap on prime_limit-driven sectors. Above 12 the angular bumps
# overlap so densely that diversity collapses back into "isotropic".
_MAX_SECTORS = 12


# ============================================================ hex helpers


def _hex_mask(R: int) -> np.ndarray:
    """Boolean mask of axial-coordinate cells inside the hex of radius ``R``.

    Axial coordinates ``(q, r)`` are indexed into a ``(2R+1, 2R+1)`` array
    by ``(q + R, r + R)``. The hex constraint is ``max(|q|, |r|, |q+r|) ≤ R``.
    """
    qs = np.arange(-R, R + 1)
    Q, RR = np.meshgrid(qs, qs, indexing="ij")
    return np.maximum.reduce([np.abs(Q), np.abs(RR), np.abs(Q + RR)]) <= R


def _hex_neighbor_sum(arr: np.ndarray) -> np.ndarray:
    """Sum of the 6 hex neighbors at every cell (zero-padded boundaries).

    Uses np.pad + slicing rather than np.roll to avoid wraparound from the
    opposite edge of the rectangular array.
    """
    padded = np.pad(arr, 1, mode="constant", constant_values=0.0)
    n, m = arr.shape
    return (
        padded[2:n + 2, 1:m + 1]
        + padded[0:n, 1:m + 1]
        + padded[1:n + 1, 2:m + 2]
        + padded[1:n + 1, 0:m]
        + padded[2:n + 2, 0:m]
        + padded[0:n, 2:m + 2]
    )


def _hex_neighbor_or(mask: np.ndarray) -> np.ndarray:
    """Boolean OR of the 6 hex neighbors at every cell (zero-padded)."""
    padded = np.pad(mask, 1, mode="constant", constant_values=False)
    n, m = mask.shape
    return (
        padded[2:n + 2, 1:m + 1]
        | padded[0:n, 1:m + 1]
        | padded[1:n + 1, 2:m + 2]
        | padded[1:n + 1, 0:m]
        | padded[2:n + 2, 0:m]
        | padded[0:n, 2:m + 2]
    )


# =================================================== CA core


def _crystallize_reiter(
    *,
    grid_radius: int,
    n_steps: int,
    humidity: float,
    diffusion: float,
    initial_water: float,
    anisotropy_bias: Optional[np.ndarray] = None,
    target_fill: float = 1.0,
    noise_temperature: float = 0.0,
    rng_seed: Optional[int] = None,
    seed_cells: Optional[Sequence[tuple[int, int]]] = None,
) -> tuple[np.ndarray, np.ndarray]:
    """Run the Reiter snowflake CA on a hex grid; return ``(water, frozen)``.

    Parameters
    ----------
    target_fill : float
        Stop early when ``frozen.sum() / hex_mask.sum() >= target_fill``.
        Defaults to ``1.0`` (no early stop on fill). Used to keep crystals
        sub-saturated so dendritic morphology is visible rather than being
        masked by hex-boundary saturation.
    noise_temperature : float
        Standard deviation of additive humidity noise applied to receptive
        cells each step.
    rng_seed : int, optional
        Seed for the noise RNG (None → fresh entropy).
    seed_cells : sequence of (q, r), optional
        Additional axial-coordinate cells to freeze at initialization
        (in addition to the center cell). Used by ``seed_strategy=
        "polygon"`` to plant the chord's Tonnetz polygon as the
        starting frozen pattern.
    """
    R = int(grid_radius)
    mask = _hex_mask(R)
    n_cells = int(mask.sum())
    water = np.where(mask, float(initial_water), 0.0)
    water[R, R] = 1.0  # center seed (origin / unison)
    if seed_cells:
        for q, r in seed_cells:
            qi, ri = int(q), int(r)
            # Reject cells outside the hex.
            if (abs(qi) <= R and abs(ri) <= R and abs(qi + ri) <= R):
                water[qi + R, ri + R] = 1.0
    frozen = water >= 1.0
    base_humidity = float(humidity)
    rng = np.random.default_rng(rng_seed)

    for _ in range(int(n_steps)):
        recept = (frozen | _hex_neighbor_or(frozen)) & mask
        v = np.where(recept, water, 0.0)
        w = np.where(recept, 0.0, water)

        # Receptive: gain humidity (optionally per-direction-modulated
        # and optionally with per-step noise).
        gamma_field = base_humidity
        if anisotropy_bias is not None:
            gamma_field = base_humidity * anisotropy_bias
        if noise_temperature > 0:
            gamma_field = gamma_field + rng.normal(
                0.0, noise_temperature, size=v.shape
            )
        v_new = v + gamma_field * recept

        # Non-receptive: diffusion via (α/2) · (mean_neighbors − self).
        neighbor_sum = _hex_neighbor_sum(w)
        w_new = w + (float(diffusion) / 2.0) * (neighbor_sum / 6.0 - w)
        w_new = np.where(mask, w_new, 0.0)

        water = v_new + w_new
        frozen = water >= 1.0

        # Stop when target fill is reached OR boundary is touched.
        if target_fill < 1.0 and frozen.sum() >= target_fill * n_cells:
            break
        if (frozen[0, :].any() or frozen[-1, :].any()
                or frozen[:, 0].any() or frozen[:, -1].any()):
            break

    return water, frozen


# ============================================================ rasterization


def _hex_to_cartesian(
    values: np.ndarray,
    hex_mask: np.ndarray,
    grid_radius: int,
    resolution: int,
) -> tuple[np.ndarray, tuple[np.ndarray, np.ndarray]]:
    """Project a hex-grid array to a cartesian ``(resolution, resolution)`` field.

    Uses nearest-neighbor axial sampling. The cartesian domain is
    ``[-extent, extent]²`` with ``extent = R + 0.5``. Cells outside the
    hex are returned as ``NaN`` so renderers can drop or mask them.
    """
    R = int(grid_radius)
    extent = R + 0.5
    x = np.linspace(-extent, extent, resolution)
    y = np.linspace(-extent, extent, resolution)
    X, Y = np.meshgrid(x, y, indexing="xy")

    # Inverse cartesian → axial: x = q + r/2, y = r · √3/2.
    inv_r = Y * 2.0 / np.sqrt(3.0)
    inv_q = X - inv_r / 2.0
    q_round = np.clip(np.round(inv_q).astype(int), -R, R)
    r_round = np.clip(np.round(inv_r).astype(int), -R, R)

    sampled = values[q_round + R, r_round + R]
    valid = hex_mask[q_round + R, r_round + R]
    out = np.where(valid, sampled.astype(np.float64), np.nan)
    return out, (X, Y)


def _frozen_boundary_contour(
    frozen: np.ndarray,
    hex_mask: np.ndarray,
    grid_radius: int,
    resolution: int,
) -> list[np.ndarray]:
    """Extract the frozen-region boundary as a list of (N_i, 2) polylines."""
    try:
        from skimage.measure import find_contours
    except ImportError as exc:
        raise ImportError(
            "Boundary extraction requires `scikit-image`. Install with "
            "`pip install scikit-image` and retry."
        ) from exc

    field, (X, Y) = _hex_to_cartesian(
        frozen.astype(np.float64), hex_mask, grid_radius, resolution
    )
    safe = np.where(np.isfinite(field), field, 0.0)
    contours_idx = find_contours(safe, level=0.5)
    x_axis = X[0, :]
    y_axis = Y[:, 0]
    curves: list[np.ndarray] = []
    for c in contours_idx:
        rows = c[:, 0]
        cols = c[:, 1]
        ys = np.interp(rows, np.arange(len(y_axis)), y_axis)
        xs = np.interp(cols, np.arange(len(x_axis)), x_axis)
        curves.append(np.stack([xs, ys], axis=1))
    return curves


# ============================================================== anisotropy


def _chord_seed(chord: HarmonicInput) -> int:
    """Deterministic 32-bit seed from the chord's ratios.

    Used so ``asymmetry`` wobbles and other random elements are
    reproducible per chord while still differing between chords.
    """
    ratios = [float(r) for r in chord.to_ratios()]
    accum = 0
    for r in ratios:
        # Pull bits from the float's mantissa.
        accum = (accum * 1_000_003 + int(r * 1e6)) & 0xFFFFFFFF
    return int(accum) or 1


def _select_sectors(chord: HarmonicInput) -> int:
    """Pick the anisotropy-sectors integer from the chord's structure.

    Uses the chord's *prime limit* — the highest prime in any
    numerator or denominator — as the base symmetry. Falls back to
    ``6`` for the degenerate 2-limit (octave-only) case so the
    rotational symmetry of the angular bias remains meaningful.

    Primes incompatible with the hex grid's 6-fold neighborhood
    (5, 7, 11) produce *geometric frustration* — the snowflake
    cannot be cleanly 5-fold on a hex grid, so the resulting
    morphology is visibly distinct from clean 6-fold growth.
    """
    p = _structure.prime_limit(chord)
    if p < 3:
        return 6
    return int(min(p, _MAX_SECTORS))


def _polygon_seed_cells(
    chord: HarmonicInput,
    grid_radius: int,
    seed_scale: float = 0.22,
    branch_length: int = 0,
) -> list[tuple[int, int]]:
    """Convert the chord's Tonnetz polygon to a list of axial seed cells.

    The polygon is centered, normalized to a unit max radius, then
    scaled to ``seed_scale * grid_radius`` cells from the center. Each
    polygon vertex becomes a frozen seed cell (in axial ``(q, r)``
    coordinates). With ``branch_length > 0``, an additional radial
    line of ``branch_length`` cells is planted between the center and
    each polygon vertex, giving the snowflake an initial dendritic
    skeleton.
    """
    polygon = _structure.tonnetz_polygon(chord)
    if not polygon:
        return []
    # Center polygon at its centroid.
    cx = sum(x for x, _ in polygon) / len(polygon)
    cy = sum(y for _, y in polygon) / len(polygon)
    centered = [(x - cx, y - cy) for x, y in polygon]
    max_r = max(np.hypot(x, y) for x, y in centered)
    if max_r < 1e-6:
        return []
    scale = seed_scale * grid_radius / max_r

    cells: list[tuple[int, int]] = []
    sqrt3 = np.sqrt(3.0)
    for x, y in centered:
        gx, gy = x * scale, y * scale
        # Inverse of axial → cartesian (X = q + r/2, Y = r·√3/2).
        ri = int(round(gy * 2.0 / sqrt3))
        qi = int(round(gx - ri / 2.0))
        cells.append((qi, ri))
        # Radial branch of length `branch_length` from origin to vertex.
        if branch_length > 0:
            steps = max(branch_length, 1)
            for k in range(1, steps + 1):
                f = k / float(steps + 1)
                bx, by = gx * f, gy * f
                br = int(round(by * 2.0 / sqrt3))
                bq = int(round(bx - br / 2.0))
                cells.append((bq, br))
    return cells


def _angular_bias(
    chord: HarmonicInput,
    hex_mask: np.ndarray,
    grid_radius: int,
    strength: float,
    sectors: int,
    kernel_width: float,
    asymmetry: float,
) -> np.ndarray:
    """Per-cell humidity multiplier from the chord's ratios.

    Each chord component is mapped to a pitch-class angle on the unit
    circle via ``2π · (log2(r_i) mod 1)``. Each angle is then replicated
    ``sectors`` times (giving the snowflake its base ``sectors``-fold
    symmetry) and weighted by the chord's normalized amplitudes. A
    Gaussian of width ``kernel_width`` is placed at each replicated
    angle; the resulting per-direction field is multiplied into the
    humidity term during the CA update.

    ``asymmetry`` adds a deterministic per-replica phase wobble
    (seeded by the chord) so different chords with the same sector
    count still print distinct fingerprints.
    """
    R = int(grid_radius)
    qs = np.arange(-R, R + 1)
    Q, RR = np.meshgrid(qs, qs, indexing="ij")
    X = Q + RR / 2.0
    Y = RR * np.sqrt(3.0) / 2.0
    theta = np.arctan2(Y, X)

    ratios = np.asarray(
        [float(r) for r in chord.to_ratios()], dtype=np.float64
    )
    amps = chord.normalized_amplitudes()
    if amps.size != ratios.size:
        amps = np.ones_like(ratios) / max(ratios.size, 1)
    # Per-ratio consonance weights — arms toward consonant chord
    # components grow more strongly. Combined with amplitudes so loud
    # consonant components dominate.
    cons_w = _structure.per_ratio_consonance_weights(chord)
    if cons_w.size != ratios.size:
        cons_w = np.ones_like(ratios)
    per_arm = amps * cons_w
    # Pitch-class angles in [0, 2π).
    base_angles = 2.0 * np.pi * np.mod(np.log2(np.maximum(ratios, 1e-12)), 1.0)

    rng = np.random.default_rng(_chord_seed(chord)) if asymmetry > 0 else None

    bias = np.ones_like(theta)
    for ang, w in zip(base_angles, per_arm):
        for k in range(int(sectors)):
            ang_k = ang + k * 2.0 * np.pi / float(sectors)
            if rng is not None:
                ang_k = ang_k + float(rng.uniform(-asymmetry, asymmetry))
            ang_k = np.mod(ang_k + np.pi, 2.0 * np.pi) - np.pi
            d = np.mod(theta - ang_k + np.pi, 2.0 * np.pi) - np.pi
            bias += strength * float(w) * np.exp(
                -(d ** 2) / (2.0 * kernel_width ** 2)
            )
    return np.where(hex_mask, bias, 1.0)


# ============================================================ Crystallization


[docs] class Crystallization(Medium): """Morphogenetic-family medium: chord-shaped Reiter CA snowflake. Parameters ---------- humidity : float, optional Background humidity gain γ per step. Higher gives plumper shapes; lower gives sharper dendrites. If ``None`` (default), derived from the chord via ``coupling.spectral_spread(chord)`` mapped into ``[1.0e-3, 8.0e-3]``. diffusion : float, optional Water-diffusion coefficient α (in ``[0, 1]``). Higher smooths the crystal; lower gives crisp fingers. If ``None`` (default), derived from the chord via ``coupling.consonance(chord)`` mapped into ``[0.15, 0.85]`` (consonant → lower diffusion → sharper). initial_water : float, default 0.4 Uniform initial water content β in the unfrozen field. n_steps : int, default 2000 Maximum CA steps. Simulation stops early when ``target_fill`` is reached or growth touches the hex boundary. grid_radius : int, default 110 Half-extent of the hex grid (cells from center to edge along each axial direction). Total cells ≈ ``3·R²``. output_resolution : int, default 256 Resolution of the cartesian rasterization. output_mode : {"water", "frozen", "boundary"}, default "water" ``"water"`` returns the continuous water-content field; ``"frozen"`` returns a binary {0, 1} mask; ``"boundary"`` returns the frozen-region outline as ``curve_set_2d``. target_fill : float, default 0.20 Stop simulation when frozen fraction reaches this. ``1.0`` keeps only the boundary-touch stop. Lower values keep crystals sub-saturated so morphology stays visible. anisotropy_strength : float, default 0.8 Strength of the per-direction humidity boost derived from the chord's ratios. ``0`` disables anisotropy (pure isotropic Reiter CA); larger values bias growth toward chord-specific angles. anisotropy_sectors : int, optional Number of equally-spaced replicas per chord-derived angle. Sets the base symmetry of the angular bias. If ``None`` (default), set to the chord's prime limit (highest prime in any p/q), clamped into ``[3, 12]``. Primes 5, 7, 11 are incompatible with the hex grid's 6-fold neighborhood, producing geometric frustration patterns that visibly differ from clean hex growth. anisotropy_kernel_width : float, default π/16 Angular standard deviation (radians) of each Gaussian bump in the bias. Smaller → sharper dendritic rays. asymmetry : float, optional Per-replica phase wobble (radians), deterministically seeded by the chord. Breaks perfect ``sectors``-fold symmetry — two chords with the same ``sectors`` value still print distinct fingerprints. If ``None`` (default), derived from :func:`structure.max_common_int` mapped into ``[0, 0.6]`` — chords with larger common-denominator integers get more wobble. seed_strategy : {"single", "polygon"}, default "polygon" Initial frozen-cell pattern. - ``"single"`` plants only the center cell (classical Reiter). - ``"polygon"`` additionally plants the chord's Tonnetz polygon (each ratio projected to the just-intonation lattice) as frozen seed cells. This is the strongest source of chord- dependent diversity: major and minor triads, for instance, produce mirror-flipped polygons and therefore visibly mirror-flipped snowflakes. seed_scale : float, default 0.22 Fraction of ``grid_radius`` used as the polygon seed's outer radius (only when ``seed_strategy="polygon"``). Larger values plant seeds farther from center; smaller keeps the polygon compact near the origin. seed_branch_length : int, default 0 When ``> 0`` and ``seed_strategy="polygon"``, plant ``N`` extra frozen cells along the radial line from the center to each polygon vertex. Gives the snowflake an explicit initial dendritic skeleton. noise_temperature : float, optional Standard deviation of per-step Gaussian noise added to the humidity gain at receptive cells. If ``None`` (default), derived from ``coupling.amplitude_entropy(chord)`` in ``[0, 3.0e-4]``. rng_seed : int, optional Seed for the noise RNG. ``None`` (default) uses a deterministic per-chord seed so the same chord reproduces. """ family = "morphogenetic" def __init__( self, *, humidity: Optional[float] = None, diffusion: Optional[float] = None, initial_water: float = 0.4, n_steps: int = 2000, grid_radius: int = 110, output_resolution: int = 256, output_mode: str = "water", target_fill: float = 0.20, anisotropy_strength: float = 0.8, anisotropy_sectors: Optional[int] = None, anisotropy_kernel_width: float = np.pi / 16.0, asymmetry: Optional[float] = None, noise_temperature: Optional[float] = None, rng_seed: Optional[int] = None, seed_strategy: str = "polygon", seed_scale: float = 0.22, seed_branch_length: int = 0, ) -> None: if output_mode not in _VALID_OUTPUTS: raise ValueError( f"output_mode must be one of {_VALID_OUTPUTS}; got " f"{output_mode!r}." ) if humidity is not None and humidity <= 0: raise ValueError("humidity must be > 0 when given.") if diffusion is not None and not (0.0 <= diffusion <= 1.0): raise ValueError("diffusion must be in [0, 1] when given.") if not (0.0 < initial_water < 1.0): raise ValueError("initial_water must be in (0, 1).") if n_steps < 1: raise ValueError("n_steps must be >= 1.") if grid_radius < 4: raise ValueError("grid_radius must be >= 4.") if output_resolution < 16: raise ValueError("output_resolution must be >= 16.") if anisotropy_strength < 0: raise ValueError("anisotropy_strength must be >= 0.") if not (0.0 < target_fill <= 1.0): raise ValueError("target_fill must be in (0, 1].") if anisotropy_sectors is not None and anisotropy_sectors < 1: raise ValueError("anisotropy_sectors must be >= 1 when given.") if anisotropy_kernel_width <= 0: raise ValueError("anisotropy_kernel_width must be > 0.") if asymmetry is not None and asymmetry < 0: raise ValueError("asymmetry must be >= 0 when given.") if noise_temperature is not None and noise_temperature < 0: raise ValueError("noise_temperature must be >= 0 when given.") if seed_strategy not in _VALID_SEED_STRATEGIES: raise ValueError( f"seed_strategy must be one of {_VALID_SEED_STRATEGIES}; " f"got {seed_strategy!r}." ) if not (0.0 < seed_scale < 1.0): raise ValueError("seed_scale must be in (0, 1).") if seed_branch_length < 0: raise ValueError("seed_branch_length must be >= 0.") self.humidity = humidity self.diffusion = diffusion self.initial_water = float(initial_water) self.n_steps = int(n_steps) self.grid_radius = int(grid_radius) self.output_resolution = int(output_resolution) self.output_mode = output_mode self.target_fill = float(target_fill) self.anisotropy_strength = float(anisotropy_strength) self.anisotropy_sectors = anisotropy_sectors self.anisotropy_kernel_width = float(anisotropy_kernel_width) self.asymmetry = asymmetry self.noise_temperature = noise_temperature self.rng_seed = rng_seed self.seed_strategy = seed_strategy self.seed_scale = float(seed_scale) self.seed_branch_length = int(seed_branch_length) # ----------------------------------------------------------- contract
[docs] def default_source(self) -> None: return None
[docs] def respond( self, forcing: HarmonicInput, **overrides: Any, ) -> GeometryData: if not isinstance(forcing, HarmonicInput): raise TypeError( "Crystallization.respond requires a HarmonicInput; got " f"{type(forcing).__name__}." ) humidity_arg = overrides.pop("humidity", self.humidity) diffusion_arg = overrides.pop("diffusion", self.diffusion) initial_water = float(overrides.pop("initial_water", self.initial_water)) n_steps = int(overrides.pop("n_steps", self.n_steps)) grid_radius = int(overrides.pop("grid_radius", self.grid_radius)) output_resolution = int(overrides.pop( "output_resolution", self.output_resolution)) output_mode = overrides.pop("output_mode", self.output_mode) target_fill = float(overrides.pop("target_fill", self.target_fill)) anisotropy_strength = float(overrides.pop( "anisotropy_strength", self.anisotropy_strength)) anisotropy_sectors = overrides.pop( "anisotropy_sectors", self.anisotropy_sectors) anisotropy_kernel_width = float(overrides.pop( "anisotropy_kernel_width", self.anisotropy_kernel_width)) asymmetry_arg = overrides.pop("asymmetry", self.asymmetry) noise_temperature_arg = overrides.pop( "noise_temperature", self.noise_temperature) rng_seed = overrides.pop("rng_seed", self.rng_seed) seed_strategy = overrides.pop("seed_strategy", self.seed_strategy) seed_scale = float(overrides.pop("seed_scale", self.seed_scale)) seed_branch_length = int(overrides.pop( "seed_branch_length", self.seed_branch_length)) if seed_strategy not in _VALID_SEED_STRATEGIES: raise ValueError( f"seed_strategy must be one of {_VALID_SEED_STRATEGIES}." ) if overrides: raise TypeError( f"Unexpected override keys: {sorted(overrides)}." ) if output_mode not in _VALID_OUTPUTS: raise ValueError(f"output_mode must be one of {_VALID_OUTPUTS}.") # ----- chord-driven defaults if humidity_arg is None: spread = _coupling.spectral_spread(forcing) # spectral_spread typically in [0, ~2]; map to [1e-3, 8e-3]. humidity_arg = 1.0e-3 + min(spread, 2.0) / 2.0 * (8.0e-3 - 1.0e-3) humidity = float(humidity_arg) if diffusion_arg is None: cons = _coupling.consonance(forcing) # consonance ∈ [0, 1]; consonant → low diffusion (sharper). diffusion_arg = 0.15 + (1.0 - cons) * (0.85 - 0.15) diffusion = float(diffusion_arg) if anisotropy_sectors is None: sectors = _select_sectors(forcing) else: sectors = int(anisotropy_sectors) if asymmetry_arg is None: # Chord-driven from max_common_int: chords with a larger # common-denominator integer form (e.g. minor 10:12:15 vs # major 4:5:6) get more wobble. Saturates at max_int=20 # to keep the range bounded. max_int = _structure.max_common_int(forcing) asymmetry_arg = 0.6 * min(max(max_int - 3, 0) / 17.0, 1.0) asymmetry = float(asymmetry_arg) if noise_temperature_arg is None: ent = _coupling.amplitude_entropy(forcing) # entropy ∈ [0, 1]; flat amplitudes → more noise. noise_temperature_arg = ent * 3.0e-4 noise_temperature = float(noise_temperature_arg) # Deterministic per-chord seed when rng_seed is None. seed = (int(rng_seed) if rng_seed is not None else _chord_seed(forcing)) # ----- angular bias anisotropy = None if anisotropy_strength > 0: mask = _hex_mask(grid_radius) anisotropy = _angular_bias( forcing, mask, grid_radius, anisotropy_strength, sectors, anisotropy_kernel_width, asymmetry, ) # ----- chord-polygon seed cells (Tonnetz projection) seed_cells: Optional[list[tuple[int, int]]] = None if seed_strategy == "polygon": seed_cells = _polygon_seed_cells( forcing, grid_radius, seed_scale, seed_branch_length ) water, frozen = _crystallize_reiter( grid_radius=grid_radius, n_steps=n_steps, humidity=humidity, diffusion=diffusion, initial_water=initial_water, anisotropy_bias=anisotropy, target_fill=target_fill, noise_temperature=noise_temperature, rng_seed=seed, seed_cells=seed_cells, ) mask = _hex_mask(grid_radius) parameters = { "humidity": humidity, "diffusion": diffusion, "initial_water": initial_water, "n_steps": n_steps, "grid_radius": grid_radius, "output_resolution": output_resolution, "output_mode": output_mode, "target_fill": target_fill, "anisotropy_strength": anisotropy_strength, "anisotropy_sectors": sectors, "anisotropy_kernel_width": anisotropy_kernel_width, "asymmetry": asymmetry, "noise_temperature": noise_temperature, "rng_seed": seed, "seed_strategy": seed_strategy, "seed_scale": seed_scale, "seed_branch_length": seed_branch_length, } metadata = { "kind": f"crystallization_{output_mode}", "family": "morphogenetic", "domain": "hexagonal_grid", "frozen_cells": int(frozen.sum()), "total_cells": int(mask.sum()), "frozen_fraction": float(frozen.sum()) / float(max(mask.sum(), 1)), } if output_mode == "boundary": curves = _frozen_boundary_contour( frozen, mask, grid_radius, output_resolution ) return GeometryData( geom_type="curve_set_2d", coordinates=curves, parameters=parameters, metadata=metadata, ) source = water if output_mode == "water" else frozen.astype(np.float64) field, grid = _hex_to_cartesian( source, mask, grid_radius, output_resolution ) return GeometryData( geom_type="field_2d", coordinates=field, field_grid=grid, parameters=parameters, metadata=metadata, )
def __repr__(self) -> str: return ( f"Crystallization(humidity={self.humidity}, " f"diffusion={self.diffusion}, " f"anisotropy_strength={self.anisotropy_strength}, " f"anisotropy_sectors={self.anisotropy_sectors}, " f"target_fill={self.target_fill}, " f"seed_strategy={self.seed_strategy!r})" )