Source code for pynetdesign.modelling.geometry

"""Synthetic DAS geometry builders used by examples and tests.

These helpers generate simple borehole, surface, and dark-fiber layouts in
the tab-delimited geometry format consumed by :mod:`pynetdesign.modelling.io`.
"""

import numpy as np

__all__ = [
    'generate_geometry',
    'generate_borehole_geometry',
    'generate_surface_geometry',
    'generate_darkfiber_geometry',
]


def _format_header_value(value: float) -> str:
    """Format numeric header values without forcing integer rounding."""
    value = float(value)
    if value.is_integer():
        return str(int(value))
    return f"{value:g}"


[docs] def generate_geometry( mode: str, # Common parameters center_x: float = 0.0, center_y: float = 0.0, spacing: float = 10.0, noise_level: float = 5.0e-5, gauge_length: float = 10.0, cable_length: float = 5000.0, # Borehole parameters start_depth: float = 0.0, azimuth: float = 0.0, dip: float = 90.0, surface_noise: float = None, noise_decay_depth: float = 150.0, noise_low_depth: float = 200.0, # Surface / dark-fiber common depth: float = 0.0, # Surface zigzag surface_shape: str = 'zigzag', zigzag_amplitude: float = 500.0, zigzag_segment_length: float = 1000.0, # Surface L-shape l_arm_x: float = 3000.0, l_arm_y: float = 3000.0, # Surface square / rectangle rect_width: float = 3000.0, rect_height: float = 3000.0, # Surface double lines double_line_separation: float = 500.0, # Dark-fiber parameters start: dict = None, turns: dict = None, behavior: dict = None, seed: int = 42, ): """Generate a DAS geometry file in PyNetDesign tab-delimited format.""" if mode == 'borehole': coords = generate_borehole_geometry( center_x, center_y, spacing, start_depth, cable_length, azimuth, dip, ) elif mode == 'surface': coords = generate_surface_geometry( center_x, center_y, spacing, depth, cable_length, surface_shape, zigzag_amplitude, zigzag_segment_length, l_arm_x, l_arm_y, rect_width, rect_height, double_line_separation, ) elif mode == 'darkfiber': coords = generate_darkfiber_geometry( center_x, center_y, spacing, depth, cable_length, start, turns, behavior, seed, ) else: raise ValueError( f"Unknown mode: {mode}. Use 'borehole', 'surface', or 'darkfiber'." ) header = ( f"Northing(m)\tEasting(m)\tElevation(m)\t" f"NoiseLevel(1/s)\tGaugeLength={_format_header_value(gauge_length)}(m)" ) lines = [header] for x, y, z in coords: current_noise = noise_level if mode == 'borehole' and surface_noise is not None: depth_m = -z if depth_m < noise_decay_depth: current_noise = surface_noise elif depth_m < noise_low_depth: log_n0 = np.log(surface_noise) log_nf = np.log(noise_level) t = ( (depth_m - noise_decay_depth) / (noise_low_depth - noise_decay_depth) ) log_n = log_n0 + (log_nf - log_n0) * t current_noise = np.exp(log_n) lines.append(f"{y:.2f}\t{x:.2f}\t{z:.2f}\t{current_noise:.6e}") return lines
[docs] def generate_borehole_geometry( center_x: float, center_y: float, spacing: float, start_depth: float, cable_length: float, azimuth: float, dip: float, ): """Generate borehole DAS channel coordinates.""" measured_depths = start_depth + _build_sample_distances(cable_length, spacing) dip_rad = np.radians(dip) az_rad = np.radians(azimuth) coords = [] for measured_depth in measured_depths: true_vertical_depth = measured_depth * np.sin(dip_rad) horizontal_offset = measured_depth * np.cos(dip_rad) dx = horizontal_offset * np.sin(az_rad) dy = horizontal_offset * np.cos(az_rad) x = center_x + dx y = center_y + dy coords.append((x, y, -true_vertical_depth)) return coords
[docs] def generate_surface_geometry( center_x: float, center_y: float, spacing: float, depth: float, cable_length: float, shape: str, zigzag_amplitude: float, zigzag_segment_length: float, l_arm_x: float, l_arm_y: float, rect_width: float, rect_height: float, double_line_separation: float, ): """Generate surface DAS channel coordinates.""" if shape == 'line': waypoints = _surface_line(center_x, center_y, cable_length) elif shape == 'zigzag': waypoints = _surface_zigzag( center_x, center_y, cable_length, zigzag_amplitude, zigzag_segment_length, ) elif shape == 'L': waypoints = _surface_l(center_x, center_y, l_arm_x, l_arm_y) elif shape == 'square': waypoints = _surface_square(center_x, center_y, rect_width, rect_height) elif shape == 'double_line': waypoints = _surface_double_line( center_x, center_y, cable_length, double_line_separation, ) else: raise ValueError(f"Unknown surface_shape: {shape}") return _interpolate_polyline(waypoints, spacing, depth)
[docs] def generate_darkfiber_geometry( center_x: float, center_y: float, spacing: float, depth: float, cable_length: float, start: dict = None, turns: dict = None, behavior: dict = None, seed: int = 42, ): """Generate a continuous dark-fiber cable path.""" rng = np.random.RandomState(seed) start_segment = _normalize_darkfiber_start(start) if turns is None: # Turn at one quarter, one half and three quarters of the cable, so that the # default layout works for any cable length. For a 24 km cable these are the # 6, 12 and 18 km turns used historically. turns = {0.25 * cable_length: 0.0, 0.50 * cable_length: 90.0, 0.75 * cable_length: 0.0} if not isinstance(turns, dict): raise ValueError("turns must be a dictionary for darkfiber mode.") turn_distances = sorted(turns.keys()) current_dist = 0.0 for turn_distance in turn_distances: if turn_distance <= current_dist or turn_distance >= cable_length: raise ValueError( "turn distances must be strictly increasing and lie within " "cable_length." ) current_dist = turn_distance segment_behavior = _normalize_darkfiber_behavior( behavior, turn_distances, start_segment, ) seg_lengths = [] seg_azimuths = [] seg_defs = [start_segment] current_dist = 0.0 current_azimuth = start_segment['azimuth'] for turn_distance in turn_distances: seg_lengths.append(turn_distance - current_dist) seg_azimuths.append(current_azimuth) current_dist = turn_distance current_azimuth = turns[turn_distance] seg_defs.append(segment_behavior[turn_distance]) if current_dist < cable_length: seg_lengths.append(cable_length - current_dist) seg_azimuths.append(current_azimuth) all_coords = [] current_xy = np.array([0.0, 0.0], dtype=float) for index, (segment_length, segment_azimuth, segment_def) in enumerate( zip(seg_lengths, seg_azimuths, seg_defs) ): seg_coords = _generate_darkfiber_segment( start_xy=current_xy, length=segment_length, azimuth=segment_azimuth, depth=depth, spacing=spacing, segment=segment_def, rng=rng, ) if index > 0: seg_coords = seg_coords[1:] all_coords.extend(seg_coords) if seg_coords: current_xy = np.array(seg_coords[-1][:2], dtype=float) if not all_coords: return [] all_coords = np.asarray(all_coords, dtype=float) if start_segment['start_x'] is not None: all_coords[:, 0] += start_segment['start_x'] all_coords[:, 1] += start_segment['start_y'] else: min_x, max_x = np.min(all_coords[:, 0]), np.max(all_coords[:, 0]) min_y, max_y = np.min(all_coords[:, 1]), np.max(all_coords[:, 1]) all_coords[:, 0] += center_x - (min_x + max_x) / 2 all_coords[:, 1] += center_y - (min_y + max_y) / 2 return _resample_polyline_coords(all_coords, spacing)
def _build_sample_distances(total_length, spacing): """Return cumulative distances sampled at the requested spacing.""" if spacing <= 0: raise ValueError("spacing must be positive") total_length = float(total_length) n_full_steps = int(np.floor(total_length / spacing)) sample_s = spacing * np.arange(n_full_steps + 1, dtype=float) if sample_s.size == 0: sample_s = np.array([0.0], dtype=float) if not np.isclose(sample_s[-1], total_length): sample_s = np.append(sample_s, total_length) return sample_s def _resample_polyline_coords(coords, spacing): """Resample a 3D polyline so channel spacing follows the cable path.""" coords = np.asarray(coords, dtype=float) if coords.ndim != 2 or coords.shape[1] != 3: raise ValueError("coords must have shape (n_points, 3)") if coords.shape[0] == 0: return [] if coords.shape[0] == 1: return [tuple(coords[0])] seg_vecs = np.diff(coords, axis=0) seg_lens = np.linalg.norm(seg_vecs, axis=1) keep = np.concatenate(([True], seg_lens > 0.0)) coords = coords[keep] if coords.shape[0] == 1: return [tuple(coords[0])] seg_lens = np.linalg.norm(np.diff(coords, axis=0), axis=1) cum_len = np.concatenate(([0.0], np.cumsum(seg_lens))) sample_s = _build_sample_distances(cum_len[-1], spacing) x = np.interp(sample_s, cum_len, coords[:, 0]) y = np.interp(sample_s, cum_len, coords[:, 1]) z = np.interp(sample_s, cum_len, coords[:, 2]) return list(zip(x, y, z)) def _surface_line(center_x, center_y, length): """Straight line along the X axis.""" return [ (center_x - length / 2, center_y), (center_x + length / 2, center_y), ] def _surface_zigzag(center_x, center_y, length, amplitude, segment_length): """Zigzag path along the X axis with alternating Y offsets.""" x_start = center_x - length / 2 n_seg = int(length / segment_length) points = [] for idx in range(n_seg + 1): x = x_start + idx * segment_length y = center_y + amplitude * (1 if idx % 2 == 0 else -1) points.append((x, y)) return points def _surface_l(center_x, center_y, arm_x, arm_y): """L-shaped path: horizontal arm followed by vertical arm.""" return [ (center_x - arm_x / 2, center_y), (center_x + arm_x / 2, center_y), (center_x + arm_x / 2, center_y + arm_y), ] def _surface_square(center_x, center_y, width, height): """Rectangular perimeter.""" return [ (center_x - width / 2, center_y - height / 2), (center_x + width / 2, center_y - height / 2), (center_x + width / 2, center_y + height / 2), (center_x - width / 2, center_y + height / 2), (center_x - width / 2, center_y - height / 2), ] def _surface_double_line(center_x, center_y, length, separation): """Two parallel lines connected at one end.""" y1 = center_y - separation / 2 y2 = center_y + separation / 2 return [ (center_x - length / 2, y1), (center_x + length / 2, y1), (center_x + length / 2, y2), (center_x - length / 2, y2), ] def _interpolate_polyline(waypoints, spacing, depth): """Interpolate (x, y) waypoints at uniform spacing along the path.""" elevation = -depth coords = [(x, y, elevation) for x, y in waypoints] return _resample_polyline_coords(coords, spacing) def _normalize_darkfiber_start(start): """Validate and normalize the first dark-fiber segment definition.""" defaults = { 'start_x': None, 'start_y': None, 'azimuth': 90.0, 'behavior': 'sinusoid', 'sinusoid_amplitude': 500.0, 'sinusoid_period': 2000.0, 'amp_variation_amplitude': 200.0, 'amp_variation_period': 3000.0, } if start is None: start = {} if not isinstance(start, dict): raise ValueError("start must be a dictionary for darkfiber mode.") segment = defaults.copy() segment.update(start) if segment['behavior'] not in ('straight', 'sinusoid'): raise ValueError( "start['behavior'] must be either 'straight' or 'sinusoid'." ) if (segment['start_x'] is None) != (segment['start_y'] is None): raise ValueError( "start['start_x'] and start['start_y'] must be provided together." ) if segment['behavior'] == 'sinusoid': for key in ( 'sinusoid_amplitude', 'sinusoid_period', 'amp_variation_amplitude', 'amp_variation_period', ): if segment[key] is None: raise ValueError( f"start['{key}'] is required for sinusoid behavior." ) return segment def _normalize_darkfiber_behavior(behavior, turn_distances, start_segment): """Validate and normalize per-segment behavior definitions.""" if behavior is None: behavior = {td: start_segment['behavior'] for td in turn_distances} if not isinstance(behavior, dict): raise ValueError("behavior must be a dictionary for darkfiber mode.") if set(behavior.keys()) != set(turn_distances): raise ValueError( "behavior keys must match turns keys exactly for darkfiber mode." ) normalized = {} last_sinusoid = { 'sinusoid_amplitude': start_segment['sinusoid_amplitude'], 'sinusoid_period': start_segment['sinusoid_period'], 'amp_variation_amplitude': start_segment['amp_variation_amplitude'], 'amp_variation_period': start_segment['amp_variation_period'], } for turn_distance in turn_distances: spec = behavior[turn_distance] if isinstance(spec, str): spec = {'behavior': spec} elif isinstance(spec, dict): spec = spec.copy() else: raise ValueError( "Each behavior entry must be either a string or a dictionary." ) segment_behavior = spec.get('behavior') if segment_behavior not in ('straight', 'sinusoid'): raise ValueError( f"behavior[{turn_distance}] must define 'straight' or " f"'sinusoid' behavior." ) segment = {'behavior': segment_behavior} if segment_behavior == 'sinusoid': for key in last_sinusoid: segment[key] = spec.get(key, last_sinusoid[key]) last_sinusoid = {key: segment[key] for key in last_sinusoid} normalized[turn_distance] = segment return normalized def _generate_darkfiber_segment(start_xy, length, azimuth, depth, spacing, segment, rng): """Generate one dark-fiber segment before final spacing resampling.""" dense_spacing = min(max(spacing / 5.0, 0.5), 2.0) sample_s = _build_sample_distances(length, dense_spacing) az_rad = np.radians(azimuth) tangent = np.array([np.sin(az_rad), np.cos(az_rad)]) perpendicular = np.array([-tangent[1], tangent[0]]) base_x = start_xy[0] + sample_s * tangent[0] base_y = start_xy[1] + sample_s * tangent[1] wobble = np.zeros_like(sample_s) if segment['behavior'] == 'sinusoid': amp_phase = rng.uniform(0, 2 * np.pi) local_amp = ( segment['sinusoid_amplitude'] + segment['amp_variation_amplitude'] * np.sin( 2 * np.pi * sample_s / segment['amp_variation_period'] + amp_phase ) ) wobble = local_amp * np.sin( 2 * np.pi * sample_s / segment['sinusoid_period'] ) taper_radius = min(segment['sinusoid_period'] / 2.0, length / 2.0) if taper_radius > 0: taper = np.ones_like(sample_s) start_mask = sample_s < taper_radius taper[start_mask] *= 0.5 * ( 1 - np.cos(np.pi * sample_s[start_mask] / taper_radius) ) end_dist = length - sample_s end_mask = end_dist < taper_radius taper[end_mask] *= 0.5 * ( 1 - np.cos(np.pi * end_dist[end_mask] / taper_radius) ) wobble *= taper x = base_x + wobble * perpendicular[0] y = base_y + wobble * perpendicular[1] z = np.full_like(sample_s, -depth) return list(zip(x, y, z))