mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
812 lines
32 KiB
Python
812 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
# ==============================================================================
|
||
# COPYRIGHT NO ONE EVERYWHERE LLC (WYOMING HOLDING COMPANY)
|
||
# PROJECT: SOVEREIGN STACK
|
||
# This artifact is entirely proprietary and cryptographically proven.
|
||
# Open-Source usage requires explicit permission from Brandon Scott Schneider.
|
||
# ==============================================================================
|
||
"""
|
||
gcode_optimizer.py — 14D canal-metric G-code path optimizer
|
||
|
||
Virtual extruder test harness. Runs multiple optimization strategies against
|
||
G-code input and reports canal metric + φ-coherence scores without hardware.
|
||
|
||
14D planning axes:
|
||
Physical (5): X, Y, Z, E (extrusion), F (feedrate)
|
||
Planning (9): η (viscosity), τ (thermal), A (acceleration),
|
||
overhang_angle, bridge_span, retraction_state,
|
||
layer_height, wall_proximity, ringing_risk
|
||
|
||
Strategies:
|
||
baseline — original G-code order
|
||
canal — nearest-neighbour by canal metric (greedy)
|
||
phi_sorted — sorted by φ-coherence bucket per layer
|
||
thixotropic — prefer previously-visited regions (lower η)
|
||
random — shuffled within layer (sanity check, expected worst)
|
||
|
||
Usage:
|
||
python 5-Applications/scripts/gcode_optimizer.py --synthetic
|
||
python 5-Applications/scripts/gcode_optimizer.py --input benchy.gcode
|
||
python 5-Applications/scripts/gcode_optimizer.py --synthetic --strategies baseline,canal,phi
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import math
|
||
import random
|
||
import re
|
||
import sys
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
PHI = (1 + math.sqrt(5)) / 2 # 1.6180339887…
|
||
N_AXES = 18 # Extended for Layer 7 (was 14)
|
||
_EPS = 1e-9
|
||
|
||
|
||
def clamp_unit(v: float) -> float:
|
||
"""Clamp value to [0, 1] range."""
|
||
return max(0.0, min(1.0, v))
|
||
|
||
# Canal metric weighting — from HYPERFLUID_CANAL_MODEL.md
|
||
_LAMBDA_T = 0.15 # thixotropic decay rate (visit-count)
|
||
_LAMBDA_A = 0.30 # predictability (IoC) weight
|
||
_ETA_0 = 1.0 # base viscosity
|
||
|
||
# Physical axis indices
|
||
_AX_X, _AX_Y, _AX_Z, _AX_E, _AX_F = 0, 1, 2, 3, 4
|
||
|
||
# Planning axis indices (original 9)
|
||
_AX_ETA, _AX_TAU, _AX_ACC = 5, 6, 7
|
||
_AX_OVH, _AX_BRG, _AX_RET = 8, 9, 10
|
||
_AX_LYR, _AX_WLL, _AX_RNG = 11, 12, 13
|
||
|
||
# Layer 7: Regime Drift / Structured Chaos extensions
|
||
_AX_CHI = 14 # structured chaos ratio
|
||
_AX_PSI = 15 # alignment state ψ (model adaptation)
|
||
_AX_PHI = 16 # structural regime φ (target)
|
||
_AX_DELTA = 17 # regime lag |φ - ψ|
|
||
|
||
|
||
# ── Data model ────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class GCodeMove:
|
||
"""One parsed G0/G1 command with current machine state after execution."""
|
||
x: float
|
||
y: float
|
||
z: float
|
||
e: float
|
||
f: float
|
||
is_travel: bool # E == previous E (no extrusion)
|
||
line_no: int
|
||
raw: str
|
||
|
||
|
||
@dataclass
|
||
class Move14D:
|
||
"""GCodeMove expanded to 18D planning space (Layer 7 extended)."""
|
||
move: GCodeMove
|
||
vec: List[float] = field(default_factory=lambda: [0.0] * N_AXES)
|
||
# Layer 7: Scroll/orientation state for path history
|
||
scroll_id: int = 0
|
||
twist_bit: int = 1 # +1 or -1 based on direction change
|
||
bridge_candidate: bool = False # True if this move is a potential bridge node
|
||
|
||
|
||
# ── G-code parser ─────────────────────────────────────────────────────────────
|
||
|
||
_G_RE = re.compile(
|
||
r'G[01]\s*'
|
||
r'(?:X(-?[\d.]+))?\s*'
|
||
r'(?:Y(-?[\d.]+))?\s*'
|
||
r'(?:Z(-?[\d.]+))?\s*'
|
||
r'(?:E(-?[\d.]+))?\s*'
|
||
r'(?:F(-?[\d.]+))?',
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def parse_gcode(text: str) -> List[GCodeMove]:
|
||
moves: List[GCodeMove] = []
|
||
cx = cy = cz = ce = cf = 0.0
|
||
prev_e = 0.0
|
||
|
||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||
line = raw.strip()
|
||
if not line or line.startswith(';'):
|
||
continue
|
||
m = _G_RE.match(line)
|
||
if not m:
|
||
continue
|
||
x_s, y_s, z_s, e_s, f_s = m.groups()
|
||
if x_s is not None: cx = float(x_s)
|
||
if y_s is not None: cy = float(y_s)
|
||
if z_s is not None: cz = float(z_s)
|
||
if e_s is not None: ce = float(e_s)
|
||
if f_s is not None: cf = float(f_s)
|
||
|
||
is_travel = (ce == prev_e)
|
||
moves.append(GCodeMove(cx, cy, cz, ce, cf, is_travel, lineno, raw))
|
||
prev_e = ce
|
||
|
||
return moves
|
||
|
||
|
||
def emit_gcode(moves: List[GCodeMove]) -> str:
|
||
"""Re-emit G-code from a (possibly reordered) move list."""
|
||
lines: List[str] = ["; gcode_optimizer output"]
|
||
for mv in moves:
|
||
lines.append(f"G1 X{mv.x:.3f} Y{mv.y:.3f} Z{mv.z:.3f} E{mv.e:.4f} F{mv.f:.0f}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── 14D expander ──────────────────────────────────────────────────────────────
|
||
|
||
class Expander14D:
|
||
"""Convert a sequence of GCodeMoves to Move14D planning vectors."""
|
||
|
||
def __init__(self, window: int = 8):
|
||
self._window = window
|
||
|
||
def expand(self, moves: List[GCodeMove]) -> List[Move14D]:
|
||
out: List[Move14D] = []
|
||
thermal_decay = [] # recent E-delta contributions
|
||
f_history: List[float] = []
|
||
z_layers: Dict[float, List[int]] = defaultdict(list)
|
||
for i, mv in enumerate(moves):
|
||
z_layers[round(mv.z, 3)].append(i)
|
||
|
||
for i, mv in enumerate(moves):
|
||
prev = moves[i - 1] if i > 0 else mv
|
||
dx = mv.x - prev.x
|
||
dy = mv.y - prev.y
|
||
dz = mv.z - prev.z
|
||
de = mv.e - prev.e
|
||
df = mv.f - prev.f
|
||
|
||
xy_len = math.hypot(dx, dy)
|
||
xyz_len = math.sqrt(dx*dx + dy*dy + dz*dz) + _EPS
|
||
|
||
# Axis 5: η — extrusion-pressure proxy (E-per-mm-travel)
|
||
eta = abs(de) / (xy_len + _EPS) if xy_len > _EPS else 0.0
|
||
|
||
# Axis 6: τ — thermal history (sum of recent |de| with exponential decay)
|
||
thermal_decay = [v * 0.85 for v in thermal_decay[-self._window:]]
|
||
thermal_decay.append(abs(de))
|
||
tau = sum(thermal_decay)
|
||
|
||
# Axis 7: A — acceleration proxy (|Δf| over window)
|
||
f_history.append(mv.f)
|
||
if len(f_history) > self._window:
|
||
f_history.pop(0)
|
||
accel = (max(f_history) - min(f_history)) / (max(f_history) + _EPS)
|
||
|
||
# Axis 8: overhang_angle (0 = horizontal, 1 = vertical)
|
||
overhang = abs(dz) / xyz_len
|
||
|
||
# Axis 9: bridge_span — XY travel since last Z-layer move
|
||
same_layer = z_layers.get(round(mv.z, 3), [])
|
||
prev_same = max((j for j in same_layer if j < i), default=i)
|
||
span_moves = i - prev_same
|
||
bridge_span = min(1.0, span_moves / max(1, self._window))
|
||
|
||
# Axis 10: retraction_state
|
||
retraction = 1.0 if de < 0 else 0.0
|
||
|
||
# Axis 11: layer_height
|
||
layer_height = min(1.0, abs(dz) / 0.3) # normalised to typical 0.3mm layer
|
||
|
||
# Axis 12: wall_proximity (short XY moves with extrusion ≈ perimeter)
|
||
wall_prox = 1.0 if (xy_len < 5.0 and de > 0) else 0.0
|
||
|
||
# Axis 13: ringing_risk — variance of recent XY velocity
|
||
recent_f = f_history[-4:] if len(f_history) >= 4 else f_history
|
||
mean_f = sum(recent_f) / len(recent_f)
|
||
ringing = math.sqrt(sum((v - mean_f)**2 for v in recent_f) / len(recent_f)) / (mean_f + _EPS)
|
||
|
||
# Layer 7 Axes --------------------------------------------------
|
||
# Axis 14: chi — structured chaos proxy
|
||
# High when heat (traversal) dominates over congestion (A) and misalignment (τ)
|
||
# Approximation: chi = eta_indicator * (1 - congestion_indicator)
|
||
congestion = (bridge_span + overhang) / 2.0 # proxy for A
|
||
misalign = tau / max(thermal_decay + [_EPS]) # proxy for τ
|
||
chi = clamp_unit(eta * (1.0 - congestion) / (1.0 + misalign))
|
||
|
||
# Axis 15: psi — adaptive alignment state (exponentially smoothed)
|
||
# Track how well the path matches expected (via phi-coherence)
|
||
# This is a running estimate; actual requires history
|
||
psi = 0.5 # default neutral; updated per strategy
|
||
|
||
# Axis 16: phi — structural regime (optimal configuration)
|
||
# For gcode, this is the "ideal" path geometry given constraints
|
||
# Approximation: high when layer-aligned, low when bridging/overhang
|
||
phi = clamp_unit(1.0 - overhang - bridge_span * 0.5)
|
||
|
||
# Axis 17: delta — regime lag |φ - ψ|
|
||
delta = abs(phi - psi)
|
||
|
||
vec = [
|
||
mv.x, mv.y, mv.z, mv.e, mv.f, # physical (0-4)
|
||
eta, tau, accel, # material/thermal/accel (5-7)
|
||
overhang, bridge_span, retraction, # geometry (8-10)
|
||
layer_height, wall_prox, ringing, # quality (11-13)
|
||
chi, psi, phi, delta, # Layer 7 (14-17)
|
||
]
|
||
|
||
# Compute scroll_id and twist_bit for path orientation memory
|
||
scroll_id = int(mv.z * 10) + int(mv.x / 20) # Z-layer + X-bucket
|
||
twist_bit = 1 if (dx * dy >= 0) else -1 if (dx * dy < 0) else 1
|
||
|
||
out.append(Move14D(move=mv, vec=vec, scroll_id=scroll_id, twist_bit=twist_bit))
|
||
|
||
return out
|
||
|
||
|
||
# ── Canal metric ──────────────────────────────────────────────────────────────
|
||
|
||
class CanalMetric:
|
||
"""
|
||
d(p1, p2) = ‖p2-p1‖₁₈ × η(p1) × (1+τ(p1)) × (1+λ_A·A(p1)) × (1+λ_χ·(1-χ))
|
||
|
||
η is thixotropic: decreases with visit count for p1's voxel.
|
||
χ is structured chaos: rewards productive disorder.
|
||
"""
|
||
|
||
def __init__(self, grid_res: float = 5.0):
|
||
self._grid_res = grid_res
|
||
self._visit_counts: Dict[Tuple[int, int, int], int] = defaultdict(int)
|
||
|
||
def _voxel(self, v: List[float]) -> Tuple[int, int, int]:
|
||
return (
|
||
int(v[_AX_X] / self._grid_res),
|
||
int(v[_AX_Y] / self._grid_res),
|
||
int(v[_AX_Z] * 10), # finer Z resolution
|
||
)
|
||
|
||
def distance(self, a: Move14D, b: Move14D) -> float:
|
||
# ‖Φ‖ — L2 over all 18 axes (normalised to [0,1] range per axis)
|
||
# Updated norms for 18D space (14 original + 4 Layer 7)
|
||
norms = [200, 200, 10, 50, 15000, 1, 5, 1, 1, 1, 1, 1, 1, 1,
|
||
1.0, 1.0, 1.0, 1.0] # chi, psi, phi, delta are already [0,1]
|
||
diff = [(a.vec[i] - b.vec[i]) / max(norms[i], _EPS) for i in range(N_AXES)]
|
||
phi_norm = math.sqrt(sum(d*d for d in diff))
|
||
|
||
vox = self._voxel(a.vec)
|
||
visits = self._visit_counts[vox]
|
||
eta = _ETA_0 * math.exp(-_LAMBDA_T * visits)
|
||
|
||
tau = a.vec[_AX_TAU]
|
||
accel = a.vec[_AX_ACC]
|
||
chi = a.vec[_AX_CHI]
|
||
|
||
# Layer 7: Modified canal cost with structured chaos χ
|
||
# Only destructive chaos (1-χ) penalizes torsion and acceleration
|
||
tau_eff = (1.0 - chi) * tau
|
||
accel_eff = (1.0 - chi) * accel
|
||
|
||
# Base cost
|
||
base_cost = phi_norm * eta
|
||
|
||
# Add torsion alignment bonus/penalty based on twist_bit match
|
||
twist_alignment = 1.0
|
||
if a.twist_bit == b.twist_bit and a.twist_bit != 0:
|
||
twist_alignment = 0.9 # 10% discount for aligned scrolls
|
||
|
||
return base_cost * (1 + tau_eff) * (1 + _LAMBDA_A * accel_eff) * twist_alignment
|
||
|
||
def visit(self, m: Move14D) -> None:
|
||
self._visit_counts[self._voxel(m.vec)] += 1
|
||
|
||
def total_cost(self, path: List[Move14D], record_visits: bool = False) -> float:
|
||
if len(path) < 2:
|
||
return 0.0
|
||
cost = 0.0
|
||
for i in range(1, len(path)):
|
||
cost += self.distance(path[i - 1], path[i])
|
||
if record_visits:
|
||
self.visit(path[i - 1])
|
||
return cost
|
||
|
||
|
||
# ── φ-coherence gate ──────────────────────────────────────────────────────────
|
||
|
||
def phi_coherence_score(path: List[Move14D]) -> float:
|
||
"""
|
||
Fraction of consecutive delta-vectors whose 18D amplitude ratio ≈ φ.
|
||
|
||
Layer 7: Uses chi (structured chaos) to widen acceptance window when
|
||
productive disorder is detected. High chi → more tolerance for variation.
|
||
|
||
Uses the same per-axis normalization as CanalMetric.
|
||
"""
|
||
if len(path) < 2:
|
||
return 0.0
|
||
|
||
# Updated normalization scales for 18D space
|
||
norms = [200, 200, 10, 50, 15000, 1, 5, 1, 1, 1, 1, 1, 1, 1,
|
||
1.0, 1.0, 1.0, 1.0]
|
||
|
||
coherent = 0
|
||
for i in range(1, len(path)):
|
||
a, b = path[i - 1].vec, path[i].vec
|
||
chi = a[_AX_CHI] # structured chaos at this position
|
||
|
||
delta = [abs(b[j] - a[j]) / max(norms[j], _EPS) for j in range(N_AXES)]
|
||
# axis_0 = combined XY physical displacement (primary motion)
|
||
axis0 = delta[_AX_X] + delta[_AX_Y] + _EPS
|
||
rest_sum = sum(delta[2:]) # Z + E + F + all planning axes
|
||
ratio = rest_sum / axis0
|
||
|
||
# Layer 7: Dynamic tolerance based on chi
|
||
# High chi → wider acceptance (structured chaos is productive)
|
||
base_tolerance = 0.20
|
||
chi_bonus = 0.10 * chi # up to 10% extra tolerance
|
||
tolerance = base_tolerance + chi_bonus
|
||
|
||
if abs(ratio - PHI) / PHI < tolerance:
|
||
coherent += 1
|
||
|
||
return coherent / (len(path) - 1)
|
||
|
||
|
||
def is_bridge_node(a: Move14D, b: Move14D, c: Move14D, threshold: float = 10.0) -> bool:
|
||
"""Detect if b is a bridge state between a and c (dual-anchor constraint).
|
||
|
||
A bridge exists when b is between two constraints that both 'reach' toward it.
|
||
This is a simplified geometric check for 3D printing paths.
|
||
"""
|
||
# Check if b is roughly between a and c in XY plane
|
||
dx_ab = b.vec[_AX_X] - a.vec[_AX_X]
|
||
dy_ab = b.vec[_AX_Y] - a.vec[_AX_Y]
|
||
dx_bc = c.vec[_AX_X] - b.vec[_AX_X]
|
||
dy_bc = c.vec[_AX_Y] - b.vec[_AX_Y]
|
||
|
||
# Same Z-layer check
|
||
if abs(b.vec[_AX_Z] - a.vec[_AX_Z]) > 0.05:
|
||
return False
|
||
if abs(c.vec[_AX_Z] - b.vec[_AX_Z]) > 0.05:
|
||
return False
|
||
|
||
# Direction reversal check (bridge spans typically reverse direction)
|
||
dot_product = dx_ab * dx_bc + dy_ab * dy_bc
|
||
if dot_product > 0: # Same direction, not a bridge
|
||
return False
|
||
|
||
# Distance check (within threshold)
|
||
dist_ab = math.hypot(dx_ab, dy_ab)
|
||
dist_bc = math.hypot(dx_bc, dy_bc)
|
||
if dist_ab > threshold or dist_bc > threshold:
|
||
return False
|
||
|
||
return True
|
||
|
||
|
||
# ── Strategies ────────────────────────────────────────────────────────────────
|
||
|
||
def _by_layer(moves: List[Move14D]) -> Dict[float, List[Move14D]]:
|
||
layers: Dict[float, List[Move14D]] = defaultdict(list)
|
||
for m in moves:
|
||
layers[round(m.move.z, 3)].append(m)
|
||
return layers
|
||
|
||
|
||
def _detect_and_mark_bridges(moves: List[Move14D]) -> None:
|
||
"""Mark bridge_candidate flag on moves that are potential bridge states."""
|
||
if len(moves) < 3:
|
||
return
|
||
|
||
for i in range(1, len(moves) - 1):
|
||
a, b, c = moves[i-1], moves[i], moves[i+1]
|
||
if is_bridge_node(a, b, c):
|
||
moves[i].bridge_candidate = True
|
||
|
||
|
||
def strategy_baseline(moves: List[Move14D]) -> List[Move14D]:
|
||
return list(moves)
|
||
|
||
|
||
def strategy_canal(moves: List[Move14D]) -> List[Move14D]:
|
||
"""Greedy nearest-neighbour by canal metric, within each Z-layer."""
|
||
metric = CanalMetric()
|
||
result: List[Move14D] = []
|
||
for z, layer in sorted(_by_layer(moves).items()):
|
||
remaining = list(layer)
|
||
if not remaining:
|
||
continue
|
||
cur = remaining.pop(0)
|
||
result.append(cur)
|
||
while remaining:
|
||
costs = [(metric.distance(cur, nxt), i, nxt) for i, nxt in enumerate(remaining)]
|
||
costs.sort(key=lambda t: t[0])
|
||
_, best_i, best = costs[0]
|
||
metric.visit(cur)
|
||
result.append(best)
|
||
remaining.pop(best_i)
|
||
cur = best
|
||
return result
|
||
|
||
|
||
def strategy_phi_sorted(moves: List[Move14D]) -> List[Move14D]:
|
||
"""Sort moves within each layer by φ-ratio bucket (closer to φ first)."""
|
||
result: List[Move14D] = []
|
||
for z, layer in sorted(_by_layer(moves).items()):
|
||
def phi_err(m: Move14D) -> float:
|
||
v = [abs(x) for x in m.vec]
|
||
axis0 = v[_AX_X] + _EPS
|
||
return abs(sum(v[1:]) / axis0 - PHI)
|
||
result.extend(sorted(layer, key=phi_err))
|
||
return result
|
||
|
||
|
||
def strategy_thixotropic(moves: List[Move14D]) -> List[Move14D]:
|
||
"""Prefer previously-visited voxels — emergent worn-path following."""
|
||
metric = CanalMetric()
|
||
# Warm-up pass: record visits in baseline order
|
||
for m in moves:
|
||
metric.visit(m)
|
||
# Now re-sort within layers: lower canal distance from origin wins
|
||
result: List[Move14D] = []
|
||
for z, layer in sorted(_by_layer(moves).items()):
|
||
if not layer:
|
||
continue
|
||
cur = layer[0]
|
||
remaining = list(layer[1:])
|
||
result.append(cur)
|
||
while remaining:
|
||
costs = [(metric.distance(cur, nxt), i, nxt) for i, nxt in enumerate(remaining)]
|
||
costs.sort(key=lambda t: t[0])
|
||
_, best_i, best = costs[0]
|
||
result.append(best)
|
||
remaining.pop(best_i)
|
||
cur = best
|
||
return result
|
||
|
||
|
||
def strategy_random(moves: List[Move14D], seed: int = 42) -> List[Move14D]:
|
||
rng = random.Random(seed)
|
||
result: List[Move14D] = []
|
||
for z, layer in sorted(_by_layer(moves).items()):
|
||
shuffled = list(layer)
|
||
rng.shuffle(shuffled)
|
||
result.extend(shuffled)
|
||
return result
|
||
|
||
|
||
STRATEGIES = {
|
||
"baseline": strategy_baseline,
|
||
"canal": strategy_canal,
|
||
"phi_sorted": strategy_phi_sorted,
|
||
"thixotropic": strategy_thixotropic,
|
||
"random": strategy_random,
|
||
}
|
||
|
||
|
||
# ── Virtual extruder metrics ──────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ExtruderMetrics:
|
||
strategy: str
|
||
canal_cost: float
|
||
phi_coherence: float
|
||
move_count: int
|
||
travel_moves: int
|
||
layer_count: int
|
||
bridge_risk_mean: float
|
||
ringing_risk_mean: float
|
||
overhang_mean: float
|
||
estimated_time_s: float # Σ distance / feedrate
|
||
# Layer 7 extensions
|
||
chi_mean: float # structured chaos (0-1, higher = more productive)
|
||
regime_lag_mean: float # |φ - ψ| alignment lag
|
||
bridge_count: int # detected bridge states
|
||
scroll_alignment: float # fraction of moves with consistent twist_bit
|
||
|
||
|
||
def score(name: str, path: List[Move14D]) -> ExtruderMetrics:
|
||
metric = CanalMetric()
|
||
cost = metric.total_cost(path, record_visits=False)
|
||
phi = phi_coherence_score(path)
|
||
|
||
# Layer 7: Detect and mark bridges
|
||
_detect_and_mark_bridges(path)
|
||
|
||
layers = len(set(round(m.move.z, 3) for m in path))
|
||
travels = sum(1 for m in path if m.move.is_travel)
|
||
|
||
bridge_risks = [m.vec[_AX_BRG] for m in path]
|
||
ringing_risks = [m.vec[_AX_RNG] for m in path]
|
||
overhangs = [m.vec[_AX_OVH] for m in path]
|
||
|
||
br_mean = sum(bridge_risks) / max(len(bridge_risks), 1)
|
||
rr_mean = sum(ringing_risks) / max(len(ringing_risks), 1)
|
||
ov_mean = sum(overhangs) / max(len(overhangs), 1)
|
||
|
||
# Layer 7: Compute extended metrics
|
||
chis = [m.vec[_AX_CHI] for m in path]
|
||
chi_mean = sum(chis) / max(len(chis), 1)
|
||
|
||
regime_lags = [m.vec[_AX_DELTA] for m in path]
|
||
lag_mean = sum(regime_lags) / max(len(regime_lags), 1)
|
||
|
||
bridge_count = sum(1 for m in path if m.bridge_candidate)
|
||
|
||
# Scroll alignment: fraction of consecutive moves with same twist
|
||
aligned_twists = 0
|
||
total_twist_pairs = 0
|
||
for i in range(1, len(path)):
|
||
if path[i].twist_bit == path[i-1].twist_bit:
|
||
aligned_twists += 1
|
||
total_twist_pairs += 1
|
||
scroll_align = aligned_twists / max(total_twist_pairs, 1)
|
||
|
||
# Estimated time: Σ(XY distance / feedrate)
|
||
t = 0.0
|
||
for i in range(1, len(path)):
|
||
a, b = path[i-1].move, path[i].move
|
||
d = math.hypot(b.x - a.x, b.y - a.y, b.z - a.z)
|
||
f = max(b.f, 100) / 60 # mm/s
|
||
t += d / f
|
||
|
||
return ExtruderMetrics(
|
||
strategy=name,
|
||
canal_cost=round(cost, 4),
|
||
phi_coherence=round(phi, 4),
|
||
move_count=len(path),
|
||
travel_moves=travels,
|
||
layer_count=layers,
|
||
bridge_risk_mean=round(br_mean, 4),
|
||
ringing_risk_mean=round(rr_mean, 4),
|
||
overhang_mean=round(ov_mean, 4),
|
||
estimated_time_s=round(t, 1),
|
||
# Layer 7 metrics
|
||
chi_mean=round(chi_mean, 4),
|
||
regime_lag_mean=round(lag_mean, 4),
|
||
bridge_count=bridge_count,
|
||
scroll_alignment=round(scroll_align, 4),
|
||
)
|
||
|
||
|
||
# ── Synthetic benchy-like G-code generator ────────────────────────────────────
|
||
|
||
def _synthetic_gcode(seed: int = 0) -> str:
|
||
"""
|
||
Generate a minimal benchy-representative G-code sequence covering:
|
||
- Hull curve (smooth XY arcs, multiple layers)
|
||
- Stern bridge (XY travel over open space)
|
||
- Chimney overhang (Z-increasing with decreasing support)
|
||
- Porthole circles (small tight loops, high acceleration)
|
||
- Roof (45° overhang)
|
||
"""
|
||
rng = random.Random(seed)
|
||
lines = ["; synthetic benchy-like gcode (gcode_optimizer test fixture)"]
|
||
f_default = 3000
|
||
|
||
def g1(x, y, z, e_delta, f=f_default, acc_e=None):
|
||
nonlocal cur_e
|
||
cur_e += e_delta
|
||
return f"G1 X{x:.3f} Y{y:.3f} Z{z:.3f} E{cur_e:.4f} F{f}"
|
||
|
||
cur_e = 0.0
|
||
layer_heights = [round(0.2 * i, 2) for i in range(1, 16)] # 15 layers
|
||
|
||
# ── Hull curve: 3 layers of smooth ellipse ─────────────────────────────────
|
||
for lz in layer_heights[:3]:
|
||
n_pts = 36
|
||
for i in range(n_pts):
|
||
angle = 2 * math.pi * i / n_pts
|
||
x = 80 + 40 * math.cos(angle)
|
||
y = 40 + 20 * math.sin(angle)
|
||
de = 0.04 + rng.gauss(0, 0.002)
|
||
lines.append(g1(x, y, lz, de))
|
||
|
||
# ── Stern bridge: unsupported XY span ──────────────────────────────────────
|
||
lz = layer_heights[3]
|
||
for x_step in range(20):
|
||
x = 20 + x_step * 3
|
||
y = 40
|
||
de = 0.05 if x_step > 3 else 0.0 # first few are travel
|
||
lines.append(g1(x, y, lz, de))
|
||
|
||
# ── Chimney overhang: Z-increasing with shrinking XY radius ────────────────
|
||
for li, lz in enumerate(layer_heights[4:10]):
|
||
r = 8 - li * 1.0 # shrinking circle = overhang
|
||
cx, cy = 60, 60
|
||
for i in range(24):
|
||
angle = 2 * math.pi * i / 24
|
||
x = cx + r * math.cos(angle)
|
||
y = cy + r * math.sin(angle)
|
||
de = 0.03
|
||
lines.append(g1(x, y, lz, de))
|
||
|
||
# ── Porthole circles: small tight loops, high ringing risk ─────────────────
|
||
for lz in layer_heights[2:5]:
|
||
for cx_off in [30, 50]:
|
||
for i in range(16):
|
||
angle = 2 * math.pi * i / 16
|
||
x = cx_off + 3 * math.cos(angle)
|
||
y = 25 + 3 * math.sin(angle)
|
||
de = 0.015
|
||
lines.append(g1(x, y, lz, de, f=5000)) # high-speed = ringing
|
||
|
||
# ── Roof overhangs: 45° slope ──────────────────────────────────────────────
|
||
for li, lz in enumerate(layer_heights[10:]):
|
||
x_start = 30 + li * 2 # footprint shrinks = overhang
|
||
for xi in range(20):
|
||
x = x_start + xi * 1.5
|
||
y = 40 + rng.gauss(0, 0.1)
|
||
de = 0.045
|
||
lines.append(g1(x, y, lz, de))
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── Report ────────────────────────────────────────────────────────────────────
|
||
|
||
_COL_W = 14
|
||
|
||
def _row(label: str, *vals) -> str:
|
||
return f" {label:<22}" + "".join(f"{str(v):>{_COL_W}}" for v in vals)
|
||
|
||
|
||
def print_report(results: List[ExtruderMetrics]) -> None:
|
||
strategies = [r.strategy for r in results]
|
||
print()
|
||
print("=" * (24 + _COL_W * len(results)))
|
||
print(" 18D VIRTUAL EXTRUDER REPORT (Layer 7 Extended)")
|
||
print("=" * (24 + _COL_W * len(results)))
|
||
print(_row("metric", *strategies))
|
||
print(" " + "-" * (22 + _COL_W * len(results)))
|
||
|
||
fields = [
|
||
("canal_cost", "canal_cost", "lower = better path"),
|
||
("phi_coherence", "phi_coherence", "higher = more φ-locked"),
|
||
("estimated_time_s", "estimated_time_s", "lower = faster print"),
|
||
("bridge_risk_mean", "bridge_risk_mean", "lower = safer bridges"),
|
||
("ringing_risk_mean", "ringing_risk_mean", "lower = less vibration"),
|
||
("overhang_mean", "overhang_mean", "lower = better support"),
|
||
("travel_moves", "travel_moves", "lower = less stringing"),
|
||
("move_count", "move_count", ""),
|
||
("layer_count", "layer_count", ""),
|
||
# Layer 7 metrics
|
||
("chi_mean", "chi_mean", "higher = productive chaos"),
|
||
("regime_lag_mean", "regime_lag", "lower = better aligned"),
|
||
("bridge_count", "bridge_count", "count of dual-anchor states"),
|
||
("scroll_alignment", "scroll_alignment", "higher = consistent path"),
|
||
]
|
||
|
||
for attr, label, note in fields:
|
||
vals = [getattr(r, attr) for r in results]
|
||
note_str = f" ← {note}" if note else ""
|
||
print(_row(label, *vals) + note_str)
|
||
|
||
print()
|
||
|
||
# Rank by canal_cost
|
||
ranked = sorted(results, key=lambda r: r.canal_cost)
|
||
print(" Ranking by canal cost:")
|
||
for i, r in enumerate(ranked, 1):
|
||
delta = ""
|
||
if i > 1:
|
||
pct = (r.canal_cost - ranked[0].canal_cost) / max(ranked[0].canal_cost, _EPS) * 100
|
||
delta = f" (+{pct:.1f}%)"
|
||
phi_pass = "φ-COHERENT" if r.phi_coherence >= 0.5 else "φ-WEAK"
|
||
chi_tag = f" χ={r.chi_mean:.2f}" if r.chi_mean > 0.5 else ""
|
||
print(f" {i}. {r.strategy:<14} cost={r.canal_cost:<10} {phi_pass}{chi_tag}{delta}")
|
||
|
||
print()
|
||
|
||
# φ-commit gate
|
||
best = ranked[0]
|
||
if best.phi_coherence >= 0.5:
|
||
print(f" COMMIT GATE: PASS — '{best.strategy}' φ-coherence={best.phi_coherence}")
|
||
else:
|
||
alt = next((r for r in ranked if r.phi_coherence >= 0.5), None)
|
||
if alt:
|
||
print(f" COMMIT GATE: best canal '{best.strategy}' fails φ-coherence.")
|
||
print(f" Fallback: '{alt.strategy}' (cost={alt.canal_cost}, φ={alt.phi_coherence})")
|
||
else:
|
||
print(" COMMIT GATE: ALL strategies below φ-coherence threshold.")
|
||
|
||
print("=" * (24 + _COL_W * len(results)))
|
||
print()
|
||
|
||
|
||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description="14D canal-metric G-code optimizer (virtual extruder)")
|
||
src = ap.add_mutually_exclusive_group(required=True)
|
||
src.add_argument("--input", metavar="FILE", help="Input G-code file")
|
||
src.add_argument("--synthetic", action="store_true", help="Use built-in synthetic benchy fixture")
|
||
ap.add_argument("--strategies", default="baseline,canal,phi_sorted,thixotropic,random",
|
||
help="Comma-separated list of strategies to run")
|
||
ap.add_argument("--emit", metavar="DIR",
|
||
help="Emit optimized G-code files to DIR (one per strategy)")
|
||
ap.add_argument("--telemetry", metavar="FILE",
|
||
help="Log voxel telemetry to JSONL file")
|
||
ap.add_argument("--seed", type=int, default=0, help="RNG seed for synthetic/random")
|
||
args = ap.parse_args()
|
||
|
||
# Load G-code
|
||
if args.synthetic:
|
||
gcode_text = _synthetic_gcode(seed=args.seed)
|
||
print(f"[gcode_optimizer] synthetic fixture: {len(gcode_text.splitlines())} lines")
|
||
else:
|
||
path = Path(args.input)
|
||
if not path.exists():
|
||
print(f"[gcode_optimizer] ERROR: file not found: {path}", file=sys.stderr)
|
||
sys.exit(1)
|
||
gcode_text = path.read_text()
|
||
print(f"[gcode_optimizer] loaded: {path} ({len(gcode_text.splitlines())} lines)")
|
||
|
||
# Parse → expand
|
||
raw_moves = parse_gcode(gcode_text)
|
||
if not raw_moves:
|
||
print("[gcode_optimizer] ERROR: no G0/G1 moves found", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
print(f"[gcode_optimizer] parsed {len(raw_moves)} moves, expanding to 18D (Layer 7)…")
|
||
expander = Expander14D()
|
||
moves_14d = expander.expand(raw_moves)
|
||
|
||
# Run strategies
|
||
strategy_names = [s.strip() for s in args.strategies.split(",") if s.strip()]
|
||
unknown = [s for s in strategy_names if s not in STRATEGIES]
|
||
if unknown:
|
||
print(f"[gcode_optimizer] unknown strategies: {unknown}. Available: {list(STRATEGIES)}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
results: List[ExtruderMetrics] = []
|
||
for name in strategy_names:
|
||
print(f"[gcode_optimizer] running strategy: {name}…", end=" ", flush=True)
|
||
fn = STRATEGIES[name]
|
||
optimized = fn(moves_14d) if name != "random" else fn(moves_14d, seed=args.seed)
|
||
metrics = score(name, optimized)
|
||
results.append(metrics)
|
||
print(f"canal={metrics.canal_cost}, φ={metrics.phi_coherence}, χ={metrics.chi_mean:.2f}")
|
||
|
||
# Optionally emit G-code
|
||
if args.emit:
|
||
out_dir = Path(args.emit)
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
gcode_out = emit_gcode([m.move for m in optimized])
|
||
out_path = out_dir / f"{name}.gcode"
|
||
out_path.write_text(gcode_out)
|
||
print(f"[gcode_optimizer] → {out_path}")
|
||
|
||
# Optionally log telemetry (Layer 7)
|
||
if args.telemetry:
|
||
import json
|
||
telemetry_path = Path(args.telemetry)
|
||
telemetry_path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
for i, m in enumerate(optimized):
|
||
row = {
|
||
"strategy": name,
|
||
"move_idx": i,
|
||
"voxel_key": (m.scroll_id, int(m.vec[_AX_X]/5), int(m.vec[_AX_Y]/5)),
|
||
"x": round(m.vec[_AX_X], 3),
|
||
"y": round(m.vec[_AX_Y], 3),
|
||
"z": round(m.vec[_AX_Z], 3),
|
||
"chi": round(m.vec[_AX_CHI], 6),
|
||
"psi": round(m.vec[_AX_PSI], 6),
|
||
"phi": round(m.vec[_AX_PHI], 6),
|
||
"delta": round(m.vec[_AX_DELTA], 6),
|
||
"scroll_id": m.scroll_id,
|
||
"twist_bit": m.twist_bit,
|
||
"bridge_candidate": m.bridge_candidate,
|
||
}
|
||
with telemetry_path.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(row, sort_keys=True) + "\n")
|
||
|
||
print_report(results)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|