User Guide: Harness Generation =============================== Routing produces one path per cable. A **harness** is what those paths become once they are consolidated into a single physical object: cables that share a route are merged into one bundle of branches, branches meet at junctions, and each connector is fed by one leader. :meth:`~routing.core.route_planner.RoutePlanner.generate_harness` runs that whole chain — route, bundle, then optimize the geometry — from one settings object. .. contents:: On this page :local: :depth: 1 The Pipeline ------------ .. list-table:: :header-rows: 1 :widths: 18 52 30 * - Stage - What it does - Settings group * - **route** - Runs :meth:`~routing.core.route_planner.RoutePlanner.generate` on the specifications — one grid path per cable. - ``n_candidate_paths``, ``steady_mode`` * - **bundle** - Voxelizes the routed cables, thins the volume to its medial axis, fits the solutions back onto it and wires the result into a :class:`~routing.core.bundling.Bundle` (branches, junctions, port leaders). - ``bundling`` * - **unlock** - Drives the harness to feasibility. On a violation plateau the cramped zones become repulsive locks on the ``CadMap`` and the cables they carry are rerouted around them. - ``unlocking`` * - **consolidate** - Reruns coarse passes until the topology freezes (experimental — disabled by default). - ``consolidation`` * - **shape** - Refines the frozen topology with the design-rule constraints active. - ``shaping`` The solver only moves points: the topology is fixed for the whole duration of a pass. It changes between passes only — through ``Bundle.repaired()`` (the consolidation round-trip) or through the junction repair armed by ``shaping.repair``. One Call -------- .. code-block:: python from routing.core.design_rules import BranchPortDistanceRule from routing.core.finders import SmartFinder from routing.core.harness import HarnessSettings from routing.core.route_planner import RoutePlanner route_planner = RoutePlanner(cadmap, SmartFinder("AStar", "manhattan", "never")) settings = HarnessSettings( clearance=0.005, # 5 mm of CAD safety margin around every pipe radius harness_rules=[BranchPortDistanceRule(branches_min_distance=0.05, port_branch_min_distance=0.03)], ) bundle, history = route_planner.generate_harness(specifications, settings, name="harness") bundle.display_3d() # the optimized harness history.volume_models().display_3d() # the whole chronology, state by state ``settings`` may be omitted entirely — :class:`~routing.core.harness.HarnessSettings` defaults are the validated production recipe. HarnessSettings --------------- :class:`~routing.core.harness.HarnessSettings` is the single declaration surface: one group per pipeline stage, plus the optimization workshop the stages share. It is fully platform-serializable, so a run recipe can be stored and replayed. .. list-table:: :header-rows: 1 :widths: 24 20 56 * - Parameter - Default - Description * - ``clearance`` - ``0.0`` - CAD safety margin in **metres**, padding every pipe radius. One source read by both the port-base preparation and the CAD-interference constraint. * - ``harness_rules`` - ``[]`` - Design rules added on top of the cadmap's own (which are always carried). See :ref:`harness-design-rules`. * - ``n_candidate_paths`` - ``1`` - Grid paths searched per specification, kept as its alternatives; the solution stands by the cheapest. * - ``steady_mode`` - ``True`` - Keep routing the remaining specifications when one raises. A conduit no path joins is kept unrouted either way, and left out of the harness (``routed_solutions``). * - ``bundling`` - ``SkeletonSettings()`` - Skeletonization recipe — see :ref:`harness-bundling`. * - ``optimization`` - ``OptimizationSettings()`` - Search space, lattice and seed repair shared by every stage. * - ``unlocking`` - ``UnlockSettings()`` - Unlock stage (enabled). * - ``consolidation`` - ``ConsolidationSettings()`` - Consolidate stage (**disabled** — experimental, opt in explicitly). * - ``shaping`` - ``ShapingSettings()`` - Shape stage (enabled). * - ``name`` - ``""`` - Object name; an unnamed settings takes ``"_settings"`` at completion. .. code-block:: python from routing.core.harness import ( ConsolidationSettings, HarnessSettings, OptimizationSettings, ShapingSettings, UnlockSettings, ) settings = HarnessSettings( clearance=0.005, optimization=OptimizationSettings(max_shift=0.1), unlocking=UnlockSettings(max_iterations=5, lock_dilation_voxels=5.0), consolidation=ConsolidationSettings(enabled=True, max_iterations=3), shaping=ShapingSettings(max_iterations=2, repair=True), ) Derived Defaults ~~~~~~~~~~~~~~~~ Fields that depend on the computation cadmap stay ``None`` until :meth:`~routing.core.harness.HarnessSettings.complete` resolves them against it — ``generate_harness`` calls it for you, on a copy (your settings object is never mutated). Explicit values pass through untouched. The routing voxel size is the step of the map's first region (``cadmap.room_voxels``), the coarse one on a multi-scale map. .. list-table:: :header-rows: 1 :widths: 40 60 * - Field left ``None`` - Resolves to * - ``bundling.voxel_size`` - Half the routing voxel size * - ``optimization.voxel_size`` - Half the routing voxel size (the optimizer-window lattice step) * - ``optimization.max_shift`` - 5 routing voxels * - ``unlocking.ga`` - :meth:`UnlockSettings.production_ga ` — jittered seed, ``stop_when_feasible``, violation-plateau stall * - ``consolidation.ga`` / ``shaping.ga`` - ``GAConfig(jitter_sigma=max_shift)`` — the campaign's converged recipe A finer optimizer lattice than the routing one costs a one-shot recomputation of the distance fields; setting ``optimization.voxel_size`` to the cadmap's own step makes every pass read the cadmap's stored state instead. The Optimization Workshop ~~~~~~~~~~~~~~~~~~~~~~~~~ :class:`~routing.core.harness.OptimizationSettings` is shared by all three stages: it defines what the solver is allowed to move and on which lattice. .. list-table:: :header-rows: 1 :widths: 26 18 56 * - Parameter - Default - Description * - ``max_shift`` - ``None`` - Per-point movement budget in metres (``None`` = 5 routing voxels). Also the natural jitter unit of the GA seeding. * - ``voxel_size`` - ``None`` - Optimizer-window lattice step (``None`` = half the routing voxel). * - ``exclude_tree_hops`` - ``2`` - Kinship depth exempting collision pairs: ``1`` = adjacent lines only, ``2`` also exempts free-line pairs one intermediate line apart (branches forking from a shared junction). Kinship never exempts a port leader — a route folding back onto its own entry stays constrained. * - ``relaxation`` - ``RelaxationConfig()`` - Seed repair before each pass (``enabled``, ``max_search=2``, ``max_passes=8``). Moves interfering points to their best voxel-aligned neighbour so the GA starts from a legal seed. * - ``out_of_grid_penalty`` - ``1000.0`` - Charged to weighted-length samples that leave the grid. The Stages ---------- Each stage runs when its ``enabled`` flag is set, in canonical unlock → consolidate → shape order, and carries its own GA recipe (``ga``). Unlock ~~~~~~ Feasibility is the whole objective here: no length polishing. When a pass plateaus while still infeasible, every cramped free line becomes a repulsive lock on the cadmap — its cylinder dilated by ``lock_dilation_voxels`` routing voxels, weights inside multiplied by ``lock_strength`` — the cables it carries are withdrawn and rerouted around the locks, and the harness is rebuilt. Each (re)generation is snapshot into the history with its locks. .. list-table:: :header-rows: 1 :widths: 26 18 56 * - Parameter - Default - Description * - ``enabled`` - ``True`` - Run the stage. * - ``max_iterations`` - ``3`` - Lock + regenerate rounds before giving up (a warning is emitted, the run continues as is). * - ``lock_dilation_voxels`` - ``5.0`` - Dilation of each lock cylinder, in routing voxels. * - ``lock_strength`` - ``100.0`` - Weight multiplier inside a lock. * - ``ga`` - ``None`` - Stage GA recipe (``None`` = ``production_ga``). Consolidate ~~~~~~~~~~~ Experimental, **disabled by default**. Reruns a coarse pass followed by ``Bundle.repaired()`` until two successive reruns share the same topology signature, then hands the frozen bundle to the shaping stage. No design-rule term takes part: they are antagonistic to consolidation. .. list-table:: :header-rows: 1 :widths: 26 18 56 * - Parameter - Default - Description * - ``enabled`` - ``False`` - Opt in explicitly. * - ``max_iterations`` - ``5`` - Rerun cap before the topology is declared unfrozen (warning, last bundle kept). * - ``ga`` - ``None`` - Stage GA recipe. Shape ~~~~~ The final refinement, with the design-rule terms active on the frozen topology. .. list-table:: :header-rows: 1 :widths: 26 18 56 * - Parameter - Default - Description * - ``enabled`` - ``True`` - Run the stage. * - ``max_iterations`` - ``1`` - Chain that many passes on the improved seed (relaxation and GA rerun each time). * - ``repair`` - ``False`` - Arm the junction repair at the head of each pass — junctions are split, merged or given slack before the problem is built, so the design-rule terms enter on the repaired topology. Because that changes the topology in place, an armed repair runs the stage on a structural twin: the shaped harness is ``history.final().bundle`` (what ``generate_harness`` returns), and the states recorded before it stay replayable. * - ``ga`` - ``None`` - Stage GA recipe. .. _harness-bundling: Skeletonization --------------- ``settings.bundling`` is a :class:`~routing.core.skeleton.SkeletonSettings`: the recipe that turns routed cables into branches. The bundle carries it, so every rebuild during the unlock loop — and every ``Bundle.repaired()`` — reuses the same recipe. .. list-table:: :header-rows: 1 :widths: 26 18 56 * - Parameter - Default - Description * - ``voxel_size`` - ``None`` - Skeletonization lattice step (``None`` = half the routing voxel under ``complete``; the routing voxel size in a standalone :meth:`~routing.core.route_planner.RoutingResults.to_skeleton`). * - ``dilation_voxels`` - ``2`` - Dilation applied when voxelizing the cables, before thinning. * - ``opening_radius`` - ``2`` - Morphological opening applied before thinning — erases small protrusions. * - ``closing_radius`` / ``erosion_radius`` - ``0`` - Further morphology before thinning: closing fills small gaps, erosion shrinks fat regions. A radius of ``0`` skips its stage. * - ``smoothing_window`` - ``2`` - Smoothing window applied to each branch before it is split into straight segments. * - ``tangent_window`` - ``3`` - Window used to detect the direction changes a branch is split at. * - ``min_turn_angle`` - ``pi / 12`` - A direction change below this angle is not a turn — the branch is not split there. GA Recipes ---------- Each stage's ``ga`` is a :class:`~routing.core.bundle_optimization.GAConfig`. Its defaults *are* the converged production recipe (small population, ~3 mutated genes per child), so leaving ``ga=None`` is the normal case. Override it to change the budget, watch the solver, or probe an operator lever. .. code-block:: python from routing.core.bundle_optimization import GAConfig, StallCriterion, TerminationCriteria from routing.core.harness import ShapingSettings shaping = ShapingSettings( ga=GAConfig( jitter_sigma=0.1, # seed jitter; at pop_size=2 always set it verbose=True, # pymoo's per-generation table termination=TerminationCriteria( n_gen=2000, feasible_stall=StallCriterion(ftol=1e-3, evals=1000), ), ) ) .. list-table:: :header-rows: 1 :widths: 26 22 52 * - ``GAConfig`` - Default - Description * - ``pop_size`` - ``2`` - Population size. At ``2``, always set ``jitter_sigma`` — two full-domain random individuals are a dead start. * - ``seed`` - ``1`` - Random seed. * - ``x0`` / ``jitter_sigma`` / ``jitter_distribution`` - ``None`` / ``None`` / ``"normal"`` - Seeding: ``x0`` defaults to the bundle's current state; ``jitter_sigma`` ``None`` means full-domain random sampling instead of a jitter around the seed. * - ``termination`` - ``TerminationCriteria()`` - Stopping policy — every active criterion races, first to trip stops the run. * - ``verbose`` - ``False`` - pymoo's own per-generation output table. * - ``mutation`` / ``crossover`` / ``survival`` - ``PolynomialMutationConfig()`` / ``None`` / ``None`` - Operator levers; ``None`` = pymoo defaults (SBX crossover, Deb survival). * - ``term_record_period`` - ``None`` - Record the per-term objective/violation breakdown every N generations (one extra evaluation each — noticeable at tiny populations). .. list-table:: :header-rows: 1 :widths: 26 22 52 * - ``TerminationCriteria`` - Default - Description * - ``n_gen`` - ``12500`` - Generation cap; always active. * - ``max_seconds`` - ``None`` - Wall-clock cap racing against ``n_gen``. * - ``feasible_tol`` - ``1e-6`` - Best constraint violation at or below this counts as feasible. * - ``feasible_stall`` - ``StallCriterion(ftol=0.001)`` - Stagnation window on the objective while feasible. ``StallCriterion`` trips when the tracked best improves by less than ``ftol`` over ``evals`` (default ``1000``) consecutive evaluations. * - ``violation_stall`` - ``None`` - Stagnation window on the violation while infeasible. ``None`` keeps the escape on its full budget. * - ``stop_when_feasible`` - ``False`` - End the run as soon as the best individual is feasible — the unlock stage's criterion. Operator levers, all optional and documented in :mod:`routing.core.bundle_optimization`: ``PolynomialMutationConfig`` (mutated genes per child), ``CauchyMutationConfig`` (heavy-tailed jumps across violation ridges), ``ChainTranslationConfig`` (translate a whole branch chain at once), ``BlockCrossoverConfig`` (inherit a point or chain whole from one parent) and ``AnnealedSurvivalConfig`` (tolerance to infeasibility, cooling over the budget). Reading the Result ------------------ ``generate_harness`` returns ``(bundle, history)``. The bundle is left at the last pass's best state. .. code-block:: python # Every (re)generation, with the locks that led to it for snapshot in history.snapshots: print(snapshot.label, len(snapshot.bundle.lines), len(snapshot.locks)) # Every optimization pass, in order for record in history.passes: print(record.stage, record.result.f_best, record.result.max_violation, record.result.n_eval, record.result.elapsed_seconds) final = history.final() # last pass — the refined result best = history.best() # lowest feasible objective across passes final.materialize() # write that pass's state back onto the bundle history.volume_models().display_3d() # chronology: snapshots + raw/relaxed/optimized per pass history.plot_data().plot() # convergence dashboard :class:`~routing.core.bundle_optimization.HarnessOptimizationHistory` serializes with the platform object: the passes share one bundle by reference and the per-line local cadmaps stay runtime-only, so the curves and states are carried, not the gigabytes. What you can do with the bundle itself: .. list-table:: :header-rows: 1 :widths: 34 66 * - Call - Returns * - ``bundle.display_3d()`` - 3D view of the harness. * - ``bundle.to_cylinders()`` - The body branches as plain cylinders at packed radius — imposed entry geometry excluded (it is re-derived from the ports at every rebirth). * - ``bundle.distinct_solutions()`` - The routed cables the harness carries. * - ``bundle.to_skeleton()`` - Re-derive a voxel skeleton from the current geometry (round-trip). * - ``bundle.repaired()`` - A re-consolidated copy: the only step that changes the topology. Driving the Stages by Hand -------------------------- :class:`~routing.core.harness.HarnessOptimizer` is the orchestrator ``generate_harness`` builds internally. Use it directly to run one stage on an existing bundle — the shape of a calibration bench. .. code-block:: python from routing.core.harness import HarnessOptimizer, HarnessSettings settings = HarnessSettings(...).complete(cadmap) # mandatory: no derived field may stay None optimizer = HarnessOptimizer(settings) optimizer.unlock(bundle) # one feasibility pass -> SolverResult frozen = optimizer.consolidate(bundle) # reruns until the topology freezes -> Bundle history = optimizer.shape(frozen) # shaping pass(es) -> HarnessOptimizationHistory shaped = history.final().bundle # with shaping.repair armed, the stage worked on a twin history = optimizer.optimize(bundle) # the enabled stages, in canonical order optimizer.diagnose(bundle, stage="shape") # per-entity violation report at the current geometry ``diagnose`` prints the objective, each cost contribution and, per constraint, every violated entry with its residual (in metres and in voxels) and the entity carrying it — a line, a collision pair, a turn. It returns those entries, so a script can act on them. Pass ``attach=True`` to force a re-preparation; by default it reuses the local cadmaps the last pass left, so the diagnosed geometry is exactly the stuck one. .. _harness-design-rules: Design Rules on the Harness --------------------------- A design rule has two projections: the routing grid (weights, costs, heuristics) and the harness optimization problem. A rule that implements ``evaluate_on_bundle`` — today :ref:`ref-branch-port-distance-rule` — automatically becomes a term of the shaping problem, under the form its :class:`~routing.core.design_rules.RuleEnforcement` declares. .. code-block:: python from routing.core.design_rules import BranchPortDistanceRule, RuleEnforcement rule = BranchPortDistanceRule( branches_min_distance=0.05, port_branch_min_distance=0.03, enforcement=RuleEnforcement(hard=False, weight=50.0, power=2), # soft cost instead of hard constraint ) settings = HarnessSettings(harness_rules=[rule]) Rules carried by the cadmap are always part of the harness; ``harness_rules`` adds the ones that only make sense once the cables are bundled. See :ref:`design-rules-on-the-harness` for the parameter reference. Custom Optimization Terms ------------------------- The problem solved at each pass is built by :meth:`OptimizationTerm.from_bundle `: the intrinsic trio — weighted length as the objective, CAD interference and line interpenetration as hard constraints — plus one term per bundle-active rule. To add a cost of your own, subclass :class:`~routing.core.bundle_optimization.Phenomenon`, wrap it in an :class:`~routing.core.bundle_optimization.OptimizationTerm` and hand the list to the optimizer: .. code-block:: python from routing.core.bundle_optimization import OptimizationTerm terms = OptimizationTerm.from_bundle(bundle, alpha=clearance_in_voxels) terms.append(OptimizationTerm(MyPhenomenon, hard=False, soft=True, weight=10.0, power=2)) history = HarnessOptimizer(settings, terms=terms).optimize(bundle) Terms are a **runtime** override: phenomenon factories are callables, so they live on the orchestrator, never on the serialized settings. Each term declares the stages it joins through ``active_in``. Replaying a Run --------------- ``initial_routing`` starts the pipeline from an existing routing instead of routing from scratch — the replay lever on a mutable cadmap. Rerunning the unlock stage with other lock knobs, without paying for the initial generation again: .. code-block:: python from routing.core.route_planner import RoutingResults seed = history.snapshots[0] # "generated 1": full solutions, no lock cadmap.restore_solutions(seed.solutions) cadmap.withdraw_zones([lock for snapshot in history.snapshots for lock in snapshot.locks]) bundle, history = route_planner.generate_harness( specifications, settings, initial_routing=RoutingResults(cadmap.solutions, cadmap), ) Bundling Without Optimizing --------------------------- To inspect the skeleton or the bundle without running any GA, go through the routing result: .. code-block:: python from routing.core.skeleton import SkeletonSettings routing_result = route_planner.generate(specifications, n_iterations=1, steady_mode=True) skeleton = routing_result.to_skeleton(SkeletonSettings(voxel_size=0.01)) skeleton.display() bundle = routing_result.to_bundle(settings=SkeletonSettings(voxel_size=0.01), name="harness") bundle.display_3d() Both take the same recipe object. ``to_bundle`` carries the cadmap's design rules over by default; pass ``rules=[...]`` to override that set. Tuning Guide ------------ .. list-table:: :header-rows: 1 :widths: 40 60 * - Symptom - Knob * - Harness never reaches feasibility - More unlock rounds (``unlocking.max_iterations``), stronger locks (``lock_dilation_voxels``, ``lock_strength``). Read ``optimizer.diagnose(bundle, stage="unlock")`` first. * - Passes are slow - Coarsen ``optimization.voxel_size`` (the cadmap's own step avoids recomputing the distance fields), lower the GA budget (``termination.n_gen``) or arm a stall. * - Cables pinned too close to CAD - Raise ``clearance`` (metres). * - Junctions crowd each other or sit on a connector - Add a :ref:`ref-branch-port-distance-rule` to ``harness_rules``; arm ``shaping.repair``. * - Branch topology looks noisy - Coarsen ``bundling.voxel_size`` / raise ``bundling.opening_radius``; enable the consolidation stage. * - Solver barely moves the harness - Raise ``optimization.max_shift`` (also raise the GA ``jitter_sigma`` with it). Next Steps ---------- - :doc:`design_rules_reference` — every rule's parameters, including harness enforcement - :doc:`optimization` — the per-path geometry optimizer (a different, cable-by-cable step) - :doc:`results_access` — read routed paths, packs and bifurcation points - :doc:`pooling` — control which cables are allowed to share a route in the first place