mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
RouteCost.lean: - latencyClass 4 = 'offline' (Tailscale down/unreachable) - networkLatencyCost returns qOne for offline (maximum cost) - Computation continues with local-only fallback scale_space_solver.py: - detect_tailscale(): returns available=False if not installed/running - get_latency_class(): returns 4 (offline) when Tailscale unavailable - latency_to_voltage/sigma(): map any class to FPGA parameters - Chain never raises — offline is just another latency class Verified: - Tailscale up: 4 peers detected, latency classes assigned - Tailscale down: returns class 4 (offline), computation continues - Unknown IP: returns class 4 (offline), no crash
739 lines
23 KiB
Python
739 lines
23 KiB
Python
"""
|
||
Multi-scale optimization using scale space theory.
|
||
|
||
Implements coarse-to-fine optimization via cluster-based route optimization
|
||
at multiple scales, with Q16.16 fixed-point arithmetic for FPGA compatibility.
|
||
|
||
Scale mapping:
|
||
σ₃ (1.0): coarse — merge nearby nodes, solve small problem
|
||
σ₂ (0.75): medium — tighter clustering
|
||
σ₁ (0.5): fine — minimal clustering, warm-started
|
||
σ₀ (0.25): formal verification target — full problem, 2-opt polish
|
||
|
||
The Gaussian kernel is used for route-space smoothing, NOT cost matrix
|
||
smoothing. At each scale σ, nodes whose pairwise cost is below σ·max_cost
|
||
are clustered together. The reduced problem is solved, then expanded back.
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import subprocess
|
||
from typing import Optional
|
||
|
||
# ── Tailscale Detection (graceful degradation) ──────────────────────────
|
||
|
||
_LATENCY_CLASSES = {
|
||
0: {'name': 'local', 'ms_max': 1, 'voltage': 1200, 'sigma': 0.0},
|
||
1: {'name': 'near', 'ms_max': 10, 'voltage': 1000, 'sigma': 0.25},
|
||
2: {'name': 'far', 'ms_max': 100, 'voltage': 800, 'sigma': 0.50},
|
||
3: {'name': 'derp', 'ms_max': 1000, 'voltage': 600, 'sigma': 1.0},
|
||
4: {'name': 'offline', 'ms_max': None, 'voltage': 600, 'sigma': 1.0},
|
||
}
|
||
|
||
|
||
def detect_tailscale() -> dict:
|
||
"""Detect Tailscale status. Returns dict with 'available', 'peers', 'latency_map'.
|
||
|
||
If Tailscale is not installed or not running, returns available=False
|
||
with empty peers and latency_map. The chain never fails.
|
||
"""
|
||
result = {
|
||
'available': False,
|
||
'peers': {},
|
||
'latency_map': {},
|
||
'derp_region': None,
|
||
}
|
||
|
||
# Check if tailscale binary exists
|
||
try:
|
||
proc = subprocess.run(
|
||
['tailscale', 'status', '--json'],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
if proc.returncode != 0:
|
||
return result # tailscale not running
|
||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||
return result # tailscale not installed
|
||
|
||
try:
|
||
status = json.loads(proc.stdout)
|
||
result['available'] = True
|
||
result['derp_region'] = status.get('CurrentTailnet', {}).get('Name')
|
||
|
||
for peer_id, peer in status.get('Peer', {}).items():
|
||
hostname = peer.get('HostName', peer_id)
|
||
tailscale_ip = peer.get('TailscaleIPs', [None])[0]
|
||
relay = peer.get('Relay', '')
|
||
latency = peer.get('CurAddr', '')
|
||
|
||
# Classify latency
|
||
if not peer.get('Online', False):
|
||
latency_class = 4 # offline
|
||
elif relay: # DERP relay
|
||
latency_class = 3 # derp
|
||
elif tailscale_ip:
|
||
latency_class = 1 # near (same tailnet)
|
||
else:
|
||
latency_class = 2 # far
|
||
|
||
result['peers'][hostname] = {
|
||
'ip': tailscale_ip,
|
||
'latency_class': latency_class,
|
||
'relay': relay,
|
||
'online': peer.get('Online', False),
|
||
}
|
||
if tailscale_ip:
|
||
result['latency_map'][tailscale_ip] = latency_class
|
||
|
||
except (json.JSONDecodeError, KeyError, TypeError):
|
||
pass # malformed status, return what we have
|
||
|
||
return result
|
||
|
||
|
||
def get_latency_class(node_ip: str, ts_status: Optional[dict] = None) -> int:
|
||
"""Get latency class for a node. Returns 4 (offline) if Tailscale unavailable.
|
||
|
||
The chain never fails — offline is just another latency class.
|
||
"""
|
||
if ts_status is None:
|
||
ts_status = detect_tailscale()
|
||
|
||
if not ts_status['available']:
|
||
return 4 # offline — Tailscale not running
|
||
|
||
return ts_status['latency_map'].get(node_ip, 4) # default to offline
|
||
|
||
|
||
def latency_to_voltage(latency_class: int) -> int:
|
||
"""Map latency class to FPGA voltage in millivolts."""
|
||
return _LATENCY_CLASSES.get(latency_class, _LATENCY_CLASSES[4])['voltage']
|
||
|
||
|
||
def latency_to_sigma(latency_class: int) -> float:
|
||
"""Map latency class to scale space sigma."""
|
||
return _LATENCY_CLASSES.get(latency_class, _LATENCY_CLASSES[4])['sigma']
|
||
|
||
try:
|
||
import numpy as np
|
||
HAS_NUMPY = True
|
||
except ImportError:
|
||
HAS_NUMPY = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Q16.16 fixed-point arithmetic
|
||
# ---------------------------------------------------------------------------
|
||
|
||
Q16_SCALE = 65536 # 2^16
|
||
Q16_MAX = 2147483647 # 2^31 - 1
|
||
Q16_MIN = -2147483648 # -2^31
|
||
|
||
|
||
def q16_clamp(v: int) -> int:
|
||
"""Clamp integer to Q16.16 representable range."""
|
||
return max(Q16_MIN, min(Q16_MAX, v))
|
||
|
||
|
||
def q16_from_float(f: float) -> int:
|
||
"""Convert float to Q16.16 fixed-point."""
|
||
return q16_clamp(round(f * Q16_SCALE))
|
||
|
||
|
||
def q16_to_float(q: int) -> float:
|
||
"""Convert Q16.16 fixed-point to float."""
|
||
return q / Q16_SCALE
|
||
|
||
|
||
def q16_multiply(a: int, b: int) -> int:
|
||
"""Q16.16 multiplication: (a * b) >> 16."""
|
||
return q16_clamp((a * b) >> 16)
|
||
|
||
|
||
def q16_exp(x_q16: int) -> int:
|
||
"""Q16.16 exponential: exp(x) where x is in Q16.16.
|
||
|
||
Uses Python math.exp internally, converts to Q16.16.
|
||
For FPGA, this would use a LUT-based approximation.
|
||
"""
|
||
x_float = q16_to_float(x_q16)
|
||
return q16_from_float(math.exp(x_float))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gaussian kernel in Q16.16
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def gaussian_kernel_q16(sigma: float, size: int = 256) -> list[int]:
|
||
"""Compute 1D Gaussian kernel in Q16.16 fixed-point.
|
||
|
||
G(x) = exp(-x²/(2σ²)) * 65536 (Q16.16 scale)
|
||
|
||
Args:
|
||
sigma: Standard deviation of the Gaussian (in normalized coords).
|
||
size: Number of kernel taps. Must be odd for symmetry.
|
||
|
||
Returns:
|
||
List of Q16.16 kernel values, normalized so they sum to Q16_SCALE.
|
||
"""
|
||
if size % 2 == 0:
|
||
size += 1 # Ensure odd for symmetry
|
||
|
||
half = size // 2
|
||
two_sigma_sq = 2.0 * sigma * sigma
|
||
|
||
# Compute unnormalized kernel
|
||
kernel_raw = []
|
||
for i in range(size):
|
||
x = (i - half) / half # Map to [-1, 1]
|
||
g = math.exp(-(x * x) / two_sigma_sq)
|
||
kernel_raw.append(q16_from_float(g))
|
||
|
||
# Normalize so kernel sums to Q16_SCALE (1.0 in Q16.16)
|
||
raw_sum = sum(kernel_raw)
|
||
if raw_sum == 0:
|
||
# Degenerate: delta function
|
||
kernel_raw[half] = Q16_SCALE
|
||
else:
|
||
# Scale to sum to 65536
|
||
kernel_raw = [q16_clamp(round(v * Q16_SCALE / raw_sum))
|
||
for v in kernel_raw]
|
||
|
||
return kernel_raw
|
||
|
||
|
||
def gaussian_kernel_2d_q16(sigma: float, size: int = 16) -> list[list[int]]:
|
||
"""Compute 2D Gaussian kernel in Q16.16.
|
||
|
||
Args:
|
||
sigma: Standard deviation.
|
||
size: Kernel dimension (size x size).
|
||
|
||
Returns:
|
||
2D list of Q16.16 values.
|
||
"""
|
||
half = size // 2
|
||
two_sigma_sq = 2.0 * sigma * sigma
|
||
|
||
kernel = []
|
||
total = 0
|
||
for y in range(size):
|
||
row = []
|
||
for x in range(size):
|
||
dx = (x - half) / half
|
||
dy = (y - half) / half
|
||
g = math.exp(-(dx * dx + dy * dy) / two_sigma_sq)
|
||
v = q16_from_float(g)
|
||
row.append(v)
|
||
total += v
|
||
kernel.append(row)
|
||
|
||
# Normalize
|
||
if total > 0:
|
||
kernel = [[q16_clamp(round(v * Q16_SCALE / total))
|
||
for v in row] for row in kernel]
|
||
|
||
return kernel
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Voltage ↔ scale mapping
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Voltage range: 0.6V → σ=1.0 (coarse), 1.2V → σ=0.25 (fine)
|
||
_VOLTAGE_MIN = 0.6
|
||
_VOLTAGE_MAX = 1.2
|
||
_SIGMA_AT_VMIN = 1.0
|
||
_SIGMA_AT_VMAX = 0.25 # Matches finest scale in default sigmas
|
||
|
||
|
||
def voltage_to_scale(voltage_mv: float) -> float:
|
||
"""Map millivolt voltage to scale parameter σ.
|
||
|
||
Range: 0.6V (600mV, σ=1.0) to 1.2V (1200mV, σ=0.25).
|
||
|
||
Linear mapping: σ = 1.0 - (V - 0.6) / 0.6 * 0.75
|
||
|
||
Args:
|
||
voltage_mv: Voltage in millivolts.
|
||
|
||
Returns:
|
||
Scale parameter σ.
|
||
"""
|
||
voltage_v = voltage_mv / 1000.0
|
||
# Clamp to range
|
||
voltage_v = max(_VOLTAGE_MIN, min(_VOLTAGE_MAX, voltage_v))
|
||
# Linear interpolation: σ = 1.0 - (V - 0.6) / 0.6 * 0.75
|
||
t = (voltage_v - _VOLTAGE_MIN) / (_VOLTAGE_MAX - _VOLTAGE_MIN)
|
||
sigma = _SIGMA_AT_VMIN + t * (_SIGMA_AT_VMAX - _SIGMA_AT_VMIN)
|
||
return max(0.01, sigma)
|
||
|
||
|
||
def scale_to_voltage(sigma: float) -> float:
|
||
"""Map scale parameter σ to millivolt voltage.
|
||
|
||
Args:
|
||
sigma: Scale parameter.
|
||
|
||
Returns:
|
||
Voltage in millivolts.
|
||
"""
|
||
sigma = max(_SIGMA_AT_VMAX, min(_SIGMA_AT_VMIN, sigma))
|
||
# Inverse of voltage_to_scale
|
||
t = (sigma - _SIGMA_AT_VMIN) / (_SIGMA_AT_VMAX - _SIGMA_AT_VMIN)
|
||
voltage_v = _VOLTAGE_MIN + t * (_VOLTAGE_MAX - _VOLTAGE_MIN)
|
||
return voltage_v * 1000.0 # Return in mV
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cluster-based multi-scale solver
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _single_linkage_clusters(cost_matrix: list[list[float]],
|
||
threshold: float) -> list[list[int]]:
|
||
"""Cluster nodes using single-linkage clustering.
|
||
|
||
Merge nodes whose minimum pairwise cost is below the threshold.
|
||
|
||
Args:
|
||
cost_matrix: n×n cost matrix.
|
||
threshold: Cost threshold for merging.
|
||
|
||
Returns:
|
||
List of clusters, where each cluster is a list of node indices.
|
||
"""
|
||
n = len(cost_matrix)
|
||
if n == 0:
|
||
return []
|
||
|
||
# Union-find for single-linkage
|
||
parent = list(range(n))
|
||
|
||
def find(x: int) -> int:
|
||
while parent[x] != x:
|
||
parent[x] = parent[parent[x]]
|
||
x = parent[x]
|
||
return x
|
||
|
||
def union(x: int, y: int) -> None:
|
||
rx, ry = find(x), find(y)
|
||
if rx != ry:
|
||
parent[rx] = ry
|
||
|
||
# Merge pairs whose cost is below threshold
|
||
for i in range(n):
|
||
for j in range(i + 1, n):
|
||
if cost_matrix[i][j] < threshold:
|
||
union(i, j)
|
||
|
||
# Group nodes by cluster root
|
||
clusters: dict[int, list[int]] = {}
|
||
for i in range(n):
|
||
root = find(i)
|
||
if root not in clusters:
|
||
clusters[root] = []
|
||
clusters[root].append(i)
|
||
|
||
return list(clusters.values())
|
||
|
||
|
||
def _build_reduced_cost_matrix(cost_matrix: list[list[float]],
|
||
clusters: list[list[int]]) -> list[list[float]]:
|
||
"""Build a reduced cost matrix for cluster representatives.
|
||
|
||
The cost between two clusters is the minimum cost between any pair of
|
||
nodes across the two clusters.
|
||
|
||
Args:
|
||
cost_matrix: Original n×n cost matrix.
|
||
clusters: List of clusters (each a list of node indices).
|
||
|
||
Returns:
|
||
Reduced k×k cost matrix where k = number of clusters.
|
||
"""
|
||
k = len(clusters)
|
||
reduced = [[0.0] * k for _ in range(k)]
|
||
|
||
for ci in range(k):
|
||
for cj in range(ci + 1, k):
|
||
# Minimum cost across cluster boundaries
|
||
min_cost = float('inf')
|
||
for ni in clusters[ci]:
|
||
for nj in clusters[cj]:
|
||
if cost_matrix[ni][nj] < min_cost:
|
||
min_cost = cost_matrix[ni][nj]
|
||
reduced[ci][cj] = min_cost
|
||
reduced[cj][ci] = min_cost
|
||
|
||
return reduced
|
||
|
||
|
||
def _expand_tour(cluster_tour: list[int],
|
||
clusters: list[list[int]],
|
||
cost_matrix: list[list[float]]) -> list[int]:
|
||
"""Expand a cluster-level tour back to individual nodes.
|
||
|
||
For each cluster in the tour, we need to enter and exit through specific
|
||
nodes. We pick the entry/exit nodes that minimize the inter-cluster edges.
|
||
|
||
Args:
|
||
cluster_tour: Tour over cluster indices.
|
||
clusters: List of clusters (each a list of node indices).
|
||
cost_matrix: Original cost matrix.
|
||
|
||
Returns:
|
||
Tour over original node indices.
|
||
"""
|
||
if len(cluster_tour) <= 1:
|
||
# Single cluster — order nodes greedily within cluster
|
||
nodes = clusters[cluster_tour[0]]
|
||
if len(nodes) <= 1:
|
||
return nodes
|
||
return _greedy_tour_subgraph(nodes, cost_matrix)[0]
|
||
|
||
# For each consecutive pair of clusters, find the best entry/exit nodes
|
||
k = len(cluster_tour)
|
||
entry_node = [0] * k # Which node in cluster i we enter through
|
||
exit_node = [0] * k # Which node in cluster i we exit through
|
||
|
||
for i in range(k):
|
||
ci = cluster_tour[i]
|
||
cj = cluster_tour[(i + 1) % k]
|
||
|
||
# Find the pair of nodes (one in ci, one in cj) with minimum cost
|
||
best_cost = float('inf')
|
||
best_exit = clusters[ci][0]
|
||
best_entry = clusters[cj][0]
|
||
|
||
for ni in clusters[ci]:
|
||
for nj in clusters[cj]:
|
||
c = cost_matrix[ni][nj]
|
||
if c < best_cost:
|
||
best_cost = c
|
||
best_exit = ni
|
||
best_entry = nj
|
||
|
||
exit_node[i] = best_exit
|
||
entry_node[(i + 1) % k] = best_entry
|
||
|
||
# Build the full tour by visiting each cluster's nodes between entry/exit
|
||
full_tour = []
|
||
for i in range(k):
|
||
ci = cluster_tour[i]
|
||
cluster_nodes = clusters[ci]
|
||
entry = entry_node[i]
|
||
exit_nd = exit_node[i]
|
||
|
||
if len(cluster_nodes) == 1:
|
||
full_tour.append(cluster_nodes[0])
|
||
else:
|
||
# Build a path through the cluster from entry to exit
|
||
# Use greedy nearest-neighbor within the cluster, starting at entry
|
||
path = _build_cluster_path(cluster_nodes, entry, exit_nd, cost_matrix)
|
||
full_tour.extend(path)
|
||
|
||
return full_tour
|
||
|
||
|
||
def _build_cluster_path(nodes: list[int], start: int, end: int,
|
||
cost_matrix: list[list[float]]) -> list[int]:
|
||
"""Build a path through cluster nodes from start to end.
|
||
|
||
Uses nearest-neighbor heuristic constrained to the cluster.
|
||
"""
|
||
if len(nodes) <= 2:
|
||
# Just return all nodes, start first
|
||
result = [start]
|
||
for n in nodes:
|
||
if n != start:
|
||
result.append(n)
|
||
return result
|
||
|
||
visited = {start}
|
||
path = [start]
|
||
current = start
|
||
|
||
# Visit all nodes except the end node
|
||
remaining = set(nodes) - {start, end}
|
||
|
||
while remaining:
|
||
best_next: int = nodes[0] # will be overwritten
|
||
best_cost = float('inf')
|
||
for n in remaining:
|
||
c = cost_matrix[current][n]
|
||
if c < best_cost:
|
||
best_cost = c
|
||
best_next = n
|
||
path.append(best_next)
|
||
visited.add(best_next)
|
||
remaining.remove(best_next)
|
||
current = best_next
|
||
|
||
# End at the exit node
|
||
if end != start:
|
||
path.append(end)
|
||
|
||
return path
|
||
|
||
|
||
def _greedy_tour_subgraph(nodes: list[int],
|
||
cost_matrix: list[list[float]]) -> tuple[list[int], float]:
|
||
"""Greedy nearest-neighbor tour on a subset of nodes."""
|
||
if len(nodes) <= 1:
|
||
return nodes, 0.0
|
||
|
||
visited = {nodes[0]}
|
||
tour = [nodes[0]]
|
||
current = nodes[0]
|
||
total_cost = 0.0
|
||
|
||
for _ in range(len(nodes) - 1):
|
||
best_j = -1
|
||
best_c = float('inf')
|
||
for j in nodes:
|
||
if j not in visited and cost_matrix[current][j] < best_c:
|
||
best_c = cost_matrix[current][j]
|
||
best_j = j
|
||
tour.append(best_j)
|
||
visited.add(best_j)
|
||
total_cost += best_c
|
||
current = best_j
|
||
|
||
total_cost += cost_matrix[current][tour[0]]
|
||
return tour, total_cost
|
||
|
||
|
||
def _greedy_tour(cost_matrix: list[list[float]]) -> tuple[list[int], float]:
|
||
"""Nearest-neighbor heuristic for TSP."""
|
||
n = len(cost_matrix)
|
||
if n == 0:
|
||
return [], 0.0
|
||
|
||
visited = {0}
|
||
tour = [0]
|
||
current = 0
|
||
total_cost = 0.0
|
||
|
||
while len(tour) < n:
|
||
best_j = -1
|
||
best_c = float('inf')
|
||
for j in range(n):
|
||
if j not in visited and cost_matrix[current][j] < best_c:
|
||
best_c = cost_matrix[current][j]
|
||
best_j = j
|
||
tour.append(best_j)
|
||
visited.add(best_j)
|
||
total_cost += best_c
|
||
current = best_j
|
||
|
||
total_cost += cost_matrix[current][tour[0]]
|
||
return tour, total_cost
|
||
|
||
|
||
def _2opt_improve(tour: list[int],
|
||
cost_matrix: list[list[float]]) -> tuple[list[int], float]:
|
||
"""2-opt local search improvement."""
|
||
n = len(tour)
|
||
if n < 4:
|
||
cost = sum(cost_matrix[tour[i]][tour[(i + 1) % n]] for i in range(n))
|
||
return tour, cost
|
||
|
||
improved = True
|
||
while improved:
|
||
improved = False
|
||
for i in range(1, n - 1):
|
||
for j in range(i + 1, n):
|
||
# Cost of current edges
|
||
d1 = (cost_matrix[tour[i - 1]][tour[i]] +
|
||
cost_matrix[tour[j]][tour[(j + 1) % n]])
|
||
# Cost of reversed segment edges
|
||
d2 = (cost_matrix[tour[i - 1]][tour[j]] +
|
||
cost_matrix[tour[i]][tour[(j + 1) % n]])
|
||
|
||
if d2 < d1 - 1e-10:
|
||
tour[i:j + 1] = reversed(tour[i:j + 1])
|
||
improved = True
|
||
|
||
cost = sum(cost_matrix[tour[i]][tour[(i + 1) % n]] for i in range(n))
|
||
return tour, cost
|
||
|
||
|
||
def solve_multiscale(cost_matrix: list[list[float]],
|
||
sigmas: Optional[list[float]] = None) -> dict:
|
||
"""Solve routing problem at multiple scales using cluster-based optimization.
|
||
|
||
Coarse-to-fine strategy with clustering:
|
||
σ=1.0: coarse — merge nearby nodes, solve small problem
|
||
σ=0.75: medium — tighter clustering
|
||
σ=0.5: fine — minimal clustering, warm-started
|
||
σ=0.25: formal verification target — full problem, 2-opt polish
|
||
|
||
At each scale σ, nodes whose pairwise cost is below σ·max_cost are
|
||
clustered together. The reduced problem is solved on cluster representatives,
|
||
then expanded back to individual nodes. Solutions from coarser scales
|
||
warm-start finer scales.
|
||
|
||
Args:
|
||
cost_matrix: n×n cost matrix.
|
||
sigmas: List of scale parameters (coarse to fine).
|
||
|
||
Returns:
|
||
{'solutions': {sigma: {'tour': list, 'cost': float}},
|
||
'converged': bool, 'best_sigma': float}
|
||
"""
|
||
if sigmas is None:
|
||
sigmas = [1.0, 0.75, 0.5, 0.25]
|
||
|
||
n = len(cost_matrix)
|
||
if n == 0:
|
||
return {'solutions': {}, 'converged': True, 'best_sigma': 0.0}
|
||
|
||
# Find the maximum cost for threshold computation
|
||
max_cost = 0.0
|
||
for i in range(n):
|
||
for j in range(n):
|
||
if cost_matrix[i][j] > max_cost:
|
||
max_cost = cost_matrix[i][j]
|
||
|
||
if max_cost == 0.0:
|
||
# All costs are zero — any tour is optimal
|
||
tour = list(range(n))
|
||
return {
|
||
'solutions': {sigmas[0]: {'tour': tour, 'cost': 0.0,
|
||
'n_clusters': 1, 'sigma': sigmas[0]}},
|
||
'converged': True,
|
||
'best_sigma': sigmas[0],
|
||
'best_cost': 0.0,
|
||
}
|
||
|
||
solutions = {}
|
||
best_cost = float('inf')
|
||
best_sigma = sigmas[0]
|
||
prev_tour = None
|
||
|
||
for sigma in sigmas:
|
||
# Compute clustering threshold: merge nodes with cost < sigma * max_cost
|
||
threshold = sigma * max_cost
|
||
|
||
# Cluster nodes using single-linkage
|
||
clusters = _single_linkage_clusters(cost_matrix, threshold)
|
||
n_clusters = len(clusters)
|
||
|
||
if n_clusters == 1:
|
||
# All nodes in one cluster — solve the full problem
|
||
if prev_tour is not None:
|
||
tour, cost = _2opt_improve(prev_tour[:], cost_matrix)
|
||
else:
|
||
tour, cost = _greedy_tour(cost_matrix)
|
||
tour, cost = _2opt_improve(tour, cost_matrix)
|
||
else:
|
||
# Build reduced cost matrix for cluster representatives
|
||
reduced_matrix = _build_reduced_cost_matrix(cost_matrix, clusters)
|
||
|
||
# Solve the reduced problem
|
||
if n_clusters <= 2:
|
||
# Trivial: just order the clusters
|
||
cluster_tour = list(range(n_clusters))
|
||
else:
|
||
cluster_tour, _ = _greedy_tour(reduced_matrix)
|
||
if n_clusters >= 4:
|
||
cluster_tour, _ = _2opt_improve(cluster_tour, reduced_matrix)
|
||
|
||
# Expand cluster tour back to individual nodes
|
||
tour = _expand_tour(cluster_tour, clusters, cost_matrix)
|
||
|
||
# Polish with 2-opt on the full problem
|
||
if prev_tour is not None:
|
||
# Warm-start: try both the expanded tour and the previous tour
|
||
tour_a, cost_a = _2opt_improve(tour, cost_matrix)
|
||
tour_b, cost_b = _2opt_improve(prev_tour[:], cost_matrix)
|
||
if cost_a <= cost_b:
|
||
tour, cost = tour_a, cost_a
|
||
else:
|
||
tour, cost = tour_b, cost_b
|
||
else:
|
||
tour, cost = _2opt_improve(tour, cost_matrix)
|
||
|
||
solutions[sigma] = {
|
||
'tour': tour,
|
||
'cost': cost,
|
||
'n_clusters': n_clusters,
|
||
'sigma': sigma,
|
||
}
|
||
|
||
if cost < best_cost:
|
||
best_cost = cost
|
||
best_sigma = sigma
|
||
|
||
prev_tour = tour
|
||
|
||
# Check convergence: did the solution stabilize at the finest scale?
|
||
converged = False
|
||
if len(sigmas) >= 2:
|
||
costs = [solutions[s]['cost'] for s in sigmas]
|
||
if len(costs) >= 2:
|
||
# Converged if last two scales are within 1%
|
||
last = costs[-1]
|
||
second_last = costs[-2]
|
||
if second_last > 0:
|
||
converged = abs(last - second_last) / second_last < 0.01
|
||
else:
|
||
converged = abs(last - second_last) < 1e-10
|
||
|
||
return {
|
||
'solutions': solutions,
|
||
'converged': converged,
|
||
'best_sigma': best_sigma,
|
||
'best_cost': best_cost,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI / demo
|
||
# ---------------------------------------------------------------------------
|
||
|
||
if __name__ == '__main__':
|
||
import random
|
||
|
||
# Generate a random TSP instance
|
||
n = 20
|
||
random.seed(42)
|
||
points = [(random.uniform(0, 100), random.uniform(0, 100))
|
||
for _ in range(n)]
|
||
|
||
cost = [[0.0] * n for _ in range(n)]
|
||
for i in range(n):
|
||
for j in range(n):
|
||
dx = points[i][0] - points[j][0]
|
||
dy = points[i][1] - points[j][1]
|
||
cost[i][j] = math.sqrt(dx * dx + dy * dy)
|
||
|
||
print(f"Random TSP instance: {n} cities")
|
||
print(f"Cost matrix range: [{min(min(row) for row in cost):.1f}, "
|
||
f"{max(max(row) for row in cost):.1f}]")
|
||
|
||
# Solve multi-scale
|
||
result = solve_multiscale(cost)
|
||
|
||
print(f"\nConverged: {result['converged']}")
|
||
print(f"Best sigma: {result['best_sigma']}")
|
||
print(f"Best cost: {result['best_cost']:.2f}")
|
||
|
||
for sigma, data in sorted(result['solutions'].items(), reverse=True):
|
||
print(f" σ={sigma:.2f}: tour cost={data['cost']:.2f}, "
|
||
f"clusters={data['n_clusters']}")
|
||
|
||
# Demo voltage mapping
|
||
print("\nVoltage ↔ Scale mapping:")
|
||
for mv in [600, 700, 800, 900, 1000, 1100, 1200]:
|
||
s = voltage_to_scale(mv)
|
||
v_back = scale_to_voltage(s)
|
||
print(f" {mv}mV → σ={s:.3f} → {v_back:.0f}mV")
|
||
|
||
# Demo Q16 kernel
|
||
print("\nQ16.16 Gaussian kernel (σ=0.5, 9 taps):")
|
||
k = gaussian_kernel_q16(0.5, 9)
|
||
total_q16 = sum(k)
|
||
print(f" Values: {k}")
|
||
print(f" Sum: {total_q16} (target: {Q16_SCALE})")
|
||
print(f" As floats: [{', '.join(f'{q16_to_float(v):.4f}' for v in k)}]")
|