Source code for nav.sim.sim_ring

"""Simulated ring rendering for navigation testing.

This module provides functions to render planetary rings in simulated images
for navigation testing. Rings are rendered as circular or elliptical features
with anti-aliased edges.
"""

import math
from typing import Any, cast

import numpy as np

from nav.support.types import NDArrayBoolType, NDArrayFloatType


[docs] def compute_edge_radius_mode1( center_v: float, center_u: float, pixel_v: float, pixel_u: float, *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float, ) -> float: """Compute edge radius from mode 1 parameters at a specific pixel. Parameters: center_v: V coordinate of ring center. center_u: U coordinate of ring center. pixel_v: V coordinate of pixel. pixel_u: U coordinate of pixel. a: Semi-major axis in pixels. ae: Eccentricity times semi-major axis in pixels. long_peri: Longitude of pericenter in degrees. rate_peri: Rate of precession in degrees/day. epoch: Epoch time (TDB seconds). time: Current time (TDB seconds). Returns: Edge radius in pixels at the given pixel position. """ # Compute angle from center to pixel dv = pixel_v - center_v du = pixel_u - center_u angle = math.atan2(dv, du) # Delegate to compute_edge_radius_at_angle which contains the shared ellipse formula return compute_edge_radius_at_angle( angle=angle, a=a, ae=ae, long_peri=long_peri, rate_peri=rate_peri, epoch=epoch, time=time, )
[docs] def compute_edge_radius_at_angle( angle: float, *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float, ) -> float: """Compute edge radius at a specific angle using mode 1 parameters. Parameters: angle: Angle in radians from center. a: Semi-major axis in pixels. ae: Eccentricity times semi-major axis in pixels. long_peri: Longitude of pericenter in degrees. rate_peri: Rate of precession in degrees/day. epoch: Epoch time (TDB seconds). time: Current time (TDB seconds). Returns: Edge radius in pixels at the given angle. """ # Compute current longitude of pericenter days_since_epoch = (time - epoch) / 86400.0 current_long_peri = math.radians(long_peri + rate_peri * days_since_epoch) # Compute true anomaly (angle relative to pericenter) true_anomaly = angle - current_long_peri # Compute radius using elliptical orbit equation e = ae / a if a > 0 else 0.0 if e >= 1.0: e = 0.99 # Clamp eccentricity to valid range r = a * (1.0 - e * e) / (1.0 + e * math.cos(true_anomaly)) return r
def _compute_edge_radii_array( angles: NDArrayFloatType, *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float, ) -> NDArrayFloatType: """Compute edge radii array for all angles using mode 1 parameters. Parameters: angles: Array of angles in radians from center. a: Semi-major axis in pixels. ae: Eccentricity times semi-major axis in pixels. long_peri: Longitude of pericenter in degrees. rate_peri: Rate of precession in degrees/day. epoch: Epoch time (TDB seconds). time: Current time (TDB seconds). Returns: Array of edge radii in pixels at the given angles. """ # Compute current longitude of pericenter days_since_epoch = (time - epoch) / 86400.0 current_long_peri = math.radians(long_peri + rate_peri * days_since_epoch) # Compute true anomaly (angle relative to pericenter) true_anomaly = angles - current_long_peri # Compute radius using elliptical orbit equation: r = a(1 - e^2) / (1 + e*cos(v)) # where e = ae / a e = ae / a if a > 0 else 0.0 if e >= 1.0: e = 0.99 # Clamp eccentricity to valid range r = a * (1.0 - e * e) / (1.0 + e * np.cos(true_anomaly)) return cast(NDArrayFloatType, r)
[docs] def compute_border_atop_simulated( size_v: int, size_u: int, center_v: float, center_u: float, *, a: float, ae: float, long_peri: float, rate_peri: float, epoch: float, time: float, ) -> NDArrayBoolType: """Compute border_atop mask for simulated ring edge. This simulates the border_atop backplane function for simulated rings by finding pixels where the distance from center transitions across the edge radius computed from mode 1 parameters. Parameters: size_v: Image height in pixels. size_u: Image width in pixels. center_v: V coordinate of ring center. center_u: U coordinate of ring center. a: Semi-major axis in pixels (mode 1 'a' value). ae: Eccentricity times semi-major axis in pixels. long_peri: Longitude of pericenter in degrees. rate_peri: Rate of precession in degrees/day. epoch: Epoch time (TDB seconds). time: Current time (TDB seconds). Returns: Boolean array where True indicates pixels at the edge. """ # Create coordinate grids at pixel centers (0.5 offset from integer coordinates) v_coords = np.arange(size_v, dtype=np.float64) + 0.5 u_coords = np.arange(size_u, dtype=np.float64) + 0.5 v_grid, u_grid = np.meshgrid(v_coords, u_coords, indexing='ij') # Compute distances from center at pixel centers dv = v_grid - center_v du = u_grid - center_u distances = np.sqrt(dv * dv + du * du) # Compute angles angles = np.arctan2(dv, du) # Compute edge radius at each angle using elliptical orbit equation edge_radii = _compute_edge_radii_array( angles, a=a, ae=ae, long_peri=long_peri, rate_peri=rate_peri, epoch=epoch, time=time, ) # Compute difference from target edge radius # Use the computed edge radius at each angle, not the constant edge_radius # For border_atop, we want pixels where distance transitions across edge_radii diff = distances - edge_radii sign = np.sign(diff) abs_diff = np.abs(diff) # Initialize border mask (pixels exactly at edge) border = abs_diff == 0.0 # Find transitions: pixels where sign changes between neighbors # Check vertical neighbors sign_v = sign[:-1, :] sign_v_next = sign[1:, :] abs_diff_v = abs_diff[:-1, :] abs_diff_v_next = abs_diff[1:, :] # Pixels where sign flips and current pixel is closer to edge border[:-1, :] |= (sign_v == -sign_v_next) & (abs_diff_v <= abs_diff_v_next) border[1:, :] |= (sign_v_next == -sign_v) & (abs_diff_v_next <= abs_diff_v) # Check horizontal neighbors sign_u = sign[:, :-1] sign_u_next = sign[:, 1:] abs_diff_u = abs_diff[:, :-1] abs_diff_u_next = abs_diff[:, 1:] # Pixels where sign flips and current pixel is closer to edge border[:, :-1] |= (sign_u == -sign_u_next) & (abs_diff_u <= abs_diff_u_next) border[:, 1:] |= (sign_u_next == -sign_u) & (abs_diff_u_next <= abs_diff_u) return cast(NDArrayBoolType, border)
def _compute_antialiasing_shade(edge_dist: NDArrayFloatType, resolution: float) -> NDArrayFloatType: """Compute anti-aliasing shade from edge distance. Parameters: edge_dist: Distance from pixel center to edge (positive = outside, negative = inside). resolution: Pixel resolution for anti-aliasing. Returns: Anti-aliasing shade value [0, 1] where 0.5 means pixel center is at edge. """ shade = 0.5 + edge_dist / resolution shade[shade < 0.0] = 0.0 shade[shade > 1.0] = 1.0 return shade def _compute_fade_factor(edge_dist: NDArrayFloatType, shading_distance: float) -> NDArrayFloatType: """Compute fade factor for edge shading. Parameters: edge_dist: Distance from pixel center to edge (positive = outside, negative = inside). shading_distance: Distance in pixels for edge fading. Returns: Fade factor [0, 1] where 1.0 is at the edge and 0.0 is at shading_distance away. """ fade_dist = np.maximum(0.0, edge_dist) if shading_distance <= 0.0: # Step-function fade: 1.0 for edge_dist <= 0, else 0.0 return cast(NDArrayFloatType, (edge_dist <= 0.0).astype(np.float64)) return cast(NDArrayFloatType, np.clip(1.0 - fade_dist / shading_distance, 0.0, 1.0))
[docs] def render_ring( img: NDArrayFloatType, ring_params: dict[str, Any], offset_v: float, offset_u: float, *, time: float = 0.0, epoch: float = 0.0, shade_solid: bool = False, ) -> None: """Render a single ring or gap into the image. Parameters: img: Image array to modify in-place. ring_params: Dictionary containing ring parameters: - name: str, ring name - feature_type: str, 'RINGLET' or 'GAP' - center_v: float, V coordinate of ring center - center_u: float, U coordinate of ring center - shading_distance: float, distance in pixels for edge fading - inner_data: list[dict], mode data for inner edge (mode 1 required) - outer_data: list[dict], mode data for outer edge (mode 1 required) offset_v: V offset to apply. offset_u: U offset to apply. time: Current time in TDB seconds (default 0.0). epoch: Epoch time in TDB seconds (default 0.0). shade_solid: If True, solid rings (with both edges) are shaded on both sides as if they were two rings (one with inner edge only, one with outer edge only). """ size_v, size_u = img.shape feature_type = ring_params.get('feature_type', 'RINGLET') center_v = float(ring_params.get('center_v', size_v / 2.0)) + offset_v center_u = float(ring_params.get('center_u', size_u / 2.0)) + offset_u # Extract mode 1 data for inner and outer edges inner_data = ring_params.get('inner_data', []) outer_data = ring_params.get('outer_data', []) inner_mode1 = next((m for m in inner_data if m.get('mode') == 1), None) outer_mode1 = next((m for m in outer_data if m.get('mode') == 1), None) # At least one edge must be specified if inner_mode1 is None and outer_mode1 is None: raise ValueError('At least one edge (inner or outer) must be specified') # Extract mode 1 parameters (use defaults if not present) inner_a = float(inner_mode1.get('a', 0.0)) if inner_mode1 is not None else 0.0 inner_ae = float(inner_mode1.get('ae', 0.0)) if inner_mode1 is not None else 0.0 inner_long_peri = float(inner_mode1.get('long_peri', 0.0)) if inner_mode1 is not None else 0.0 inner_rate_peri = float(inner_mode1.get('rate_peri', 0.0)) if inner_mode1 is not None else 0.0 outer_a = float(outer_mode1.get('a', 0.0)) if outer_mode1 is not None else 0.0 outer_ae = float(outer_mode1.get('ae', 0.0)) if outer_mode1 is not None else 0.0 outer_long_peri = float(outer_mode1.get('long_peri', 0.0)) if outer_mode1 is not None else 0.0 outer_rate_peri = float(outer_mode1.get('rate_peri', 0.0)) if outer_mode1 is not None else 0.0 # Create coordinate grids at pixel centers (0.5 offset from integer coordinates) v_coords = np.arange(size_v, dtype=np.float64) + 0.5 u_coords = np.arange(size_u, dtype=np.float64) + 0.5 v_grid, u_grid = np.meshgrid(v_coords, u_coords, indexing='ij') # Compute distances from center at pixel centers dv = v_grid - center_v du = u_grid - center_u distances = np.sqrt(dv * dv + du * du) # Compute angles angles = np.arctan2(dv, du) # Compute edge radii at each angle resolution = 1.0 # Pixel resolution for anti-aliasing # Get shading distance parameter (default 20.0 pixels) shading_distance = float(ring_params.get('shading_distance', 20.0)) # Initialize model array for this ring ring_model = np.zeros((size_v, size_u), dtype=np.float64) # Compute inner edge radii if inner edge is specified if inner_mode1 is not None: inner_radii = _compute_edge_radii_array( angles, a=inner_a, ae=inner_ae, long_peri=inner_long_peri, rate_peri=inner_rate_peri, epoch=epoch, time=time, ) else: inner_radii = None # Compute outer edge radii if outer edge is specified if outer_mode1 is not None: outer_radii = _compute_edge_radii_array( angles, a=outer_a, ae=outer_ae, long_peri=outer_long_peri, rate_peri=outer_rate_peri, epoch=epoch, time=time, ) else: outer_radii = None # Apply anti-aliasing and shading based on edge configuration and feature type # Anti-aliasing formula matches base class: # shade = 0.5 + sign * (edge_radius - radii) / resolution # When pixel center is at edge (radii == edge_radius), shade = 0.5 if feature_type == 'RINGLET': # For ringlets: fill region between edges (if both), or shade from single edge if inner_radii is not None and outer_radii is not None: if shade_solid: # Both edges with shade_solid: shade on both sides as if two rings inner_edge_dist = distances - inner_radii inner_shade = _compute_antialiasing_shade(inner_edge_dist, resolution) inner_fade = _compute_fade_factor(inner_edge_dist, shading_distance) outer_edge_dist = outer_radii - distances outer_shade = _compute_antialiasing_shade(outer_edge_dist, resolution) outer_fade = _compute_fade_factor(outer_edge_dist, shading_distance) ring_model = np.maximum(inner_shade * inner_fade, outer_shade * outer_fade) else: # Both edges: no shading, just fill the entire region with anti-aliasing inner_edge_dist = distances - inner_radii inner_shade = _compute_antialiasing_shade(inner_edge_dist, resolution) outer_edge_dist = outer_radii - distances outer_shade = _compute_antialiasing_shade(outer_edge_dist, resolution) # Coverage is minimum (must be inside both edges) ring_model = np.minimum(inner_shade, outer_shade) elif inner_radii is not None: # Only inner edge: shade outward from inner edge inner_edge_dist = distances - inner_radii inner_shade = _compute_antialiasing_shade(inner_edge_dist, resolution) inner_fade = _compute_fade_factor(inner_edge_dist, shading_distance) ring_model = inner_shade * inner_fade else: # outer_radii is not None # Only outer edge: shade inward from outer edge outer_edge_dist = outer_radii - distances outer_shade = _compute_antialiasing_shade(outer_edge_dist, resolution) outer_fade = _compute_fade_factor(outer_edge_dist, shading_distance) ring_model = outer_shade * outer_fade # Apply ringlet: add brightness where ring exists img[:] = np.clip(img + ring_model, 0.0, 1.0) else: # GAP # For gaps: shading extends beyond the defined ring area gap_model = cast(NDArrayFloatType, np.zeros((size_v, size_u), dtype=np.float64)) if inner_radii is not None and outer_radii is not None: # Both edges: shade inward from inner edge AND outward from outer edge inner_edge_dist = inner_radii - distances inner_shade = _compute_antialiasing_shade(inner_edge_dist, resolution) inner_fade = 1 - _compute_fade_factor(inner_edge_dist, shading_distance) outer_edge_dist = distances - outer_radii outer_shade = _compute_antialiasing_shade(outer_edge_dist, resolution) outer_fade = 1 - _compute_fade_factor(outer_edge_dist, shading_distance) gap_model = np.maximum(inner_shade * inner_fade, outer_shade * outer_fade) elif inner_radii is not None: # Only inner edge: shade inward from inner edge (beyond the edge) inner_edge_dist = inner_radii - distances inner_shade = _compute_antialiasing_shade(inner_edge_dist, resolution) inner_fade = 1 - _compute_fade_factor(inner_edge_dist, shading_distance) gap_model = inner_shade * inner_fade else: # outer_radii is not None # Only outer edge: shade outward from outer edge (beyond the edge) outer_edge_dist = distances - outer_radii outer_shade = _compute_antialiasing_shade(outer_edge_dist, resolution) outer_fade = 1 - _compute_fade_factor(outer_edge_dist, shading_distance) gap_model = outer_shade * outer_fade # Apply gap: subtract brightness where gap shading exists img[:] = np.clip(img - gap_model, 0.0, 1.0)