Source code for Karana.KUtils.kdpy_loops

#!/usr/bin/env python3
# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

#!/usr/bin/env python3
"""Analyze, report, and safely edit kdFlex closed-loop mobility.

The module follows a staged workflow: discover coupled loop groups, inspect
their constraint Jacobians, explore connected closure-feasible motion, classify
coordinate participation, and only then apply an explicitly requested edit.
Read-only analysis and serialized-model mutation have separate class APIs.
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from itertools import combinations
import math
from pathlib import Path
import tempfile
from typing import Any, Callable
import os

import click
import Karana.Core as kc
import Karana.Dynamics as kd
import Karana.Frame as kf
import numpy as np
from numpy.typing import NDArray
from scipy import linalg
from scipy.optimize import least_squares
from Karana.Dynamics.SOADyn_types import SubGraphDS
from Karana.KUtils.BasicPrefab import BasicPrefabDS
from Karana.Math.Ktyping import QuantityOrValue

from Karana.KUtils.kdpy_model_io import LoadedModel, _loadSubGraph, loadModel


INERT_SUBHINGE_TOLERANCE = 2.0e-7


[docs] @dataclass(frozen=True) class MobilityRequest: """Define the target nullity and admissible drivers for one loop group.""" anchor: str target_mobility: int drivers: tuple[str, ...] allowed_components: tuple[str, ...]
[docs] @dataclass(frozen=True) class LoopGroup: """Identify constraint rows and velocity columns coupled into one loop.""" constraints: tuple[str, ...] rows: tuple[int, ...] columns: tuple[int, ...] coordinate_ids: tuple[int, ...] coordinate_names: tuple[str, ...]
[docs] @dataclass(frozen=True) class MobilityCandidate: """Describe one canonical cut-joint coordinate considered for insertion.""" constraint: str component: str subhinge_type: str axis: tuple[float, float, float] column: tuple[float, ...]
[docs] @dataclass(frozen=True) class MobilityChange: """Summarize an accepted connected-loop mobility augmentation.""" anchor: str constraints: tuple[str, ...] before_rank: int before_nullity: int target_mobility: int additions: tuple[MobilityCandidate, ...] predicted_rank: int predicted_nullity: int before_smallest_singular: float predicted_smallest_singular: float
[docs] @dataclass(frozen=True) class GeometryChange: """Summarize one accepted or rejected weak-mode axis reconciliation.""" constraints: tuple[str, ...] accepted: bool before_rank: int after_rank: int before_nullity: int after_nullity: int before_weak_singular: float after_weak_singular: float axis_changes: tuple[tuple[str, float], ...] validation_spans: tuple[tuple[str, float, float, str], ...] = () rejection_reason: str | None = None
[docs] def mobilityRequests( loop_options: dict[str, Any] | None, ) -> tuple[MobilityRequest, ...]: """Validate and merge repeatable four-field DOF requests by loop anchor. Kclick returns one record per ``--add-dofs`` occurrence. Multiple records may contribute drivers to the same loop, but target mobility and allowed component families must agree. The result is canonical input for the Jacobian-based recommendation phase. """ # Kclick records an omitted chained subcommand as an explicit None value. loop_options = loop_options or {} raw_records = loop_options.get("add_dofs", ()) records = raw_records if isinstance(raw_records, tuple) else () # Build parallel maps keyed by anchor so repeated records can contribute # drivers while enforcing one target and component policy per loop. targets: dict[str, int] = {} drivers: dict[str, list[str]] = {} allowed: dict[str, tuple[str, ...]] = {} # Expand comma-delimited family selectors into canonical components once. component_families = { "pin": ("Rx", "Ry", "Rz"), "linear": ("Tx", "Ty", "Tz"), "rx": ("Rx",), "ry": ("Ry",), "rz": ("Rz",), "tx": ("Tx",), "ty": ("Ty",), "tz": ("Tz",), } # Validate and canonicalize every record before constructing immutable # MobilityRequest values. for anchor, target_value, driver, allowed_value in records: target_mobility = int(target_value) components: list[str] = [] if allowed_value != "-": selectors = allowed_value.split(",") if any(not selector for selector in selectors): raise click.UsageError( f"loops --add-dofs has an empty allowed type for loop '{anchor}'" ) for selector in selectors: family = component_families.get(selector.lower()) if family is None: raise click.UsageError( f"unsupported loops --add-dofs allowed type '{selector}'" ) for component in family: if component not in components: components.append(component) component_tuple = tuple(components) if anchor in targets: if target_mobility != targets[anchor]: raise click.UsageError( f"loops --add-dofs entries for loop '{anchor}' have conflicting target mobility" ) if component_tuple != allowed[anchor]: raise click.UsageError( f"loops --add-dofs entries for loop '{anchor}' have conflicting allowed types" ) else: targets[anchor] = target_mobility drivers[anchor] = [] allowed[anchor] = component_tuple if driver != "-": if driver in drivers[anchor]: raise click.UsageError( f"mobility driver '{driver}' is duplicated for loop '{anchor}'" ) drivers[anchor].append(driver) # Freeze the validated mutable collections into analysis input values. requests = [] for anchor, target_mobility in targets.items(): if len(drivers[anchor]) > target_mobility: raise click.UsageError( f"anchor '{anchor}' requests mobility {target_mobility} but has " f"{len(drivers[anchor])} independent drivers" ) requests.append( MobilityRequest( anchor, target_mobility, tuple(drivers[anchor]), allowed[anchor], ) ) return tuple(requests)
[docs] def normalizedLoopOptions(options: dict[str, Any]) -> dict[str, Any]: """Return concrete loop defaults in place of Kclick omission sentinels. Kclick distinguishes an uninvoked chained command from one whose options hold ordinary false or empty values. Downstream loop code consumes stable Python values and does not depend on sentinel identity. """ raw_options = options.get("loops") if not isinstance(raw_options, dict): return {} # Kclick populates every field of an uninvoked chained command with its # opaque UNSET sentinel, so accept only the concrete Click result types. return { "ranges_update": ( raw_options["ranges_update"] if isinstance(raw_options.get("ranges_update"), tuple) else () ), "optimize": ( raw_options["optimize"] if isinstance(raw_options.get("optimize"), bool) else False ), "add_dofs": ( raw_options["add_dofs"] if isinstance(raw_options.get("add_dofs"), tuple) else () ), "remove_inert": ( raw_options["remove_inert"] if isinstance(raw_options.get("remove_inert"), tuple) else () ), "reduce_ball": ( raw_options["reduce_ball"] if isinstance(raw_options.get("reduce_ball"), tuple) else () ), "reduce_spherical": ( raw_options["reduce_spherical"] if isinstance(raw_options.get("reduce_spherical"), tuple) else () ), "mobility_check": ( raw_options["mobility_check"] if isinstance(raw_options.get("mobility_check"), tuple) else () ), "ranges_info": ( raw_options["ranges_info"] if isinstance(raw_options.get("ranges_info"), bool) else False ), }
[docs] def mobilityCheckSelections( values: tuple[str, ...], ) -> tuple[tuple[str, int], ...] | None: """Parse repeatable ``SUBHINGE[/OFFSET]`` mobility-check selectors. ``None`` represents the special all-coordinate selection. A missing suffix selects offset zero, while an explicit suffix must be a nonnegative integer. """ # ``all`` is an aggregate selector; combining it with named work would make # report scope and progress ordering ambiguous. if "all" in values: if len(values) != 1: raise click.UsageError("loops --mobility-check all cannot be combined with coordinates") return None # Preserve CLI order for progress reporting while rejecting duplicate work. selections: list[tuple[str, int]] = [] for value in values: name, separator, offset_text = value.rpartition("/") if not separator: selection = (value, 0) else: if not name or not offset_text.isdigit(): raise click.UsageError("loops --mobility-check expects SUBHINGE or SUBHINGE/OFFSET") selection = (name, int(offset_text)) if selection in selections: raise click.UsageError(f"loops --mobility-check coordinate is duplicated: {value}") selections.append(selection) return tuple(selections)
def _constraintRowsByName(subgraph: kd.SubGraph) -> dict[str, tuple[int, ...]]: """Map enabled cut-joint names to their six full-error row indices.""" constraints = subgraph.enabledConstraints(kd.BilateralConstraintType.CUTJOINT_LOOP) result = {constraint.name(): [] for constraint in constraints} constraint_names = {constraint.id(): constraint.name() for constraint in constraints} for constraint in constraints: del constraint for row in range(6 * len(result)): owner, _ = subgraph.constraintErrorAt(row) try: name = constraint_names.get(owner.id()) if name is not None: result[name].append(row) finally: del owner return {name: tuple(rows) for name, rows in result.items()}
[docs] def discoverLoopGroups( subgraph: kd.SubGraph, *, activity_tolerance: float = 1.0e-10, ) -> tuple[LoopGroup, ...]: """Discover connected cut-joint groups through shared active coordinates. Two constraints belong to the same group when their Jacobian rows touch a common active coordinate, including transitive connections. Grouping first prevents kdpy from making an invalid independent decision for each cut joint when several closures are algebraically coupled. """ # Infer coupling from shared active velocity columns rather than serialized # topology alone; imported closures can share motion indirectly. rows_by_name = _constraintRowsByName(subgraph) if not rows_by_name: return () matrix = np.asarray(kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float) graph_data = subgraph.graphCoordData() try: active_by_name: dict[str, set[int]] = {} for name, rows in rows_by_name.items(): columns = np.flatnonzero( np.linalg.norm(matrix[np.asarray(rows), :], axis=0) > activity_tolerance ) coordinate_ids: set[int] = set() for column in columns: coordinate, _ = graph_data.coordAt(int(column)) coordinate_ids.add(coordinate.id()) del coordinate active_by_name[name] = coordinate_ids # Build connected components instead of assuming one cut joint is one # independent loop. Shared coordinates couple their rank decisions. remaining = set(rows_by_name) groups: list[LoopGroup] = [] while remaining: seed = min(remaining) component = {seed} component_coordinates = set(active_by_name[seed]) changed = True while changed: changed = False for name in sorted(remaining - component): if component_coordinates & active_by_name[name]: component.add(name) component_coordinates.update(active_by_name[name]) changed = True remaining.difference_update(component) # Freeze stable row, column, ID, and name inventories so later rank # analysis does not depend on pybind wrapper lifetimes. rows = tuple(sorted(row for name in component for row in rows_by_name[name])) columns = tuple( int(column) for column in np.flatnonzero( np.linalg.norm(matrix[np.asarray(rows), :], axis=0) > activity_tolerance ) ) coordinate_ids = [] coordinate_names = [] for column in columns: coordinate, _ = graph_data.coordAt(column) coordinate_ids.append(coordinate.id()) coordinate_names.append(coordinate.name()) del coordinate groups.append( LoopGroup( tuple(sorted(component)), rows, columns, tuple(coordinate_ids), tuple(coordinate_names), ) ) return tuple(groups) finally: del graph_data
def _groupForAnchor(groups: tuple[LoopGroup, ...], anchor: str) -> LoopGroup: """Resolve a unique connected loop group containing a named coordinate.""" matches = [group for group in groups if anchor in group.coordinate_names] if not matches: raise click.UsageError(f"loops --add-dofs loop '{anchor}' is not active in an enabled loop") if len(matches) != 1: raise click.UsageError(f"loops --add-dofs loop '{anchor}' resolves to multiple loop groups") return matches[0] def _groupMatrix(subgraph: kd.SubGraph, group: LoopGroup) -> np.ndarray: """Evaluate the full constraint Jacobian restricted to one loop group.""" matrix = np.asarray(kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float) return matrix[np.ix_(group.rows, group.columns)] def _treeDriverStates( subgraph: kd.SubGraph, driver_name: str, *, increment: float = 0.02, closure_tolerance: float = 1.0e-8, ) -> tuple[np.ndarray, ...]: """Return closed witness states around one prescribed tree coordinate. Existing solver freezes are restored before returning. If the driver is not a scalar tree coordinate, or neither signed perturbation closes, the nominal state is the only witness. """ tree_data = subgraph.treeCoordData() graph_data = subgraph.graphCoordData() solver = subgraph.cks() # Capture the caller's configuration and characterize the weak mode before # changing any physical axis. nominal = np.asarray(subgraph.getQ(), dtype=float).copy() # Preserve caller-owned freezes while this trial installs its own partition. frozen_coordinates = _captureFrozenCoordinates(subgraph, solver) target_column = None q_index = None try: for column in range(tree_data.nU()): coordinate, local_offset = tree_data.coordAt(column) try: if coordinate.name() == driver_name and local_offset == 0: target_column = column q_index = int(graph_data.coordOffsets(coordinate).Q) break finally: del coordinate if target_column is None or q_index is None: return (nominal,) matrix = np.asarray( kd.Algorithms.evalVelocityConstraintMatrix(subgraph, False), dtype=float ) pool = np.asarray([index for index in range(tree_data.nU()) if index != target_column]) _, triangular, pivots = linalg.qr(matrix[:, pool], mode="economic", pivoting=True) diagonal = np.abs(np.diag(triangular)) scale = diagonal[0] if diagonal.size else 0.0 rank = int(np.count_nonzero(diagonal > 1.0e-9 * scale)) if scale else 0 if rank < _matrixRank(matrix): return (nominal,) dependent = set(pool[pivots[:rank]].tolist()) solver.clearFrozenCoords() for column in range(tree_data.nU()): if column not in dependent: coordinate, local_offset = tree_data.coordAt(column) # Package the provider and local offset as the Coordinate API type. solver.freezeCoord((coordinate, local_offset)) del coordinate states = [nominal] for direction in (-1.0, 1.0): trial = nominal.copy() trial[q_index] += direction * increment subgraph.setQ(trial) if float(solver.solveQ()) <= closure_tolerance: states.append(np.asarray(subgraph.getQ(), dtype=float).copy()) return tuple(states) finally: subgraph.setQ(nominal) _restoreFrozenCoordinates(subgraph, solver, frozen_coordinates) del solver del graph_data del tree_data def _axisTangentBasis(axis: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Return two orthonormal perturbation directions for a unit axis.""" trial = np.array([1.0, 0.0, 0.0]) if abs(float(np.dot(axis, trial))) > 0.8: trial = np.array([0.0, 1.0, 0.0]) first = trial - np.dot(trial, axis) * axis first /= np.linalg.norm(first) return first, np.cross(axis, first) def _axisAngle(first: np.ndarray, second: np.ndarray) -> float: """Return unsigned angular separation between two unit directions.""" return math.acos(float(np.clip(np.dot(first, second), -1.0, 1.0))) def _weakModeProbeStates( subgraph: kd.SubGraph, group: LoopGroup, weak_directions: np.ndarray, *, angular_distance: float = 0.05, linear_distance: float = 0.005, ) -> tuple[np.ndarray, ...]: """Build finite predictor states along each weak velocity direction. The source geometry can be unable to close away from its nominal pose, so closed source poses are not a reliable optimization input. These states integrate each near-null velocity direction through direct scalar Q charts. They need not close before optimization: their purpose is to require the candidate rank dependency to persist over a finite neighborhood instead of occurring only at the nominal configuration. """ nominal = np.asarray(subgraph.getQ(), dtype=float).copy() graph_data = subgraph.graphCoordData() probes = [nominal] try: for direction in weak_directions: delta = np.zeros_like(nominal) scaled_components: list[tuple[int, float, float]] = [] for group_column, full_column in enumerate(group.columns): coordinate, local_offset = graph_data.coordAt(full_column) try: # Only direct scalar charts can be integrated without a # coordinate-specific position update rule. if coordinate.nQ() != coordinate.nU() or coordinate.nQ() != 1: continue if not isinstance(coordinate, (kd.PinSubhinge, kd.LinearSubhinge)): continue q_index = int(graph_data.coordOffsets(coordinate).Q + local_offset) distance = ( linear_distance if isinstance(coordinate, kd.LinearSubhinge) else angular_distance ) scaled_components.append((q_index, float(direction[group_column]), distance)) finally: del coordinate scale_denominator = max( (abs(value) / distance for _, value, distance in scaled_components), default=0.0, ) if scale_denominator == 0.0: continue scale = 1.0 / scale_denominator for q_index, value, _ in scaled_components: delta[q_index] += scale * value # Symmetric probes prevent a correction that works on only one # side of the nominal configuration from passing optimization. probes.extend((nominal - delta, nominal + delta)) return tuple(probes) finally: del graph_data def _validateFiniteGeometryMobility( subgraph: kd.SubGraph, group: LoopGroup, drivers: tuple[str, ...], target_rank: int, *, exact_relative_tolerance: float, angular_distance: float = 0.05, linear_distance: float = 0.005, ) -> tuple[ bool, tuple[tuple[str, float, float, str], ...], str | None, ]: """Require each selected driver to close through a finite two-sided range.""" nominal = np.asarray(subgraph.getQ(), dtype=float).copy() validation_spans: list[tuple[str, float, float, str]] = [] if not drivers: return False, (), "no direct physical driver is available for finite validation" try: for driver_name in drivers: driver = _coordinateByName(subgraph, driver_name) try: if not isinstance(driver, (kd.PinSubhinge, kd.LinearSubhinge)): return False, tuple(validation_spans), f"{driver_name} lacks a scalar axis" distance = ( linear_distance if isinstance(driver, kd.LinearSubhinge) else angular_distance ) unit = "m" if isinstance(driver, kd.LinearSubhinge) else "rad" closed_states = [nominal.copy()] measured = feasibleRangeLimit( subgraph, driver, initial_step=0.25 * distance, maximum_step=0.5 * distance, maximum_distance=distance, boundary_tolerance=distance * 1.0e-5, sample_callback=lambda state: closed_states.append(state.copy()), ) lower_distance = measured.current - measured.lower upper_distance = measured.upper - measured.current validation_spans.append((driver_name, lower_distance, upper_distance, unit)) required = distance * (1.0 - 2.0e-5) if lower_distance < required or upper_distance < required: return ( False, tuple(validation_spans), f"{driver_name} closes only -{lower_distance:.6g}/" f"+{upper_distance:.6g} {unit}; requires +/-{distance:.6g} {unit}", ) # A closed span is insufficient if the requested rank loss is # present only at nominal. Check it at every retained witness. for state in closed_states: subgraph.setQ(state) values = np.linalg.svd(_groupMatrix(subgraph, group), compute_uv=False) if values.size <= target_rank or values[0] == 0.0: return False, tuple(validation_spans), "finite rank validation is empty" if values[target_rank] > exact_relative_tolerance * values[0]: return ( False, tuple(validation_spans), f"{driver_name} loses the target rank over its finite span", ) except (LockedLoopError, IndirectCoordinateChartError) as error: return False, tuple(validation_spans), f"{driver_name}: {error}" finally: del driver return True, tuple(validation_spans), None finally: subgraph.setQ(nominal)
[docs] def optimizeLoopGeometry( subgraph: kd.SubGraph, group: LoopGroup, *, weak_relative_tolerance: float = 1.0e-3, exact_relative_tolerance: float = 1.0e-9, maximum_axis_parameter: float = 1.0e-3, ) -> GeometryChange | None: """Remove weak nonzero modes using bounded finite-motion axis corrections. Small nonzero singular values often indicate nominally aligned hinge axes that differ by modeling noise. Candidate axes are adjusted only within the configured bound and evaluated at predictor poses along the weak modes. A change is accepted only when the corrected mechanism then closes through a finite two-sided range and retains the intended rank there; otherwise the original geometry is restored. """ nominal = np.asarray(subgraph.getQ(), dtype=float).copy() before_matrix = _groupMatrix(subgraph, group) _, singular_values, right_transpose = np.linalg.svd(before_matrix, full_matrices=False) if singular_values.size == 0 or singular_values[0] == 0.0: return None relative = singular_values / singular_values[0] weak_indices = np.flatnonzero( (relative > exact_relative_tolerance) & (relative < weak_relative_tolerance) ) if weak_indices.size == 0: return None before_rank = int(np.count_nonzero(relative > exact_relative_tolerance)) target_rank = before_rank - int(weak_indices.size) minor_size = target_rank + 1 if minor_size > min(before_matrix.shape): return None minor_count = math.comb(before_matrix.shape[0], minor_size) * math.comb( before_matrix.shape[1], minor_size ) if minor_count > 5000: # Avoid an unexpectedly combinatorial command-line transformation. return GeometryChange( group.constraints, False, before_rank, before_rank, before_matrix.shape[1] - before_rank, before_matrix.shape[1] - before_rank, float(singular_values[weak_indices[0]]), float(singular_values[weak_indices[0]]), (), ) # Select one likely physical driver per weak right-singular direction. # Prefer physical tree coordinates as finite-motion witnesses because they # can be prescribed independently during closure solves. tree_data = subgraph.treeCoordData() tree_names: set[str] = set() try: for column in range(tree_data.nU()): coordinate, local_offset = tree_data.coordAt(column) if local_offset == 0 and isinstance(coordinate, (kd.PinSubhinge, kd.LinearSubhinge)): tree_names.add(coordinate.name()) del coordinate finally: del tree_data drivers = [] for weak_index in weak_indices: for column in np.argsort(-np.abs(right_transpose[weak_index, :])): name = group.coordinate_names[int(column)] if name in tree_names and name not in drivers: drivers.append(name) break # Enforce the rank dependency away from nominal even when the imperfect # source mechanism cannot provide a closed nonnominal starting pose. states = list( _weakModeProbeStates( subgraph, group, right_transpose[weak_indices, :], ) ) # Retain unique scalar axes participating in this connected group. # Parameterize each unique scalar axis by two tangent components. This keeps # optimization on the unit sphere without redundant radial variables. subhinges = [] originals = [] bases = [] seen_names: set[str] = set() try: for name in group.coordinate_names: if name in seen_names: continue try: subhinge = _coordinateByName(subgraph, name) except click.UsageError: continue subhinges.append(subhinge) axis = np.asarray(subhinge.getUnitAxis(), dtype=float) originals.append(axis) bases.append(_axisTangentBasis(axis)) seen_names.add(name) if not subhinges: return None def setAxes(parameters: np.ndarray) -> None: """Apply two tangent parameters to each participating unit axis.""" for index, subhinge in enumerate(subhinges): first, second = bases[index] axis = ( originals[index] + parameters[2 * index] * first + parameters[2 * index + 1] * second ) subhinge.setUnitAxis(axis / np.linalg.norm(axis)) def localMatrix(parameters: np.ndarray, state: np.ndarray) -> np.ndarray: """Evaluate group geometry at one fixed generalized state.""" setAxes(parameters) subgraph.setQ(state) return _groupMatrix(subgraph, group) row_subsets = tuple(combinations(range(before_matrix.shape[0]), minor_size)) column_subsets = tuple(combinations(range(before_matrix.shape[1]), minor_size)) def unscaledResiduals(parameters: np.ndarray) -> np.ndarray: """Return maximal minors that vanish at the requested lower rank.""" residuals = [] for state in states: matrix = localMatrix(parameters, state) / singular_values[0] residuals.extend( np.linalg.det(matrix[np.ix_(rows, columns)]) for rows in row_subsets for columns in column_subsets ) return np.asarray(residuals) zero = np.zeros(2 * len(subhinges)) # Normalize at the original geometry so optimizer tolerances remain # meaningful across mechanisms with different raw minor magnitudes. initial_residuals = unscaledResiduals(zero) residual_scale = 1.0 / max(float(np.max(np.abs(initial_residuals))), 1.0e-12) solution = least_squares( lambda parameters: residual_scale * unscaledResiduals(parameters), zero, bounds=(-maximum_axis_parameter, maximum_axis_parameter), xtol=1.0e-13, ftol=1.0e-13, gtol=1.0e-13, max_nfev=300, ) setAxes(solution.x) axis_changes = tuple( ( subhinge.name(), _axisAngle(original, np.asarray(subhinge.getUnitAxis(), dtype=float)), ) for subhinge, original in zip(subhinges, originals, strict=True) ) worst_after = 0.0 after_rank = 0 for state in states: matrix = localMatrix(solution.x, state) values = np.linalg.svd(matrix, compute_uv=False) worst_after = max(worst_after, float(values[target_rank])) after_rank = max(after_rank, _matrixRank(matrix)) maximum_change = max((change for _, change in axis_changes), default=0.0) rank_accepted = ( worst_after <= exact_relative_tolerance * singular_values[0] and maximum_change <= math.sqrt(2.0) * maximum_axis_parameter * 1.001 ) validation_spans: tuple[tuple[str, float, float, str], ...] = () rejection_reason = None accepted = rank_accepted if rank_accepted: accepted, validation_spans, rejection_reason = _validateFiniteGeometryMobility( subgraph, group, tuple(drivers), target_rank, exact_relative_tolerance=exact_relative_tolerance, ) else: rejection_reason = "bounded optimization did not preserve the target finite rank" if not accepted: for subhinge, original in zip(subhinges, originals, strict=True): subhinge.setUnitAxis(original) after_rank = before_rank worst_after = float(singular_values[weak_indices[0]]) axis_changes = () subgraph.setQ(nominal) return GeometryChange( group.constraints, accepted, before_rank, after_rank, before_matrix.shape[1] - before_rank, before_matrix.shape[1] - after_rank, float(singular_values[weak_indices[0]]), worst_after, axis_changes, validation_spans, rejection_reason, ) finally: subgraph.setQ(nominal) for subhinge in subhinges: del subhinge
def _matrixRank(matrix: np.ndarray, tolerance: float = 1.0e-9) -> int: """Return numerical rank relative to the matrix's leading singular value.""" singular_values = np.linalg.svd(matrix, compute_uv=False) if singular_values.size == 0 or singular_values[0] == 0.0: return 0 return int(np.count_nonzero(singular_values > tolerance * singular_values[0])) def _coordinateByName(subgraph: kd.SubGraph, name: str) -> kd.PhysicalSubhinge: """Return a unique scalar graph subhinge by name.""" # Search packed velocity columns because a provider may occupy several local # offsets; offset zero identifies a named scalar coordinate once. graph_data = subgraph.graphCoordData() result = None seen: set[int] = set() try: for column in range(graph_data.nU()): coordinate, local_offset = graph_data.coordAt(column) if coordinate.name() == name and local_offset == 0: if coordinate.id() in seen: del coordinate continue if result is not None: del coordinate del result raise click.UsageError(f"subhinge name '{name}' is ambiguous") result = coordinate seen.add(coordinate.id()) else: del coordinate if result is None: raise click.UsageError(f"subhinge '{name}' was not found") if not isinstance(result, (kd.PinSubhinge, kd.LinearSubhinge)): del result raise click.UsageError(f"subhinge '{name}' is not a scalar pin or slider") return result finally: del graph_data def _mobilityStates( subgraph: kd.SubGraph, drivers: tuple[str, ...], *, increment: float = 0.02, closure_tolerance: float = 1.0e-8, ) -> tuple[np.ndarray, ...]: """Generate nominal and nearby closed states for requested driver names.""" nominal = np.asarray(subgraph.getQ(), dtype=float).copy() states = [nominal] for driver_name in drivers: driver = _coordinateByName(subgraph, driver_name) solver = subgraph.cks() # Each driver trial starts from and returns to the caller's partition. frozen_coordinates = _captureFrozenCoordinates(subgraph, solver) try: try: solver, q_index = _configureLoopPartition( subgraph, driver, 0, rank_tolerance=1.0e-9, solver=solver ) except LockedLoopError: # A currently locked target has no nonlinear neighboring state. continue for direction in (-1.0, 1.0): trial = nominal.copy() trial[q_index] += direction * increment subgraph.setQ(trial) residual = float(solver.solveQ()) if residual <= closure_tolerance: states.append(np.asarray(subgraph.getQ(), dtype=float).copy()) finally: subgraph.setQ(nominal) _restoreFrozenCoordinates(subgraph, solver, frozen_coordinates) del solver del driver return tuple(states) def _mobilityCandidates( group: LoopGroup, rows_by_name: dict[str, tuple[int, ...]], ) -> tuple[MobilityCandidate, ...]: """Create canonical local twist candidates on every closure in a group.""" # A fixed local twist basis makes the combinatorial search reproducible and # keeps accepted candidates directly serializable as pin or linear hinges. definitions = ( ("Rx", "PIN", (1.0, 0.0, 0.0), 0), ("Ry", "PIN", (0.0, 1.0, 0.0), 1), ("Rz", "PIN", (0.0, 0.0, 1.0), 2), ("Tx", "LINEAR", (1.0, 0.0, 0.0), 3), ("Ty", "LINEAR", (0.0, 1.0, 0.0), 4), ("Tz", "LINEAR", (0.0, 0.0, 1.0), 5), ) row_offsets = {row: index for index, row in enumerate(group.rows)} candidates = [] for constraint_name in group.constraints: constraint_rows = rows_by_name[constraint_name] if len(constraint_rows) != 6: raise click.ClickException( f"constraint '{constraint_name}' does not own six error rows" ) for component, subhinge_type, axis, local_row in definitions: column = np.zeros(len(group.rows)) column[row_offsets[constraint_rows[local_row]]] = 1.0 candidates.append( MobilityCandidate( constraint_name, component, subhinge_type, axis, tuple(float(value) for value in column), ) ) return tuple(candidates) def _driverProjectionRank( matrix: np.ndarray, driver_indices: tuple[int, ...], rank_tolerance: float = 1.0e-9, ) -> tuple[int, int, int]: """Return matrix rank, nullity, and driver projection rank.""" _, singular_values, right_transpose = np.linalg.svd(matrix, full_matrices=True) if singular_values.size and singular_values[0] > 0.0: rank = int(np.count_nonzero(singular_values > rank_tolerance * singular_values[0])) else: rank = 0 nullspace = right_transpose[rank:, :].T driver_rank = ( _matrixRank(nullspace[np.asarray(driver_indices), :], rank_tolerance) if driver_indices else 0 ) return rank, matrix.shape[1] - rank, driver_rank
[docs] def recommendMobilityAdditions( subgraph: kd.SubGraph, group: LoopGroup, request: MobilityRequest, states: tuple[np.ndarray, ...], ) -> MobilityChange: """Select the smallest well-conditioned coordinate set meeting a request. Candidate combinations are evaluated against every retained closed state. A valid result reaches the requested nullity, preserves independent driver projections, and avoids weaker singular modes than same-size alternatives. """ if any(driver not in group.coordinate_names for driver in request.drivers): outside = next(driver for driver in request.drivers if driver not in group.coordinate_names) raise click.UsageError( f"mobility driver '{outside}' is outside anchor '{request.anchor}' loop group" ) driver_indices = tuple(group.coordinate_names.index(driver) for driver in request.drivers) nominal_matrix = _groupMatrix(subgraph, group) before_rank = _matrixRank(nominal_matrix) before_nullity = nominal_matrix.shape[1] - before_rank before_singular = np.linalg.svd(nominal_matrix, compute_uv=False) before_smallest = float(before_singular[before_rank - 1]) if before_rank else 0.0 if before_nullity >= request.target_mobility: return MobilityChange( request.anchor, group.constraints, before_rank, before_nullity, request.target_mobility, (), before_rank, before_nullity, before_smallest, before_smallest, ) rows_by_name = _constraintRowsByName(subgraph) candidates = _mobilityCandidates(group, rows_by_name) if request.allowed_components: # An explicit allow-list restricts the otherwise unrestricted search. candidates = tuple( candidate for candidate in candidates if candidate.component in request.allowed_components ) # Build one Jacobian per finite witness and restore the caller's state before # any combinatorial search or possible exception leaves this phase. saved_q = np.asarray(subgraph.getQ(), dtype=float).copy() matrices = [] try: for state in states: subgraph.setQ(state) matrices.append(_groupMatrix(subgraph, group)) finally: subgraph.setQ(saved_q) minimum_count = request.target_mobility - before_nullity best = None # Search cardinality first: conditioning breaks ties only among minimal sets, # never in favor of unnecessary added coordinates. for count in range(minimum_count, min(len(candidates), minimum_count + 4) + 1): for selection in combinations(range(len(candidates)), count): # Avoid redundant same-component additions to one constraint. selected = tuple(candidates[index] for index in selection) valid = True worst_condition = np.inf predicted_rank = 0 predicted_nullity = 0 for matrix in matrices: augmented = np.column_stack( (matrix, *(np.asarray(item.column) for item in selected)) ) rank, nullity, driver_rank = _driverProjectionRank(augmented, driver_indices) if nullity < request.target_mobility or driver_rank < len(driver_indices): valid = False break singular_values = np.linalg.svd(augmented, compute_uv=False) retained = float(singular_values[rank - 1]) if rank else 0.0 worst_condition = min(worst_condition, retained) predicted_rank = rank predicted_nullity = nullity if valid and (best is None or worst_condition > best[0]): best = ( worst_condition, selected, predicted_rank, predicted_nullity, ) if best is not None: break if best is None: restriction = ( f" within allowed components {', '.join(request.allowed_components)}" if request.allowed_components else "" ) raise click.ClickException( f"no tested cut-joint additions{restriction} satisfy mobility request " f"'{request.anchor}'" ) condition, selected, predicted_rank, predicted_nullity = best return MobilityChange( request.anchor, group.constraints, before_rank, before_nullity, request.target_mobility, selected, predicted_rank, predicted_nullity, before_smallest, condition, )
def _applyMobilityChanges( model: SubGraphDS, changes: tuple[MobilityChange, ...], ) -> None: """Add selected canonical subhinges to serialized cut-joint hinges.""" # Regroup the analysis audit by serialized owner; one connected-loop change # may contribute additions to several cut joints. additions_by_constraint: dict[str, list[MobilityCandidate]] = {} for change in changes: for addition in change.additions: additions_by_constraint.setdefault(addition.constraint, []).append(addition) found: set[str] = set() for constraint in model.loop_constraints: additions = additions_by_constraint.get(constraint.name) if not additions: continue # Preserve all existing subhinges before appending selected freedoms. subhinges = [item.model_copy(deep=True) for item in constraint.hinge.subhinge_params] for addition in additions: axis = np.asarray(addition.axis, dtype=float) if addition.subhinge_type == "PIN": subhinges.append(kd.PinSubhingeParams(unit_axis=axis, prescribed=False)) else: subhinges.append(kd.LinearSubhingeParams(unit_axis=axis, prescribed=False)) is_ujoint = len(subhinges) == 2 and all( isinstance(item, kd.PinSubhingeParams) for item in subhinges ) constraint.hinge = kd.PhysicalHingeParams( hinge_type=kd.HingeType.UJOINT if is_ujoint else kd.HingeType.CUSTOM, subhinge_params=subhinges, ) found.add(constraint.name) missing = set(additions_by_constraint) - found if missing: raise click.ClickException( f"could not modify cut-joint constraints: {', '.join(sorted(missing))}" ) def _printMobilityReport(changes: tuple[MobilityChange, ...]) -> None: """Print an aligned summary of requested mobility changes.""" if not changes: return click.echo("Mobility additions:") anchor_width = max(len(change.anchor) for change in changes) for change in changes: if not change.additions: click.echo( f" {change.anchor:<{anchor_width}} OK " f"nullity={change.before_nullity} target={change.target_mobility}" ) continue additions = ", ".join(f"{item.constraint}:{item.component}" for item in change.additions) click.echo( f" {change.anchor:<{anchor_width}} " f"nullity {change.before_nullity}->{change.predicted_nullity} " f"rank {change.before_rank}->{change.predicted_rank} " f"sigma {change.before_smallest_singular:.6g}->" f"{change.predicted_smallest_singular:.6g} add {additions}" ) def _printGeometryReport(changes: tuple[GeometryChange, ...]) -> None: """Print weak-mode geometry reconciliation results in aligned columns.""" if not changes: click.echo("Loop geometry optimization: no weak nonzero modes found.") return click.echo("Loop geometry optimization:") label_width = max(len(" + ".join(change.constraints)) for change in changes) for change in changes: label = " + ".join(change.constraints) status = "UPDATED" if change.accepted else "UNCHANGED" click.echo( f" {label:<{label_width}} {status:<9} " f"rank {change.before_rank}->{change.after_rank} " f"nullity {change.before_nullity}->{change.after_nullity} " f"weak sigma {change.before_weak_singular:.6g}->" f"{change.after_weak_singular:.6g}" ) for coordinate, angle in change.axis_changes: click.echo(f" axis {coordinate:<48} delta={angle:.6g} rad") for coordinate, lower, upper, unit in change.validation_spans: click.echo(f" finite {coordinate:<46} -{lower:.6g}/+{upper:.6g} {unit}") if change.rejection_reason is not None: click.echo(f" rejected: {change.rejection_reason}")
[docs] @dataclass(frozen=True) class SerializedSubhingeRef: """Locate one serialized subhinge and the object that owns its hinge.""" name: str owner: Any owner_attribute: str subhinge_index: int
def _serializedSubgraph(specification: Any) -> SubGraphDS: """Return the mutable SubGraphDS nested in a supported root model.""" if isinstance(specification, SubGraphDS): return specification if isinstance(specification, BasicPrefabDS): return specification.params.subtree raise click.UsageError("loop hinge simplification requires a SubGraph or BasicPrefab model") def _bodyBranches(subgraph: SubGraphDS): """Yield every serialized body branch without retaining native handles.""" pending = list(reversed(subgraph.base_bodies)) while pending: branch = pending.pop() yield branch pending.extend(reversed(branch.children)) def _serializedSubhinges(subgraph: SubGraphDS) -> dict[str, SerializedSubhingeRef]: """Index serialized physical and cutjoint subhinges by runtime name.""" # Keep lightweight parameter references rather than native subhinges so edit # operations remain independent of temporary live-model lifetimes. result = {} def addHinge(owner: Any, attribute: str, prefix: str) -> None: """Add every parameter in one serialized hinge to the name index.""" hinge = getattr(owner, attribute) for index, params in enumerate(hinge.subhinge_params): type_name = type(params).__name__.removesuffix("Params") name = f"{prefix}-{type_name}{index}" result[name] = SerializedSubhingeRef(name, owner, attribute, index) for branch in _bodyBranches(subgraph): addHinge(branch.body.params, "hinge_params", branch.body.name) for constraint in subgraph.loop_constraints: # Scalar ConVel and other imported loop constraints do not own a # physical cut-joint hinge and therefore contribute no subhinges. if hasattr(constraint, "hinge"): addHinge(constraint, "hinge", f"{constraint.name}_pframe") return result
[docs] def synchronizeSerializedRangeLimits( subgraph: kd.SubGraph, model_target: Any, ) -> None: """Copy live Q-coordinate range limits into a retained BasicPrefab specification. SubGraph outputs serialize their live object directly. BasicPrefab loading, however, retains the input DataStruct so that its wrapper, assets, and model metadata survive output. Range analysis changes the instantiated Multibody, so this synchronization step transfers each coordinate result back to the matching serialized subhinge immediately before ``--to-model``. """ if not isinstance(model_target, BasicPrefabDS): return serialized = _serializedSubhinges(_serializedSubgraph(model_target)) graph_data = subgraph.graphCoordData() visited: set[int] = set() try: for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in visited: continue visited.add(coordinate.id()) if not isinstance(coordinate, kd.PhysicalSubhinge): continue reference = serialized.get(coordinate.name()) if reference is None: continue hinge = getattr(reference.owner, reference.owner_attribute) parameters = hinge.subhinge_params[reference.subhinge_index] # Copy the array so the serialized specification owns its data. if 0: parameters.range_limits = np.asarray( coordinate.getRangeLimits(), dtype=float ).copy() parameters.range_limits = _getVec(coordinate.getRangeLimits()).copy() finally: del coordinate finally: del graph_data # Runtime prefab bindings are process-local IDs and must never leak into a # portable model file. Some source prefabs resolve stale context IDs while # loading, so clear both fields explicitly at serialization time. model_target.context.parent_body_raw = None model_target.context.model_manager_raw = None
def _replacementHinge(subhinges: list[Any]) -> kd.PhysicalHingeParams: """Choose the standard hinge type matching retained scalar subhinges.""" if len(subhinges) == 1 and isinstance(subhinges[0], kd.PinSubhingeParams): hinge_type = kd.HingeType.REVOLUTE elif len(subhinges) == 1 and isinstance(subhinges[0], kd.LinearSubhingeParams): hinge_type = kd.HingeType.SLIDER elif len(subhinges) == 2 and all(isinstance(item, kd.PinSubhingeParams) for item in subhinges): hinge_type = kd.HingeType.UJOINT elif len(subhinges) == 3 and all(isinstance(item, kd.PinSubhingeParams) for item in subhinges): hinge_type = kd.HingeType.GIMBAL else: hinge_type = kd.HingeType.CUSTOM return kd.PhysicalHingeParams( hinge_type=hinge_type, subhinge_params=subhinges, ) def _validatedSelection(values: tuple[str, ...], option: str) -> set[str] | None: """Validate a repeatable name-or-all simplification selection.""" if not values: return set() if "all" in values: if len(values) != 1: raise click.UsageError(f"{option} all cannot be combined with names") return None if len(set(values)) != len(values): raise click.UsageError(f"{option} subhinge names must be unique") return set(values) def _readSimplifiableSpecification(model_file: Path) -> Any: """Read a mutable SubGraph or BasicPrefab specification.""" try: return BasicPrefabDS.fromFile(model_file, get_meta=True) except Exception: # Clear native identity maps before trying the other supported schema. SubGraphDS.resetAllObjectsFromIds() try: return SubGraphDS.fromFile(model_file, get_meta=True) except Exception as error: raise click.UsageError( "loop hinge simplification requires a SubGraph or BasicPrefab model" ) from error def _analyzeSimplificationSpecification( specification: Any, *, inert_tolerance: float = 2.0e-7, status_output: list[LoopMobilityStatus] | None = None, ) -> tuple[list[CoordinateMobilityEvidence], list[BallReductionHint]]: """Collect complete inertness evidence and local BALL hints. Serialized parameters cannot answer mobility questions by themselves, so a temporary live multibody is constructed. Analysis is explicitly non-mutating (``apply_limits=False``), and both native containers are discarded before serialized editing begins, even if analysis fails. """ # Build analysis-only native containers from the current serialized edits; # this avoids mixing evidence from an earlier live-model revision. frames = kf.FrameContainer.create("kdpy_simplification_analysis_frames") multibody = _serializedSubgraph(specification).toMultibody(frames) evidence: list[CoordinateMobilityEvidence] = [] hints: list[BallReductionHint] = [] try: multibody.ensureHealthy() multibody.resetData() _setLoopRangeLimits( multibody, selected_names=("all",), evidence_output=evidence, ball_hint_output=hints, status_output=status_output, inert_tolerance=inert_tolerance, apply_limits=False, ) return evidence, hints finally: kc.discard(multibody) kc.discard(frames) def _removeInertSubhinges( specification: Any, requested: tuple[str, ...], ) -> tuple[str, ...]: """Remove only requested scalar coordinates proven globally inert.""" # Resolve selection independently from evidence so explicit names receive a # clear error instead of being silently ignored when proof is insufficient. selection = _validatedSelection(requested, "loops --remove-inert") if selection == set(): return () statuses: list[LoopMobilityStatus] = [] evidence, _ = _analyzeSimplificationSpecification( specification, status_output=statuses, ) inert = {item.coordinate for item in evidence if item.classification == "INERT"} references = _serializedSubhinges(_serializedSubgraph(specification)) scalar_inert = set() for name in inert: reference = references.get(name) if reference is None: continue hinge = getattr(reference.owner, reference.owner_attribute) params = hinge.subhinge_params[reference.subhinge_index] if isinstance(params, (kd.PinSubhingeParams, kd.LinearSubhingeParams)): scalar_inert.add(name) targets = scalar_inert if selection is None else selection missing = targets - scalar_inert if missing: status_by_name = {status.coordinate: status for status in statuses} for name in sorted(missing): status = status_by_name.get(name) if status is None: raise click.UsageError( f"loops --remove-inert coordinate is not an active loop subhinge: {name}" ) click.echo( f"Rejected removal of {name}: measured range " f"{status.feasible_range}; inertness threshold is " f"{INERT_SUBHINGE_TOLERANCE:.1e} rad." ) targets -= missing # Apply edits only after every requested target has complete scalar evidence; # validation failure therefore leaves the specification untouched. changed = [] for name in sorted(targets): reference = references.get(name) if reference is None: raise click.UsageError(f"cannot serialize inert subhinge '{name}'") hinge = getattr(reference.owner, reference.owner_attribute) params = hinge.subhinge_params[reference.subhinge_index] if not isinstance(params, (kd.PinSubhingeParams, kd.LinearSubhingeParams)): raise click.UsageError(f"'{name}' is not a removable scalar pin or linear subhinge") retained = [ item.model_copy(deep=True) for index, item in enumerate(hinge.subhinge_params) if index != reference.subhinge_index ] if not retained: raise click.UsageError( f"cannot remove sole subhinge '{name}'; replace its hinge with LOCKED" ) setattr(reference.owner, reference.owner_attribute, _replacementHinge(retained)) changed.append(name) return tuple(changed) def _reduceBallSubhinges( specification: Any, requested: tuple[str, ...], ) -> tuple[str, ...]: """Replace selected reducible BALL coordinates through gimbal validation.""" selection = _validatedSelection(requested, "loops --reduce-ball") if selection == set(): return () # Local SVD axes propose a fixed-axis chart but do not authorize deletion; # the second full scan below supplies range-wide inertness evidence. _, hints = _analyzeSimplificationSpecification(specification) hints_by_name = {hint.coordinate: hint for hint in hints} targets = set(hints_by_name) if selection is None else selection missing = targets - set(hints_by_name) if missing: raise click.UsageError( f"loops --reduce-ball lacks a reduction hint for: {', '.join(sorted(missing))}" ) references = _serializedSubhinges(_serializedSubgraph(specification)) # Retain deep copies so candidates that fail global validation can restore # their original axis parameterization exactly. original_hinges = {} for name in sorted(targets): reference = references.get(name) if reference is None: raise click.UsageError(f"cannot serialize BALL subhinge '{name}'") hinge = getattr(reference.owner, reference.owner_attribute) if hinge.hinge_type != kd.HingeType.BALL: raise click.UsageError(f"'{name}' does not belong to a BALL hinge") original_hinges[name] = hinge.model_copy(deep=True) axes = hints_by_name[name].axes pins = [kd.PinSubhingeParams(unit_axis=np.asarray(axis), prescribed=False) for axis in axes] setattr( reference.owner, reference.owner_attribute, kd.PhysicalHingeParams( hinge_type=kd.HingeType.GIMBAL, subhinge_params=pins, ), ) # Only complete range-wide evidence may remove the aligned gimbal pins. # Rebuild and rescan provisional gimbals. Only pins proven inert over the # connected range may be removed from the serialized hinge. gimbal_evidence, _ = _analyzeSimplificationSpecification(specification) inert_names = {item.coordinate for item in gimbal_evidence if item.classification == "INERT"} references = _serializedSubhinges(_serializedSubgraph(specification)) changed = [] for name in sorted(targets): reference = references[name.replace("SphericalSubhinge0", "PinSubhinge0")] hinge = getattr(reference.owner, reference.owner_attribute) prefix = name.removesuffix("SphericalSubhinge0") retained = [ item.model_copy(deep=True) for index, item in enumerate(hinge.subhinge_params) if f"{prefix}PinSubhinge{index}" not in inert_names ] if len(retained) not in (1, 2): # Restore the BALL when fixed-axis evidence is incomplete or empty. setattr(reference.owner, reference.owner_attribute, original_hinges[name]) continue setattr(reference.owner, reference.owner_attribute, _replacementHinge(retained)) changed.append(name) return tuple(changed) def _reduceSphericalSubhinges( specification: Any, requested: tuple[str, ...], ) -> tuple[str, ...]: """Reduce selected spherical subhinges while preserving their parent hinges. Parameters ---------- specification: Any Mutable serialized SubGraph or BasicPrefab model. requested: tuple[str, ...] Spherical subhinge names, or ``all``. Returns ------- tuple[str, ...] Names of spherical subhinges whose parent hinge was reduced. """ selection = _validatedSelection(requested, "loops --reduce-spherical") if selection == set(): return () _, hints = _analyzeSimplificationSpecification(specification) hints_by_name = {hint.coordinate: hint for hint in hints} targets = set(hints_by_name) if selection is None else selection missing = targets - set(hints_by_name) if missing: raise click.UsageError( f"loops --reduce-spherical lacks a reduction hint for: {', '.join(sorted(missing))}" ) references = _serializedSubhinges(_serializedSubgraph(specification)) original_hinges = {} original_references = {} original_indices: dict[str, int] = {} for name in sorted(targets, key=lambda item: references[item].subhinge_index, reverse=True): click.echo(f"Reducing spherical subhinge: {name}") reference = references.get(name) if reference is None: raise click.UsageError(f"cannot serialize spherical subhinge '{name}'") hinge = getattr(reference.owner, reference.owner_attribute) original_hinges[name] = hinge.model_copy(deep=True) original_references[name] = reference spherical_index = reference.subhinge_index original_indices[name] = spherical_index axes = hints_by_name[name].axes pins = [kd.PinSubhingeParams(unit_axis=np.asarray(axis), prescribed=False) for axis in axes] retained = list(hinge.subhinge_params) retained[spherical_index : spherical_index + 1] = pins setattr(reference.owner, reference.owner_attribute, _replacementHinge(retained)) # Expanding an earlier spherical block shifts later blocks in the same # parent hinge. Compute the provisional indices after all expansions so # ``--reduce-spherical all`` remains correct for composite hinges. provisional_pin_indices = { name: tuple( range( index + 2 * sum( other_index < index and original_references[other].owner is reference.owner and original_references[other].owner_attribute == reference.owner_attribute for other, other_index in original_indices.items() ), index + 2 * sum( other_index < index and original_references[other].owner is reference.owner and original_references[other].owner_attribute == reference.owner_attribute for other, other_index in original_indices.items() ) + 3, ) ) for name, index in original_indices.items() for reference in (original_references[name],) } # Explicit spherical reduction accepts the small numerical residuals that # arise when a loop solver holds a nominally fixed axis at a tight but # nonzero range. Strict scalar removal keeps its original tolerance. gimbal_evidence, _ = _analyzeSimplificationSpecification( specification, inert_tolerance=1.0e-4, ) inert_names = {item.coordinate for item in gimbal_evidence if item.classification == "INERT"} changed = [] for name in sorted(targets): reference = original_references[name] original = original_hinges[name] hinge = getattr(reference.owner, reference.owner_attribute) prefix = name.rsplit("-SphericalSubhinge", 1)[0] pin_indices = provisional_pin_indices[name] retained = [ item.model_copy(deep=True) for index, item in enumerate(hinge.subhinge_params) if index not in pin_indices or f"{prefix}-PinSubhinge{index}" not in inert_names ] if len(retained) == len(hinge.subhinge_params): setattr(reference.owner, reference.owner_attribute, original) click.echo( f"Spherical subhinge unchanged: {name} " "(no globally inert fixed-axis pin was proven)." ) continue setattr(reference.owner, reference.owner_attribute, _replacementHinge(retained)) changed.append(name) click.echo( f"Spherical subhinge changed: {name}; retained {len(retained)} parent-hinge subhinges." ) return tuple(changed)
[docs] def loadSimplifiedModel( model_file: Path, remove_inert: tuple[str, ...], reduce_ball: tuple[str, ...], reduce_spherical: tuple[str, ...], ) -> tuple[LoadedModel, tuple[str, ...], tuple[str, ...]]: """Apply validated hinge reductions and load the rebuilt root model.""" # Keep edits serialized until validation completes, then cross a temporary # file boundary to rebuild clean runtime identity maps. specification, meta = _readSimplifiableSpecification(model_file) temporary_path = None output_specification = None try: # Removal precedes hinge reductions so each later scan sees the topology # that will actually be loaded and written. removed = _removeInertSubhinges(specification, remove_inert) reduced = _reduceBallSubhinges(specification, reduce_ball) reduced += _reduceSphericalSubhinges(specification, reduce_spherical) # A final full scan rejects topology changes that destroy continuation. _analyzeSimplificationSpecification(specification) with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as temporary: temporary_path = Path(temporary.name) specification.toFile(temporary_path, meta=meta) # Keep an untouched serialization target. Model loading binds and # mutates its own BasicPrefabDS copy with runtime node references. output_specification = specification.model_copy(deep=True) finally: del specification SubGraphDS.resetAllObjectsFromIds() try: loaded = loadModel(temporary_path) finally: temporary_path.unlink(missing_ok=True) return ( LoadedModel( loaded.sim, loaded.model_type, loaded.state_target, output_specification, ), removed, reduced, )
def _printSimplificationReport( removed: tuple[str, ...], reduced: tuple[str, ...], remove_requested: bool, reduce_requested: bool, reduce_spherical_requested: bool, ) -> None: """Report every range-validated hinge simplification.""" if removed: click.echo("Removed globally inert subhinges:") for name in removed: click.echo(f" {name}") if reduced: click.echo("Reduced spherical subhinges:") for name in reduced: click.echo(f" {name}") if remove_requested and not removed: click.echo("No requested scalar subhinges had complete INERT evidence.") if reduce_requested and not reduced: click.echo("No requested BALL passed the complete fixed-axis reduction test.") if reduce_spherical_requested and not reduced: click.echo("No requested spherical subhinge passed the complete fixed-axis reduction test.")
[docs] def loadTransformedModel( model_file: Path, optimize_mobility: bool, requests: tuple[MobilityRequest, ...], ) -> tuple[ LoadedModel, tuple[GeometryChange, ...], tuple[MobilityChange, ...], ]: """Optimize geometry, add requested DOFs, and load a rebuilt SubGraph.""" try: specification, meta = SubGraphDS.fromFile(model_file, get_meta=True) except Exception as error: raise click.ClickException( "loops --add-dofs currently requires a serialized SubGraph " "model: " f"{type(error).__name__}: {error}" ) from error # Geometry and mobility decisions share one temporary live graph so each # later decision observes earlier accepted transformations. frames = kf.FrameContainer.create("kdpy_mobility_analysis_frames") multibody = specification.toMultibody(frames) modified_specification = None temporary_path = None try: multibody.ensureHealthy() multibody.resetData() # Establish initial algebraic coupling before attempting geometry edits. groups = discoverLoopGroups(multibody) geometry_changes = [] if optimize_mobility: for group in groups: change = optimizeLoopGeometry(multibody, group) if change is not None: geometry_changes.append(change) # Accepted axis changes affect subsequent rank and mobility analysis. multibody.setQ(np.asarray(multibody.getQ(), dtype=float)) # Accepted axis changes can alter numerical activity and rank, so do # not reuse pre-optimization grouping for mobility additions. groups = discoverLoopGroups(multibody) used_groups: set[tuple[str, ...]] = set() mobility_changes = [] for request in requests: group = _groupForAnchor(groups, request.anchor) if group.constraints in used_groups: raise click.UsageError( "multiple loops --add-dofs requests resolve to loop " "group " f"{', '.join(group.constraints)}" ) used_groups.add(group.constraints) states = _mobilityStates(multibody, request.drivers) mobility_changes.append(recommendMobilityAdditions(multibody, group, request, states)) geometry_result = tuple(geometry_changes) mobility_result = tuple(mobility_changes) # Convert the final live graph once, then replay accepted cut-joint # additions into its serialized hinge parameters. modified_specification = multibody.toDS() _applyMobilityChanges(modified_specification, mobility_result) # Serialize once to detach DataStruct identity maps from analysis objects. with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as temporary: temporary_path = Path(temporary.name) modified_specification.toFile(temporary_path, meta=meta) finally: del modified_specification del specification SubGraphDS.resetAllObjectsFromIds() kc.discard(multibody) kc.discard(frames) try: sim, state_target, model_target = _loadSubGraph(temporary_path) finally: temporary_path.unlink(missing_ok=True) return ( LoadedModel(sim, "SubGraph", state_target, model_target), geometry_result, mobility_result, )
[docs] @dataclass(frozen=True) class FeasibleRangeLimit: """Record one connected feasible interval and its evidence quality. Limit flags distinguish a closure boundary from a configured search bound. ``worst_feasible_residual`` records the largest accepted pose error, while ``range_limits_supported`` states whether native limits can store the result. """ coordinate: str current: float lower: float upper: float lower_limited: bool upper_limited: bool worst_feasible_residual: float old_limits: tuple[float, float] | None = None dof_index: int = 0 range_limits_supported: bool = True
[docs] @dataclass(frozen=True) class CoordinateMobilityEvidence: """Summarize range-wide evidence for one candidate inert DOF. Global inertness requires complete connected-range coverage and acceptable participation and displacement at every retained closed state. """ coordinate: str dof_index: int classification: str sampled_states: int max_participation: float max_displacement: float complete_connected_range: bool
[docs] @dataclass(frozen=True) class BallReductionHint: """Describe a BALL coordinate's local active rotational subspace. The axes are candidates for fixed-axis gimbal validation, not proof that a rotational component can be removed. """ coordinate: str participation_rank: int coordinate_dofs: int axes: tuple[tuple[float, float, float], ...]
[docs] @dataclass(frozen=True) class LoopMobilityStatus: """Provide one presentation-ready loop-coordinate classification record.""" coordinate: str coordinate_type: str dof_index: int mobility: str feasible_range: tuple[float, float] | None details: str
[docs] class LockedLoopError(ValueError): """Indicate that a loop coordinate cannot be retained as independent."""
[docs] class ZeroLocalMobilityError(LockedLoopError): """Indicate that a coordinate is zero in every local nullspace vector."""
[docs] class IndirectCoordinateChartError(LockedLoopError): """Indicate that scalar Q continuation cannot represent one velocity DOF."""
[docs] class FrozenLoopCoordinateError(ValueError): """Indicate that a loop coordinate was frozen before range exploration."""
[docs] class RangeLimitComputationError(RuntimeError): """Indicate that numerical closure did not establish a range limit."""
def _subhingeLabel( subhinge: kd.PhysicalSubhinge, dof_index: int | None = None, ) -> str: """Combine a physical subhinge's name and concrete type for reports. Parameters ---------- subhinge: kd.PhysicalSubhinge Physical coordinate provider to label. dof_index: int | None Optional local offset appended for multi-DOF providers. Returns ------- str Human-readable coordinate/type label. """ # The pybind concrete class names distinguish pin and linear coordinates. label = f"{subhinge.name()}/{type(subhinge).__name__}" if dof_index is not None and subhinge.nU() > 1: label += f"[{dof_index}]" return label def _finishRangeLimitProgress(measured: FeasibleRangeLimit) -> None: """Complete one pending range-limit progress line with its result. Parameters ---------- measured: FeasibleRangeLimit Completed range measurement to describe. """ if measured.old_limits is not None and np.allclose( (measured.lower, measured.upper), measured.old_limits, rtol=1.0e-10, atol=1.0e-12, ): # Matching limits need only confirmation, while retaining their values. old_lower, old_upper = measured.old_limits click.echo( f": OK; original limits " f"[{_formatRangeEndpoint(old_lower)}, {_formatRangeEndpoint(old_upper)}]" ) return # ``hard`` marks a detected feasibility boundary; ``open`` marks only the # configured exploration boundary and permits travel beyond the old value. lower_kind = "hard" if measured.lower_limited else "open" upper_kind = "hard" if measured.upper_limited else "open" old_text = "" if measured.old_limits is not None: old_lower, old_upper = measured.old_limits old_text = ( f"; old limits [{_formatRangeEndpoint(old_lower)}, {_formatRangeEndpoint(old_upper)}]" ) result_kind = "new range" if measured.range_limits_supported else "range" click.echo( f": {result_kind} " f"[{_formatRangeEndpoint(measured.lower)}, " f"{_formatRangeEndpoint(measured.upper)}]" f"{old_text} ({lower_kind}, {upper_kind})" ) def _constraintRank(matrix: np.ndarray, relative_tolerance: float) -> int: """Return a scale-aware numerical rank for a constraint matrix. Parameters ---------- matrix: np.ndarray Matrix whose singular values are inspected. relative_tolerance: float Fraction of the largest singular value considered numerically nonzero. Returns ------- int Numerical matrix rank. """ singular_values = np.linalg.svd(matrix, compute_uv=False) if singular_values.size == 0 or singular_values[0] == 0.0: return 0 return int(np.count_nonzero(singular_values > relative_tolerance * singular_values[0])) def _coordinateColumn( coord_data: kd.CoordData, subhinge: kd.PhysicalSubhinge, dof_index: int, ) -> tuple[int, int]: """Return the packed velocity column for one local subhinge DOF. Parameters ---------- coord_data: kd.CoordData Graph coordinate mapping to search. subhinge: kd.PhysicalSubhinge Coordinate provider whose packed column is required. dof_index: int Local DOF offset within ``subhinge``. Returns ------- tuple[int, int] Packed U column and confirmed local offset. """ for column in range(coord_data.nU()): coordinate, local_offset = coord_data.coordAt(column) try: if coordinate.id() == subhinge.id() and local_offset == dof_index: return column, int(local_offset) finally: # Release temporary native handles as soon as they are inspected. del coordinate raise ValueError(f"subhinge {subhinge.name()!r} is not in this SubGraph") def _configureLoopPartition( subgraph: kd.SubGraph, subhinge: kd.PhysicalSubhinge, dof_index: int, rank_tolerance: float, solver: kd.ConstraintKinematicsSolver | None = None, ) -> tuple[kd.ConstraintKinematicsSolver, int]: """Freeze an independent full-graph partition containing the target. Parameters ---------- subgraph: kd.SubGraph Graph whose constraint partition is configured. subhinge: kd.PhysicalSubhinge Coordinate provider retained as an independent target. dof_index: int Local target offset within ``subhinge``. rank_tolerance: float Relative threshold used for QR rank selection. solver: kd.ConstraintKinematicsSolver | None Optional existing CK solver to reconfigure. Returns ------- tuple[kd.ConstraintKinematicsSolver, int] Configured CK solver and packed target Q index. """ graph_data = subgraph.graphCoordData() try: if not 0 <= dof_index < subhinge.nU(): raise IndexError(f"DOF index {dof_index} is outside {subhinge.name()!r}") target_column, local_offset = _coordinateColumn(graph_data, subhinge, dof_index) if subhinge.nQ() != subhinge.nU(): raise IndirectCoordinateChartError( f"{subhinge.name()!r}[{dof_index}] does not have a direct position-coordinate chart" ) # Full constraint rows and graph columns include cut-joint coordinates. matrix = np.asarray( kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float, ) pool = np.asarray( [index for index in range(graph_data.nU()) if index != target_column], dtype=int, ) _, triangular, pivots = linalg.qr(matrix[:, pool], mode="economic", pivoting=True) diagonal = np.abs(np.diag(triangular)) scale = diagonal[0] if diagonal.size else 0.0 rank = int(np.count_nonzero(diagonal > rank_tolerance * scale) if scale > 0.0 else 0) if rank < _constraintRank(matrix, rank_tolerance): raise ZeroLocalMobilityError( f"{subhinge.name()!r} cannot be retained as an independent " "coordinate with the current constraint rank" ) # QR selects the dependent columns; every other coordinate is prescribed. dependent = set(pool[pivots[:rank]].tolist()) if solver is None: solver = subgraph.cks() solver.clearFrozenCoords() for index in range(graph_data.nU()): if index not in dependent: coordinate, offset = graph_data.coordAt(index) try: # Package the provider and local offset as one Coordinate. solver.freezeCoord((coordinate, offset)) finally: del coordinate offsets = graph_data.coordOffsets(subhinge) return solver, int(offsets.Q + local_offset) finally: del graph_data def _captureFrozenCoordinates( subgraph: kd.SubGraph, solver: kd.ConstraintKinematicsSolver, ) -> tuple[tuple[int, int, kd.CKFrozenCoordType], ...]: """Capture Q/U solver freezes as subhinge IDs and local indices. Parameters ---------- subgraph: kd.SubGraph Graph that owns the coordinates used by ``solver``. solver: kd.ConstraintKinematicsSolver Constraint-kinematics solver whose partition is captured. Returns ------- tuple[tuple[int, int, kd.CKFrozenCoordType], ...] Stable coordinate-ID, local-offset, and freeze-type records suitable for restoring the partition after a temporary range scan. """ # Native inventories use packed Q/U indices. Convert them to provider IDs and # local offsets so later repartitioning cannot invalidate the snapshot. frozen_q = {int(index) for index in solver.frozenCoords(True)} frozen_u = {int(index) for index in solver.frozenCoords(False)} graph_data = subgraph.graphCoordData() frozen_types: dict[tuple[int, int], set[str]] = {} seen_ids: set[int] = set() try: for column in range(graph_data.nU()): coordinate, local_u = graph_data.coordAt(column) try: coordinate_id = coordinate.id() if column in frozen_u: frozen_types.setdefault((coordinate_id, local_u), set()).add("U") if coordinate_id in seen_ids: continue seen_ids.add(coordinate_id) q_offset = int(graph_data.coordOffsets(coordinate).Q) for local_q in range(coordinate.nQ()): if q_offset + local_q in frozen_q: frozen_types.setdefault((coordinate_id, local_q), set()).add("Q") finally: del coordinate finally: del graph_data # Collapse separate Q and U observations into the combined enum expected by # freezeCoord during restoration. result = [] for (coordinate_id, local_index), kinds in frozen_types.items(): if kinds == {"Q", "U"}: frozen_type = kd.CKFrozenCoordType.Q_U elif kinds == {"Q"}: frozen_type = kd.CKFrozenCoordType.Q else: frozen_type = kd.CKFrozenCoordType.U result.append((coordinate_id, local_index, frozen_type)) return tuple(result) def _restoreFrozenCoordinates( subgraph: kd.SubGraph, solver: kd.ConstraintKinematicsSolver, frozen_coordinates: tuple[tuple[int, int, kd.CKFrozenCoordType], ...], ) -> None: """Replace the temporary solver partition with an exact saved snapshot. Parameters ---------- subgraph: kd.SubGraph Graph used to resolve saved coordinate IDs. solver: kd.ConstraintKinematicsSolver Constraint-kinematics solver whose freezes are replaced. frozen_coordinates: tuple[tuple[int, int, kd.CKFrozenCoordType], ...] Records returned by :func:`_captureFrozenCoordinates` before temporary changes. """ # Range continuation intentionally replaces the whole partition. Clearing # that temporary state first avoids leaving a target freeze behind when Q # and U partitions differ, then each caller-owned freeze is restored once. solver.clearFrozenCoords() # Store unresolved records separately from graph traversal. Saved records # use stable native object IDs because packed Q/U indices can change when # the solver rebuilds its dependent-coordinate partition. pending = list(frozen_coordinates) graph_data = subgraph.graphCoordData() visited_ids: set[int] = set() try: # A physical coordinate may occupy several packed columns. Visit each # provider once, then restore every saved local component belonging to # that provider with its original Q, U, or combined freeze type. for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: coordinate_id = coordinate.id() if coordinate_id in visited_ids: continue visited_ids.add(coordinate_id) matching = [item for item in pending if item[0] == coordinate_id] for record in matching: _, local_index, frozen_type = record solver.freezeCoord((coordinate, local_index), frozen_type) pending.remove(record) finally: del coordinate finally: del graph_data # A remaining record means the graph changed while the scan was active or # the snapshot referred to an object outside this SubGraph. Silently # dropping it would leave the caller with a different solver partition. if pending: raise RuntimeError("could not resolve saved frozen coordinates") def _targetWasFrozen( subgraph: kd.SubGraph, solver: kd.ConstraintKinematicsSolver, subhinge: kd.PhysicalSubhinge, dof_index: int, ) -> bool: """Return whether the target DOF has a pre-existing Q or U freeze. Parameters ---------- subgraph: kd.SubGraph Graph containing the target coordinate. solver: kd.ConstraintKinematicsSolver Solver whose current frozen-coordinate partition is inspected. subhinge: kd.PhysicalSubhinge Physical subhinge that provides the target coordinate. dof_index: int Local coordinate offset within ``subhinge``. Returns ------- bool ``True`` when either the target Q or U component is already frozen. """ # The CK solver reports packed indices, whereas this API receives a # subhinge-local offset. Resolve both representations before inspecting the # caller's partition so a Q-only or U-only freeze is treated consistently. graph_data = subgraph.graphCoordData() try: u_index, _ = _coordinateColumn(graph_data, subhinge, dof_index) q_index = int(graph_data.coordOffsets(subhinge).Q + dof_index) finally: del graph_data return q_index in solver.frozenCoords(True) or u_index in solver.frozenCoords(False) def _refineLoopBoundary( subgraph: kd.SubGraph, solver: kd.ConstraintKinematicsSolver, q_index: int, current: float, direction: float, feasible_distance: float, infeasible_distance: float, feasible_state: np.ndarray, closure_tolerance: float, boundary_tolerance: float, sample_callback: Callable[[np.ndarray], None] | None = None, ) -> tuple[float, float]: """Bisect a feasibility transition while staying on its feasible side. Parameters ---------- subgraph: kd.SubGraph Graph whose Q state is varied during refinement. solver: kd.ConstraintKinematicsSolver CK solver used to restore closure after prescribing target Q. q_index: int Packed Q index of the frozen target coordinate. current: float Target-coordinate value at the nominal closed state. direction: float ``-1`` for the lower endpoint or ``+1`` for the upper. feasible_distance: float Known feasible distance from ``current``. infeasible_distance: float Known infeasible distance from ``current``. feasible_state: np.ndarray Full closed Q state at ``feasible_distance``. closure_tolerance: float Largest ``solveQ()`` error accepted as closed. boundary_tolerance: float Desired width of the final distance bracket. sample_callback: Callable[[np.ndarray], None] | None Optional consumer for each accepted closed Q state. Returns ------- tuple[float, float] The last demonstrated feasible distance and the largest accepted CK error encountered while refining the boundary. """ # Distances are measured from the original coordinate value. ``lower`` is # known to close and ``upper`` is known to exceed the closure tolerance; # shrinking this bracket avoids mistaking the failed trial itself for the # last usable coordinate value. lower = feasible_distance upper = infeasible_distance state = feasible_state.copy() worst_residual = 0.0 while upper - lower > boundary_tolerance: # Always seed the midpoint from the most recent feasible configuration. # A failed solve may alter dependent Q values, so using its resulting # state would make later trials drift away from the connected branch. midpoint = 0.5 * (lower + upper) trial = state.copy() trial[q_index] = current + direction * midpoint subgraph.setQ(trial) residual = float(solver.solveQ()) if residual <= closure_tolerance: # The solver found a closed configuration at the midpoint. Advance # the feasible side and retain the complete solved state as the # seed for the next, more distant midpoint. lower = midpoint state = np.asarray(subgraph.getQ(), dtype=float).copy() # Retain every accepted continuation state for range-wide analysis. if sample_callback is not None: sample_callback(state) worst_residual = max(worst_residual, residual) else: # Pull the infeasible side inward, then explicitly reinstall the # last accepted state because solveQ() is allowed to modify Q even # when its final residual remains above tolerance. upper = midpoint subgraph.setQ(state) # Returning the feasible side ensures the stored endpoint represents a # configuration that was actually demonstrated to satisfy closure. return lower, worst_residual def _searchLoopDirection( subgraph: kd.SubGraph, solver: kd.ConstraintKinematicsSolver, nominal: np.ndarray, q_index: int, current: float, direction: float, initial_step: float, maximum_step: float, maximum_distance: float, closure_tolerance: float, boundary_tolerance: float, sample_callback: Callable[[np.ndarray], None] | None = None, ) -> tuple[float, bool, float]: """Continue from the nominal state to one local interval endpoint. Parameters ---------- subgraph: kd.SubGraph Graph whose target Q component is prescribed. solver: kd.ConstraintKinematicsSolver CK solver used to close each trial configuration. nominal: np.ndarray Full closed Q state from which this direction starts. q_index: int Packed Q index of the frozen target coordinate. current: float Target-coordinate value in ``nominal``. direction: float ``-1`` for decreasing Q or ``+1`` for increasing Q. initial_step: float First distance increment away from ``current``. maximum_step: float Largest continuation increment permitted. maximum_distance: float Search horizon measured from ``current``. closure_tolerance: float Largest ``solveQ()`` error accepted as closed. boundary_tolerance: float Resolution used when refining a failed trial. sample_callback: Callable[[np.ndarray], None] | None Optional consumer for each accepted closed Q state. Returns ------- tuple[float, bool, float] The explored distance, whether it ended at a refined hard boundary, and the largest accepted CK error observed in this direction. """ # Each direction starts independently from the nominal closed pose. This # prevents the negative scan's dependent-coordinate solution from choosing # the assembly branch used by the positive scan. subgraph.setQ(nominal) distance = 0.0 step = initial_step feasible_state = nominal.copy() worst_residual = 0.0 while distance < maximum_distance: # Propose a farther target coordinate while retaining all other Q values # from the last closed solution. The frozen target survives solveQ(); # the CK solver changes only coordinates selected to restore closure. candidate_distance = min(distance + step, maximum_distance) trial = feasible_state.copy() trial[q_index] = current + direction * candidate_distance subgraph.setQ(trial) residual = float(solver.solveQ()) feasible = residual <= closure_tolerance if not feasible: # The previous state and this failed target bracket the first loss # of closure on the connected branch. Refine that bracket rather # than reporting a step-size-dependent endpoint. boundary, refined_residual = _refineLoopBoundary( subgraph, solver, q_index, current, direction, distance, candidate_distance, feasible_state, closure_tolerance, boundary_tolerance, sample_callback, ) return boundary, True, max(worst_residual, refined_residual) # Promote the complete CK solution—not merely the prescribed target # value—to the next seed so dependent coordinates follow continuously. distance = candidate_distance feasible_state = np.asarray(subgraph.getQ(), dtype=float).copy() # Preserve the closed state before the next nonlinear trial changes it. if sample_callback is not None: sample_callback(feasible_state) worst_residual = max(worst_residual, residual) # Grow cautiously to avoid jumping to another nonlinear solution branch. step = min(1.35 * step, maximum_step) # No loss of closure was observed within the configured exploration # distance. ``False`` tells the storage layer to encode this side as open. return maximum_distance, False, worst_residual def _projectionComponent( subgraph: kd.SubGraph, target_column: int, activity_tolerance: float = 1.0e-10, ) -> tuple[np.ndarray, np.ndarray]: """Return the full constraint-coordinate component containing a target. Unlike loop-edit grouping, range projection must include non-cut bilateral constraint rows as well. A bipartite closure finds every row and column connected through a nonzero Jacobian entry without pulling in unrelated vehicle corners or mechanisms. """ matrix = np.asarray(kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float) active = np.abs(matrix) > activity_tolerance columns = {int(target_column)} rows: set[int] = set() while True: next_rows = set(np.flatnonzero(np.any(active[:, list(columns)], axis=1))) next_columns = set(np.flatnonzero(np.any(active[list(next_rows), :], axis=0))) if next_rows == rows and next_columns == columns: break rows = next_rows columns = next_columns return np.asarray(sorted(rows), dtype=int), np.asarray(sorted(columns), dtype=int)
[docs] def feasibleRangeLimit( subgraph: kd.SubGraph, subhinge: kd.PhysicalSubhinge, *, dof_index: int = 0, initial_step: float | None = None, maximum_step: float | None = None, maximum_distance: float | None = None, closure_tolerance: float = 1.0e-9, rank_tolerance: float = 1.0e-9, boundary_tolerance: float = 1.0e-7, sample_callback: Callable[[np.ndarray], None] | None = None, ) -> FeasibleRangeLimit: """Find the connected projected feasible interval around one subhinge DOF. For every tested value of the selected coordinate, all remaining direct position coordinates are optimization variables. Consequently the test answers ``exists q_other: C(q) = 0`` and does not impose the artificial path or frozen-coordinate partition used by the older continuation scan. Scanning stops at the first infeasible value in each direction, retaining only the connected interval containing the initial configuration. """ is_linear = subhinge.subhingeType() in ( kd.SubhingeType.LINEAR, kd.SubhingeType.LINEAR3, ) initial_step = initial_step or (0.0005 if is_linear else 0.01) maximum_step = maximum_step or (0.005 if is_linear else 0.05) # A complete revolution is required to distinguish a genuine angular # boundary from the arbitrary +/-pi coordinate-chart seam. maximum_distance = maximum_distance or (0.5 if is_linear else 2.0 * math.pi) if not 0.0 < initial_step <= maximum_step <= maximum_distance: raise ValueError("step sizes and maximum distance must be positive and ordered") if not 0.0 < boundary_tolerance < initial_step: raise ValueError("boundary tolerance must lie between zero and initial step") # Preserve caller-visible mutable state. The finalizer restores generalized # position and solver freezes after success or any failed continuation. saved_q = np.asarray(subgraph.getQ(), dtype=float).copy() solver = subgraph.cks() if _targetWasFrozen(subgraph, solver, subhinge, dof_index): del solver raise FrozenLoopCoordinateError(f"{subhinge.name()!r}[{dof_index}] is already frozen") try: del rank_tolerance # Rank selection is intentionally absent here. graph_data = subgraph.graphCoordData() try: if subhinge.nQ() != subhinge.nU(): raise IndirectCoordinateChartError( f"{subhinge.name()!r}[{dof_index}] does not have a direct " "position-coordinate chart" ) target_column, local_offset = _coordinateColumn(graph_data, subhinge, dof_index) q_index = int(graph_data.coordOffsets(subhinge).Q + local_offset) # Restrict the optimization to the connected closure component. # Coordinates outside this component do not occur in these rows and # therefore cannot alter the projection result. _, component_columns = _projectionComponent(subgraph, target_column) # poseError() is the authoritative nonlinear residual vector. Its # row packing can be shorter than the velocity matrix when imported # scalar ConVel constraints are present, so evaluate all pose rows; # unrelated components remain at their already closed nominal state. residual_rows = np.arange(np.asarray(subgraph.poseError(), dtype=float).size, dtype=int) group_q_indices: list[int] = [] visited_ids: set[int] = set() for column in component_columns: coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in visited_ids: continue visited_ids.add(coordinate.id()) offsets = graph_data.coordOffsets(coordinate) group_q_indices.extend( int(offsets.Q + local_q) for local_q in range(coordinate.nQ()) ) finally: del coordinate finally: del graph_data variable_indices = np.asarray( [index for index in group_q_indices if index != q_index], dtype=int ) jacobian_columns = np.asarray( [column for column in component_columns if column != target_column], dtype=int, ) use_analytic_jacobian = variable_indices.size == jacobian_columns.size # Prescribe target Q and solve only its connected closure component, # trying retained states as seeds to stay on the same solution branch. def solveProjected( target_value: float, seeds: tuple[np.ndarray, ...], ) -> tuple[np.ndarray | None, float]: """Minimize closure error with only the target coordinate fixed.""" best_state = None best_residual = math.inf for seed in seeds: work_state = np.asarray(seed, dtype=float).copy() work_state[q_index] = target_value if variable_indices.size == 0: # A one-coordinate component has nothing to optimize; its # prescribed trial is either closed as-is or infeasible. subgraph.setQ(work_state) closure = np.asarray(subgraph.poseError(), dtype=float)[residual_rows] value = float(np.linalg.norm(closure, ord=np.inf)) return ( work_state.copy() if value <= closure_tolerance else None, value, ) def residual( values: np.ndarray, trial: np.ndarray = work_state, ) -> np.ndarray: """Evaluate full cut-joint pose closure at one free state.""" trial[variable_indices] = values trial[q_index] = target_value subgraph.setQ(trial) return np.asarray(subgraph.poseError(), dtype=float)[residual_rows] def jacobian( values: np.ndarray, trial: np.ndarray = work_state, ) -> np.ndarray: """Return the pose gradient when Q and U charts align.""" trial[variable_indices] = values trial[q_index] = target_value subgraph.setQ(trial) # Evaluate the full pose gradient only after installing this # trial state, then restrict it to projected rows and variables. matrix = np.asarray( kd.Algorithms.evalConstraintPoseGradientMatrix(subgraph), dtype=float, ) return matrix[np.ix_(residual_rows, jacobian_columns)] result = least_squares( residual, work_state[variable_indices], # Fall back to numerical Q derivatives for imported blocks # whose active U columns and position chart sizes differ. jac=jacobian if use_analytic_jacobian else "2-point", xtol=1.0e-12, ftol=1.0e-12, gtol=1.0e-12, max_nfev=400, ) candidate = work_state.copy() candidate[variable_indices] = result.x candidate[q_index] = target_value subgraph.setQ(candidate) closure = np.asarray(subgraph.poseError(), dtype=float)[residual_rows] value = float(np.linalg.norm(closure, ord=np.inf)) if value < best_residual: best_state = candidate.copy() best_residual = value if value <= closure_tolerance: return candidate, value return best_state if best_residual <= closure_tolerance else None, best_residual # Positive and negative scans share projection policy but keep separate # branch histories and boundary evidence. def searchDirection(direction: float) -> tuple[float, bool, float]: """Scan one side of the nominal projection and refine its boundary.""" distance = 0.0 step = initial_step witness = saved_q.copy() accepted = [saved_q.copy()] worst = 0.0 while distance < maximum_distance: candidate_distance = min(distance + step, maximum_distance) state, residual = solveProjected( float(saved_q[q_index] + direction * candidate_distance), (witness, saved_q, *accepted[-3:]), ) if state is None: feasible_distance = distance infeasible_distance = candidate_distance # Bisection uses every recent witness so a local optimizer # failure is less likely to masquerade as a hard boundary. while infeasible_distance - feasible_distance > boundary_tolerance: midpoint = 0.5 * (feasible_distance + infeasible_distance) refined, refined_residual = solveProjected( float(saved_q[q_index] + direction * midpoint), (witness, saved_q, *accepted[-3:]), ) if refined is None: infeasible_distance = midpoint else: feasible_distance = midpoint witness = refined accepted.append(refined.copy()) worst = max(worst, refined_residual) if sample_callback is not None: sample_callback(refined) return feasible_distance, True, worst distance = candidate_distance witness = state accepted.append(state.copy()) worst = max(worst, residual) if sample_callback is not None: sample_callback(state) step = min(1.35 * step, maximum_step) return maximum_distance, False, worst current = float(saved_q[q_index]) negative, lower_limited, negative_residual = searchDirection(-1.0) positive, upper_limited, positive_residual = searchDirection(1.0) return FeasibleRangeLimit( subhinge.name(), current, current - negative, current + positive, lower_limited, upper_limited, max(negative_residual, positive_residual), dof_index=dof_index, ) finally: # No solver partition is changed; restore only the caller's state. subgraph.setQ(saved_q) del solver
def _sameRangeLimits(first: np.ndarray, second: np.ndarray) -> bool: """Return whether two stored endpoint pairs agree, including NaNs. Parameters ---------- first: np.ndarray First lower/upper endpoint pair. second: np.ndarray Second lower/upper endpoint pair. Returns ------- bool ``True`` when corresponding values match and NaNs occupy the same positions. """ return bool(np.array_equal(first, second, equal_nan=True))
[docs] def updateSubhingeRangeLimits( subgraph: kd.SubGraph, subhinge: kd.PhysicalSubhinge, coordinate_offset: int, ) -> bool: """Measure and store one physical-subhinge coordinate's feasible range. Finite values denote established feasibility boundaries, infinities denote open search directions, and NaNs denote a failed numerical measurement. Frozen coordinates and unsupported indirect charts are left unchanged. Parameters ---------- subgraph: kd.SubGraph Graph supplying closure constraints and its CK solver. subhinge: kd.PhysicalSubhinge Physical subhinge whose range-limit row is updated. coordinate_offset: int Local Q row to measure and store. Returns ------- bool ``True`` when the stored endpoint pair changed; otherwise ``False``. """ # Read the old row before running continuation so the return value describes # an observable metadata change, not merely whether the calculation ran. # Quantity-backed angular limits expose ``magnitude``; plain arrays do not. stored_limits = subhinge.getRangeLimits() old_limits = np.asarray( getattr(stored_limits, "magnitude", stored_limits), dtype=float, )[coordinate_offset].copy() try: # Keep numerical exploration in feasibleRangeLimit(); this method owns # only policy for skipped, failed, bounded, and open measurements. measured = feasibleRangeLimit( subgraph, subhinge, dof_index=coordinate_offset, ) except (FrozenLoopCoordinateError, IndirectCoordinateChartError, LockedLoopError): # A skipped coordinate retains its prior metadata because no new # statement about its feasible excursion was established. return False except (FloatingPointError, RuntimeError, ValueError): # The computation started but could not produce trustworthy endpoints. # NaN explicitly invalidates stale computed limits so later inertness # checks cannot treat an older finite interval as current evidence. new_limits = np.full(2, np.nan) else: # Finite values represent boundaries found after a solveQ() transition. # Reaching the search horizon without failure proves only that travel # remains available, so encode that direction as mathematically open. new_limits = np.asarray( ( measured.lower if measured.lower_limited else -np.inf, measured.upper if measured.upper_limited else np.inf, ), dtype=float, ) if _sameRangeLimits(old_limits, new_limits): # Avoid notifying kdFlex variables when recomputation reproduces the # exact metadata already stored, treating NaN pairs as equal here. return False # Publish the pair atomically so callers never observe one old endpoint and # one endpoint from the new computation. subhinge.setRangeLimits(new_limits, coordinate_offset) return True
[docs] def updateRangeLimits( subgraph: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, ) -> None: """Update feasible ranges for physical coordinates in a subgraph. When ``selected_dofs`` is supplied, only the named coordinate offsets are processed. Otherwise every physical hinge and cut-joint coordinate in the graph is updated once. Parameters ---------- subgraph: kd.SubGraph Graph whose physical-subhinge range metadata is updated. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset pairs. ``None`` updates every physical coordinate in the graph. """ # Normalize selections once. Duplicate selectors are rejected because a # repeated request should not run nonlinear continuation twice for one row. if selected_dofs is not None and len(set(selected_dofs)) != len(selected_dofs): raise ValueError("range-limit coordinate selections must be unique") requested = set(selected_dofs) if selected_dofs is not None else None graph_data = subgraph.graphCoordData() try: if requested is not None: # Validate the entire selection before changing any stored limits. available: dict[tuple[str, int], int] = {} inventoried: set[int] = set() for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in inventoried or not isinstance( coordinate, kd.PhysicalSubhinge ): continue inventoried.add(coordinate.id()) for offset in range(coordinate.nQ()): key = (coordinate.name(), offset) available[key] = available.get(key, 0) + 1 finally: del coordinate missing = requested - available.keys() ambiguous = {key for key in requested if available.get(key, 0) > 1} if missing or ambiguous: # Fail before the update pass begins. This makes invalid # selection handling atomic: no earlier coordinate is changed # before the caller learns that a later selector is unusable. invalid = missing | ambiguous labels = ", ".join(f"{name}/{offset}" for name, offset in sorted(invalid)) raise click.UsageError( f"loops --mobility-check coordinate is not uniquely available: {labels}" ) # graphCoordData exposes one entry per packed U component, so a # multi-component subhinge appears repeatedly. Visit each physical # provider once, then explicitly iterate its Q rows in local order. visited: set[int] = set() for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in visited or not isinstance(coordinate, kd.PhysicalSubhinge): continue visited.add(coordinate.id()) for offset in range(coordinate.nQ()): key = (coordinate.name(), offset) if requested is not None and key not in requested: continue updateSubhingeRangeLimits(subgraph, coordinate, offset) finally: del coordinate finally: del graph_data
def _loopSubhingeCoordinates( subgraph: kd.SubGraph, activity_tolerance: float = 1.0e-10, ) -> tuple[tuple[int, int], ...]: """Return every DOF of each subhinge active in loop equations. Parameters ---------- subgraph: kd.SubGraph Graph whose velocity-constraint matrix is inspected. activity_tolerance: float Minimum column norm considered constraint-active. Defaults to 1.0e-10. Returns ------- tuple[tuple[int, int], ...] Coordinate-ID and local-DOF pairs in graph traversal order. """ graph_data = subgraph.graphCoordData() matrix = np.asarray(kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float) active_subhinge_ids: list[int] = [] try: active = np.linalg.norm(matrix, axis=0) > activity_tolerance for column in np.flatnonzero(active): coordinate, _ = graph_data.coordAt(int(column)) try: coordinate_id = coordinate.id() if coordinate_id not in active_subhinge_ids: active_subhinge_ids.append(coordinate_id) finally: del coordinate result = [] for coordinate_id in active_subhinge_ids: for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() == coordinate_id: result.extend( (coordinate_id, dof_index) for dof_index in range(coordinate.nU()) ) break finally: del coordinate return tuple(result) finally: del graph_data def _coordinateMobilityEvidence( subgraph: kd.SubGraph, group: LoopGroup, coordinate_id: int, dof_index: int, states: tuple[np.ndarray, ...], complete_connected_range: bool, *, rank_tolerance: float = 1.0e-9, displacement_tolerance: float = 1.0e-9, ) -> CoordinateMobilityEvidence: """Classify a nominally immobile coordinate over retained closed states. At every retained pose, the coordinate's row in an orthonormal null-space basis measures whether any constraint-compatible velocity uses that DOF. Zero participation at one pose is only configuration-dependent evidence. ``INERT`` requires zero participation and displacement over a complete connected one-dimensional range; incomplete coverage yields a candidate. Parameters ---------- subgraph: kd.SubGraph Graph used to evaluate constraint matrices at sampled states. group: LoopGroup Connected loop group containing the target coordinate. coordinate_id: int Stable native ID of the target coordinate provider. dof_index: int Local target offset within its provider. states: tuple[np.ndarray, ...] Closed Q states retained during range continuation. complete_connected_range: bool Whether the samples cover both hard endpoints of the connected one-mobility branch. rank_tolerance: float Relative threshold for null-space participation. Defaults to 1.0e-9. displacement_tolerance: float Largest observed Q displacement considered zero. Defaults to 1.0e-9. Returns ------- CoordinateMobilityEvidence Classification and numerical evidence for the target coordinate. """ graph_data = subgraph.graphCoordData() saved_q = np.asarray(subgraph.getQ(), dtype=float).copy() coordinate_name = "" group_column = None q_index = None try: # Resolve both packed indices once so every sample uses the same DOF. for local_group_column, full_column in enumerate(group.columns): coordinate, local_offset = graph_data.coordAt(full_column) try: if coordinate.id() == coordinate_id and local_offset == dof_index: coordinate_name = coordinate.name() group_column = local_group_column q_index = int(graph_data.coordOffsets(coordinate).Q + dof_index) break finally: del coordinate if group_column is None or q_index is None: raise RuntimeError("locally immobile loop coordinate disappeared") initial_value = float(states[0][q_index]) max_participation = 0.0 max_displacement = 0.0 nullities: list[int] = [] for state in states: subgraph.setQ(state) matrix = _groupMatrix(subgraph, group) # An orthonormal basis makes the row norm invariant to basis choice. nullspace = linalg.null_space(matrix, rcond=rank_tolerance) nullities.append(int(nullspace.shape[1])) if nullspace.shape[1]: max_participation = max( max_participation, float(np.linalg.norm(nullspace[group_column, :])), ) max_displacement = max( max_displacement, abs(float(state[q_index]) - initial_value), ) remains_zero = ( max_participation <= rank_tolerance and max_displacement <= displacement_tolerance ) coverage_is_complete = ( complete_connected_range and bool(nullities) and all(nullity == 1 for nullity in nullities) ) if not remains_zero: classification = "CONFIGURATION-DEPENDENT" elif coverage_is_complete: classification = "INERT" else: classification = "INERT CANDIDATE" return CoordinateMobilityEvidence( coordinate_name, dof_index, classification, len(states), max_participation, max_displacement, coverage_is_complete, ) finally: # Evidence evaluation must not change the caller-visible configuration. subgraph.setQ(saved_q) del graph_data def _coordinateParticipationRank( subgraph: kd.SubGraph, group: LoopGroup, coordinate_id: int, *, rank_tolerance: float = 1.0e-9, ) -> tuple[int, int, tuple[tuple[float, float, float], ...]]: """Return local participation rank and an aligned three-axis basis. Parameters ---------- subgraph: kd.SubGraph Graph supplying the current constraint matrix. group: LoopGroup Connected loop group containing the coordinate. coordinate_id: int Stable native ID of the coordinate provider. rank_tolerance: float Relative singular-value threshold used for rank. Defaults to 1.0e-9. Returns ------- tuple[int, int, tuple[tuple[float, float, float], ...]] Participation rank, coordinate DOF count, and aligned three-axis basis. """ graph_data = subgraph.graphCoordData() coordinate_rows = [] coordinate_dofs = 0 try: for local_group_column, full_column in enumerate(group.columns): coordinate, _ = graph_data.coordAt(full_column) try: if coordinate.id() == coordinate_id: coordinate_rows.append(local_group_column) coordinate_dofs = coordinate.nU() finally: del coordinate finally: del graph_data if not coordinate_rows or coordinate_dofs == 0: raise RuntimeError("indirect-chart loop coordinate disappeared") matrix = _groupMatrix(subgraph, group) nullspace = linalg.null_space(matrix, rcond=rank_tolerance) participation = nullspace[np.asarray(coordinate_rows), :] rank = _constraintRank(participation, rank_tolerance) # Left singular vectors align the first axes with the locally active BALL # velocity subspace and the remaining axes with its orthogonal complement. left_vectors, _, _ = np.linalg.svd(participation, full_matrices=True) axes = tuple(tuple(float(value) for value in left_vectors[:, index]) for index in range(3)) return rank, coordinate_dofs, axes def _setLoopRangeLimits( subgraph: kd.SubGraph, *, report_progress: bool = False, progress_with_offsets: bool = False, selected_names: tuple[str, ...] | None = None, selected_dofs: tuple[tuple[str, int], ...] | None = None, evidence_output: list[CoordinateMobilityEvidence] | None = None, ball_hint_output: list[BallReductionHint] | None = None, status_output: list[LoopMobilityStatus] | None = None, inert_tolerance: float = 2.0e-7, apply_limits: bool = True, ) -> tuple[FeasibleRangeLimit, ...]: """Analyze loop-coordinate ranges and optionally apply supported Q limits. This pipeline inventories active subhinge DOFs, continues scalar DOFs over closure-feasible ranges, retains accepted states for global inertness evidence, derives BALL reduction hints, and emits status records. Direct multi-DOF charts store each measured Q row independently. Indirect charts remain explicitly unresolved by the scalar scanner. Temporary states and solver partitions are restored before the function returns. Parameters ---------- subgraph: kd.SubGraph Graph whose loop-coordinate ranges are analyzed. report_progress: bool Whether to emit live per-coordinate progress. Defaults to ``False``. progress_with_offsets: bool Whether progress labels include local offsets. Defaults to ``False``. selected_names: tuple[str, ...] | None Optional subhinge names used by range-update commands. selected_dofs: tuple[tuple[str, int], ...] | None Optional name/offset pairs used by mobility checks. evidence_output: list[CoordinateMobilityEvidence] | None Optional list populated with inertness evidence. ball_hint_output: list[BallReductionHint] | None Optional list populated with spherical-joint hints. status_output: list[LoopMobilityStatus] | None Optional list populated with report-ready statuses. apply_limits: bool Whether measured endpoint pairs are stored on subhinges. Defaults to ``True``. Returns ------- tuple[FeasibleRangeLimit, ...] Measurements produced for coordinates whose scalar scans completed. """ # Phase 1 inventories scope. Read-only analysis starts with loop-active # coordinates; limit application also includes physical tree coordinates. coordinates = _loopSubhingeCoordinates(subgraph) if apply_limits: # The update inventory includes free tree coordinates, e.g. a base pin. data = subgraph.graphCoordData() try: physical_coordinates = [] for column in range(data.nU()): coordinate, offset = data.coordAt(column) try: if isinstance(coordinate, kd.PhysicalSubhinge): physical_coordinates.append((coordinate.id(), offset)) finally: del coordinate coordinates = tuple(physical_coordinates) finally: del data # Phase 2 maps coordinates to connected closures whose retained states and # scan completeness determine later inertness evidence. loop_groups = discoverLoopGroups(subgraph) group_by_coordinate = { coordinate_id: group_index for group_index, group in enumerate(loop_groups) for coordinate_id in group.coordinate_ids } missing_group_ids = { coordinate_id for coordinate_id, _ in coordinates if coordinate_id not in group_by_coordinate } if missing_group_ids: # Some imported models express closure with non-cut bilateral rows. # Keep those coordinates reportable even though edit-oriented cut-joint # grouping intentionally excludes them. matrix = np.asarray(kd.Algorithms.evalVelocityConstraintMatrix(subgraph, True), dtype=float) fallback_index = len(loop_groups) fallback_group = LoopGroup( (), tuple(range(matrix.shape[0])), tuple(range(matrix.shape[1])), tuple(sorted(missing_group_ids)), (), ) loop_groups = (*loop_groups, fallback_group) group_by_coordinate.update( {coordinate_id: fallback_index for coordinate_id in missing_group_ids} ) # Every group starts with the same nominal witness. Continuation appends # distinct closed states and the finalizer restores this configuration. nominal_q = np.asarray(subgraph.getQ(), dtype=float).copy() sampled_states = {group_index: [nominal_q.copy()] for group_index in range(len(loop_groups))} complete_group_ranges: set[int] = set() zero_local_coordinates: list[tuple[int, int, int]] = [] reduction_hint_coordinates: dict[int, tuple[str, int]] = {} summaries: list[FeasibleRangeLimit] = [] range_results: dict[tuple[int, int], tuple[float, float]] = {} graph_data = subgraph.graphCoordData() try: # Determine one name-column width before emitting live progress lines. coordinate_names: dict[int, str] = {} coordinate_labels: dict[tuple[int, int], str] = {} participating_ids = {coordinate_id for coordinate_id, _ in coordinates} for column in range(graph_data.nU()): coordinate, local_offset = graph_data.coordAt(column) try: if coordinate.id() in participating_ids: coordinate_names[coordinate.id()] = coordinate.name() coordinate_labels[(coordinate.id(), local_offset)] = _subhingeLabel( coordinate, local_offset ) finally: del coordinate if selected_names is not None and selected_dofs is not None: raise ValueError("select ranges by names or name/offset pairs, not both") if selected_names is not None: # Validate the complete selection before starting a potentially # lengthy scan, and retain the user's requested ordering. if "all" in selected_names: if len(selected_names) != 1: raise click.UsageError( "loops --ranges-update all cannot be combined with names" ) else: if len(set(selected_names)) != len(selected_names): raise click.UsageError("loops --ranges-update subhinge names must be unique") ids_by_name: dict[str, list[int]] = {} for coordinate_id, name in coordinate_names.items(): ids_by_name.setdefault(name, []).append(coordinate_id) missing = [name for name in selected_names if name not in ids_by_name] if missing: raise click.UsageError( "loops --ranges-update subhinge is not active in a " "loop: " f"{', '.join(missing)}" ) ambiguous = [name for name in selected_names if len(ids_by_name[name]) != 1] if ambiguous: raise click.UsageError( f"loops --ranges-update subhinge name is ambiguous: {', '.join(ambiguous)}" ) selected_ids = tuple(ids_by_name[name][0] for name in selected_names) coordinates = tuple( item for coordinate_id in selected_ids for item in coordinates if item[0] == coordinate_id ) elif selected_dofs is not None: # Range-check selectors identify one specific component; a missing # CLI suffix has already been normalized to offset zero. ids_by_name: dict[str, list[int]] = {} for coordinate_id, name in coordinate_names.items(): ids_by_name.setdefault(name, []).append(coordinate_id) missing = [name for name, _ in selected_dofs if name not in ids_by_name] if missing: raise click.UsageError( f"loops --mobility-check subhinge is not active in a loop: {', '.join(missing)}" ) ambiguous = [name for name, _ in selected_dofs if len(ids_by_name[name]) != 1] if ambiguous: raise click.UsageError( f"loops --mobility-check subhinge name is ambiguous: {', '.join(ambiguous)}" ) requested = tuple((ids_by_name[name][0], offset) for name, offset in selected_dofs) unavailable = [ f"{name}/{offset}" for name, offset in selected_dofs if (ids_by_name[name][0], offset) not in coordinates ] if unavailable: raise click.UsageError( "loops --mobility-check offset is outside the active subhinge: " f"{', '.join(unavailable)}" ) coordinates = requested # Freeze labels before scanning because coordinate wrappers are released # between iterations and report order must remain deterministic. selected_coordinate_labels = ( f"{coordinate_names[coordinate_id]}/{dof_index}" if progress_with_offsets else coordinate_labels[(coordinate_id, dof_index)] for coordinate_id, dof_index in coordinates ) name_width = max(map(len, selected_coordinate_labels), default=0) # Measure all ranges first so newly assigned limits cannot affect scans. for coordinate_id, dof_index in coordinates: target = None for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) if coordinate.id() == coordinate_id: target = coordinate break del coordinate if target is None: raise RuntimeError(f"loop coordinate ID {coordinate_id} disappeared") try: if isinstance(target, kd.SphericalSubhinge): # Evaluate BALL reducibility as one coordinate block rather # than interpreting basis-dependent scalar components. reduction_hint_coordinates[coordinate_id] = ( target.name(), group_by_coordinate[coordinate_id], ) if report_progress: # Keep the line open while the two continuation searches run. progress_label = ( f"{target.name()}/{dof_index}" if progress_with_offsets else _subhingeLabel(target, dof_index) ) click.echo( f"Checking {progress_label:<{name_width}}", nl=False, ) old_limits = None supports_limits = isinstance(target, kd.PhysicalSubhinge) if supports_limits: # Range metadata has one min/max row for every Q coordinate. stored_limits = target.getRangeLimits() old_values = np.asarray( getattr(stored_limits, "magnitude", stored_limits), dtype=float, )[dof_index] if not np.isnan(old_values).any(): old_limits = (float(old_values[0]), float(old_values[1])) try: group_index = group_by_coordinate[coordinate_id] def retainFeasibleState( state: np.ndarray, *, destination: list[np.ndarray] = sampled_states[group_index], ) -> None: """Retain a closed state for later nullspace evidence. Parameters ---------- state: np.ndarray Newly accepted full Q state. destination: list[np.ndarray] Group-specific state list receiving it. Defaults to ``sampled_states[group_index]```. """ # Avoid adjacent duplicates from boundary refinement. if not np.array_equal(destination[-1], state): destination.append(np.asarray(state, dtype=float).copy()) measured = feasibleRangeLimit( subgraph, target, dof_index=dof_index, sample_callback=retainFeasibleState, ) except FrozenLoopCoordinateError: if report_progress: # Respect persistent user freezes and keep scanning. click.echo(": FROZEN") if status_output is not None: status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, "FROZEN", None, "Coordinate was already frozen by the user.", ) ) continue except ZeroLocalMobilityError: if report_progress: # This coordinate is zero in the nominal nullspace. click.echo(": ZERO LOCAL MOBILITY") zero_local_coordinates.append( (coordinate_id, dof_index, group_by_coordinate[coordinate_id]) ) continue except IndirectCoordinateChartError: if report_progress: # Quaternion and other indirect charts cannot be driven # one scalar component at a time by this range scanner. click.echo(": UNRESOLVED (indirect coordinate chart)") if status_output is not None: status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, "UNRESOLVED", None, "Scalar continuation cannot represent this indirect coordinate chart.", ) ) continue except LockedLoopError: if report_progress: # A missing direct chart prevents a mobility conclusion. click.echo(": UNRESOLVED") if status_output is not None: status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, "UNRESOLVED", None, "No direct independent-coordinate chart was found.", ) ) continue except RangeLimitComputationError as error: if report_progress: click.echo(": UNRESOLVED (range computation failed)") if supports_limits and apply_limits: range_results[(coordinate_id, dof_index)] = (np.nan, np.nan) if status_output is not None: status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, "UNRESOLVED", None, str(error), ) ) continue measured = replace( measured, lower=measured.lower if measured.lower_limited else -np.inf, upper=measured.upper if measured.upper_limited else np.inf, old_limits=old_limits, range_limits_supported=supports_limits, ) if np.isnan((measured.lower, measured.upper)).any(): # Each endpoint is independent: retain a valid opposite # endpoint while marking only failed directions as NaN. summaries.append(measured) if supports_limits and apply_limits: range_results[(coordinate_id, dof_index)] = ( measured.lower, measured.upper, ) if status_output is not None: status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, "UNRESOLVED", None, "One or both range endpoints could not be computed.", ) ) if report_progress: click.echo(": UNRESOLVED (range endpoint computation failed)") continue if np.isfinite( (measured.lower, measured.upper) ).all() and target.subhingeType() in ( kd.SubhingeType.PIN, kd.SubhingeType.SPHERICAL, kd.SubhingeType.SCREW, ): # A rotational coordinate has at most one revolution of # distinct physical values. Normalize before recording, # reporting, or passing the interval to setRangeLimits(). bounded_lower, bounded_upper = _boundedAngularInterval( measured.lower, measured.upper, measured.current, ) measured = replace( measured, lower=bounded_lower, upper=bounded_upper, ) summaries.append(measured) span = measured.upper - measured.lower projection_inert = ( measured.lower_limited and measured.upper_limited and span <= inert_tolerance ) if projection_inert and evidence_output is not None: # An isolated projected value is stronger evidence than a # zero Jacobian component at only the nominal pose. evidence_output.append( CoordinateMobilityEvidence( target.name(), dof_index, "INERT", len(sampled_states[group_index]), 0.0, span, True, ) ) if status_output is not None: is_angular = isinstance(target, (kd.PinSubhinge, kd.SphericalSubhinge)) mobility = ( "INERT" if projection_inert else "LOW" if is_angular and 0.0 < span < np.deg2rad(10.0) else "MOBILE" ) lower_kind = "hard" if measured.lower_limited else "open" upper_kind = "hard" if measured.upper_limited else "open" status_output.append( LoopMobilityStatus( target.name(), type(target).__name__, dof_index, mobility, (measured.lower, measured.upper), f"{lower_kind}; {upper_kind}", ) ) if measured.lower_limited and measured.upper_limited and not projection_inert: # A bounded scalar scan covers a one-mobility branch only # after the sampled nullity checks performed below. complete_group_ranges.add(group_index) if supports_limits: # Store the measured range, including open scan endpoints and # degenerate intervals; these are metadata, not joint stops. range_results[(coordinate_id, dof_index)] = (measured.lower, measured.upper) if report_progress: # Finish the pending progress line with the measured values. _finishRangeLimitProgress(measured) finally: del target # Phase 4 revisits nominally immobile coordinates using every closed # state retained for their connected loop group. for coordinate_id, dof_index, group_index in zero_local_coordinates: evidence = _coordinateMobilityEvidence( subgraph, loop_groups[group_index], coordinate_id, dof_index, tuple(sampled_states[group_index]), group_index in complete_group_ranges, ) if evidence_output is not None: evidence_output.append(evidence) if status_output is not None: coordinate_type = ( coordinate_labels[(coordinate_id, dof_index)].rsplit("/", 1)[1].split("[", 1)[0] ) coverage = ( "complete connected range" if evidence.complete_connected_range else "sampled range only" ) status_output.append( LoopMobilityStatus( evidence.coordinate, coordinate_type, evidence.dof_index, evidence.classification, None, f"{evidence.sampled_states} states; max participation " f"{evidence.max_participation:.3g}; max displacement " f"{evidence.max_displacement:.3g}; {coverage}", ) ) if report_progress: coverage = "complete" if evidence.complete_connected_range else "sampled" click.echo( f"Mobility evidence {evidence.coordinate}[{evidence.dof_index}]: " f"{evidence.classification}; states={evidence.sampled_states}; " f"max participation={evidence.max_participation:.3g}; " f"max displacement={evidence.max_displacement:.3g}; " f"coverage={coverage}" ) # A rank-deficient spherical participation block is a basis-invariant # hint to try an explicit fixed-axis gimbal; it is never deletion proof. for coordinate_id, (coordinate_name, group_index) in reduction_hint_coordinates.items(): participation_rank, coordinate_dofs, axes = _coordinateParticipationRank( subgraph, loop_groups[group_index], coordinate_id, ) if participation_rank < coordinate_dofs and ball_hint_output is not None: ball_hint_output.append( BallReductionHint( coordinate_name, participation_rank, coordinate_dofs, axes, ) ) if report_progress and participation_rank < coordinate_dofs: click.echo( f"BALL reduction candidate {coordinate_name}: local " f"participation rank={participation_rank}/{coordinate_dofs}; " "validate with an explicit-axis GIMBAL over the full range" ) if not apply_limits: return tuple(summaries) for column in range(graph_data.nU()): coordinate, local_offset = graph_data.coordAt(column) try: limits = range_results.get((coordinate.id(), local_offset)) if limits is not None: # The setter takes one lower/upper pair and its Q-row offset. coordinate.setRangeLimits(np.asarray(limits, dtype=float), local_offset) finally: del coordinate return tuple(summaries) finally: del graph_data
[docs] def updateLoopRangeLimits( subgraph: kd.SubGraph, selected_names: tuple[str, ...] = ("all",), ) -> None: """Find, apply, and report Q ranges for loop-active physical subhinges. Parameters ---------- subgraph: kd.SubGraph Graph whose physical range metadata is updated. selected_names: tuple[str, ...] Requested subhinge names, or ``("all",)``. Defaults to ``("all",)``. """ # The numerical utility restores the nominal graph state after every scan. coordinate_ranges = _setLoopRangeLimits( subgraph, report_progress=True, selected_names=selected_names, ) if not coordinate_ranges: click.echo("No loop-coordinate ranges were determined.") return
def _markdownCell(text: str) -> str: """Escape text for use in one Markdown table cell. Parameters ---------- text: str Unescaped cell content. Returns ------- str Cell-safe text with pipes and line breaks escaped or replaced. """ return text.replace("|", "\\|").replace("\n", " ") def _markdownTable( headers: tuple[str, ...], rows: list[tuple[str, ...]], right_aligned: frozenset[int] = frozenset(), ) -> list[str]: """Return a Markdown table whose source columns are padded and aligned. Parameters ---------- headers: tuple[str, ...] Column headings in display order. rows: list[tuple[str, ...]] Cell values for each body row. right_aligned: frozenset[int] Zero-based columns rendered with right alignment. Defaults to ``frozenset()``. Returns ------- list[str] Markdown source lines for the complete table. """ # Include header widths so an empty or very narrow result remains readable. widths = [len(header) for header in headers] for row in rows: if len(row) != len(headers): raise ValueError("Markdown table row has the wrong number of columns") widths = [max(width, len(cell)) for width, cell in zip(widths, row, strict=True)] def formatRow(row: tuple[str, ...]) -> str: """Pad cells while preserving requested numeric right alignment. Parameters ---------- row: tuple[str, ...] Cell values matching the table's column count. Returns ------- str One pipe-delimited Markdown table line. """ cells = [] for index, (cell, width) in enumerate(zip(row, widths, strict=True)): cells.append(cell.rjust(width) if index in right_aligned else cell.ljust(width)) return "| " + " | ".join(cells) + " |" separators = tuple( ("-" * max(3, width - 1) + ":") if index in right_aligned else (":" + "-" * max(3, width - 1)) for index, width in enumerate(widths) ) return [formatRow(headers), formatRow(separators), *(formatRow(row) for row in rows)] def _shortSubhingeType(coordinate_type: str, dof_index: int) -> str: """Convert a concrete pybind class name to a concise type/DOF label. Parameters ---------- coordinate_type: str Concrete Python subhinge class name. dof_index: int Local component offset represented by the row. Returns ------- str Concise uppercase type label, including an offset when useful. """ short_type = coordinate_type.removesuffix("Subhinge").upper() if dof_index or coordinate_type == "SphericalSubhinge": short_type += f"[{dof_index}]" return short_type def _normalizedAngularInterval(lower: float, upper: float) -> tuple[float, float]: """Return an ordered equivalent interval spanning no more than one turn. Parameters ---------- lower: float Unnormalized lower angular endpoint in radians. upper: float Unnormalized upper angular endpoint in radians. Returns ------- tuple[float, float] Ordered equivalent endpoints anchored in the principal chart. """ # A periodic coordinate cannot expose more than one revolution of distinct # values. Anchor the ordered interval at an equivalent lower endpoint in # the principal chart rather than wrapping its endpoints independently. span = min(max(upper - lower, 0.0), 2.0 * math.pi) if math.isclose(span, 2.0 * math.pi, abs_tol=1.0e-8): return -math.pi, math.pi normalized_lower = (lower + math.pi) % (2.0 * math.pi) - math.pi return normalized_lower, normalized_lower + span def _boundedAngularInterval( lower: float, upper: float, current: float, ) -> tuple[float, float]: """Bound a rotational interval to one revolution around its current value. Parameters ---------- lower: float Measured lower angular endpoint in radians. upper: float Measured upper angular endpoint in radians. current: float Nominal angular coordinate value in radians. Returns ------- tuple[float, float] Original endpoints when already bounded, otherwise one revolution centered on ``current``. """ span = max(upper - lower, 0.0) if span <= 2.0 * math.pi: # Preserve bounded intervals and their relationship to the current Q. return lower, upper # Coverage beyond one revolution contains no additional physical angles. return current - math.pi, current + math.pi def _formatRangeSpan(magnitude: float, is_angular: bool) -> str: """Format angular spans in degrees and linear spans in meters. Parameters ---------- magnitude: float Interval width in native radians or meters. is_angular: bool Whether to convert the value from radians to degrees. Returns ------- str Four-decimal span with a degree or meter suffix. """ # Explicit unit suffixes keep mixed rotational and translational report # rows directly interpretable without consulting the model conventions. value = np.rad2deg(magnitude) if is_angular else magnitude suffix = "°" if is_angular else "m" return f"{value:.4f}{suffix}" def _formatRangeEndpoint(value: float) -> str: """Format one displayed range endpoint with four decimal places. Parameters ---------- value: float Endpoint value in its native coordinate unit. Returns ------- str Fixed-precision endpoint text. """ return f"{value:.4f}" @dataclass(frozen=True) class _MobilityAnalysisResult: """Retain one non-mutating scan for the public mobility operations.""" ranges: tuple[FeasibleRangeLimit, ...] evidence: tuple[CoordinateMobilityEvidence, ...] ball_hints: tuple[BallReductionHint, ...] statuses: tuple[LoopMobilityStatus, ...] def _mobilityAnalysis( multibody: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, report_progress: bool = False, apply_limits: bool = False, ) -> _MobilityAnalysisResult: """Run the shared non-mutating scan for public mobility operations. Range continuation supplies the closed-state witnesses needed by inert-DOF and BALL-reduction analysis, so the three public operations share this one private result when called through :func:`mobilityCheck`. Parameters ---------- multibody: kd.SubGraph SubGraph-compatible multibody to analyze. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. report_progress: bool Whether to emit continuation progress. Defaults to ``False``. apply_limits: bool Whether to store measured ranges after preserving the pre-scan limits for comparison and reporting. Defaults to ``False``. Returns ------- _MobilityAnalysisResult Immutable range, inertness, spherical-hint, and status collections. """ evidence: list[CoordinateMobilityEvidence] = [] hints: list[BallReductionHint] = [] statuses: list[LoopMobilityStatus] = [] ranges = _setLoopRangeLimits( multibody, report_progress=report_progress, progress_with_offsets=report_progress, selected_dofs=selected_dofs, evidence_output=evidence, ball_hint_output=hints, status_output=statuses, apply_limits=apply_limits, ) # Stable coordinate/offset ordering keeps API consumers and reports # diff-friendly across equivalent runs. statuses.sort(key=lambda item: (item.coordinate, item.dof_index)) return _MobilityAnalysisResult( ranges, tuple(evidence), tuple(hints), tuple(statuses), )
[docs] def checkRangeLimits( multibody: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, report_progress: bool = False, _analysis: _MobilityAnalysisResult | None = None, ) -> tuple[tuple[FeasibleRangeLimit, ...], tuple[LoopMobilityStatus, ...]]: """Check feasible ranges without applying them to the multibody. ``multibody`` is first so this operation can move to :class:`SubGraph` without changing its user-facing arguments. The returned statuses include unresolved or immobile coordinates for which no interval was measured. Parameters ---------- multibody: kd.SubGraph SubGraph-compatible multibody to analyze. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. report_progress: bool Whether to emit continuation progress. Defaults to ``False``. _analysis: _MobilityAnalysisResult | None Optional shared internal result supplied by ``mobilityCheck``. Returns ------- tuple[tuple[FeasibleRangeLimit, ...], tuple[LoopMobilityStatus, ...]] The completed range measurements and report-ready coordinate statuses. """ analysis = _analysis or _mobilityAnalysis( multibody, selected_dofs, report_progress=report_progress, ) return analysis.ranges, analysis.statuses
[docs] def detectInertDofs( multibody: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, tolerance: float = 2.0e-7, update_ranges: bool = True, ) -> tuple[CoordinateMobilityEvidence, ...]: """Update ranges and return physical coordinates with finite zero spans. Only two finite endpoints can establish inertness. Open intervals and failed measurements therefore remain non-inert without auxiliary flags. Parameters ---------- multibody: kd.SubGraph SubGraph-compatible multibody whose ranges are inspected. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. tolerance: float Largest finite interval width classified as inert. Defaults to 2.0e-7. update_ranges: bool Whether to recompute stored ranges before classification. Defaults to ``True``. Returns ------- tuple[CoordinateMobilityEvidence, ...] Deterministically ordered evidence for coordinates classified inert. """ # A negative tolerance would turn the interval-width test into an # impossible condition and usually signals a caller error, so reject it # instead of quietly returning no inert coordinates. if tolerance < 0.0: raise ValueError("inert-DOF tolerance must be nonnegative") if update_ranges: # Refresh metadata first so classification reflects the mechanism's # current configuration and constraints rather than stale model input. updateRangeLimits(multibody, selected_dofs) # The optional selection is reapplied while reading because stored limits # also contain coordinates outside the user's requested mobility check. requested = set(selected_dofs) if selected_dofs is not None else None evidence: list[CoordinateMobilityEvidence] = [] graph_data = multibody.graphCoordData() visited: set[int] = set() try: # Traverse providers rather than packed columns. This keeps a spherical # subhinge's three Q rows together and prevents duplicate evidence. for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in visited or not isinstance(coordinate, kd.PhysicalSubhinge): continue visited.add(coordinate.id()) stored_limits = coordinate.getRangeLimits() limits = np.asarray( getattr(stored_limits, "magnitude", stored_limits), dtype=float, ) for offset, (lower, upper) in enumerate(limits): if requested is not None and (coordinate.name(), offset) not in requested: continue span = upper - lower # Finiteness is the evidence-quality gate: infinities mean # at least one side remained open, while NaN means the # calculation failed. Only two demonstrated boundaries may # support the small-interval inertness conclusion. if np.isfinite((lower, upper)).all() and 0.0 <= span <= tolerance: evidence.append( CoordinateMobilityEvidence( coordinate.name(), offset, "INERT", 0, 0.0, float(span), True, ) ) finally: del coordinate finally: del graph_data # Stable name/offset ordering makes direct API results and generated reports # deterministic even when native graph traversal order changes. evidence.sort(key=lambda item: (item.coordinate, item.dof_index)) return tuple(evidence)
[docs] def findReducibleBallJoints( multibody: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, report_progress: bool = False, _analysis: _MobilityAnalysisResult | None = None, ) -> tuple[BallReductionHint, ...]: """Return BALL joints whose local rotational subspace merits reduction. Results are candidates rather than topology edits; range-wide fixed-axis validation remains required before a BALL is replaced. This separation keeps a future ``SubGraph`` method non-mutating and independently usable. Parameters ---------- multibody: kd.SubGraph SubGraph-compatible multibody to analyze. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. report_progress: bool Whether to emit continuation progress. Defaults to ``False``. _analysis: _MobilityAnalysisResult | None Optional shared internal result supplied by ``mobilityCheck``. Returns ------- tuple[BallReductionHint, ...] Advisory spherical-joint reduction candidates. """ analysis = _analysis or _mobilityAnalysis( multibody, selected_dofs, report_progress=report_progress, ) return analysis.ball_hints
[docs] def mobilityCheck( multibody: kd.SubGraph, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, report_progress: bool = False, ) -> tuple[ tuple[CoordinateMobilityEvidence, ...], tuple[BallReductionHint, ...], tuple[LoopMobilityStatus, ...], ]: """Update ranges and combine inertness, BALL hints, and status records. Parameters ---------- multibody: kd.SubGraph SubGraph-compatible multibody to analyze. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. report_progress: bool Whether to emit continuation progress. Defaults to False. Returns ------- tuple[ tuple[CoordinateMobilityEvidence, ...], tuple[BallReductionHint, ...], tuple[LoopMobilityStatus, ...], ] Inert-coordinate evidence, spherical reduction hints, and report-ready coordinate statuses. """ # Analyze and persist ranges in one pass. The scanner captures each old # limit before writing its measured result, so an unset input remains # distinguishable from a range computed earlier in this command. analysis = _mobilityAnalysis( multibody, selected_dofs, report_progress=report_progress, apply_limits=True, ) _ranges, statuses = checkRangeLimits( multibody, selected_dofs, report_progress=report_progress, _analysis=analysis, ) hints = findReducibleBallJoints( multibody, selected_dofs, report_progress=report_progress, _analysis=analysis, ) # Keep the historical tuple shape consumed by the CLI report layer while # allowing each lower-level operation to remain independently callable. return analysis.evidence, hints, statuses
[docs] def loopMobilityInfo( subgraph: kd.SubGraph, source: Path, selected_dofs: tuple[tuple[str, int], ...] | None = None, *, report_progress: bool = False, ) -> str: """Update ranges and return a Markdown loop-coordinate mobility report. The report combines continuation status, feasible spans, and advisory BALL hints without editing topology. Optional selections restrict projection work while retaining the same report schema. Parameters ---------- subgraph: kd.SubGraph Graph to analyze and update. source: Path Source-model path displayed in the report. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. report_progress: bool Whether to emit continuation progress. Defaults to False. Returns ------- str Markdown mobility report text. """ # Keep Markdown policy outside the reusable numerical method so callers can # consume typed results without fabricating a source path or parsing text. _evidence, hints, statuses = mobilityCheck( subgraph, selected_dofs, report_progress=report_progress, ) counts: dict[str, int] = {} for status in statuses: counts[status.mobility] = counts.get(status.mobility, 0) + 1 summary = ( ", ".join(f"{count} {mobility}" for mobility, count in sorted(counts.items())) or "no loop-coordinate entries" ) # Build sections as lines so stdout and file destinations receive identical # Markdown, including final-newline behavior. lines = [ "# Loop-mobility information", "", f"- Source model: `{source}`" if not os.getenv("DTEST_RUNNING", False) else "", f"- Summary: {summary}", "- Analysis mode: measured physical-subhinge ranges were updated", "", "## Loop-coordinate status", "", "Angular coordinates are `LOW` when their nonzero measured span is less than 10 degrees.", "", ] status_rows: list[tuple[str, ...]] = [] for status in statuses: coordinate_type = _shortSubhingeType( status.coordinate_type, status.dof_index, ) feasible_range = "—" span = "—" details = status.details if status.feasible_range is not None: lower, upper = status.feasible_range magnitude = upper - lower is_angular = status.coordinate_type in ( "PinSubhinge", "SphericalSubhinge", ) if is_angular: lower, upper = _normalizedAngularInterval(lower, upper) magnitude = upper - lower # Four fixed decimals keep ranges compact and directly comparable. feasible_range = f"[{lower:.4f}, {upper:.4f}]" span = _formatRangeSpan(magnitude, is_angular) status_rows.append( ( _markdownCell(status.coordinate), coordinate_type, status.mobility, feasible_range, span, _markdownCell(details), ) ) lines.extend( _markdownTable( ( "Loop subhinge", "Type/DOF", "Mobility", "Feasible range", "Full span", "Details", ), status_rows, frozenset((3, 4)), ) ) if hints: # Local BALL rank is advisory; actual reduction requires the separate # range-wide fixed-axis validator. lines.extend( [ "", "## BALL reduction candidates", "", "A local rank deficiency is a hint to run `loops --reduce-ball`; it is not proof that a fixed axis is globally inert.", "", ] ) hint_rows = [] for hint in sorted(hints, key=lambda item: item.coordinate): hint_rows.append( ( _markdownCell(hint.coordinate), f"{hint.participation_rank}/{hint.coordinate_dofs}", "Validate with `loops --reduce-ball`", ) ) lines.extend( _markdownTable( ( "BALL spherical subhinge", "Local participation rank", "Suggested next step", ), hint_rows, frozenset((1,)), ) ) lines.extend( [ "", "## Suggested next steps", "", "- Review `LOW` and `CONFIGURATION-DEPENDENT` entries before changing mobility.", "- Remove only range-proven scalar `INERT` entries with `loops --remove-inert`.", "- Test BALL candidates with `loops --reduce-ball`; incomplete proof leaves the BALL unchanged.", "- Investigate `UNRESOLVED` entries manually before modifying their hinges.", "", ] ) return "\n".join(lines)
[docs] def writeLoopMobilityInfo( subgraph: kd.SubGraph, source: Path, destination: str, selected_dofs: tuple[tuple[str, int], ...] | None = None, ) -> None: """Write a loop-mobility Markdown report to a file or standard output. Parameters ---------- subgraph: kd.SubGraph Graph to analyze and update. source: Path Source-model path displayed in the report. destination: str Output filename, or ``-`` for standard output. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and local-offset selections. """ # Range checks can take long enough that users need coordinate-level # feedback before the complete Markdown report becomes available. report = loopMobilityInfo( subgraph, source, selected_dofs, report_progress=True, ) if destination == "-": click.echo(report, nl=False) return # Path.write_text deliberately replaces the explicitly selected report file. Path(destination).write_text(report, encoding="utf-8")
def _storedRangeSpan(lower: float, upper: float, subhinge_type: kd.SubhingeType) -> str: """Format a stored Q span in its physical units without changing its limits. Parameters ---------- lower: float Stored lower endpoint. upper: float Stored upper endpoint. subhinge_type: kd.SubhingeType Coordinate type determining the displayed unit. Returns ------- str Formatted span, or an em dash when either endpoint is unavailable. """ if np.isnan([lower, upper]).any(): return "—" # Report the stored width exactly so legacy invalid intervals remain visible. span = upper - lower if subhinge_type in (kd.SubhingeType.PIN, kd.SubhingeType.SPHERICAL, kd.SubhingeType.SCREW): return _formatRangeSpan(span, True) if subhinge_type in (kd.SubhingeType.LINEAR, kd.SubhingeType.LINEAR3): return _formatRangeSpan(span, False) # Quaternion Q components are dimensionless, not angles or translations. return f"{span:.4f}" def _subhingeTypeName(subhinge_type: kd.SubhingeType) -> str: """Return the kdFlex subhinge enum label used in report type columns. Parameters ---------- subhinge_type: kd.SubhingeType Native subhinge enumeration value. Returns ------- str Enumeration member name used in reports. """ return subhinge_type.name def _getVec(dark: QuantityOrValue[NDArray[np.float64]]) -> NDArray[np.float64]: # strip out units if needed if isinstance(dark, np.ndarray): return dark else: return dark.m
[docs] def loopStoredRangesInfo(subgraph: kd.SubGraph, source: Path) -> str: """Return stored range limits for every physical subhinge Q coordinate. This inventory performs no closure solve and does not infer feasibility. It reports exactly what the currently loaded model stores, making it suitable for quickly checking the result of ``loops --ranges-update``. Parameters ---------- subgraph: kd.SubGraph Graph whose stored physical-subhinge limits are inventoried. source: Path Source-model path displayed in the report. Returns ------- str Markdown inventory of stored range-limit rows. """ rows: list[tuple[str, ...]] = [] graph_data = subgraph.graphCoordData() visited: set[int] = set() try: for column in range(graph_data.nU()): coordinate, _ = graph_data.coordAt(column) try: if coordinate.id() in visited or not isinstance(coordinate, kd.PhysicalSubhinge): continue visited.add(coordinate.id()) # Keep stored endpoints unchanged; format the span in physical units. # limits = np.asarray(coordinate.getRangeLimits(), dtype=float) limits = _getVec(coordinate.getRangeLimits()) for offset, (lower, upper) in enumerate(limits): rows.append( ( _markdownCell(f"{coordinate.name()}/{offset}"), _subhingeTypeName(coordinate.subhingeType()), "—" if np.isnan(lower) else _formatRangeEndpoint(lower), "—" if np.isnan(upper) else _formatRangeEndpoint(upper), _storedRangeSpan(lower, upper, coordinate.subhingeType()), ) ) finally: del coordinate finally: del graph_data # rows.sort(key=lambda row: row[0]) lines = [ "# Stored physical-subhinge Q ranges", "", f"- Source model: `{source}`" if not os.getenv("DTEST_RUNNING", False) else "", "- Analysis mode: stored values only; no feasibility solve was run", "- Bounds: radians for angles, meters for translations; quaternion components are dimensionless", "- Spans: degrees for angles, meters for translations", f"- Q-coordinate rows: {len(rows)}", "", ] lines.extend( _markdownTable( ("Physical subhinge/Q", "Type", "Lower limit", "Upper limit", "Span"), rows, frozenset((2, 3, 4)), ) ) lines.append("") return "\n".join(lines)
[docs] def writeLoopStoredRangesInfo( subgraph: kd.SubGraph, source: Path, destination: str, ) -> None: """Write the stored Q-coordinate range inventory to a destination. Parameters ---------- subgraph: kd.SubGraph Graph whose stored ranges are inventoried. source: Path Source-model path displayed in the report. destination: str Output filename, or ``-`` for standard output. """ report = loopStoredRangesInfo(subgraph, source) if destination == "-": click.echo(report, nl=False) return Path(destination).write_text(report, encoding="utf-8")
[docs] class LoopAnalyzer: """Provide reusable analysis services for one live closed-loop graph. The class owns no native object and caches no configuration. Each operation delegates to the functional analysis pipeline, which restores the caller-visible graph state after its temporary continuation solves. """ def __init__(self, subgraph: kd.SubGraph) -> None: """Bind analysis operations to a graph without taking ownership. Parameters ---------- subgraph: kd.SubGraph Live graph retained by reference for later operations. """ # The caller retains the graph and controls its native-object lifetime. self.subgraph = subgraph
[docs] def scan( self, selected_names: tuple[str, ...] = ("all",), ) -> tuple[ tuple[FeasibleRangeLimit, ...], tuple[CoordinateMobilityEvidence, ...], tuple[BallReductionHint, ...], tuple[LoopMobilityStatus, ...], ]: """Return ranges, inertness evidence, BALL hints, and status records. ``scan`` selects the non-mutating pipeline used by advisory tooling. The four result collections are kept separate so callers can choose their own presentation without parsing Markdown output. Parameters ---------- selected_names: tuple[str, ...] Requested subhinge names, or ``("all",)``. Defaults to ``("all",)``. Returns ------- tuple[ tuple[FeasibleRangeLimit, ...], tuple[CoordinateMobilityEvidence, ...], tuple[BallReductionHint, ...], tuple[LoopMobilityStatus, ...], ] Range measurements, inertness evidence, spherical reduction hints, and report-ready statuses. """ evidence: list[CoordinateMobilityEvidence] = [] hints: list[BallReductionHint] = [] statuses: list[LoopMobilityStatus] = [] ranges = _setLoopRangeLimits( self.subgraph, selected_names=selected_names, evidence_output=evidence, ball_hint_output=hints, status_output=statuses, apply_limits=False, ) return ranges, tuple(evidence), tuple(hints), tuple(statuses)
[docs] def updateRanges(self, selected_names: tuple[str, ...] = ("all",)) -> None: """Measure, report, and store Q range limits on the bound graph. Parameters ---------- selected_names: tuple[str, ...] Requested subhinge names, or ``("all",)``. Defaults to ``("all",)``. """ # Retain the established live-progress output for long continuation runs. updateLoopRangeLimits(self.subgraph, selected_names)
[docs] def writeMobilityCheck( self, source: Path, destination: str, selected_dofs: tuple[tuple[str, int], ...] | None, ) -> None: """Update ranges and write the resulting mobility-analysis report. Parameters ---------- source: Path Source-model path displayed in the report. destination: str Output filename, or ``-`` for standard output. selected_dofs: tuple[tuple[str, int], ...] | None Optional coordinate-name and offset selections. """ writeLoopMobilityInfo(self.subgraph, source, destination, selected_dofs)
[docs] def writeRangesInfo(self, source: Path, destination: str) -> None: """Write stored Q-coordinate ranges without running analysis. Parameters ---------- source: Path Source-model path displayed in the report. destination: str Output filename, or ``-`` for standard output. """ writeLoopStoredRangesInfo(self.subgraph, source, destination)
[docs] class LoopModelEditor: """Apply validated topology and geometry changes to one serialized model. Keeping mutation behind this class makes launcher control flow explicit and prevents report-only operations from accidentally selecting an editing path. Calls reload ``model_file`` and return a live model plus an audit summary of accepted changes. """ def __init__(self, model_file: Path) -> None: """Retain the source path without loading or modifying the model. Parameters ---------- model_file: Path Serialized model used by later editing operations. """ self.model_file = model_file
[docs] def simplify( self, remove_inert: tuple[str, ...], reduce_ball: tuple[str, ...], reduce_spherical: tuple[str, ...], ) -> tuple[LoadedModel, tuple[str, ...], tuple[str, ...]]: """Remove proven inert coordinates and reduce validated spherical hinges. Parameters ---------- remove_inert: tuple[str, ...] Requested scalar subhinge names to remove. reduce_ball: tuple[str, ...] Requested spherical subhinge names to reduce. reduce_spherical: tuple[str, ...] Requested spherical subhinge names to reduce in any parent hinge. Returns ------- tuple[LoadedModel, tuple[str, ...], tuple[str, ...]] Reloaded model, applied geometry changes, and mobility changes. """ return loadSimplifiedModel(self.model_file, remove_inert, reduce_ball, reduce_spherical)
[docs] def transform( self, optimize_mobility: bool, requests: tuple[MobilityRequest, ...], ) -> tuple[ LoadedModel, tuple[GeometryChange, ...], tuple[MobilityChange, ...], ]: """Optimize loop geometry and apply requested mobility additions. Parameters ---------- optimize_mobility: bool Whether to optimize loop-frame geometry first. requests: tuple[MobilityRequest, ...] Explicit mobility additions to apply. Returns ------- tuple[LoadedModel, tuple[GeometryChange, ...], tuple[MobilityChange, ...]] Reloaded model, accepted geometry changes, and accepted mobility changes. """ return loadTransformedModel( self.model_file, optimize_mobility, requests, )