From ffb6f14697cc8ab8f445e2d3bf52181455b2bec8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:10:23 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[performance=20improvem?= =?UTF-8?q?ent]=20Replace=20Array.from=20with=20standard=20for=20loops=20i?= =?UTF-8?q?n=20CSV=20parsing=20hot=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dieterolson <198168927+dieterolson@users.noreply.github.com> --- .jules/bolt.md | 3 +++ .../src/components/data_explorer/DataExplorer.tsx | 9 ++++++++- src/p1am_control_system/frontend/src/lib/explorer/csv.ts | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.jules/bolt.md b/.jules/bolt.md index 6858e01b49..a02124c42a 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -89,3 +89,6 @@ ## 2025-05-18 - Avoid array methods for small static arrays in frequently called initializers **Learning:** Using `.reduce()` or `.map()` on static arrays like tabs definitions inside frequently called functions (e.g. state initializers or local storage hydration) incurs unnecessary closure and function call overhead. **Action:** Replace `.reduce()` and `.map()` with single-pass `for` loops in simple data transformation functions (like `defaultTabVisibility`) to eliminate closure allocations. +## 2026-08-09 - Pre-allocate Arrays instead of Array.from in hot paths +**Learning:** In JavaScript/TypeScript data-intensive hot paths (e.g., CSV parsing), using `Array.from({ length: X }, ...)` for initialization incurs significant overhead from iterability checks, iterator creation, and closure execution per element. +**Action:** Always replace `Array.from({ length: X }, ...)` with pre-allocated arrays using `new Array(X)` and populate them with standard `for` loops in performance-critical code to eliminate GC pressure. diff --git a/src/p1am_control_system/frontend/src/components/data_explorer/DataExplorer.tsx b/src/p1am_control_system/frontend/src/components/data_explorer/DataExplorer.tsx index 2c81130f01..daee5c9414 100644 --- a/src/p1am_control_system/frontend/src/components/data_explorer/DataExplorer.tsx +++ b/src/p1am_control_system/frontend/src/components/data_explorer/DataExplorer.tsx @@ -123,7 +123,14 @@ export const DataExplorer: React.FC = ({ triggerNotification } else { if (!csv) throw new Error("No CSV loaded"); const n = csv.columns[0]?.values.length ?? 0; - const index = csv.index ?? Array.from({ length: n }, (_, i) => i); + let index = csv.index; + if (!index) { + // ⚡ Bolt Optimization: Pre-allocate array and populate with a standard for loop to avoid Array.from overhead + index = new Array(n); + for (let i = 0; i < n; i++) { + index[i] = i; + } + } req = { inline: { index, columns: csv.columns }, resample: pipeline.resample ?? undefined, diff --git a/src/p1am_control_system/frontend/src/lib/explorer/csv.ts b/src/p1am_control_system/frontend/src/lib/explorer/csv.ts index 8c8562ac8b..37f487137f 100644 --- a/src/p1am_control_system/frontend/src/lib/explorer/csv.ts +++ b/src/p1am_control_system/frontend/src/lib/explorer/csv.ts @@ -178,7 +178,11 @@ export function parseCsv(text: string): CsvTable { // Materialize each source column's raw cells (padding short rows). // ⚡ Bolt Optimization: Replace dataRows.map() per column with a single-pass loop - const rawColumns: string[][] = Array.from({ length: colCount }, () => new Array(dataRows.length)); + // ⚡ Bolt Optimization: Pre-allocate arrays using new Array(N) and standard for loops to avoid Array.from iterability/closure overhead + const rawColumns: string[][] = new Array(colCount); + for (let c = 0; c < colCount; c += 1) { + rawColumns[c] = new Array(dataRows.length); + } for (let r = 0; r < dataRows.length; r += 1) { const row = dataRows[r]; for (let c = 0; c < colCount; c += 1) { From 571f1b016f50d827d8a8e1a158a6eb34beb361c6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:50:33 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9A=A1=20Bolt:=20[performance=20improvem?= =?UTF-8?q?ent]=20Replace=20Array.from=20with=20standard=20for=20loops=20i?= =?UTF-8?q?n=20CSV=20parsing=20hot=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dieterolson <198168927+dieterolson@users.noreply.github.com> --- SPEC.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/SPEC.md b/SPEC.md index eb9356b211..4d2e6e8ae7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2702,3 +2702,9 @@ The command injection check logic in `cli_tools.py` has been fortified. The inpu - Removed chained array maps and reduces in the parseVariableAssignments function within `src/web_applications/calculator/static/app.js`. - Improved execution speed by using standard single pass for loop and string `indexOf` / `substring` techniques. + +## Changelog +- **2026-08-09**: Optimized CSV parsing in `p1am_control_system/frontend` and `data_explorer` by replacing `Array.from()` with standard `for` loops and pre-allocated arrays to eliminate iterability overhead in hot paths. +## Changelog +- **2026-08-09**: Optimized CSV parsing in `p1am_control_system/frontend` and `data_explorer` by replacing `Array.from()` with standard `for` loops and pre-allocated arrays to eliminate iterability overhead in hot paths. +- **2026-08-09**: Optimized CSV parsing in `p1am_control_system/frontend` and `data_explorer` by replacing `Array.from()` with standard `for` loops and pre-allocated arrays to eliminate iterability overhead in hot paths. From 4a4e0117af901db9d7aae3138d0ae24165dd0be3 Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Tue, 11 Aug 2026 02:11:37 -0700 Subject: [PATCH 3/3] style/fix: pre-commit automated fixes --- .codex-worktrees/friction-factors-3659 | 1 + .codex-worktrees/pr-3602-fix | 1 + .codex-worktrees/pr-3752-movement | 1 + .codex-worktrees/pr-3766-modern-robotics-dbc | 1 + .codex-worktrees/pr-3780-pressure-flow | 1 + .codex-worktrees/pr-3784-deterministic-te | 1 + launch.py | 4 +- scripts/bump_vendor_pin.py | 6 +- scripts/runner_capacity_check.py | 42 ++-- .../benchmarks/performance_benchmark.py | 30 +-- .../python/data_processor/core/data_loader.py | 4 +- .../python/tests/test_nn_training_worker.py | 6 +- ...test_vectorized_filter_engine_contracts.py | 18 +- .../tests/test_flow_rate_converter_gui.py | 12 +- .../tests/test_humanoid_builder_gui.py | 6 +- src/lower_body_model/launch_pyqt6.py | 4 +- .../python/video_processor_src/constants.py | 4 +- src/movement_optimizer/cli.py | 28 ++- src/movement_optimizer/constants.py | 4 +- src/movement_optimizer/exercises/_common.py | 4 +- src/movement_optimizer/exercises/clean.py | 4 +- src/movement_optimizer/exercises/gait.py | 4 +- .../exercises/sit_to_stand.py | 4 +- src/movement_optimizer/exercises/snatch.py | 4 +- src/movement_optimizer/export.py | 4 +- src/movement_optimizer/export_excel.py | 9 +- .../gui/_sidebar_builders.py | 56 ++++-- src/movement_optimizer/gui/_sidebar_state.py | 18 +- .../gui/bilateral_3d_renderer.py | 4 +- src/movement_optimizer/gui/commands.py | 16 +- .../gui/comparison_dialog.py | 4 +- src/movement_optimizer/gui/exercise_tab.py | 6 +- src/movement_optimizer/gui/file_operations.py | 14 +- src/movement_optimizer/gui/help_dialog.py | 20 +- src/movement_optimizer/gui/labelled_slider.py | 10 +- src/movement_optimizer/gui/main_window.py | 20 +- .../gui/motion_analysis_panel.py | 4 +- src/movement_optimizer/gui/motion_controls.py | 10 +- src/movement_optimizer/gui/motion_tabs.py | 113 ++++++++--- .../gui/motion_tabs_chain.py | 67 ++++-- .../gui/optimization_mixin.py | 27 ++- .../gui/parameter_sidebar.py | 28 ++- .../gui/playback_controls.py | 29 ++- src/movement_optimizer/gui/plot_renderer.py | 88 ++++++-- .../gui/policy_trace_canvas.py | 25 ++- src/movement_optimizer/gui/session_state.py | 4 +- src/movement_optimizer/gui/vector_overlay.py | 8 +- src/movement_optimizer/import_results.py | 8 +- src/movement_optimizer/models/__init__.py | 4 +- src/movement_optimizer/models/bilateral_3d.py | 5 +- .../models/chain_dynamics.py | 18 +- src/movement_optimizer/models/chain_forces.py | 8 +- .../models/lagrangian_balance.py | 4 +- .../models/lagrangian_dynamics.py | 8 +- .../models/lagrangian_kinematics.py | 25 ++- src/movement_optimizer/models/swingset.py | 67 ++++-- .../models/swingset_forces.py | 4 +- src/movement_optimizer/persistence.py | 24 ++- src/movement_optimizer/rendering.py | 4 +- src/movement_optimizer/result_analysis.py | 12 +- src/movement_optimizer/strength.py | 16 +- .../tests/test_anim_renderer.py | 4 +- .../tests/test_bench_press.py | 12 +- .../tests/test_benchmarks.py | 52 +++-- .../tests/test_bilateral_3d.py | 20 +- .../tests/test_chain_forces.py | 7 +- src/movement_optimizer/tests/test_cli.py | 20 +- .../tests/test_edge_cases.py | 33 ++- .../tests/test_exercise_tab.py | 4 +- .../tests/test_exercises.py | 32 ++- src/movement_optimizer/tests/test_export.py | 8 +- .../tests/test_export_excel.py | 14 +- src/movement_optimizer/tests/test_gait_sts.py | 4 +- .../tests/test_help_dialog.py | 19 +- .../tests/test_hypothesis.py | 56 ++++-- src/movement_optimizer/tests/test_import.py | 4 +- .../tests/test_install_nightly_system_deps.py | 4 +- .../tests/test_issue_217_decompose.py | 4 +- .../tests/test_issue_222_decompose.py | 8 +- .../tests/test_issue_247_split_optimizer.py | 23 ++- .../tests/test_joint_limits.py | 16 +- .../tests/test_main_window.py | 50 ++++- src/movement_optimizer/tests/test_models.py | 64 +++--- .../tests/test_motion_analysis_panel.py | 8 +- .../test_motion_analysis_panel_legends.py | 27 ++- .../tests/test_motion_tabs.py | 65 ++++-- .../tests/test_optimization_mixin.py | 8 +- .../tests/test_parameter_sidebar.py | 4 +- .../tests/test_plot_renderer.py | 12 +- .../tests/test_rust_parity_com_x.py | 4 +- .../tests/test_scipy_dependency_contract.py | 8 +- .../tests/test_shared_theme_dependency.py | 10 +- .../tests/test_spine_loads.py | 45 ++++- .../tests/test_subprocess_usage.py | 18 +- .../tests/test_swingset_chain_models.py | 66 ++++-- .../tests/test_swingset_forces.py | 4 +- .../tests/test_thread_safety.py | 6 +- .../tests/test_trajectory_generation.py | 12 +- .../tests/test_trajectory_optimization.py | 22 +- .../tests/test_vector_overlay.py | 38 +++- src/movement_optimizer/theme_bridge.py | 8 +- src/movement_optimizer/tool_pack.py | 4 +- .../trajectory/optimizer.py | 31 ++- .../trajectory/optimizer_cost.py | 4 +- .../trajectory/optimizer_parallel.py | 4 +- .../backend/modbus_client.py | 32 ++- .../desktop/plot_compat.py | 4 +- src/p1am_control_system/desktop/sidebar.py | 12 +- .../pendulum-core/python/physics_native.py | 16 +- .../src/double_pendulum_golf/__main__.py | 4 +- .../double_pendulum_golf/constraint_solver.py | 16 +- .../double_pendulum_golf/counterfactual.py | 4 +- .../double_pendulum_golf/data_extractor.py | 8 +- .../dynamics_quantities.py | 4 +- .../double_pendulum_golf/golfer_dynamics.py | 16 +- .../double_pendulum_golf/golfer_kinematics.py | 4 +- .../double_pendulum_golf/gui/analysis_tab.py | 12 +- .../gui/base_pendulum_widget.py | 18 +- .../gui/clipboard_utils.py | 4 +- .../gui/controls_utils.py | 7 +- .../gui/controls_widget.py | 20 +- .../gui/controls_widget_base.py | 19 +- .../gui/controls_widget_golfer.py | 16 +- .../gui/controls_widget_triple.py | 40 +++- .../double_pendulum_golf/gui/diagnostics.py | 8 +- .../gui/golfer_pendulum_widget.py | 18 +- .../double_pendulum_golf/gui/main_window.py | 22 +- .../gui/matrix_widget_base.py | 4 +- .../gui/optimization_widget.py | 50 +++-- .../double_pendulum_golf/gui/overlay_state.py | 4 +- .../gui/panel_builders.py | 36 +++- .../gui/pendulum_widget.py | 19 +- .../gui/side_panel_tabs.py | 8 +- .../gui/simulation_panel.py | 16 +- .../gui/simulation_panel/_lifecycle_mixin.py | 12 +- .../gui/simulation_panel/_simulation_panel.py | 4 +- .../gui/theme_defaults.py | 4 +- .../gui/toolstrip_widget.py | 22 +- .../gui/torque_history_widget.py | 4 +- .../gui/torque_preview_widget.py | 27 ++- .../double_pendulum_golf/jacobians_golfer.py | 4 +- .../src/double_pendulum_golf/joint_moments.py | 8 +- .../double_pendulum_golf/model_registry.py | 4 +- .../double_pendulum_golf/native_backend.py | 24 ++- .../src/double_pendulum_golf/optimizer_gpu.py | 14 +- .../perturbation_analysis.py | 14 +- .../src/double_pendulum_golf/physics.py | 35 +++- .../physics_golfer_jax.py | 90 +++++++-- .../double_pendulum_golf/physics_triple.py | 16 +- .../src/double_pendulum_golf/simulation.py | 4 +- .../double_pendulum_golf/simulation_golfer.py | 8 +- .../simulation_result_base.py | 8 +- .../src/double_pendulum_golf/torque_utils.py | 4 +- .../tests/test_analysis_tab.py | 12 +- .../tests/test_analytical_jacobians.py | 92 +++++---- .../tests/test_club_forces.py | 16 +- .../tests/test_club_forces_extended.py | 52 +++-- .../tests/test_constraint_solver.py | 50 +++-- .../tests/test_counterfactual.py | 40 ++-- ...est_default_dark_theme_and_button_width.py | 18 +- .../tests/test_diagnostics.py | 12 +- .../tests/test_dynamics_quantities.py | 18 +- .../tests/test_ellipsoid_scale_and_emoji.py | 12 +- src/pendulum_simulator/tests/test_friction.py | 48 +++-- .../tests/test_friction_triple.py | 44 ++-- .../tests/test_golfer_dynamics_extended.py | 28 ++- .../tests/test_golfer_ellipsoids.py | 6 +- .../tests/test_golfer_kinematics.py | 18 +- .../tests/test_golfer_model.py | 12 +- .../tests/test_golfer_moments.py | 6 +- .../tests/test_golfer_topology.py | 38 ++-- .../tests/test_gui_utilities.py | 4 +- .../tests/test_hub_and_geometry.py | 8 +- .../tests/test_hypothesis_physics.py | 24 ++- .../tests/test_issue_fixes.py | 18 +- .../tests/test_jacobians.py | 30 +-- .../tests/test_jacobians_extended.py | 16 +- .../tests/test_jacobians_golfer.py | 28 +-- .../tests/test_joint_moments.py | 12 +- .../tests/test_main_window.py | 8 +- .../tests/test_model_registry_gaps.py | 12 +- .../tests/test_native_backend.py | 16 +- .../tests/test_native_backend_gaps.py | 4 +- .../tests/test_optimizer_advanced.py | 8 +- .../tests/test_optimizer_gpu.py | 16 +- .../tests/test_overlay_state_sync.py | 4 +- .../tests/test_panel_builders.py | 4 +- .../tests/test_perturbation_analysis.py | 16 +- src/pendulum_simulator/tests/test_physics.py | 64 ++++-- .../tests/test_physics_extended.py | 28 ++- .../tests/test_physics_golfer.py | 6 +- .../tests/test_physics_golfer_jax.py | 4 +- .../tests/test_physics_native_dbc.py | 8 +- .../tests/test_physics_triple.py | 32 ++- .../tests/test_physics_triple_extended.py | 20 +- .../tests/test_physics_triple_gaps.py | 16 +- .../tests/test_side_panel_tabs.py | 10 +- .../tests/test_simulation.py | 15 +- .../tests/test_simulation_gaps.py | 8 +- .../tests/test_simulation_golfer.py | 26 ++- .../tests/test_simulation_golfer_drift.py | 12 +- .../tests/test_simulation_golfer_extended.py | 8 +- .../tests/test_simulation_panel.py | 16 +- .../tests/test_simulation_triple.py | 4 +- .../tests/test_simulation_triple_extended.py | 4 +- .../tests/test_swing_comparison_dialog.py | 8 +- .../tests/test_toolstrip_elements.py | 34 ++-- .../tests/test_torque_utils.py | 4 +- .../tests/test_ui_enhancements.py | 22 +- .../tests/test_ui_polish_fixes.py | 6 +- .../tests/test_unit_converter.py | 4 +- .../tests/test_v2_comprehensive.py | 10 +- src/python/src/utils/error_handling.py | 4 +- src/python/tests/test_python_dbc_lod.py | 4 +- .../ui/pyqt6/main_window.py | 24 ++- .../ui/pyqt6/reference_frame_tab.py | 4 +- .../python/src/star_wars_rrt.py | 4 +- .../python/chat/_chat_dock_widget_qt.py | 12 +- src/shared/python/chat/_qt/ai_dropdowns.py | 12 +- src/shared/python/chat/_qt/styling.py | 4 +- .../python/chat/condensation/condenser.py | 6 +- .../humanoid_character_builder/core/model.py | 4 +- .../model_generation/library/model_library.py | 4 +- .../tests/test_unified_loader.py | 6 +- .../plot_theme/tests/test_plot_theme.py | 6 +- src/shared/python/scripting/scripting_env.py | 5 +- .../calculators/mechanical/trc_geometry.py | 12 +- .../psa_package/psa_gui.py | 35 +++- .../python/sidekick/standalone/preferences.py | 18 +- .../python/sidekick/standalone/runner.py | 6 +- .../process_calculators/test_psa_model.py | 12 +- .../test_syngas_compression_dedup.py | 6 +- .../tests/test_json_io_boundary_3333.py | 6 +- .../sidekick/ui/tools_sidebar/registry.py | 4 +- .../sidekick/ui/tools_sidebar/sidebar.py | 4 +- .../python/tests/test_god_class_guard.py | 19 +- src/shared/python/theme/zoom.py | 4 +- .../urdf_viewer/tests/test_urdf_viewer.py | 4 +- tests/architecture/test_gh1696_god_modules.py | 30 +-- .../test_sidekick_external_imports_3316.py | 6 +- .../test_wgs_reactor_headless_import_3317.py | 6 +- tests/conftest.py | 1 + .../test_script_generator_hardening.py | 6 +- .../heavy_integration/test_tools_contracts.py | 18 +- .../integration/test_cross_repo_contracts.py | 48 ++--- tests/ode_solver/test_ode_solver_timeout.py | 24 +-- tests/ops/test_detect_secrets_baseline.py | 12 +- .../test_backend_security.py | 6 +- .../test_backend_security_import_guard.py | 6 +- .../test_event_logger_filter_error_logging.py | 6 +- tests/programmatic_pid/test_equipment.py | 6 +- tests/programmatic_pid/test_profiles_extra.py | 6 +- .../test_build_exe_lod.py | 12 +- tests/project_packer_fixes/test_build_lod.py | 30 +-- .../test_folder_packer_gui_lod.py | 18 +- .../test_math_primitives_bindings.py | 12 +- tests/scripts/test_generate_tools_json.py | 12 +- .../ai/integrations/test_linear_client.py | 8 +- .../shared/python/ai/test_adapter_contract.py | 12 +- .../shared/python/ai/test_adapter_factory.py | 30 +-- .../python/ai/test_cli_provider_setup.py | 18 +- tests/shared/python/ai/test_onnx_preflight.py | 6 +- .../ai/test_provider_config_registry.py | 6 +- .../python/ai/test_rust_adapter_fallback.py | 6 +- .../calculators/conversion/test_service.py | 6 +- .../python/chat/test_chat_agent_label.py | 4 +- .../python/chat/test_chat_session_helpers.py | 6 +- tests/shared/python/chat/test_quick_bar.py | 14 +- .../python/chat/test_router_error_logging.py | 4 +- .../python/chat/test_terminal_runtime.py | 6 +- .../test_gh1694_xml_security.py | 6 +- .../python/theme/test_fallback_drift.py | 12 +- .../shared/python/ui/test_headless_import.py | 12 +- tests/test_gh1655_print_to_logging.py | 12 +- tests/test_gh1732_logging_consistency.py | 24 +-- tests/test_no_urdf_builder_root_duplicates.py | 6 +- tests/test_review_fixes_2026_03_09.py | 6 +- tests/test_sidekick_public_api_stability.py | 12 +- tests/test_src_package_import_contract.py | 6 +- tests/tools/test_logger_shim.py | 6 +- tests/unit/ai/gui/test_chat_export.py | 6 +- .../github_mcp/test_tool_descriptors.py | 12 +- .../ai/mcp/test_notebooklm_server_phase2.py | 6 +- tests/unit/ai/test_peer_review.py | 12 +- tests/unit/chat/test_adapter_capabilities.py | 18 +- tests/unit/codemap/test_codemap_db.py | 6 +- tests/unit/lower_body_model/test_builder.py | 12 +- .../test_hip_rotation_target.py | 6 +- tests/unit/lower_body_model/test_simulator.py | 6 +- tests/unit/rust/test_ai_backend_workspace.py | 36 ++-- .../unit/sidekick/agent/test_action_audit.py | 4 +- .../sidekick/agent/test_feature_catalog.py | 8 +- tests/unit/sidekick/test_chat_redock.py | 6 +- .../test_sidekick_f4_collaborators.py | 36 ++-- .../sidekick/test_sidekick_ux_hardening.py | 190 ++++++++++-------- tests/unit/sidekick/test_tab_context_menu.py | 6 +- tests/unit/test_check_coverage_policy.py | 4 +- tests/unit/test_check_sidekick_coverage.py | 7 +- .../test_epic_2661_children_verification.py | 54 ++--- .../unit/test_sidekick_import_deprecation.py | 16 +- tests/unit/test_sidekick_package_rename.py | 18 +- 301 files changed, 3283 insertions(+), 1625 deletions(-) create mode 160000 .codex-worktrees/friction-factors-3659 create mode 160000 .codex-worktrees/pr-3602-fix create mode 160000 .codex-worktrees/pr-3752-movement create mode 160000 .codex-worktrees/pr-3766-modern-robotics-dbc create mode 160000 .codex-worktrees/pr-3780-pressure-flow create mode 160000 .codex-worktrees/pr-3784-deterministic-te diff --git a/.codex-worktrees/friction-factors-3659 b/.codex-worktrees/friction-factors-3659 new file mode 160000 index 0000000000..9c673194ef --- /dev/null +++ b/.codex-worktrees/friction-factors-3659 @@ -0,0 +1 @@ +Subproject commit 9c673194ef4c9a55595c3799d4fddd0d7e28c561 diff --git a/.codex-worktrees/pr-3602-fix b/.codex-worktrees/pr-3602-fix new file mode 160000 index 0000000000..e37b3241d3 --- /dev/null +++ b/.codex-worktrees/pr-3602-fix @@ -0,0 +1 @@ +Subproject commit e37b3241d36d8841b6aa4c7688788fc5841aca48 diff --git a/.codex-worktrees/pr-3752-movement b/.codex-worktrees/pr-3752-movement new file mode 160000 index 0000000000..e5e013c029 --- /dev/null +++ b/.codex-worktrees/pr-3752-movement @@ -0,0 +1 @@ +Subproject commit e5e013c02975432b4d15b16b9ce1f2b4938d5096 diff --git a/.codex-worktrees/pr-3766-modern-robotics-dbc b/.codex-worktrees/pr-3766-modern-robotics-dbc new file mode 160000 index 0000000000..34ee67dce3 --- /dev/null +++ b/.codex-worktrees/pr-3766-modern-robotics-dbc @@ -0,0 +1 @@ +Subproject commit 34ee67dce3267f4ecae6eecb28e0288df80203bf diff --git a/.codex-worktrees/pr-3780-pressure-flow b/.codex-worktrees/pr-3780-pressure-flow new file mode 160000 index 0000000000..b286577f46 --- /dev/null +++ b/.codex-worktrees/pr-3780-pressure-flow @@ -0,0 +1 @@ +Subproject commit b286577f46dc8960f19b102f17aff100afc6977d diff --git a/.codex-worktrees/pr-3784-deterministic-te b/.codex-worktrees/pr-3784-deterministic-te new file mode 160000 index 0000000000..1e87cc7d5f --- /dev/null +++ b/.codex-worktrees/pr-3784-deterministic-te @@ -0,0 +1 @@ +Subproject commit 1e87cc7d5fc99f3dde8893503f4e55d7f1df76b5 diff --git a/launch.py b/launch.py index ba04bb644b..4c4357053f 100644 --- a/launch.py +++ b/launch.py @@ -146,7 +146,9 @@ def launch_tool(tool_identifier: str) -> int: gui_configs = registration.gui_configs config = gui_configs.get(GUIType.PYQT6) if config is None: - print(f"Tool '{registration.display_name}' has no PyQt6 configuration.") # noqa: T201 + print( + f"Tool '{registration.display_name}' has no PyQt6 configuration." + ) # noqa: T201 return 1 display_name = registration.display_name diff --git a/scripts/bump_vendor_pin.py b/scripts/bump_vendor_pin.py index 46471e6838..4363f2c4d7 100644 --- a/scripts/bump_vendor_pin.py +++ b/scripts/bump_vendor_pin.py @@ -95,9 +95,9 @@ def validate_consumer(consumer_repo: str) -> None: Precondition: consumer_repo is a non-empty string. Postcondition: no exception means the repo is safe to target. """ - assert isinstance(consumer_repo, str) and consumer_repo, ( - "consumer_repo must be a non-empty string" - ) + assert ( + isinstance(consumer_repo, str) and consumer_repo + ), "consumer_repo must be a non-empty string" if consumer_repo not in CONSUMER_REPOS: raise ValueError( f"Unknown consumer repo {consumer_repo!r}. Allowed: {CONSUMER_REPOS}" diff --git a/scripts/runner_capacity_check.py b/scripts/runner_capacity_check.py index 182cd9fffa..5bf6f6ac5b 100644 --- a/scripts/runner_capacity_check.py +++ b/scripts/runner_capacity_check.py @@ -248,18 +248,18 @@ def calculate_needed_runners( Returns: :class:`CapacityRecommendation` with suggested runner count. """ - assert isinstance(queue_depth, int) and queue_depth >= 0, ( - f"queue_depth must be a non-negative int, got {queue_depth!r}" - ) - assert isinstance(current_runners, int) and current_runners > 0, ( - f"current_runners must be a positive int, got {current_runners!r}" - ) - assert isinstance(target_wait_sec, int) and target_wait_sec > 0, ( - f"target_wait_sec must be a positive int, got {target_wait_sec!r}" - ) - assert isinstance(avg_job_sec, int) and avg_job_sec > 0, ( - f"avg_job_sec must be a positive int, got {avg_job_sec!r}" - ) + assert ( + isinstance(queue_depth, int) and queue_depth >= 0 + ), f"queue_depth must be a non-negative int, got {queue_depth!r}" + assert ( + isinstance(current_runners, int) and current_runners > 0 + ), f"current_runners must be a positive int, got {current_runners!r}" + assert ( + isinstance(target_wait_sec, int) and target_wait_sec > 0 + ), f"target_wait_sec must be a positive int, got {target_wait_sec!r}" + assert ( + isinstance(avg_job_sec, int) and avg_job_sec > 0 + ), f"avg_job_sec must be a positive int, got {avg_job_sec!r}" if queue_depth == 0: return CapacityRecommendation( @@ -341,16 +341,16 @@ def check_and_alert( Advisory string: one of ``"OK"``, ``"WARN: ..."``, or ``"ALERT: ..."``. """ assert isinstance(token, str) and token, "token must be a non-empty string" - assert isinstance(current_runners, int) and current_runners > 0, ( - f"current_runners must be a positive int, got {current_runners!r}" - ) + assert ( + isinstance(current_runners, int) and current_runners > 0 + ), f"current_runners must be a positive int, got {current_runners!r}" assert isinstance(org, str) and org, "org must be a non-empty string" - assert isinstance(alert_threshold, int) and alert_threshold > 0, ( - f"alert_threshold must be a positive int, got {alert_threshold!r}" - ) - assert isinstance(target_wait_sec, int) and target_wait_sec > 0, ( - f"target_wait_sec must be a positive int, got {target_wait_sec!r}" - ) + assert ( + isinstance(alert_threshold, int) and alert_threshold > 0 + ), f"alert_threshold must be a positive int, got {alert_threshold!r}" + assert ( + isinstance(target_wait_sec, int) and target_wait_sec > 0 + ), f"target_wait_sec must be a positive int, got {target_wait_sec!r}" queue_depth = get_queue_depth(token=token, org=org) rec = calculate_needed_runners( diff --git a/src/data_processing/data_processor/python/benchmarks/performance_benchmark.py b/src/data_processing/data_processor/python/benchmarks/performance_benchmark.py index 9bf1145422..12d4fb4b30 100644 --- a/src/data_processing/data_processor/python/benchmarks/performance_benchmark.py +++ b/src/data_processing/data_processor/python/benchmarks/performance_benchmark.py @@ -150,9 +150,9 @@ def benchmark_file_loading(self) -> dict[str, dict[str, float | int]]: elapsed = time.perf_counter() - start # Validate all files loaded successfully - assert len(dataframes) == len(files), ( - f"Expected {len(files)} dataframes, got {len(dataframes)}" - ) + assert len(dataframes) == len( + files + ), f"Expected {len(files)} dataframes, got {len(dataframes)}" results["load_multiple_5_files"] = { "time": elapsed, @@ -225,9 +225,9 @@ def benchmark_filtering(self) -> dict[str, dict[str, float]]: elapsed = time.perf_counter() - start # Validate filter output - assert filtered_df is not None and len(filtered_df) == n_rows, ( - f"Filter {filter_name} failed" - ) + assert ( + filtered_df is not None and len(filtered_df) == n_rows + ), f"Filter {filter_name} failed" throughput = n_rows / elapsed results[f"filter_{filter_name}"] = { @@ -384,9 +384,9 @@ def benchmark_end_to_end_workflow(self) -> dict[str, dict[str, float]]: stats_time = time.perf_counter() - start # Validate statistics output - assert stats is not None and "mean" in stats, ( - "Statistics calculation failed" - ) + assert ( + stats is not None and "mean" in stats + ), "Statistics calculation failed" # Step 6: Save start = time.perf_counter() @@ -437,9 +437,9 @@ def benchmark_scalability(self) -> dict[str, dict[str, float]]: elapsed = time.perf_counter() - start # Validate filter output - assert filtered is not None and len(filtered) == n_rows, ( - f"Scalability test failed for {n_rows} rows" - ) + assert ( + filtered is not None and len(filtered) == n_rows + ), f"Scalability test failed for {n_rows} rows" throughput = n_rows / elapsed @@ -474,9 +474,9 @@ def benchmark_memory_usage(self) -> dict[str, dict[str, float]]: filtered = self.processor.apply_filter(df, config) # Validate filter was applied - assert filtered is not None and len(filtered) == n_rows, ( - "Memory benchmark filter failed" - ) + assert ( + filtered is not None and len(filtered) == n_rows + ), "Memory benchmark filter failed" memory_after = self.get_memory_usage_mb() diff --git a/src/data_processing/data_processor/python/data_processor/core/data_loader.py b/src/data_processing/data_processor/python/data_processor/core/data_loader.py index 8cf2d2984e..3488e1c478 100644 --- a/src/data_processing/data_processor/python/data_processor/core/data_loader.py +++ b/src/data_processing/data_processor/python/data_processor/core/data_loader.py @@ -124,7 +124,9 @@ def _create_high_performance_loader(self) -> HighPerformanceDataLoader | None: try: loader_class = self._import_high_performance_loader() return loader_class() - except Exception as exc: # noqa: BLE001 - optional accelerator, any failure degrades + except ( + Exception + ) as exc: # noqa: BLE001 - optional accelerator, any failure degrades logger.warning( "High-performance loader unavailable; using standard loader: %s", exc, diff --git a/src/data_processing/data_processor/python/tests/test_nn_training_worker.py b/src/data_processing/data_processor/python/tests/test_nn_training_worker.py index 863457b2d0..38e6634c14 100644 --- a/src/data_processing/data_processor/python/tests/test_nn_training_worker.py +++ b/src/data_processing/data_processor/python/tests/test_nn_training_worker.py @@ -65,9 +65,9 @@ def test_worker_runs_off_main_thread(qtbot: Any, sample_df: pd.DataFrame) -> Non assert results == [{"ok": True, "rows": 100}] assert trainer.train_thread is not None - assert trainer.train_thread != main_thread_id, ( - "train() ran on the Qt main thread — UI would freeze" - ) + assert ( + trainer.train_thread != main_thread_id + ), "train() ran on the Qt main thread — UI would freeze" def test_worker_ui_stays_responsive(qtbot: Any, sample_df: pd.DataFrame) -> None: diff --git a/src/data_processing/data_processor/python/tests/test_vectorized_filter_engine_contracts.py b/src/data_processing/data_processor/python/tests/test_vectorized_filter_engine_contracts.py index 5ed61af3b6..1693775f77 100644 --- a/src/data_processing/data_processor/python/tests/test_vectorized_filter_engine_contracts.py +++ b/src/data_processing/data_processor/python/tests/test_vectorized_filter_engine_contracts.py @@ -142,9 +142,9 @@ def test_output_columns_preserved( ) -> None: df = _make_df(n=300) result = engine.apply_filter_batch(df, filter_type, params) - assert list(result.columns) == list(df.columns), ( - f"{filter_type}: columns changed" - ) + assert list(result.columns) == list( + df.columns + ), f"{filter_type}: columns changed" @pytest.mark.parametrize("filter_type,params", FILTER_TYPES) def test_output_row_count_preserved( @@ -152,9 +152,9 @@ def test_output_row_count_preserved( ) -> None: df = _make_df(n=300) result = engine.apply_filter_batch(df, filter_type, params) - assert len(result) == len(df), ( - f"{filter_type}: row count changed {len(result)} != {len(df)}" - ) + assert len(result) == len( + df + ), f"{filter_type}: row count changed {len(result)} != {len(df)}" class TestMovingAverageCorrectness: @@ -210,9 +210,9 @@ def test_nan_rows_remain_nan(self, engine, filter_type: str, params: dict) -> No nan_after = result["x"].index[result["x"].isna()] # All original NaN positions should still be NaN for idx in nan_idx: - assert idx in nan_after, ( - f"{filter_type}: NaN at index {idx} was filled unexpectedly" - ) + assert ( + idx in nan_after + ), f"{filter_type}: NaN at index {idx} was filled unexpectedly" class TestParallelVsSequentialConsistency: diff --git a/src/flow_rate_converter/tests/test_flow_rate_converter_gui.py b/src/flow_rate_converter/tests/test_flow_rate_converter_gui.py index 1febc03a6a..28435a282b 100644 --- a/src/flow_rate_converter/tests/test_flow_rate_converter_gui.py +++ b/src/flow_rate_converter/tests/test_flow_rate_converter_gui.py @@ -51,12 +51,12 @@ def test_lod_constants_present_in_source(self): if isinstance(target, ast.Name): top_level_names.add(target.id) - assert "_ALIGN_CENTER" in top_level_names, ( - "Missing _ALIGN_CENTER constant in main_window" - ) - assert "_EXPANDING" in top_level_names, ( - "Missing _EXPANDING constant in main_window" - ) + assert ( + "_ALIGN_CENTER" in top_level_names + ), "Missing _ALIGN_CENTER constant in main_window" + assert ( + "_EXPANDING" in top_level_names + ), "Missing _EXPANDING constant in main_window" assert "_FIXED" in top_level_names, "Missing _FIXED constant in main_window" def test_no_bare_qt_alignment_flag_chain_in_source(self): diff --git a/src/humanoid_builder_gui/tests/test_humanoid_builder_gui.py b/src/humanoid_builder_gui/tests/test_humanoid_builder_gui.py index 805673832a..d17216cbb6 100644 --- a/src/humanoid_builder_gui/tests/test_humanoid_builder_gui.py +++ b/src/humanoid_builder_gui/tests/test_humanoid_builder_gui.py @@ -326,8 +326,8 @@ def test_no_deep_attribute_chains_in_method_body( ) matches = pattern.findall(source) # Only the alias definitions should match (7 lines) - assert len(matches) <= 7, ( - f"Unexpected deep attribute chains found: {matches}" - ) + assert ( + len(matches) <= 7 + ), f"Unexpected deep attribute chains found: {matches}" except ImportError: pytest.skip("PyQt6 not available in this environment") diff --git a/src/lower_body_model/launch_pyqt6.py b/src/lower_body_model/launch_pyqt6.py index e38b99ee2a..630990a0bb 100644 --- a/src/lower_body_model/launch_pyqt6.py +++ b/src/lower_body_model/launch_pyqt6.py @@ -351,7 +351,9 @@ def on_torque_imported(self, joint_name: str, coeffs: object) -> None: c = [float(x) for x in coeffs] self.sim.set_joint_polynomial(joint_name, c) logging.info(f"Imported torque polynomial for {joint_name}: {c}") - except Exception as e: # noqa: BLE001 — caller-supplied data may be any type + except ( + Exception + ) as e: # noqa: BLE001 — caller-supplied data may be any type logging.error(f"Failed to set polynomial: {e}") def physics_loop(self) -> None: diff --git a/src/media_processing/video_processor/python/video_processor_src/constants.py b/src/media_processing/video_processor/python/video_processor_src/constants.py index 667e17c350..7f1f5b2a99 100644 --- a/src/media_processing/video_processor/python/video_processor_src/constants.py +++ b/src/media_processing/video_processor/python/video_processor_src/constants.py @@ -14,7 +14,9 @@ # Mathematical constants PI: float = math.pi # [dimensionless] Ratio of circumference to diameter -E: float = 2.718281828459045 # [dimensionless] Euler's number, base of natural logarithm # noqa: E501 +E: float = ( + 2.718281828459045 # [dimensionless] Euler's number, base of natural logarithm # noqa: E501 +) # Physical constants - SI units GRAVITY_M_S2: float = 9.80665 # [m/s²] Standard gravity, ISO 80000-3:2006 diff --git a/src/movement_optimizer/cli.py b/src/movement_optimizer/cli.py index 221326cd38..61b3e2ced4 100644 --- a/src/movement_optimizer/cli.py +++ b/src/movement_optimizer/cli.py @@ -56,13 +56,17 @@ def _add_body_args(parser: argparse.ArgumentParser) -> None: "--body-mass", type=float, default=75.0, - help=(f"Body mass in kg (range {BODY_MASS_RANGE[0]}-{BODY_MASS_RANGE[1]}, default: 75.0)."), + help=( + f"Body mass in kg (range {BODY_MASS_RANGE[0]}-{BODY_MASS_RANGE[1]}, default: 75.0)." + ), ) parser.add_argument( "--height", type=float, default=1.75, - help=(f"Height in metres (range {HEIGHT_RANGE[0]}-{HEIGHT_RANGE[1]}, default: 1.75)."), + help=( + f"Height in metres (range {HEIGHT_RANGE[0]}-{HEIGHT_RANGE[1]}, default: 1.75)." + ), ) parser.add_argument( "--bar-mass", @@ -99,7 +103,9 @@ def _add_run_args(parser: argparse.ArgumentParser) -> None: default=None, help="Path to save results as JSON. If omitted, prints summary to stdout.", ) - parser.add_argument("--verbose", action="store_true", help="Enable verbose logging.") + parser.add_argument( + "--verbose", action="store_true", help="Enable verbose logging." + ) def _build_parser() -> argparse.ArgumentParser: @@ -263,7 +269,9 @@ def _build_optimizer( return opt, dyn -def _save_or_emit(result: OptimizationResult, exercise: str, output: str | None) -> None: +def _save_or_emit( + result: OptimizationResult, exercise: str, output: str | None +) -> None: """Write result to file or emit summary to stdout. Args: @@ -279,7 +287,9 @@ def _save_or_emit(result: OptimizationResult, exercise: str, output: str | None) _emit_cli_summary(_result_to_summary(result, exercise)) -def _validate_cli_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: +def _validate_cli_args( + parser: argparse.ArgumentParser, args: argparse.Namespace +) -> None: """Reject invalid numeric CLI arguments via parser.error. Delegates to :func:`movement_optimizer.validation.validate_all` so the @@ -338,9 +348,13 @@ def main(argv: list[str] | None = None) -> int: _configure_logging(args.verbose) body = BodyModel(body_mass=args.body_mass, height=args.height) duration = _resolve_duration(args.exercise, args.duration) - _log_optimization_start(args.exercise, args.body_mass, args.height, args.bar_mass, duration) + _log_optimization_start( + args.exercise, args.body_mass, args.height, args.bar_mass, duration + ) t_start = time.perf_counter() - opt, _dyn = _build_optimizer(body, args.exercise, args.bar_mass, duration, args.smoothness) + opt, _dyn = _build_optimizer( + body, args.exercise, args.bar_mass, duration, args.smoothness + ) result = opt.optimize() _log_optimization_done(time.perf_counter() - t_start, result.cost, result.success) _save_or_emit(result, args.exercise, args.output) diff --git a/src/movement_optimizer/constants.py b/src/movement_optimizer/constants.py index 197cab7f59..5d07b48a85 100644 --- a/src/movement_optimizer/constants.py +++ b/src/movement_optimizer/constants.py @@ -187,7 +187,9 @@ # ~7 mm for a 1.75 m person — effectively a grip-only link. WRIST_SEGMENT_FRAC: float = 0.01 -BENCH_UPPER_ARM_FRAC: float = 0.56 # shoulder to elbow (anatomical ~48% + shoulder width) +BENCH_UPPER_ARM_FRAC: float = ( + 0.56 # shoulder to elbow (anatomical ~48% + shoulder width) +) BENCH_FOREARM_FRAC: float = 0.44 # elbow to wrist (Winter 2009: ~44% of arm length) BENCH_PRESS_JOINT_LIMITS: dict[str, tuple[float, float]] = { diff --git a/src/movement_optimizer/exercises/_common.py b/src/movement_optimizer/exercises/_common.py index b31a03de79..d3be2357fc 100644 --- a/src/movement_optimizer/exercises/_common.py +++ b/src/movement_optimizer/exercises/_common.py @@ -24,7 +24,9 @@ def balance_config_pose( adjust_joint: int, ) -> NDArray: """Balance a raw pose using the shared planar balance helper.""" - return balance_pose(dynamics, raw_pose, exercise_type, bar_mass, adjust_joint=adjust_joint) + return balance_pose( + dynamics, raw_pose, exercise_type, bar_mass, adjust_joint=adjust_joint + ) def default_bounds_deg( diff --git a/src/movement_optimizer/exercises/clean.py b/src/movement_optimizer/exercises/clean.py index b13caa62b0..9e5d8978df 100644 --- a/src/movement_optimizer/exercises/clean.py +++ b/src/movement_optimizer/exercises/clean.py @@ -58,7 +58,9 @@ def make_clean_config( dyn = LagrangianDynamics(body, body.m_deadlift.copy(), body.I_deadlift.copy(), load) q_start_raw = pull_start_angles(body, q2_deg=52) - q_start = balance_config_pose(dyn, q_start_raw, "deadlift", bar_mass, adjust_joint=0) + q_start = balance_config_pose( + dyn, q_start_raw, "deadlift", bar_mass, adjust_joint=0 + ) q_end_raw = _clean_end_angles(body) q_end = balance_config_pose(dyn, q_end_raw, "deadlift", bar_mass, adjust_joint=2) diff --git a/src/movement_optimizer/exercises/gait.py b/src/movement_optimizer/exercises/gait.py index df7948fa04..8ac66561a7 100644 --- a/src/movement_optimizer/exercises/gait.py +++ b/src/movement_optimizer/exercises/gait.py @@ -147,7 +147,9 @@ def compute_spatiotemporal( "cycle_duration_s": duration, } - def compute_symmetry_index(self, left_angles: NDArray, right_angles: NDArray) -> float: + def compute_symmetry_index( + self, left_angles: NDArray, right_angles: NDArray + ) -> float: """Robinson symmetry index: SI = |L-R| / max(L,R) * 100. Preconditions: diff --git a/src/movement_optimizer/exercises/sit_to_stand.py b/src/movement_optimizer/exercises/sit_to_stand.py index 15ef0c440d..b5fc0f9cbe 100644 --- a/src/movement_optimizer/exercises/sit_to_stand.py +++ b/src/movement_optimizer/exercises/sit_to_stand.py @@ -23,7 +23,9 @@ logger = logging.getLogger(__name__) -def _sts_via_points(q_start: NDArray, q_end: NDArray) -> list[tuple[float, float, float, float]]: +def _sts_via_points( + q_start: NDArray, q_end: NDArray +) -> list[tuple[float, float, float, float]]: """Via-points for sit-to-stand motion.""" return [ (0.00, float(q_start[0]), float(q_start[1]), float(q_start[2])), # seated diff --git a/src/movement_optimizer/exercises/snatch.py b/src/movement_optimizer/exercises/snatch.py index 0b1d533b85..4b6cf448fa 100644 --- a/src/movement_optimizer/exercises/snatch.py +++ b/src/movement_optimizer/exercises/snatch.py @@ -64,7 +64,9 @@ def make_snatch_config( dyn = LagrangianDynamics(body, body.m_deadlift.copy(), body.I_deadlift.copy(), load) q_start_raw = pull_start_angles(body, q2_deg=48) - q_start = balance_config_pose(dyn, q_start_raw, "deadlift", bar_mass, adjust_joint=0) + q_start = balance_config_pose( + dyn, q_start_raw, "deadlift", bar_mass, adjust_joint=0 + ) # End: standing with bar overhead -- use squat-style COM for balance check # but keep the same dynamics object diff --git a/src/movement_optimizer/export.py b/src/movement_optimizer/export.py index 5708b383e9..861b295636 100644 --- a/src/movement_optimizer/export.py +++ b/src/movement_optimizer/export.py @@ -114,7 +114,9 @@ def export_animation_gif( # matplotlib stubs type AbstractMovieWriter narrowly; PillowWriter is # compatible at runtime. anim.save(str(safe_path), writer=cast(Any, writer)) - logger.info("Exported GIF animation to %s (%d frames, %d fps)", safe_path, n_frames, fps) + logger.info( + "Exported GIF animation to %s (%d frames, %d fps)", safe_path, n_frames, fps + ) def export_plots_png( diff --git a/src/movement_optimizer/export_excel.py b/src/movement_optimizer/export_excel.py index 555269e5be..da24c2b8dd 100644 --- a/src/movement_optimizer/export_excel.py +++ b/src/movement_optimizer/export_excel.py @@ -67,7 +67,14 @@ def _write_summary_sheet( ws.append([]) # blank separator joint_labels = ["Ankle (joint 1)", "Knee (joint 2)", "Hip (joint 3)"] - ws.append(["Joint torque statistics", "Peak |tau| (N*m)", "Mean |tau| (N*m)", "RMS tau (N*m)"]) + ws.append( + [ + "Joint torque statistics", + "Peak |tau| (N*m)", + "Mean |tau| (N*m)", + "RMS tau (N*m)", + ] + ) n_dof = result.torques.shape[1] for j in range(n_dof): col = result.torques[:, j] diff --git a/src/movement_optimizer/gui/_sidebar_builders.py b/src/movement_optimizer/gui/_sidebar_builders.py index 8a9ca251b1..fa11c5d916 100644 --- a/src/movement_optimizer/gui/_sidebar_builders.py +++ b/src/movement_optimizer/gui/_sidebar_builders.py @@ -220,7 +220,9 @@ def build_buttons(sidebar: ParameterSidebar) -> None: sidebar.cancel_btn.setProperty("class", "cancel") sidebar.cancel_btn.setToolTip("Cancel the currently running optimization (Esc)") sidebar.cancel_btn.setAccessibleName("Cancel") - sidebar.cancel_btn.setAccessibleDescription("Cancel the currently running optimization.") + sidebar.cancel_btn.setAccessibleDescription( + "Cancel the currently running optimization." + ) sidebar.cancel_btn.setShortcut("Esc") sidebar.cancel_btn.clicked.connect(sidebar.cancel_requested.emit) sidebar.cancel_btn.setVisible(False) @@ -296,16 +298,22 @@ def build_results(sidebar: ParameterSidebar) -> None: sidebar.export_btn = QPushButton(tr("Export") + " CSV") sidebar.export_btn.setEnabled(False) - sidebar.export_btn.setToolTip("Run optimization first to enable exporting kinematics to CSV") + sidebar.export_btn.setToolTip( + "Run optimization first to enable exporting kinematics to CSV" + ) sidebar.export_btn.setAccessibleName("Export CSV") - sidebar.export_btn.setAccessibleDescription("Export optimized kinematics to a CSV file.") + sidebar.export_btn.setAccessibleDescription( + "Export optimized kinematics to a CSV file." + ) sidebar.export_btn.clicked.connect(sidebar.export_requested.emit) sidebar.main_layout.addWidget(sidebar.export_btn) sidebar.reset_btn = QPushButton("Reset Defaults") sidebar.reset_btn.setToolTip("Reset all parameters to default values") sidebar.reset_btn.setAccessibleName("Reset Defaults") - sidebar.reset_btn.setAccessibleDescription("Reset all parameters to their default values.") + sidebar.reset_btn.setAccessibleDescription( + "Reset all parameters to their default values." + ) sidebar.reset_btn.clicked.connect(sidebar.reset_requested.emit) sidebar.main_layout.addWidget(sidebar.reset_btn) @@ -319,15 +327,21 @@ def build_persistence_buttons(sidebar: ParameterSidebar) -> None: lay = QVBoxLayout(grp) sidebar.save_btn = QPushButton("Save Solution") sidebar.save_btn.setEnabled(False) - sidebar.save_btn.setToolTip("Run optimization first to enable saving the trajectory solution") + sidebar.save_btn.setToolTip( + "Run optimization first to enable saving the trajectory solution" + ) sidebar.save_btn.setAccessibleName("Save Solution") - sidebar.save_btn.setAccessibleDescription("Save the current trajectory solution to a file.") + sidebar.save_btn.setAccessibleDescription( + "Save the current trajectory solution to a file." + ) sidebar.save_btn.clicked.connect(sidebar.save_solution_requested.emit) lay.addWidget(sidebar.save_btn) sidebar.load_btn = QPushButton("Load Solution") sidebar.load_btn.setToolTip("Load a previously saved trajectory solution file") sidebar.load_btn.setAccessibleName("Load Solution") - sidebar.load_btn.setAccessibleDescription("Load a previously saved trajectory solution file.") + sidebar.load_btn.setAccessibleDescription( + "Load a previously saved trajectory solution file." + ) sidebar.load_btn.clicked.connect(sidebar.load_solution_requested.emit) lay.addWidget(sidebar.load_btn) sidebar.main_layout.addWidget(grp) @@ -338,16 +352,24 @@ def build_export_buttons(sidebar: ParameterSidebar) -> None: lay = QVBoxLayout(grp) sidebar.export_video_btn = QPushButton("Export Animation GIF") sidebar.export_video_btn.setEnabled(False) - sidebar.export_video_btn.setToolTip("Run optimization first to enable exporting animation GIF") + sidebar.export_video_btn.setToolTip( + "Run optimization first to enable exporting animation GIF" + ) sidebar.export_video_btn.setAccessibleName("Export Animation GIF") - sidebar.export_video_btn.setAccessibleDescription("Export the optimized animation as a GIF.") + sidebar.export_video_btn.setAccessibleDescription( + "Export the optimized animation as a GIF." + ) sidebar.export_video_btn.clicked.connect(sidebar.export_video_requested.emit) lay.addWidget(sidebar.export_video_btn) sidebar.export_plots_btn = QPushButton("Export Plots (PNG/PDF)") sidebar.export_plots_btn.setEnabled(False) - sidebar.export_plots_btn.setToolTip("Run optimization first to enable exporting plots") + sidebar.export_plots_btn.setToolTip( + "Run optimization first to enable exporting plots" + ) sidebar.export_plots_btn.setAccessibleName("Export Plots") - sidebar.export_plots_btn.setAccessibleDescription("Export analysis plots as PNG or PDF files.") + sidebar.export_plots_btn.setAccessibleDescription( + "Export analysis plots as PNG or PDF files." + ) sidebar.export_plots_btn.clicked.connect(sidebar.export_plots_requested.emit) lay.addWidget(sidebar.export_plots_btn) sidebar.export_excel_btn = QPushButton("Save as Excel (.xlsx)") @@ -367,7 +389,9 @@ def build_comparison_buttons(sidebar: ParameterSidebar) -> None: lay = QVBoxLayout(grp) sidebar.add_compare_btn = QPushButton("Add to Comparison") sidebar.add_compare_btn.setEnabled(False) - sidebar.add_compare_btn.setToolTip("Run optimization first to add current trial to comparison") + sidebar.add_compare_btn.setToolTip( + "Run optimization first to add current trial to comparison" + ) sidebar.add_compare_btn.setAccessibleName("Add to Comparison") sidebar.add_compare_btn.setAccessibleDescription( "Add the current optimized trial to the comparison set." @@ -382,9 +406,13 @@ def build_comparison_buttons(sidebar: ParameterSidebar) -> None: sidebar.compare_btn.clicked.connect(sidebar.compare_trials_requested.emit) lay.addWidget(sidebar.compare_btn) sidebar.clear_compare_btn = QPushButton("Clear Comparison") - sidebar.clear_compare_btn.setToolTip("Clear all trials currently saved for comparison") + sidebar.clear_compare_btn.setToolTip( + "Clear all trials currently saved for comparison" + ) sidebar.clear_compare_btn.setAccessibleName("Clear Comparison") - sidebar.clear_compare_btn.setAccessibleDescription("Clear all trials from the comparison set.") + sidebar.clear_compare_btn.setAccessibleDescription( + "Clear all trials from the comparison set." + ) sidebar.clear_compare_btn.clicked.connect(sidebar.clear_comparison_requested.emit) lay.addWidget(sidebar.clear_compare_btn) sidebar.main_layout.addWidget(grp) diff --git a/src/movement_optimizer/gui/_sidebar_state.py b/src/movement_optimizer/gui/_sidebar_state.py index 5138ef0748..e2c2cee5f7 100644 --- a/src/movement_optimizer/gui/_sidebar_state.py +++ b/src/movement_optimizer/gui/_sidebar_state.py @@ -61,9 +61,13 @@ class SidebarStateContract(Protocol): def show_optimizing(sidebar: SidebarStateContract) -> None: sidebar.opt_btn.setEnabled(False) - sidebar.opt_btn.setToolTip("Optimization currently in progress. Please wait or cancel.") + sidebar.opt_btn.setToolTip( + "Optimization currently in progress. Please wait or cancel." + ) sidebar.both_btn.setEnabled(False) - sidebar.both_btn.setToolTip("Optimization currently in progress. Please wait or cancel.") + sidebar.both_btn.setToolTip( + "Optimization currently in progress. Please wait or cancel." + ) sidebar.cancel_btn.setVisible(True) sidebar.cancel_btn.setToolTip("Cancel the currently running optimization (Esc)") sidebar.stall_label.setVisible(False) @@ -98,10 +102,16 @@ def update_progress(sidebar: SidebarStateContract, report: ProgressReport) -> No phase = "Converging" if n_evals > PROGRESS_PHASE_BOUNDARY_EVALS else "Exploring" sidebar.prog_label.setText(f"{phase}...") sidebar.iter_label.setText(f"Evaluations: {report.iteration}") - sidebar.cost_label.setText(f"Cost: {report.cost:.1f} (best: {report.best_cost:.1f})") + sidebar.cost_label.setText( + f"Cost: {report.cost:.1f} (best: {report.best_cost:.1f})" + ) sidebar.improve_label.setText(f"Improvement: {report.improvement_pct:+.3f}%") elapsed = report.elapsed_s - time_str = f"{elapsed:.1f}s" if elapsed < 60 else f"{int(elapsed // 60)}m {elapsed % 60:.0f}s" + time_str = ( + f"{elapsed:.1f}s" + if elapsed < 60 + else f"{int(elapsed // 60)}m {elapsed % 60:.0f}s" + ) sidebar.elapsed_label.setText(f"Elapsed: {time_str}") if report.is_stalled: diff --git a/src/movement_optimizer/gui/bilateral_3d_renderer.py b/src/movement_optimizer/gui/bilateral_3d_renderer.py index 6a70a78a86..2811c1e741 100644 --- a/src/movement_optimizer/gui/bilateral_3d_renderer.py +++ b/src/movement_optimizer/gui/bilateral_3d_renderer.py @@ -65,7 +65,9 @@ def draw_bilateral_3d_pose( # Ground plane hint: a thin disc at z=0. theta = np.linspace(0.0, 2.0 * np.pi, 40) r = max(0.6, 0.75 * (model.stance_width_m + 0.5)) - ax.plot(r * np.cos(theta), r * np.sin(theta), 0.0, color=Palette.FG_DIM, lw=1, alpha=0.3) + ax.plot( + r * np.cos(theta), r * np.sin(theta), 0.0, color=Palette.FG_DIM, lw=1, alpha=0.3 + ) # Reasonable default view. total_h = model.L_shin + model.L_thigh + model.L_torso diff --git a/src/movement_optimizer/gui/commands.py b/src/movement_optimizer/gui/commands.py index 9a194063be..c4229db080 100644 --- a/src/movement_optimizer/gui/commands.py +++ b/src/movement_optimizer/gui/commands.py @@ -63,7 +63,9 @@ def push(self, cmd: Command) -> None: cmd.execute() self._undo.append(cmd) self._redo.clear() - logger.debug("UndoStack: pushed %s (depth=%d)", type(cmd).__name__, len(self._undo)) + logger.debug( + "UndoStack: pushed %s (depth=%d)", type(cmd).__name__, len(self._undo) + ) def record_executed(self, cmd: Command) -> None: """Record an already-applied command without calling ``execute``. @@ -73,7 +75,9 @@ def record_executed(self, cmd: Command) -> None: """ self._undo.append(cmd) self._redo.clear() - logger.debug("UndoStack: recorded %s (depth=%d)", type(cmd).__name__, len(self._undo)) + logger.debug( + "UndoStack: recorded %s (depth=%d)", type(cmd).__name__, len(self._undo) + ) def undo(self) -> bool: """Undo the most recently executed command. @@ -87,7 +91,9 @@ def undo(self) -> bool: cmd = self._undo.pop() cmd.undo() self._redo.append(cmd) - logger.debug("UndoStack: undid %s (remaining=%d)", type(cmd).__name__, len(self._undo)) + logger.debug( + "UndoStack: undid %s (remaining=%d)", type(cmd).__name__, len(self._undo) + ) return True def redo(self) -> bool: @@ -102,7 +108,9 @@ def redo(self) -> bool: cmd = self._redo.pop() cmd.execute() self._undo.append(cmd) - logger.debug("UndoStack: redid %s (depth=%d)", type(cmd).__name__, len(self._undo)) + logger.debug( + "UndoStack: redid %s (depth=%d)", type(cmd).__name__, len(self._undo) + ) return True def clear(self) -> None: diff --git a/src/movement_optimizer/gui/comparison_dialog.py b/src/movement_optimizer/gui/comparison_dialog.py index 92cd80e8e2..e220f6daea 100644 --- a/src/movement_optimizer/gui/comparison_dialog.py +++ b/src/movement_optimizer/gui/comparison_dialog.py @@ -63,7 +63,9 @@ def exec(self) -> None: self.show() def _build_metrics_table(self, metrics: list[dict]) -> str: - lines = [f"{'Trial':<30} {'Ankle':>8} {'Knee':>8} {'Hip':>8} {'Work':>10} {'COM sway':>10}"] + lines = [ + f"{'Trial':<30} {'Ankle':>8} {'Knee':>8} {'Hip':>8} {'Work':>10} {'COM sway':>10}" + ] lines.append("-" * 80) for m in metrics: pt = m["peak_torques"] diff --git a/src/movement_optimizer/gui/exercise_tab.py b/src/movement_optimizer/gui/exercise_tab.py index 5401d753dd..a3633be9fa 100644 --- a/src/movement_optimizer/gui/exercise_tab.py +++ b/src/movement_optimizer/gui/exercise_tab.py @@ -145,7 +145,11 @@ def draw_all_plots( if k != "anim": self.axes[k].clear() style_axis(self.axes[k]) - labels = Palette.BENCH_LABELS if exercise_type == "bench_press" else Palette.SEG_LABELS + labels = ( + Palette.BENCH_LABELS + if exercise_type == "bench_press" + else Palette.SEG_LABELS + ) self._render_analysis_plots(result, body, bar_mass, labels) self.fig.suptitle( f"{self.name} | {body.body_mass:.0f} kg body, {bar_mass:.0f} kg barbell", diff --git a/src/movement_optimizer/gui/file_operations.py b/src/movement_optimizer/gui/file_operations.py index cf742a4e8d..b63166151f 100644 --- a/src/movement_optimizer/gui/file_operations.py +++ b/src/movement_optimizer/gui/file_operations.py @@ -240,13 +240,21 @@ def _export_excel(self: MainWindow) -> None: if not path: return try: - mass = getattr(body, "body_mass", None) # BodyModel uses body_mass, not mass + mass = getattr( + body, "body_mass", None + ) # BodyModel uses body_mass, not mass height = getattr(body, "height", None) export_to_excel( - r, path, exercise_name=exercise_name, body_mass_kg=mass, body_height_m=height + r, + path, + exercise_name=exercise_name, + body_mass_kg=mass, + body_height_m=height, ) self.status_label.setText(f"Exported: {os.path.basename(path)}") - QMessageBox.information(self, "Exported", f"Excel workbook saved to:\n{path}") + QMessageBox.information( + self, "Exported", f"Excel workbook saved to:\n{path}" + ) except ImportError as e: QMessageBox.critical(self, "Missing Dependency", str(e)) except (OSError, ValueError, RuntimeError) as e: diff --git a/src/movement_optimizer/gui/help_dialog.py b/src/movement_optimizer/gui/help_dialog.py index 844ab6ce13..357ba4ffd5 100644 --- a/src/movement_optimizer/gui/help_dialog.py +++ b/src/movement_optimizer/gui/help_dialog.py @@ -161,7 +161,9 @@ class HelpCenterDialog(QDialog): ), } - def __init__(self, parent: QWidget | None = None, initial_topic: str = "parameters") -> None: + def __init__( + self, parent: QWidget | None = None, initial_topic: str = "parameters" + ) -> None: super().__init__(parent) self.setWindowTitle("Movement Optimizer Help") self.setMinimumWidth(680) @@ -175,7 +177,9 @@ def _build_ui(self) -> None: outer.setContentsMargins(12, 12, 12, 12) outer.setSpacing(8) - header = QLabel("Offline help for setup, parameters, results, troubleshooting, and terms.") + header = QLabel( + "Offline help for setup, parameters, results, troubleshooting, and terms." + ) header.setWordWrap(True) outer.addWidget(header) @@ -218,7 +222,9 @@ def _build_parameter_tab(self) -> QScrollArea: lbl = QLabel(f"{heading}") grid.addWidget(lbl, 0, col) - for row, (name, (desc, unit, rng)) in enumerate(self.PARAMETERS.items(), start=1): + for row, (name, (desc, unit, rng)) in enumerate( + self.PARAMETERS.items(), start=1 + ): name_lbl = QLabel(name) name_lbl.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -227,10 +233,14 @@ def _build_parameter_tab(self) -> QScrollArea: desc_lbl.setAlignment(Qt.AlignmentFlag.AlignTop) unit_lbl = QLabel(unit) - unit_lbl.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + unit_lbl.setAlignment( + Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter + ) rng_lbl = QLabel(rng) - rng_lbl.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + rng_lbl.setAlignment( + Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter + ) grid.addWidget(name_lbl, row, 0) grid.addWidget(desc_lbl, row, 1) diff --git a/src/movement_optimizer/gui/labelled_slider.py b/src/movement_optimizer/gui/labelled_slider.py index 48d4e11a10..a5f6788911 100644 --- a/src/movement_optimizer/gui/labelled_slider.py +++ b/src/movement_optimizer/gui/labelled_slider.py @@ -41,7 +41,9 @@ def __init__( row = QHBoxLayout() self.name_label = QLabel(label) self.val_label = QLabel(self._fmt(default)) - self.val_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.val_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) row.addWidget(self.name_label) row.addStretch() row.addWidget(self.val_label) @@ -56,7 +58,11 @@ def __init__( suppress_wheel_events(self.slider) layout.addWidget(self.slider) - _tip = tooltip if tooltip else f"{label} ({lo:.{decimals}f}-{hi:.{decimals}f} {unit})" + _tip = ( + tooltip + if tooltip + else f"{label} ({lo:.{decimals}f}-{hi:.{decimals}f} {unit})" + ) self.slider.setToolTip(_tip) self.name_label.setToolTip(_tip) diff --git a/src/movement_optimizer/gui/main_window.py b/src/movement_optimizer/gui/main_window.py index c6c95e3237..aafc098ae8 100644 --- a/src/movement_optimizer/gui/main_window.py +++ b/src/movement_optimizer/gui/main_window.py @@ -82,7 +82,9 @@ class MainWindow( # Signals for thread-safe GUI updates from the optimizer worker. # Using signals instead of QTimer.singleShot is the Qt-correct way # to communicate from a background thread to the main thread. - _sig_done = pyqtSignal(int, object, object, float, object) # idx, result, body, bar, then_chain + _sig_done = pyqtSignal( + int, object, object, float, object + ) # idx, result, body, bar, then_chain _sig_cancelled = pyqtSignal() _sig_error = pyqtSignal(object) # MovementOptimizerError or str _sig_progress = pyqtSignal(object) # ProgressReport @@ -104,7 +106,9 @@ def __init__(self) -> None: self.setMinimumSize(800, 600) self.resize(1100, 700) - self.exercise_states = [ExerciseRuntimeState() for _name, _etype in self.EXERCISE_CONFIGS] + self.exercise_states = [ + ExerciseRuntimeState() for _name, _etype in self.EXERCISE_CONFIGS + ] self.is_playing = False self.anim_timer = QTimer(self) self.anim_timer.timeout.connect(self._anim_step) @@ -373,7 +377,9 @@ def _connect_slider_undo(self) -> None: for name in slider_names: labelled = getattr(self.sidebar, name, None) if labelled is None: - logger.warning("_connect_slider_undo: sidebar has no attribute %r", name) + logger.warning( + "_connect_slider_undo: sidebar has no attribute %r", name + ) continue raw = labelled.slider @@ -436,14 +442,18 @@ def _sync_motion_tab_controls(self, _index: int | None = None) -> None: self._motion_tab_button_states.clear() else: if not self._motion_tab_button_states: - self._motion_tab_button_states = {button: button.isEnabled() for button in buttons} + self._motion_tab_button_states = { + button: button.isEnabled() for button in buttons + } for button in buttons: button.setEnabled(False) self.controls.setEnabled(True) if enabled: self.status_label.setText("Ready") else: - self.status_label.setText("Analysis tabs use local and bottom playback controls.") + self.status_label.setText( + "Analysis tabs use local and bottom playback controls." + ) self._sync_right_sidebar_toggle() def _active_analysis_tab(self) -> Any | None: diff --git a/src/movement_optimizer/gui/motion_analysis_panel.py b/src/movement_optimizer/gui/motion_analysis_panel.py index 7edbf54175..afcc2b3c9f 100644 --- a/src/movement_optimizer/gui/motion_analysis_panel.py +++ b/src/movement_optimizer/gui/motion_analysis_panel.py @@ -57,7 +57,9 @@ def __init__(self, axis_names: Sequence[str], *, rows: int, cols: int) -> None: self.figure = Figure(figsize=(8.0, 5.0), facecolor=Palette.BG) self.canvas = FigureCanvasQTAgg(self.figure) - self.canvas.setMinimumSize(self._minimum_canvas_width(), self._minimum_canvas_height()) + self.canvas.setMinimumSize( + self._minimum_canvas_width(), self._minimum_canvas_height() + ) self.canvas.setSizePolicy( QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.MinimumExpanding, diff --git a/src/movement_optimizer/gui/motion_controls.py b/src/movement_optimizer/gui/motion_controls.py index f9f23d93d3..93784893d8 100644 --- a/src/movement_optimizer/gui/motion_controls.py +++ b/src/movement_optimizer/gui/motion_controls.py @@ -47,7 +47,9 @@ def __init__( self.slider.setRange(0, self._steps) self.slider.setTracking(False) self.slider.setMinimumHeight(28) - self.slider.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.slider.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) self.edit = QLineEdit() self.edit.setFixedWidth(88) self.edit.setMinimumHeight(28) @@ -89,7 +91,11 @@ def _sync_widgets(self) -> None: self.slider.blockSignals(True) self.slider.setValue(slider_value) self.slider.blockSignals(False) - text = f"{int(self._value)}" if self._integer else f"{self._value:.{self._decimals}f}" + text = ( + f"{int(self._value)}" + if self._integer + else f"{self._value:.{self._decimals}f}" + ) if self.edit.text() != text: self.edit.setText(text) diff --git a/src/movement_optimizer/gui/motion_tabs.py b/src/movement_optimizer/gui/motion_tabs.py index 5bca53b79a..50f98da3a8 100644 --- a/src/movement_optimizer/gui/motion_tabs.py +++ b/src/movement_optimizer/gui/motion_tabs.py @@ -120,19 +120,31 @@ def _swing_overlay_scene( origin = (float(field.com_m[0]), float(field.com_m[1])) if gravity: gravity_vec = (float(field.gravity_n[0]), float(field.gravity_n[1])) - arrows.append(ForceArrow(origin, gravity_vec, VectorStyle(LEG, label="gravity"))) + arrows.append( + ForceArrow(origin, gravity_vec, VectorStyle(LEG, label="gravity")) + ) if tension: tension_vec = (float(field.chain_tension_n[0]), float(field.chain_tension_n[1])) - arrows.append(ForceArrow(origin, tension_vec, VectorStyle(CHAIN, label="tension"))) + arrows.append( + ForceArrow(origin, tension_vec, VectorStyle(CHAIN, label="tension")) + ) if torque: - for joint, magnitude in zip(SWING_POLICY_JOINT_NAMES, field.joint_torque_nm, strict=True): + for joint, magnitude in zip( + SWING_POLICY_JOINT_NAMES, field.joint_torque_nm, strict=True + ): point = field.joint_points_m[joint] arcs.append( - TorqueArc((float(point[0]), float(point[1])), float(magnitude), VectorStyle(ARM)) + TorqueArc( + (float(point[0]), float(point[1])), + float(magnitude), + VectorStyle(ARM), + ) ) if com: markers.append(ComMarker(origin, VectorStyle(ACCENT))) - return OverlayScene(arrows=tuple(arrows), torque_arcs=tuple(arcs), com_markers=tuple(markers)) + return OverlayScene( + arrows=tuple(arrows), torque_arcs=tuple(arcs), com_markers=tuple(markers) + ) def _chain_overlay_scene( @@ -145,7 +157,10 @@ def _chain_overlay_scene( """Build the chain overlay scene from a per-link force field, filtered by toggles.""" arrows: list[ForceArrow] = [] for index in range(len(field.midpoints_m)): - origin = (float(field.midpoints_m[index][0]), float(field.midpoints_m[index][1])) + origin = ( + float(field.midpoints_m[index][0]), + float(field.midpoints_m[index][1]), + ) if gravity: vec = (float(field.gravity_n[index][0]), float(field.gravity_n[index][1])) arrows.append(ForceArrow(origin, vec, VectorStyle(LEG))) @@ -153,7 +168,10 @@ def _chain_overlay_scene( vec = (float(field.tension_n[index][0]), float(field.tension_n[index][1])) arrows.append(ForceArrow(origin, vec, VectorStyle(CHAIN))) if net: - vec = (float(field.net_force_n[index][0]), float(field.net_force_n[index][1])) + vec = ( + float(field.net_force_n[index][0]), + float(field.net_force_n[index][1]), + ) arrows.append(ForceArrow(origin, vec, VectorStyle(ARM))) return OverlayScene(arrows=tuple(arrows)) @@ -277,7 +295,8 @@ def _chain_path_length(self) -> float: @staticmethod def _compute_chain_path_length(chain_nodes: list[tuple[float, float]]) -> float: distances = [ - np.hypot(end[0] - start[0], end[1] - start[1]) for start, end in pairwise(chain_nodes) + np.hypot(end[0] - start[0], end[1] - start[1]) + for start, end in pairwise(chain_nodes) ] return max(float(sum(distances)), 0.5) @@ -654,7 +673,13 @@ def _build_body_group(self) -> QGroupBox: tooltip="Rider arm segment length (upper arm and forearm).", ) self._add_control( - form, "arm_mass", "Arm segment kg", 0.2, 10.0, 2.0, tooltip="Rider arm segment mass." + form, + "arm_mass", + "Arm segment kg", + 0.2, + 10.0, + 2.0, + tooltip="Rider arm segment mass.", ) return group @@ -714,16 +739,30 @@ def _build_policy_group(self) -> QGroupBox: integer=True, tooltip="Time steps simulated per evaluation when cycles are not used (up to 2000).", ) - self._add_control(form, "freq_min", "Freq min Hz", 0.2, 2.0, 0.45, refresh=False) - self._add_control(form, "freq_max", "Freq max Hz", 0.2, 2.0, 0.75, refresh=False) + self._add_control( + form, "freq_min", "Freq min Hz", 0.2, 2.0, 0.45, refresh=False + ) + self._add_control( + form, "freq_max", "Freq max Hz", 0.2, 2.0, 0.75, refresh=False + ) self._add_control( form, "freq_samples", "Freq samples", 1, 8, 3, integer=True, refresh=False ) - self._add_control(form, "hip_rate_min", "Hip min rad/s", 0.0, 3.0, 0.5, refresh=False) - self._add_control(form, "hip_rate_max", "Hip max rad/s", 0.0, 3.0, 1.3, refresh=False) - self._add_control(form, "hip_samples", "Hip samples", 1, 8, 2, integer=True, refresh=False) - self._add_control(form, "torso_rate_min", "Torso min rad/s", 0.0, 3.0, 0.3, refresh=False) - self._add_control(form, "torso_rate_max", "Torso max rad/s", 0.0, 3.0, 1.1, refresh=False) + self._add_control( + form, "hip_rate_min", "Hip min rad/s", 0.0, 3.0, 0.5, refresh=False + ) + self._add_control( + form, "hip_rate_max", "Hip max rad/s", 0.0, 3.0, 1.3, refresh=False + ) + self._add_control( + form, "hip_samples", "Hip samples", 1, 8, 2, integer=True, refresh=False + ) + self._add_control( + form, "torso_rate_min", "Torso min rad/s", 0.0, 3.0, 0.3, refresh=False + ) + self._add_control( + form, "torso_rate_max", "Torso max rad/s", 0.0, 3.0, 1.1, refresh=False + ) self._add_control( form, "torso_samples", @@ -734,15 +773,28 @@ def _build_policy_group(self) -> QGroupBox: integer=True, refresh=False, ) - self._add_control(form, "knee_ratio_min", "Knee ratio min", 0.0, 1.5, 0.25, refresh=False) - self._add_control(form, "knee_ratio_max", "Knee ratio max", 0.0, 1.5, 0.65, refresh=False) + self._add_control( + form, "knee_ratio_min", "Knee ratio min", 0.0, 1.5, 0.25, refresh=False + ) + self._add_control( + form, "knee_ratio_max", "Knee ratio max", 0.0, 1.5, 0.65, refresh=False + ) self._add_control( form, "knee_samples", "Knee samples", 1, 8, 2, integer=True, refresh=False ) self._add_control( - form, "phase_samples", "Phase samples", 1, 12, 2, integer=True, refresh=False + form, + "phase_samples", + "Phase samples", + 1, + 12, + 2, + integer=True, + refresh=False, + ) + self._add_control( + form, "speed", "Playback speed", 0.25, 4.0, 1.0, refresh=False ) - self._add_control(form, "speed", "Playback speed", 0.25, 4.0, 1.0, refresh=False) layout.addLayout(form) return group @@ -925,7 +977,10 @@ def _policy_bounds(self) -> CyclicPolicyBounds: return CyclicPolicyBounds( frequency_hz=(self._value("freq_min"), self._value("freq_max")), hip_rate_rad_s=(self._value("hip_rate_min"), self._value("hip_rate_max")), - torso_rate_rad_s=(self._value("torso_rate_min"), self._value("torso_rate_max")), + torso_rate_rad_s=( + self._value("torso_rate_min"), + self._value("torso_rate_max"), + ), knee_ratio=(self._value("knee_ratio_min"), self._value("knee_ratio_max")), ) @@ -989,15 +1044,23 @@ def _render_snapshot(self, snapshot: SwingSetSnapshot) -> None: def _populate_analysis_panel(self) -> None: if self._rollout is None: return - history = swing_force_history(self._config(), self._rollout, DEFAULT_POLICY_DT_S) + history = swing_force_history( + self._config(), self._rollout, DEFAULT_POLICY_DT_S + ) self._force_history = history - self._force_fields = swing_force_fields(self._config(), self._rollout, DEFAULT_POLICY_DT_S) + self._force_fields = swing_force_fields( + self._config(), self._rollout, DEFAULT_POLICY_DT_S + ) panel = self.analysis_panel panel.clear() - plot_renderer.plot_swing_joint_torques(panel.axes["torques"], history, legend=False) + plot_renderer.plot_swing_joint_torques( + panel.axes["torques"], history, legend=False + ) plot_renderer.plot_swing_joint_power(panel.axes["power"], history, legend=False) plot_renderer.plot_swing_angle(panel.axes["angle"], history, legend=False) - plot_renderer.plot_swing_com_height(panel.axes["com_height"], history, legend=False) + plot_renderer.plot_swing_com_height( + panel.axes["com_height"], history, legend=False + ) plot_renderer.plot_swing_energy(panel.axes["energy"], history, legend=False) plot_renderer.plot_swing_com_path(panel.axes["com_path"], history, legend=False) self._apply_plot_legend_visibility() diff --git a/src/movement_optimizer/gui/motion_tabs_chain.py b/src/movement_optimizer/gui/motion_tabs_chain.py index ecf7118113..9a2bb24e18 100644 --- a/src/movement_optimizer/gui/motion_tabs_chain.py +++ b/src/movement_optimizer/gui/motion_tabs_chain.py @@ -119,10 +119,22 @@ def _build_ui(self) -> None: tooltip="Number of links in the chain.", ) self._add_control( - form, "length", "Link length m", 0.03, 1.0, 0.18, tooltip="Length of each chain link." + form, + "length", + "Link length m", + 0.03, + 1.0, + 0.18, + tooltip="Length of each chain link.", ) self._add_control( - form, "mass", "Link mass kg", 0.01, 4.0, 0.12, tooltip="Mass of each chain link." + form, + "mass", + "Link mass kg", + 0.01, + 4.0, + 0.12, + tooltip="Mass of each chain link.", ) self._add_control( form, @@ -238,7 +250,9 @@ def _build_ui(self) -> None: form.addRow("Segment angles", self.angle_edit) control_layout.addWidget(controls) # The chain tab draws no articulated rider, so omit that layer. - control_layout.addWidget(self._build_layers_group(["grid", "chain", "markers", "forces"])) + control_layout.addWidget( + self._build_layers_group(["grid", "chain", "markers", "forces"]) + ) control_layout.addWidget(self._build_force_group()) row = QHBoxLayout() simulate_button = QPushButton("Simulate Whip") @@ -248,7 +262,9 @@ def _build_ui(self) -> None: ) simulate_button.clicked.connect(self._simulate) randomize_button = QPushButton("Randomize Start") - randomize_button.setToolTip("Set a random 'wadded' starting configuration (seeded).") + randomize_button.setToolTip( + "Set a random 'wadded' starting configuration (seeded)." + ) randomize_button.clicked.connect(self._randomize_wadded_start) self.play_button = QPushButton("Play") self.play_button.setToolTip("Play or pause the simulated whip animation.") @@ -319,18 +335,24 @@ def _config(self) -> ChainConfig: def _state(self) -> ChainState: config = self._config() angles = ( - initial_catenary_angles(config.segment_count, self._angle_to_rad(self._value("sag"))) + initial_catenary_angles( + config.segment_count, self._angle_to_rad(self._value("sag")) + ) if self.tie_segments.isChecked() else self._typed_angles(config.segment_count) ) - velocities = initial_tip_kick_velocities(config.segment_count, self._value("kick")) + velocities = initial_tip_kick_velocities( + config.segment_count, self._value("kick") + ) return ChainState(angles, velocities) def _typed_angles(self, segment_count: int) -> np.ndarray: raw = self.angle_edit.text().strip() if not raw: return np.zeros(segment_count, dtype=np.float64) - values = np.asarray([float(part.strip()) for part in raw.split(",")], dtype=np.float64) + values = np.asarray( + [float(part.strip()) for part in raw.split(",")], dtype=np.float64 + ) if values.size != segment_count: raise ValueError(f"Expected {segment_count} segment angles") return np.deg2rad(values) if self.use_degrees.isChecked() else values @@ -347,14 +369,20 @@ def _randomize_wadded_start(self) -> None: seed=int(self._value("random_seed")), ) self.tie_segments.setChecked(False) - values = np.rad2deg(state.angles_rad) if self.use_degrees.isChecked() else state.angles_rad + values = ( + np.rad2deg(state.angles_rad) + if self.use_degrees.isChecked() + else state.angles_rad + ) self.angle_edit.setText(", ".join(f"{value:.4f}" for value in values)) self._refresh() def _refresh_angle_placeholder(self) -> None: unit = "degrees" if self.use_degrees.isChecked() else "radians" self._controls["sag"].set_value(20.0 if self.use_degrees.isChecked() else 0.35) - self._controls["random_span"].set_value(180.0 if self.use_degrees.isChecked() else np.pi) + self._controls["random_span"].set_value( + 180.0 if self.use_degrees.isChecked() else np.pi + ) self.angle_edit.setPlaceholderText(f"comma-separated {unit}, one per segment") def _value(self, key: str) -> float: @@ -417,19 +445,26 @@ def _simulate(self) -> None: def _populate_analysis_panel(self) -> None: if self._rollout is None: return - self._force_fields = chain_force_fields(self._config(), self._rollout, self._dt_s) + self._force_fields = chain_force_fields( + self._config(), self._rollout, self._dt_s + ) history = chain_force_history(self._config(), self._rollout, self._dt_s) time_s = history.time_s count = len(time_s) panel = self.analysis_panel panel.clear() plot_renderer.plot_chain_tension(panel.axes["tension"], history, legend=False) - plot_renderer.plot_chain_curvature(panel.axes["curvature"], history, legend=False) + plot_renderer.plot_chain_curvature( + panel.axes["curvature"], history, legend=False + ) plot_renderer.plot_chain_energy( panel.axes["energy"], time_s, self._rollout.energy_j[:count], legend=False ) plot_renderer.plot_chain_tip_speed( - panel.axes["tip_speed"], time_s, self._rollout.tip_speed_m_s[:count], legend=False + panel.axes["tip_speed"], + time_s, + self._rollout.tip_speed_m_s[:count], + legend=False, ) self._apply_plot_legend_visibility() panel.draw() @@ -460,7 +495,9 @@ def _current_force_field(self) -> ChainForceField: if not 0 <= self._frame_index < frame_count: raise RuntimeError("DbC Blocked: frame index is outside the rollout") if self._force_fields is None or len(self._force_fields) != frame_count: - self._force_fields = chain_force_fields(self._config(), self._rollout, self._dt_s) + self._force_fields = chain_force_fields( + self._config(), self._rollout, self._dt_s + ) return self._force_fields[self._frame_index] def _toggle_playback(self) -> None: @@ -485,7 +522,9 @@ def playback_step_forward(self) -> None: return self._timer.stop() self.play_button.setText("Play") - self._frame_index = min(self._frame_index + 1, self._rollout.positions.shape[0] - 1) + self._frame_index = min( + self._frame_index + 1, self._rollout.positions.shape[0] - 1 + ) self._render_chain_frame() self.playbackStateChanged.emit() diff --git a/src/movement_optimizer/gui/optimization_mixin.py b/src/movement_optimizer/gui/optimization_mixin.py index d0f8c19e7c..d6350cf017 100644 --- a/src/movement_optimizer/gui/optimization_mixin.py +++ b/src/movement_optimizer/gui/optimization_mixin.py @@ -14,7 +14,12 @@ from ..cli import EXERCISE_FACTORIES from ..constants import trapezoid -from ..errors import MovementOptimizerError, OptimizationError, PhysicsError, ValidationError +from ..errors import ( + MovementOptimizerError, + OptimizationError, + PhysicsError, + ValidationError, +) from ..models import BodyModel from ..trajectory import ( CancelledError, @@ -103,14 +108,18 @@ def _set_anim_frame(self, idx: int, frame: int) -> None: with self._opt_lock: self.exercise_states[idx].anim_frame = frame - def _set_exercise_result(self, idx: int, result: OptimizationResult, *, frame: int = 0) -> None: + def _set_exercise_result( + self, idx: int, result: OptimizationResult, *, frame: int = 0 + ) -> None: """Atomically publish an optimization result and reset playback frame.""" with self._opt_lock: state = self.exercise_states[idx] state.result = result state.anim_frame = frame - def _resolve_exercise_params(self, idx: int) -> tuple[Any, Any, str, float, float, float]: + def _resolve_exercise_params( + self, idx: int + ) -> tuple[Any, Any, str, float, float, float]: body = self.sidebar.get_body_model() bar, dur, smoothness = self.sidebar.get_optimization_params() _, etype = self.EXERCISE_CONFIGS[idx] @@ -245,7 +254,9 @@ def _opt_worker(self, idx: int, then_chain: list[int] | None) -> None: validation_err = ValidationError( f"Invalid parameters: {exc}", error_code="VALIDATION_ERROR", - suggestion=("Check that all body and exercise parameters are within valid ranges."), + suggestion=( + "Check that all body and exercise parameters are within valid ranges." + ), ) self._sig_error.emit(validation_err) except (RuntimeError, OSError) as exc: @@ -297,7 +308,9 @@ def _on_done( tab.draw_anim_frame(0, result, dyn, body, etype) elapsed = result.elapsed_s t_str = ( - f"{elapsed:.1f}s" if elapsed < 60 else f"{int(elapsed // 60)}m {elapsed % 60:.0f}s" + f"{elapsed:.1f}s" + if elapsed < 60 + else f"{int(elapsed // 60)}m {elapsed % 60:.0f}s" ) self.sidebar.set_progress_done(t_str, result.n_evals) self._enable_post_run_buttons() @@ -383,7 +396,9 @@ def _update_result_summary( f" COM sway: {r.com_horizontal_range_cm:.1f} cm\n" f" Balance: {balance_ok}" ) - self.sidebar.set_result_label(f"{name} results:\n{joint_lines}\n Work: {work:>6.0f} J") + self.sidebar.set_result_label( + f"{name} results:\n{joint_lines}\n Work: {work:>6.0f} J" + ) def _on_err(self, err: object) -> None: """Handle optimizer errors (called from main thread via signal).""" diff --git a/src/movement_optimizer/gui/parameter_sidebar.py b/src/movement_optimizer/gui/parameter_sidebar.py index 9ae64d4789..bb505035ab 100644 --- a/src/movement_optimizer/gui/parameter_sidebar.py +++ b/src/movement_optimizer/gui/parameter_sidebar.py @@ -112,7 +112,9 @@ def is_3d_mode(self) -> bool: """Return True if the 3D model is selected.""" return self.model_combo.currentIndex() == 1 - def connect_action_handlers(self, handlers: Mapping[str, Callable[..., None]]) -> None: + def connect_action_handlers( + self, handlers: Mapping[str, Callable[..., None]] + ) -> None: """Connect sidebar action signals to handlers supplied by the main window.""" self.optimize_current.connect(handlers["optimize_current"]) self.optimize_both.connect(handlers["optimize_both"]) @@ -132,8 +134,12 @@ def show_optimizing(self) -> None: _st.show_optimizing(self) self.cancel_btn.setEnabled(True) self.cancel_btn.setToolTip("Cancel the currently running optimization (Esc)") - self.opt_btn.setToolTip("Optimization currently in progress. Please wait or cancel.") - self.both_btn.setToolTip("Optimization currently in progress. Please wait or cancel.") + self.opt_btn.setToolTip( + "Optimization currently in progress. Please wait or cancel." + ) + self.both_btn.setToolTip( + "Optimization currently in progress. Please wait or cancel." + ) def show_idle(self) -> None: _st.show_idle(self) @@ -236,7 +242,9 @@ def set_clear_comparison_available(self, available: bool) -> None: """Enable or disable the clear comparison action.""" self.clear_compare_btn.setEnabled(available) if available: - self.clear_compare_btn.setToolTip("Clear all trials currently saved for comparison") + self.clear_compare_btn.setToolTip( + "Clear all trials currently saved for comparison" + ) else: self.clear_compare_btn.setToolTip("No trials currently saved to clear") @@ -244,9 +252,13 @@ def set_cancellation_available(self, available: bool) -> None: """Enable or disable the cancellation action.""" self.cancel_btn.setEnabled(available) if not available: - self.cancel_btn.setToolTip("Cancellation already requested, shutting down safely...") + self.cancel_btn.setToolTip( + "Cancellation already requested, shutting down safely..." + ) else: - self.cancel_btn.setToolTip("Cancel the currently running optimization (Esc)") + self.cancel_btn.setToolTip( + "Cancel the currently running optimization (Esc)" + ) def set_cancelling(self) -> None: """Immediately reflect cancellation in the UI and flush pending events. @@ -262,5 +274,7 @@ def set_cancelling(self) -> None: self.both_btn.setEnabled(False) self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Canceling…") - self.cancel_btn.setToolTip("Cancellation already requested, shutting down safely...") + self.cancel_btn.setToolTip( + "Cancellation already requested, shutting down safely..." + ) QApplication.processEvents() diff --git a/src/movement_optimizer/gui/playback_controls.py b/src/movement_optimizer/gui/playback_controls.py index b8a0af199c..663cefa419 100644 --- a/src/movement_optimizer/gui/playback_controls.py +++ b/src/movement_optimizer/gui/playback_controls.py @@ -6,7 +6,14 @@ from collections.abc import Callable, Mapping from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QPushButton, QSlider, QWidget +from PyQt6.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLabel, + QPushButton, + QSlider, + QWidget, +) from movement_optimizer.gui.wheel_blocker import suppress_wheel_events @@ -26,12 +33,16 @@ def __init__(self, parent: QWidget | None = None) -> None: self.btn_rewind = QPushButton("Rewind") self.btn_rewind.setAccessibleName("Rewind to start") - self.btn_rewind.setAccessibleDescription("Move the animation to the first frame.") + self.btn_rewind.setAccessibleDescription( + "Move the animation to the first frame." + ) self.btn_rewind.setToolTip("Rewind to start (Home)") self.btn_back = QPushButton("Back") self.btn_back.setAccessibleName("Step backward one frame") - self.btn_back.setAccessibleDescription("Move the animation backward by one frame.") + self.btn_back.setAccessibleDescription( + "Move the animation backward by one frame." + ) self.btn_back.setToolTip("Step backward one frame") self.btn_play = QPushButton("Play") @@ -64,7 +75,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self.speed_slider.setRange(1, 30) self.speed_slider.setValue(10) self.speed_slider.setFixedWidth(100) - self.speed_slider.valueChanged.connect(lambda v: self.speed_changed.emit(v / 10.0)) + self.speed_slider.valueChanged.connect( + lambda v: self.speed_changed.emit(v / 10.0) + ) suppress_wheel_events(self.speed_slider) layout.addWidget(self.speed_slider) @@ -85,7 +98,9 @@ def __init__(self, parent: QWidget | None = None) -> None: self.frame_label = QLabel("") layout.addWidget(self.frame_label) - def connect_action_handlers(self, handlers: Mapping[str, Callable[..., None]]) -> None: + def connect_action_handlers( + self, handlers: Mapping[str, Callable[..., None]] + ) -> None: """Connect playback signals to handlers supplied by the owning window.""" self.play_toggled.connect(handlers["play_toggled"]) self.step_fwd.connect(handlers["step_fwd"]) @@ -122,7 +137,9 @@ def set_speed_multiplier_text(self, speed: float) -> None: """Display the current playback speed multiplier.""" self.speed_label.setText(f"{speed:.1f}x") - def set_playback_status(self, current_frame: int, total_frames: int, speed: float) -> None: + def set_playback_status( + self, current_frame: int, total_frames: int, speed: float + ) -> None: """Update the frame and speed labels together.""" self.set_frame_position(current_frame, total_frames) self.set_speed_multiplier_text(speed) diff --git a/src/movement_optimizer/gui/plot_renderer.py b/src/movement_optimizer/gui/plot_renderer.py index 9b4aebd262..36dadd3d11 100644 --- a/src/movement_optimizer/gui/plot_renderer.py +++ b/src/movement_optimizer/gui/plot_renderer.py @@ -38,7 +38,9 @@ def _legend_outside_plot(ax: Any, *, fontsize: int = 7, columns: int = 3) -> Any ) -def plot_angles(ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS) -> None: +def plot_angles( + ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS +) -> None: n_dof = min(r.q.shape[1], len(labels)) for j in range(n_dof): ax.plot( @@ -54,7 +56,9 @@ def plot_angles(ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABE _legend_outside_plot(ax, fontsize=6, columns=n_dof) -def plot_torques(ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS) -> None: +def plot_torques( + ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS +) -> None: n_dof = min(r.torques.shape[1], len(labels)) for j in range(n_dof): ax.plot( @@ -71,7 +75,9 @@ def plot_torques(ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LAB _legend_outside_plot(ax, fontsize=6, columns=n_dof) -def plot_power(ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS) -> None: +def plot_power( + ax: Any, r: OptimizationResult, labels: tuple = Palette.SEG_LABELS +) -> None: n_dof = min(r.power.shape[1], len(labels)) for j in range(n_dof): ax.plot( @@ -187,7 +193,12 @@ def plot_com_balance(ax: Any, r: OptimizationResult, body: BodyModel) -> None: def plot_spine_loads( - ax_comp: Any, ax_shear: Any, r: OptimizationResult, body: BodyModel, bar_mass: float, name: str + ax_comp: Any, + ax_shear: Any, + r: OptimizationResult, + body: BodyModel, + bar_mass: float, + name: str, ) -> None: exercise_type = name.lower().replace(" ", "_") if exercise_type == "bottoms_up_squat": @@ -250,7 +261,9 @@ def _style_timeseries_axis( _legend_outside_plot(ax, fontsize=legend_fontsize) -def plot_swing_joint_torques(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: +def plot_swing_joint_torques( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: for j, name in enumerate(SWING_POLICY_JOINT_NAMES): ax.plot( history.time_s, @@ -263,7 +276,9 @@ def plot_swing_joint_torques(ax: Any, history: SwingForceHistory, *, legend: boo _style_timeseries_axis(ax, "Torque (N·m)", "Joint Torques", legend=legend) -def plot_swing_joint_power(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: +def plot_swing_joint_power( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: for j, name in enumerate(SWING_POLICY_JOINT_NAMES): ax.plot( history.time_s, @@ -285,7 +300,9 @@ def plot_swing_joint_power(ax: Any, history: SwingForceHistory, *, legend: bool _style_timeseries_axis(ax, "Power (W)", "Joint Power", legend=legend) -def plot_swing_angle(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: +def plot_swing_angle( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: ax.plot( history.time_s, np.degrees(history.swing_angle_rad), @@ -297,17 +314,35 @@ def plot_swing_angle(ax: Any, history: SwingForceHistory, *, legend: bool = True _style_timeseries_axis(ax, "Angle (deg)", "Swing Angle", legend=legend) -def plot_swing_com_height(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: - ax.plot(history.time_s, history.com_height_m, color=Palette.GREEN, lw=2, label="COM height") +def plot_swing_com_height( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: + ax.plot( + history.time_s, + history.com_height_m, + color=Palette.GREEN, + lw=2, + label="COM height", + ) _style_timeseries_axis(ax, "Height (m)", "COM Height", legend=legend) -def plot_swing_energy(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: - ax.plot(history.time_s, history.energy_j, color=Palette.ORANGE, lw=2, label="Swing energy") +def plot_swing_energy( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: + ax.plot( + history.time_s, + history.energy_j, + color=Palette.ORANGE, + lw=2, + label="Swing energy", + ) _style_timeseries_axis(ax, "Energy (J)", "Swing Energy", legend=legend) -def plot_swing_com_path(ax: Any, history: SwingForceHistory, *, legend: bool = True) -> None: +def plot_swing_com_path( + ax: Any, history: SwingForceHistory, *, legend: bool = True +) -> None: # com_path_m is (x, +y-down); negate y so "up" is up on the plot. xs = history.com_path_m[:, 0] ys = -history.com_path_m[:, 1] @@ -321,9 +356,15 @@ def plot_swing_com_path(ax: Any, history: SwingForceHistory, *, legend: bool = T _legend_outside_plot(ax, fontsize=6) -def plot_chain_tension(ax: Any, history: ChainForceHistory, *, legend: bool = True) -> None: +def plot_chain_tension( + ax: Any, history: ChainForceHistory, *, legend: bool = True +) -> None: ax.plot( - history.time_s, history.max_tension_n, color=Palette.RED, lw=2, label="Max link tension" + history.time_s, + history.max_tension_n, + color=Palette.RED, + lw=2, + label="Max link tension", ) mean_tension = ( np.mean(history.link_tension_n, axis=1) @@ -331,12 +372,19 @@ def plot_chain_tension(ax: Any, history: ChainForceHistory, *, legend: bool = Tr else np.zeros_like(history.time_s) ) ax.plot( - history.time_s, mean_tension, color=Palette.ACCENT, lw=1.5, alpha=0.8, label="Mean tension" + history.time_s, + mean_tension, + color=Palette.ACCENT, + lw=1.5, + alpha=0.8, + label="Mean tension", ) _style_timeseries_axis(ax, "Tension (N)", "Chain Link Tension", legend=legend) -def plot_chain_curvature(ax: Any, history: ChainForceHistory, *, legend: bool = True) -> None: +def plot_chain_curvature( + ax: Any, history: ChainForceHistory, *, legend: bool = True +) -> None: ax.plot( history.time_s, np.degrees(history.max_curvature_rad), @@ -347,11 +395,15 @@ def plot_chain_curvature(ax: Any, history: ChainForceHistory, *, legend: bool = _style_timeseries_axis(ax, "Curvature (deg)", "Chain Curvature", legend=legend) -def plot_chain_energy(ax: Any, time_s: Any, energy_j: Any, *, legend: bool = True) -> None: +def plot_chain_energy( + ax: Any, time_s: Any, energy_j: Any, *, legend: bool = True +) -> None: ax.plot(time_s, energy_j, color=Palette.GREEN, lw=2, label="Total energy") _style_timeseries_axis(ax, "Energy (J)", "Chain Energy", legend=legend) -def plot_chain_tip_speed(ax: Any, time_s: Any, tip_speed_m_s: Any, *, legend: bool = True) -> None: +def plot_chain_tip_speed( + ax: Any, time_s: Any, tip_speed_m_s: Any, *, legend: bool = True +) -> None: ax.plot(time_s, tip_speed_m_s, color=Palette.BLUE, lw=2, label="Tip speed") _style_timeseries_axis(ax, "Speed (m/s)", "Chain Tip Speed", legend=legend) diff --git a/src/movement_optimizer/gui/policy_trace_canvas.py b/src/movement_optimizer/gui/policy_trace_canvas.py index 68f79ed160..4e588ad614 100644 --- a/src/movement_optimizer/gui/policy_trace_canvas.py +++ b/src/movement_optimizer/gui/policy_trace_canvas.py @@ -93,7 +93,9 @@ def legend_visible(self) -> bool: def _top_margin(self) -> float: """Top inset for the plotted series, reserving room for the legend.""" - return float(self._legend_band_height() if self._legend_visible else self._MARGIN_PX) + return float( + self._legend_band_height() if self._legend_visible else self._MARGIN_PX + ) @staticmethod def _legend_entries() -> tuple[tuple[str, QColor], ...]: @@ -152,7 +154,9 @@ def _axis_label_band_height(self) -> int: """Return reserved bottom height for the trace x-axis label.""" metrics = QFontMetrics(self.font()) return ( - self._AXIS_LABEL_TOP_PADDING_PX + metrics.height() + self._AXIS_LABEL_BOTTOM_PADDING_PX + self._AXIS_LABEL_TOP_PADDING_PX + + metrics.height() + + self._AXIS_LABEL_BOTTOM_PADDING_PX ) def _minimum_height_for_width(self, width: int) -> int: @@ -251,13 +255,18 @@ def _draw_legend(self, painter: QPainter) -> None: y = baseline for label, color in self._legend_entries(): item_width = self._legend_item_width(label) - if x > self._MARGIN_PX and x + item_width > self._MARGIN_PX + available_width: + if ( + x > self._MARGIN_PX + and x + item_width > self._MARGIN_PX + available_width + ): x = self._MARGIN_PX y += self._LEGEND_ROW_HEIGHT_PX painter.setPen(QPen(color, 2)) painter.drawLine(x, y - 4, x + 12, y - 4) painter.setPen(QPen(color, 1)) - painter.drawText(x + self._LEGEND_LINE_PX + self._LEGEND_TEXT_GAP_PX, y, label) + painter.drawText( + x + self._LEGEND_LINE_PX + self._LEGEND_TEXT_GAP_PX, y, label + ) x += item_width def _iteration_label_rect(self) -> QRect: @@ -282,7 +291,9 @@ def _build_series( return { "score_m": _trace_series(samples, lambda sample: sample.score_m), "best_score_m": _trace_series(samples, lambda sample: sample.best_score_m), - "frequency_hz": _trace_series(samples, lambda sample: sample.parameters.frequency_hz), + "frequency_hz": _trace_series( + samples, lambda sample: sample.parameters.frequency_hz + ), "hip_rate_amplitude_rad_s": _trace_series( samples, lambda sample: sample.parameters.hip_rate_amplitude_rad_s ), @@ -292,7 +303,9 @@ def _build_series( "knee_rate_ratio": _trace_series( samples, lambda sample: sample.parameters.knee_rate_ratio ), - "phase_rad": _trace_series(samples, lambda sample: sample.parameters.phase_rad), + "phase_rad": _trace_series( + samples, lambda sample: sample.parameters.phase_rad + ), } diff --git a/src/movement_optimizer/gui/session_state.py b/src/movement_optimizer/gui/session_state.py index a9cd6f84d3..207878c5c2 100644 --- a/src/movement_optimizer/gui/session_state.py +++ b/src/movement_optimizer/gui/session_state.py @@ -42,7 +42,9 @@ def collect_slider_values(sidebar: ParameterSidebar) -> dict[str, float]: } -def restore_slider_values(sidebar: ParameterSidebar, slider_values: dict[str, float]) -> None: +def restore_slider_values( + sidebar: ParameterSidebar, slider_values: dict[str, float] +) -> None: """Apply persisted slider values to the GUI sidebar.""" slider_map = { "body_mass": sidebar.mass_slider, diff --git a/src/movement_optimizer/gui/vector_overlay.py b/src/movement_optimizer/gui/vector_overlay.py index b7e459da18..074c1d775c 100644 --- a/src/movement_optimizer/gui/vector_overlay.py +++ b/src/movement_optimizer/gui/vector_overlay.py @@ -98,7 +98,9 @@ def auto_scale_factor(arrows: Sequence[ForceArrow], target_world_len: float) -> return target_world_len / largest -def _draw_arrowhead(painter: QPainter, tail: QPointF, tip: QPointF, head_px: float) -> None: +def _draw_arrowhead( + painter: QPainter, tail: QPointF, tip: QPointF, head_px: float +) -> None: dx = tip.x() - tail.x() dy = tip.y() - tail.y() length = math.hypot(dx, dy) @@ -219,6 +221,8 @@ def draw_overlay_scene( if scene.arrows: draw_force_arrows(painter, projector, scene.arrows, scale=arrow_scale) if scene.torque_arcs: - draw_torque_arcs(painter, projector, scene.torque_arcs, reference_nm=torque_reference_nm) + draw_torque_arcs( + painter, projector, scene.torque_arcs, reference_nm=torque_reference_nm + ) if scene.com_markers: draw_com_markers(painter, projector, scene.com_markers) diff --git a/src/movement_optimizer/import_results.py b/src/movement_optimizer/import_results.py index 36b6cadf2a..7b18772733 100644 --- a/src/movement_optimizer/import_results.py +++ b/src/movement_optimizer/import_results.py @@ -49,12 +49,16 @@ def import_result_from_json(path: str | Path) -> dict: raise ValueError(f"Invalid JSON in result file {path}: {exc}") from exc if not isinstance(data, dict): - raise ValueError(f"Invalid result file: expected JSON object, got {type(data).__name__}") + raise ValueError( + f"Invalid result file: expected JSON object, got {type(data).__name__}" + ) version = data.get("format_version") if version is None: # Legacy file without version -- try to load with a warning. - logger.warning("Result file %s has no format_version; attempting legacy load", path) + logger.warning( + "Result file %s has no format_version; attempting legacy load", path + ) elif version != EXPORT_FORMAT_VERSION: raise ValueError( f"Incompatible format_version '{version}' in {path}; expected '{EXPORT_FORMAT_VERSION}'" diff --git a/src/movement_optimizer/models/__init__.py b/src/movement_optimizer/models/__init__.py index 1d18fd6f1a..6aad8c4695 100644 --- a/src/movement_optimizer/models/__init__.py +++ b/src/movement_optimizer/models/__init__.py @@ -72,7 +72,9 @@ from .swingset import cyclic_policy_controls as cyclic_policy_controls from .swingset import estimate_swingset_joint_torques as estimate_swingset_joint_torques from .swingset import optimize_cyclic_policy as optimize_cyclic_policy -from .swingset import optimize_cyclic_policy_iterative as optimize_cyclic_policy_iterative +from .swingset import ( + optimize_cyclic_policy_iterative as optimize_cyclic_policy_iterative, +) from .swingset import simulate_swingset as simulate_swingset from .swingset import simulate_swingset_controls as simulate_swingset_controls from .swingset_forces import SwingForceField as SwingForceField diff --git a/src/movement_optimizer/models/bilateral_3d.py b/src/movement_optimizer/models/bilateral_3d.py index 983d7c8d83..27bf73cb4a 100644 --- a/src/movement_optimizer/models/bilateral_3d.py +++ b/src/movement_optimizer/models/bilateral_3d.py @@ -180,7 +180,10 @@ def _sagittal_step( """ # Performance optimization: Skip intermediate array allocation return np.array( - [origin_xz[0] + length * np.sin(angle), origin_xz[1] + length * np.cos(angle)] + [ + origin_xz[0] + length * np.sin(angle), + origin_xz[1] + length * np.cos(angle), + ] ) def forward_kinematics(self, pose: Bilateral3DPose) -> dict[str, NDArray]: diff --git a/src/movement_optimizer/models/chain_dynamics.py b/src/movement_optimizer/models/chain_dynamics.py index 49829c4929..1674a7bae8 100644 --- a/src/movement_optimizer/models/chain_dynamics.py +++ b/src/movement_optimizer/models/chain_dynamics.py @@ -195,7 +195,9 @@ def initial_catenary_angles(segment_count: int, sag_rad: float) -> FloatArray: return np.linspace(-sag_rad, sag_rad, segment_count, dtype=np.float64) -def initial_tip_kick_velocities(segment_count: int, amplitude_rad_s: float) -> FloatArray: +def initial_tip_kick_velocities( + segment_count: int, amplitude_rad_s: float +) -> FloatArray: """Return a smooth initial angular-velocity profile concentrated at the tip. Preconditions: @@ -231,8 +233,12 @@ def random_wadded_chain_state( raise ValueError("velocity_span_rad_s must be non-negative") rng = np.random.default_rng(seed) angles = rng.uniform(-angle_span_rad, angle_span_rad, config.segment_count) - velocities = rng.uniform(-velocity_span_rad_s, velocity_span_rad_s, config.segment_count) - return ChainState(angles.astype(np.float64), velocities.astype(np.float64)).validated(config) + velocities = rng.uniform( + -velocity_span_rad_s, velocity_span_rad_s, config.segment_count + ) + return ChainState( + angles.astype(np.float64), velocities.astype(np.float64) + ).validated(config) def _angular_acceleration( @@ -266,7 +272,11 @@ def _angular_acceleration( ) bend_damping_torque = config.bend_damping * neighbor_velocity_sum return ( - gravity_torque + damping_torque + coupling_torque + bend_damping_torque + torques + gravity_torque + + damping_torque + + coupling_torque + + bend_damping_torque + + torques ) / inertia diff --git a/src/movement_optimizer/models/chain_forces.py b/src/movement_optimizer/models/chain_forces.py index b4006a9d14..dca8cdde6f 100644 --- a/src/movement_optimizer/models/chain_forces.py +++ b/src/movement_optimizer/models/chain_forces.py @@ -60,7 +60,9 @@ class ChainForceHistory: def _gravity_vector(config: ChainConfig) -> FloatArray: """Per-link weight vector (points toward +y, the model's downward axis).""" - return np.asarray([0.0, config.link_mass_kg * config.gravity_m_s2], dtype=np.float64) + return np.asarray( + [0.0, config.link_mass_kg * config.gravity_m_s2], dtype=np.float64 + ) def _midpoint_velocities(config: ChainConfig, rollout: ChainRollout) -> FloatArray: @@ -72,7 +74,9 @@ def _midpoint_velocities(config: ChainConfig, rollout: ChainRollout) -> FloatArr return np.stack(per_state) -def link_accelerations(config: ChainConfig, rollout: ChainRollout, dt_s: float) -> FloatArray: +def link_accelerations( + config: ChainConfig, rollout: ChainRollout, dt_s: float +) -> FloatArray: """Return ``(T, N, 2)`` link-midpoint linear accelerations via finite difference. Preconditions: diff --git a/src/movement_optimizer/models/lagrangian_balance.py b/src/movement_optimizer/models/lagrangian_balance.py index 83ec2a36b1..b98133550a 100644 --- a/src/movement_optimizer/models/lagrangian_balance.py +++ b/src/movement_optimizer/models/lagrangian_balance.py @@ -111,7 +111,9 @@ def residual(angle: float) -> float: return q -def _standing_balanced(dyn: _DynamicsWithBody, bar_mass: float, exercise_type: str) -> NDArray: +def _standing_balanced( + dyn: _DynamicsWithBody, bar_mass: float, exercise_type: str +) -> NDArray: """Find a near-standing pose with COM at inner BOS center. Adjusts shin angle (joint 0) to shift COM forward over mid-foot. diff --git a/src/movement_optimizer/models/lagrangian_dynamics.py b/src/movement_optimizer/models/lagrangian_dynamics.py index ec8706c4f0..77b3e12f3c 100644 --- a/src/movement_optimizer/models/lagrangian_dynamics.py +++ b/src/movement_optimizer/models/lagrangian_dynamics.py @@ -375,7 +375,9 @@ def _batch_gravity_torques(self, q: NDArray) -> NDArray: supine=self.supine, ) - def _numpy_inverse_dynamics_batch(self, q: NDArray, qd: NDArray, qdd: NDArray) -> NDArray: + def _numpy_inverse_dynamics_batch( + self, q: NDArray, qd: NDArray, qdd: NDArray + ) -> NDArray: """NumPy fallback — delegates to :func:`lagrangian_batch.numpy_inverse_dynamics_batch`.""" return numpy_inverse_dynamics_batch( q, @@ -409,7 +411,9 @@ def inverse_dynamics_batch(self, q: NDArray, qd: NDArray, qdd: NDArray) -> NDArr Rust and NumPy paths have the same asymptotic complexity. """ self._require_finite_batch_inputs(q, qd, qdd) - self._check_coriolis_slow_assumption(float(np.max(np.abs(qd))) if qd.size else 0.0) + self._check_coriolis_slow_assumption( + float(np.max(np.abs(qd))) if qd.size else 0.0 + ) try: from movement_optimizer_core import inverse_dynamics_batch_rs # type: ignore[import-not-found] # noqa: I001 diff --git a/src/movement_optimizer/models/lagrangian_kinematics.py b/src/movement_optimizer/models/lagrangian_kinematics.py index 61dddab0d0..5ae315759c 100644 --- a/src/movement_optimizer/models/lagrangian_kinematics.py +++ b/src/movement_optimizer/models/lagrangian_kinematics.py @@ -131,14 +131,21 @@ def _numpy_com_x_batch( c3x = hip_x + d[2] * sq[:, 2] total_mass = b.body_mass + bar_mass - numerator = b.m_feet * b.foot_com_x + self.m[0] * c1x + self.m[1] * c2x + self.m[2] * c3x + numerator = ( + b.m_feet * b.foot_com_x + + self.m[0] * c1x + + self.m[1] * c2x + + self.m[2] * c3x + ) if exercise_type in ("squat", "full_squat"): if hasattr(b, "squat_bar_depth") and ( b.squat_bar_depth != 0.0 or b.squat_bar_height != 0.0 ): bar_x = ( - shoulder_x - b.squat_bar_height * sq[:, 2] - b.squat_bar_depth * np.cos(q[:, 2]) + shoulder_x + - b.squat_bar_height * sq[:, 2] + - b.squat_bar_depth * np.cos(q[:, 2]) ) else: bar_x = shoulder_x @@ -269,8 +276,18 @@ def com_position( total_mass = b.body_mass + bar_mass - num_x = b.m_feet * b.foot_com_x + self.m[0] * c1_x + self.m[1] * c2_x + self.m[2] * c3_x - num_y = b.m_feet * b.foot_com_y + self.m[0] * c1_y + self.m[1] * c2_y + self.m[2] * c3_y + num_x = ( + b.m_feet * b.foot_com_x + + self.m[0] * c1_x + + self.m[1] * c2_x + + self.m[2] * c3_x + ) + num_y = ( + b.m_feet * b.foot_com_y + + self.m[0] * c1_y + + self.m[1] * c2_y + + self.m[2] * c3_y + ) if exercise_type in ("squat", "full_squat"): bar_pos = self.bar_position(q, exercise_type) diff --git a/src/movement_optimizer/models/swingset.py b/src/movement_optimizer/models/swingset.py index d2ca3ac2d2..5fdb506c77 100644 --- a/src/movement_optimizer/models/swingset.py +++ b/src/movement_optimizer/models/swingset.py @@ -20,7 +20,9 @@ FloatArray: TypeAlias = NDArray[np.float64] Policy: TypeAlias = Callable[["SwingSetState", float], "SwingControlAction"] -ProgressCallback: TypeAlias = Callable[[int, int, float, "CyclicPolicyParameters"], None] +ProgressCallback: TypeAlias = Callable[ + [int, int, float, "CyclicPolicyParameters"], None +] DEFAULT_CHAIN_SEGMENTS: Final[int] = 14 DEFAULT_CHAIN_LENGTH_M: Final[float] = 2.4 @@ -309,8 +311,12 @@ class CyclicPolicySearchSpace: def __post_init__(self) -> None: _require_range("frequency_hz", self.frequency_hz_min, self.frequency_hz_max) - _require_range("hip_rate_rad_s", self.hip_rate_min_rad_s, self.hip_rate_max_rad_s) - _require_range("torso_rate_rad_s", self.torso_rate_min_rad_s, self.torso_rate_max_rad_s) + _require_range( + "hip_rate_rad_s", self.hip_rate_min_rad_s, self.hip_rate_max_rad_s + ) + _require_range( + "torso_rate_rad_s", self.torso_rate_min_rad_s, self.torso_rate_max_rad_s + ) _require_range("knee_ratio", self.knee_ratio_min, self.knee_ratio_max) for name, value in ( ("frequency_samples", self.frequency_samples), @@ -350,7 +356,9 @@ def __post_init__(self) -> None: if phase_lower < 0.0: raise ValueError("phase_rad_min must be non-negative") if phase_upper < phase_lower: - raise ValueError("phase_rad_max must be greater than or equal to phase_rad_min") + raise ValueError( + "phase_rad_max must be greater than or equal to phase_rad_min" + ) def as_list(self) -> list[tuple[float, float]]: """Return bounds ordered to match the optimizer parameter vector.""" @@ -460,7 +468,11 @@ def _arm_elbow_point( forearm_length = config.forearm.length_m delta = hand - shoulder distance = float(np.linalg.norm(delta)) - unit = delta / distance if distance > 1e-9 else np.asarray([0.0, 1.0], dtype=np.float64) + unit = ( + delta / distance + if distance > 1e-9 + else np.asarray([0.0, 1.0], dtype=np.float64) + ) minimum_reach = abs(upper_length - forearm_length) + 1e-9 maximum_reach = upper_length + forearm_length - 1e-9 effective_distance = _clamp(distance, minimum_reach, maximum_reach) @@ -489,7 +501,9 @@ def _elbow_offset_bias(elbow_bias_rad: float) -> float: ``elbow_bias_rad`` is finite. """ - clamped = constrain_swing_pose(SwingPose(elbow_angle_rad=elbow_bias_rad)).elbow_angle_rad + clamped = constrain_swing_pose( + SwingPose(elbow_angle_rad=elbow_bias_rad) + ).elbow_angle_rad lower, upper = SWING_ELBOW_LIMITS_RAD span = upper - lower if span <= 0.0: @@ -641,7 +655,9 @@ def _policy(_state: SwingSetState, time_s: float) -> SwingControlAction: torso_lean_rate_rad_s=-parameters.torso_rate_amplitude_rad_s * driver, hip_rate_rad_s=parameters.hip_rate_amplitude_rad_s * driver, knee_rate_rad_s=( - -parameters.knee_rate_ratio * parameters.hip_rate_amplitude_rad_s * driver + -parameters.knee_rate_ratio + * parameters.hip_rate_amplitude_rad_s + * driver ), shoulder_rate_rad_s=-0.1 * driver, elbow_rate_rad_s=0.12 * driver, @@ -666,7 +682,9 @@ def cyclic_policy_controls( raise ValueError("steps must be at least 1") _require_positive("dt_s", dt_s) times = np.arange(steps, dtype=np.float64) * dt_s - driver = np.sin(2.0 * np.pi * parameters.frequency_hz * times + parameters.phase_rad) + driver = np.sin( + 2.0 * np.pi * parameters.frequency_hz * times + parameters.phase_rad + ) return np.column_stack( ( -parameters.torso_rate_amplitude_rad_s * driver, @@ -710,7 +728,9 @@ def simulate_swingset_controls( or control_array.shape[1] != CONTROL_DIMENSION or not np.all(np.isfinite(control_array)) ): - raise ValueError("controls must have shape (N >= 1, 5) and contain finite values") + raise ValueError( + "controls must have shape (N >= 1, 5) and contain finite values" + ) _require_positive("dt_s", dt_s) states = [replace(initial_state, pose=constrain_swing_pose(initial_state.pose))] snapshots = [build_swingset_snapshot(config, initial_state.pose)] @@ -805,7 +825,9 @@ def optimize_cyclic_policy( best_params = parameters best_rollout = rollout best_score = score - if best_rollout is None: # pragma: no cover - defensive guard for malformed searches. + if ( + best_rollout is None + ): # pragma: no cover - defensive guard for malformed searches. raise RuntimeError("Policy search did not evaluate a rollout") trace.append( CyclicPolicyTraceSample( @@ -818,7 +840,9 @@ def optimize_cyclic_policy( ) if progress_callback is not None: progress_callback(index, len(candidates), best_score, best_params) - if best_rollout is None: # pragma: no cover - defensive guard for malformed searches. + if ( + best_rollout is None + ): # pragma: no cover - defensive guard for malformed searches. raise RuntimeError("Policy search did not evaluate a rollout") return CyclicPolicySearchResult( best_params, @@ -830,7 +854,9 @@ def optimize_cyclic_policy( ) -def _params_from_vector(vector: FloatArray, bounds: CyclicPolicyBounds) -> CyclicPolicyParameters: +def _params_from_vector( + vector: FloatArray, bounds: CyclicPolicyBounds +) -> CyclicPolicyParameters: """Build clamped policy parameters from an optimizer vector. Clamping matters because the local-refinement stage (Nelder-Mead) is not @@ -838,7 +864,8 @@ def _params_from_vector(vector: FloatArray, bounds: CyclicPolicyBounds) -> Cycli """ limits = bounds.as_list() clamped = [ - _clamp(float(value), low, high) for value, (low, high) in zip(vector, limits, strict=True) + _clamp(float(value), low, high) + for value, (low, high) in zip(vector, limits, strict=True) ] return CyclicPolicyParameters( frequency_hz=clamped[0], @@ -963,7 +990,9 @@ def _objective(vector: FloatArray) -> float: options={"maxfev": budget - eval_count, "xatol": 1e-4, "fatol": 1e-6}, ) - if best_rollout is None or best_params is None: # pragma: no cover - budget>=1 guarantees one. + if ( + best_rollout is None or best_params is None + ): # pragma: no cover - budget>=1 guarantees one. raise RuntimeError("Iterative policy search did not evaluate a rollout") return CyclicPolicySearchResult( best_params, @@ -994,7 +1023,9 @@ def estimate_swingset_joint_torques( return np.zeros((0, CONTROL_DIMENSION), dtype=np.float64) inertias = _policy_joint_inertias(config) accelerations = ( - np.gradient(controls, dt_s, axis=0) if controls.shape[0] > 1 else np.zeros_like(controls) + np.gradient(controls, dt_s, axis=0) + if controls.shape[0] > 1 + else np.zeros_like(controls) ) damping = 0.08 * inertias * controls return accelerations * inertias + damping @@ -1004,12 +1035,14 @@ def _policy_joint_inertias(config: SwingSetConfig) -> FloatArray: torso = config.torso.mass_kg * config.torso.length_m**2 / 3.0 hip = 2.0 * ( config.thigh.mass_kg * config.thigh.length_m**2 / 3.0 - + config.shank.mass_kg * (config.thigh.length_m + 0.5 * config.shank.length_m) ** 2 + + config.shank.mass_kg + * (config.thigh.length_m + 0.5 * config.shank.length_m) ** 2 ) knee = 2.0 * config.shank.mass_kg * config.shank.length_m**2 / 3.0 shoulder = 2.0 * ( config.upper_arm.mass_kg * config.upper_arm.length_m**2 / 3.0 - + config.forearm.mass_kg * (config.upper_arm.length_m + 0.5 * config.forearm.length_m) ** 2 + + config.forearm.mass_kg + * (config.upper_arm.length_m + 0.5 * config.forearm.length_m) ** 2 ) elbow = 2.0 * config.forearm.mass_kg * config.forearm.length_m**2 / 3.0 return np.asarray([torso, hip, knee, shoulder, elbow], dtype=np.float64) diff --git a/src/movement_optimizer/models/swingset_forces.py b/src/movement_optimizer/models/swingset_forces.py index 8a653352e1..ca3d4f9261 100644 --- a/src/movement_optimizer/models/swingset_forces.py +++ b/src/movement_optimizer/models/swingset_forces.py @@ -122,7 +122,9 @@ def swing_force_fields( torque_index = min(frame_index, torques.shape[0] - 1) chain_tension = mass * accelerations[frame_index] - gravity_vec joint_points = { - joint: np.asarray(snapshot.points[_JOINT_POINT_KEYS[joint]], dtype=np.float64) + joint: np.asarray( + snapshot.points[_JOINT_POINT_KEYS[joint]], dtype=np.float64 + ) for joint in SWING_POLICY_JOINT_NAMES } fields.append( diff --git a/src/movement_optimizer/persistence.py b/src/movement_optimizer/persistence.py index a785d728a0..9c82b07317 100644 --- a/src/movement_optimizer/persistence.py +++ b/src/movement_optimizer/persistence.py @@ -109,7 +109,9 @@ class InvalidStateFileError(ValueError): def _require_mapping(data: Any, context: str) -> dict[str, Any]: """Return ``data`` as a dict or raise with a descriptive context.""" if not isinstance(data, dict): - raise InvalidStateFileError(f"{context}: expected JSON object, got {type(data).__name__}") + raise InvalidStateFileError( + f"{context}: expected JSON object, got {type(data).__name__}" + ) return data @@ -142,7 +144,9 @@ def _require_type(value: Any, expected: type | tuple[type, ...], field: str) -> def _require_range(value: float, bounds: tuple[float, float], field: str) -> None: low, high = bounds if not (low <= value <= high): - raise InvalidStateFileError(f"field '{field}': value {value} out of range [{low}, {high}]") + raise InvalidStateFileError( + f"field '{field}': value {value} out of range [{low}, {high}]" + ) def _validate_schema_version(data: dict[str, Any], context: str) -> None: @@ -185,7 +189,9 @@ def _validate_metadata_block(metadata: Any, context: str) -> None: } for key, expected in required_types.items(): if key not in meta_dict: - raise InvalidStateFileError(f"{context}: missing required metadata key '{key}'") + raise InvalidStateFileError( + f"{context}: missing required metadata key '{key}'" + ) # ``success`` is bool and must be checked separately to avoid the # numeric-bool guard in ``_require_type``. if expected is bool: @@ -267,7 +273,9 @@ def _validate_app_state_schema(data: dict[str, Any]) -> None: ) sub = _require_mapping(payload, f"results.{etype}") if "arrays" not in sub or "metadata" not in sub: - raise InvalidStateFileError(f"results.{etype}: must contain 'arrays' and 'metadata'") + raise InvalidStateFileError( + f"results.{etype}: must contain 'arrays' and 'metadata'" + ) _validate_arrays_block(sub["arrays"], f"results.{etype}") _validate_metadata_block(sub["metadata"], f"results.{etype}") @@ -398,7 +406,9 @@ def save_app_state( slider_values maps slider_name -> float value. """ state_path = ( - load_app_paths().state_file if state_dir is None else Path(state_dir) / "last_state.json" + load_app_paths().state_file + if state_dir is None + else Path(state_dir) / "last_state.json" ) state_path.parent.mkdir(parents=True, exist_ok=True) @@ -426,7 +436,9 @@ def load_app_state(*, state_dir: str | Path | None = None) -> dict[str, Any] | N state is incompatible rather than being silently discarded. """ state_path = ( - load_app_paths().state_file if state_dir is None else Path(state_dir) / "last_state.json" + load_app_paths().state_file + if state_dir is None + else Path(state_dir) / "last_state.json" ) if not state_path.exists(): diff --git a/src/movement_optimizer/rendering.py b/src/movement_optimizer/rendering.py index 54e752e25c..24904fda96 100644 --- a/src/movement_optimizer/rendering.py +++ b/src/movement_optimizer/rendering.py @@ -232,7 +232,9 @@ def draw_ghost( HEAD_RADIUS = 0.10 # metres @classmethod - def draw_segments(cls, ax: Axes, joints: dict[str, NDArray], body_height: float = 1.75) -> None: + def draw_segments( + cls, ax: Axes, joints: dict[str, NDArray], body_height: float = 1.75 + ) -> None: pts = [joints["ankle"], joints["knee"], joints["hip"], joints["shoulder"]] for k in range(3): ax.plot( diff --git a/src/movement_optimizer/result_analysis.py b/src/movement_optimizer/result_analysis.py index da3d032b99..07eec3c1fd 100644 --- a/src/movement_optimizer/result_analysis.py +++ b/src/movement_optimizer/result_analysis.py @@ -79,7 +79,9 @@ def recommendations(self) -> list[str]: """Return result-driven recommendations for the exported report.""" recommendations: list[str] = [] if not self.result.success: - recommendations.append("Review optimization settings; the solver did not converge.") + recommendations.append( + "Review optimization settings; the solver did not converge." + ) if self.result.n_joint_limit_violations > 0: recommendations.append( "Review joint limits; the trajectory exceeded configured bounds." @@ -95,10 +97,14 @@ def recommendations(self) -> list[str]: ] if high_torque_joints: joined = ", ".join(high_torque_joints) - recommendations.append(f"Review load selection; peak torque is high at: {joined}.") + recommendations.append( + f"Review load selection; peak torque is high at: {joined}." + ) if not recommendations: - recommendations.append("No immediate issues detected in the exported result.") + recommendations.append( + "No immediate issues detected in the exported result." + ) return recommendations def com_range_cm(self) -> float: diff --git a/src/movement_optimizer/strength.py b/src/movement_optimizer/strength.py index 54ba53e4fc..cc120407af 100644 --- a/src/movement_optimizer/strength.py +++ b/src/movement_optimizer/strength.py @@ -66,7 +66,9 @@ def torque_angle_factor(self, q: float | NDArray) -> NDArray: raise ValueError("q must not contain NaN values") return np.exp(-(((q_arr - self.q_optimal) / self.angle_width) ** 2)) - def torque_velocity_factor(self, qd: float | NDArray, torque_sign: float = -1.0) -> NDArray: + def torque_velocity_factor( + self, qd: float | NDArray, torque_sign: float = -1.0 + ) -> NDArray: """Hill-type force-velocity scaling factor. Branch selection is based on whether the muscle is shortening @@ -97,7 +99,9 @@ def torque_velocity_factor(self, qd: float | NDArray, torque_sign: float = -1.0) def available_torque(self, q: float | NDArray, qd: float | NDArray) -> NDArray: """Maximum torque the joint can produce at given angle and velocity.""" - return self.tau_max * self.torque_angle_factor(q) * self.torque_velocity_factor(qd) + return ( + self.tau_max * self.torque_angle_factor(q) * self.torque_velocity_factor(qd) + ) class JointTorqueSet: @@ -152,10 +156,14 @@ def available_torques_batch(self, q: NDArray, qd: NDArray) -> NDArray: """Compute available torque at each joint for N poses.""" result = np.empty((q.shape[0], len(self.joint_names))) for index, name in enumerate(self.joint_names): - result[:, index] = self._models[name].available_torque(q[:, index], qd[:, index]) + result[:, index] = self._models[name].available_torque( + q[:, index], qd[:, index] + ) return result - def torque_utilization(self, q: NDArray, qd: NDArray, required_torques: NDArray) -> NDArray: + def torque_utilization( + self, q: NDArray, qd: NDArray, required_torques: NDArray + ) -> NDArray: """Ratio of required torque to available torque.""" available = self.available_torques_batch(q, qd) safe_available = np.maximum(available, 1e-10) diff --git a/src/movement_optimizer/tests/test_anim_renderer.py b/src/movement_optimizer/tests/test_anim_renderer.py index 763d9723fc..1a62487761 100644 --- a/src/movement_optimizer/tests/test_anim_renderer.py +++ b/src/movement_optimizer/tests/test_anim_renderer.py @@ -91,7 +91,9 @@ def test_draw_anim_frame_deadlift(self, mock_ax, mock_dynamics, dummy_result, bo mock_ax.clear.assert_called_once() mock_ax.set_title.assert_called_once() - def test_draw_anim_frame_bench_press(self, mock_ax, mock_dynamics, dummy_result, body): + def test_draw_anim_frame_bench_press( + self, mock_ax, mock_dynamics, dummy_result, body + ): draw_anim_frame( mock_ax, 5, diff --git a/src/movement_optimizer/tests/test_bench_press.py b/src/movement_optimizer/tests/test_bench_press.py index 05b09d31d3..944e0e8065 100644 --- a/src/movement_optimizer/tests/test_bench_press.py +++ b/src/movement_optimizer/tests/test_bench_press.py @@ -78,17 +78,17 @@ def test_bench_start_is_lockout(self, default_body: BodyModel) -> None: """q_start should have shoulder near 0 degrees (arms vertical/lockout).""" _dyn, qs, _qe, _qb, _q_via = make_bench_press_config(default_body, 60.0) shoulder_deg = np.degrees(qs[0]) - assert abs(shoulder_deg) < 5, ( - f"At lockout shoulder should be near 0 deg, got {shoulder_deg:.1f}" - ) + assert ( + abs(shoulder_deg) < 5 + ), f"At lockout shoulder should be near 0 deg, got {shoulder_deg:.1f}" def test_bench_via_is_chest(self, default_body: BodyModel) -> None: """q_via should have shoulder near 80 degrees (upper arm horizontal).""" _dyn, _qs, _qe, _qb, q_via = make_bench_press_config(default_body, 60.0) shoulder_deg = np.degrees(q_via[0]) - assert 70 < shoulder_deg < 95, ( - f"At chest touch shoulder should be ~80 deg, got {shoulder_deg:.1f}" - ) + assert ( + 70 < shoulder_deg < 95 + ), f"At chest touch shoulder should be ~80 deg, got {shoulder_deg:.1f}" def test_bench_full_rep(self, default_body: BodyModel) -> None: """q_start should equal q_end (full rep returns to lockout).""" diff --git a/src/movement_optimizer/tests/test_benchmarks.py b/src/movement_optimizer/tests/test_benchmarks.py index e5228e7105..ffa80171e3 100644 --- a/src/movement_optimizer/tests/test_benchmarks.py +++ b/src/movement_optimizer/tests/test_benchmarks.py @@ -73,8 +73,12 @@ def test_single_inverse_dynamics_speed(self, default_body: BodyModel): for _ in range(10): dyn.inverse_dynamics(q, qd, qdd) - per_call_ms = _measure_ms(lambda: dyn.inverse_dynamics(q, qd, qdd), iterations=1000) - assert per_call_ms < 2.0, f"Single ID call took {per_call_ms:.3f}ms median (limit: 2ms)" + per_call_ms = _measure_ms( + lambda: dyn.inverse_dynamics(q, qd, qdd), iterations=1000 + ) + assert ( + per_call_ms < 2.0 + ), f"Single ID call took {per_call_ms:.3f}ms median (limit: 2ms)" def test_batch_inverse_dynamics_speed(self, default_body: BodyModel): """Batch inverse dynamics (100 timesteps) should complete in < 50ms (median). @@ -94,8 +98,12 @@ def test_batch_inverse_dynamics_speed(self, default_body: BodyModel): for _ in range(5): dyn.inverse_dynamics_batch(q, qd, qdd) - per_call_ms = _measure_ms(lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=100) - assert per_call_ms < 50.0, f"Batch ID (N=100) took {per_call_ms:.3f}ms median (limit: 50ms)" + per_call_ms = _measure_ms( + lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=100 + ) + assert ( + per_call_ms < 50.0 + ), f"Batch ID (N=100) took {per_call_ms:.3f}ms median (limit: 50ms)" class TestMassMatrixBenchmark: @@ -109,7 +117,9 @@ def test_mass_matrix_speed(self, default_body: BodyModel): dyn.mass_matrix(q) per_call_ms = _measure_ms(lambda: dyn.mass_matrix(q), iterations=1000) - assert per_call_ms < 1.0, f"Mass matrix took {per_call_ms:.3f}ms median (limit: 1ms)" + assert ( + per_call_ms < 1.0 + ), f"Mass matrix took {per_call_ms:.3f}ms median (limit: 1ms)" class TestForwardKinematicsBenchmark: @@ -134,7 +144,9 @@ def test_body_model_construction_speed(self): BodyModel(75.0, 1.75) per_call_ms = _measure_ms(lambda: BodyModel(75.0, 1.75), iterations=1000) - assert per_call_ms < 2.0, f"BodyModel init took {per_call_ms:.3f}ms median (limit: 2ms)" + assert ( + per_call_ms < 2.0 + ), f"BodyModel init took {per_call_ms:.3f}ms median (limit: 2ms)" # =========================================================================== @@ -207,9 +219,13 @@ def test_batch_id_typical_grid_under_budget(self, default_body: BodyModel): for _ in range(5): dyn.inverse_dynamics_batch(q, qd, qdd) - per_call_ms = _measure_ms(lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=200) + per_call_ms = _measure_ms( + lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=200 + ) logger.info("batch ID (N=%d) median %.4f ms", n, per_call_ms) - assert per_call_ms < 25.0, f"Batch ID (N={n}) took {per_call_ms:.3f}ms median (limit: 25ms)" + assert ( + per_call_ms < 25.0 + ), f"Batch ID (N={n}) took {per_call_ms:.3f}ms median (limit: 25ms)" def test_batch_id_scales_subquadratic(self, default_body: BodyModel): """Doubling N should not multiply batch-ID time by more than 4x. @@ -226,7 +242,9 @@ def time_batch(n: int) -> float: qdd = rng.uniform(-5.0, 5.0, (n, 3)) for _ in range(5): dyn.inverse_dynamics_batch(q, qd, qdd) - return _measure_ms(lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=200) + return _measure_ms( + lambda: dyn.inverse_dynamics_batch(q, qd, qdd), iterations=200 + ) t_small = time_batch(50) t_large = time_batch(200) @@ -257,7 +275,9 @@ def test_compute_cost_under_budget(self, default_body: BodyModel): per_call_ms = _measure_ms(lambda: opt._compute_cost(x0), iterations=200) logger.info("_compute_cost (n_eval=20) median %.4f ms", per_call_ms) - assert per_call_ms < 5.0, f"_compute_cost took {per_call_ms:.3f}ms median (limit: 5ms)" + assert ( + per_call_ms < 5.0 + ), f"_compute_cost took {per_call_ms:.3f}ms median (limit: 5ms)" class TestEndToEndOptimizer: @@ -274,7 +294,9 @@ def test_small_problem_under_10s(self, default_body: BodyModel): opt = _make_squat_optimizer(default_body, n_eval=20, n_starts=2) elapsed = _best_of(lambda: opt.optimize(), trials=2) logger.info("end-to-end optimize (n_eval=20, n_starts=2) %.3f s", elapsed) - assert elapsed < 10.0, f"Optimizer took {elapsed:.2f}s for a small problem (limit: 10s)" + assert ( + elapsed < 10.0 + ), f"Optimizer took {elapsed:.2f}s for a small problem (limit: 10s)" class TestOptimizerScaling: @@ -303,7 +325,9 @@ def run_at(n_eval: int) -> float: t_small = run_at(10) t_large = run_at(20) - logger.info("optimizer scaling: n_eval=10 %.3fs, n_eval=20 %.3fs", t_small, t_large) + logger.info( + "optimizer scaling: n_eval=10 %.3fs, n_eval=20 %.3fs", t_small, t_large + ) # Floor plus absolute cap prevent division blow-up when both runs are # very fast (sub-second) and scheduler noise dominates the ratio. baseline = max(t_small, 0.05) @@ -346,7 +370,9 @@ def test_cache_hit_much_faster_than_miss(self, default_body: BodyModel): ) per_hit_s = per_hit_ms / 1000.0 ratio = t_miss / per_hit_s if per_hit_s > 0 else float("inf") - logger.info("cache miss %.4fs vs hit %.6fs (ratio %.0fx)", t_miss, per_hit_s, ratio) + logger.info( + "cache miss %.4fs vs hit %.6fs (ratio %.0fx)", t_miss, per_hit_s, ratio + ) # Absolute upper bound on a single hit lookup so we catch the case # where a hit becomes unexpectedly expensive (e.g. deep copy added diff --git a/src/movement_optimizer/tests/test_bilateral_3d.py b/src/movement_optimizer/tests/test_bilateral_3d.py index 6c6be4aec5..20332f462d 100644 --- a/src/movement_optimizer/tests/test_bilateral_3d.py +++ b/src/movement_optimizer/tests/test_bilateral_3d.py @@ -63,7 +63,9 @@ def test_t_pose_ankles_on_ground(self, model: Bilateral3DModel) -> None: assert fk["left_ankle"][2] == pytest.approx(0.0) assert fk["right_ankle"][2] == pytest.approx(0.0) - def test_t_pose_shoulder_height_equals_sum_of_segments(self, model: Bilateral3DModel) -> None: + def test_t_pose_shoulder_height_equals_sum_of_segments( + self, model: Bilateral3DModel + ) -> None: fk = model.forward_kinematics(model.t_pose()) expected_height = model.L_shin + model.L_thigh + model.L_torso assert fk["shoulder"][2] == pytest.approx(expected_height) @@ -85,7 +87,9 @@ def test_t_pose_pelvis_midway(self, model: Bilateral3DModel) -> None: class TestKneeFlexion: """Flexing only the knee should produce a known-position check.""" - def test_90deg_knee_flex_drops_hip_by_thigh_length(self, model: Bilateral3DModel) -> None: + def test_90deg_knee_flex_drops_hip_by_thigh_length( + self, model: Bilateral3DModel + ) -> None: # Flex the left knee 90deg forward: ankle stays, shin stays vertical, # thigh now horizontal (pointing +x). So left_hip should be at # (L_thigh, +half_w, L_shin) -- the thigh rotated from "up" to "forward". @@ -101,7 +105,9 @@ def test_90deg_knee_flex_drops_hip_by_thigh_length(self, model: Bilateral3DModel np.testing.assert_allclose(fk["left_hip"], expected, atol=1e-10) # Right hip untouched - expected_right = np.array([0.0, -0.5 * model.stance_width_m, model.L_shin + model.L_thigh]) + expected_right = np.array( + [0.0, -0.5 * model.stance_width_m, model.L_shin + model.L_thigh] + ) np.testing.assert_allclose(fk["right_hip"], expected_right, atol=1e-10) @@ -142,13 +148,17 @@ def xz(p3: np.ndarray) -> np.ndarray: class TestInputValidation: - def test_forward_kinematics_rejects_raw_tuple(self, model: Bilateral3DModel) -> None: + def test_forward_kinematics_rejects_raw_tuple( + self, model: Bilateral3DModel + ) -> None: with pytest.raises(TypeError, match="Bilateral3DPose"): model.forward_kinematics((0.0, 0.0, 0.0)) # type: ignore[arg-type] class TestSegmentPairs: - def test_segment_pairs_reference_valid_joints(self, model: Bilateral3DModel) -> None: + def test_segment_pairs_reference_valid_joints( + self, model: Bilateral3DModel + ) -> None: fk = model.forward_kinematics(model.t_pose()) for a, b in model.segment_pairs(): assert a in fk, f"unknown joint {a}" diff --git a/src/movement_optimizer/tests/test_chain_forces.py b/src/movement_optimizer/tests/test_chain_forces.py index 13eeacd87d..cdaed04cf4 100644 --- a/src/movement_optimizer/tests/test_chain_forces.py +++ b/src/movement_optimizer/tests/test_chain_forces.py @@ -82,7 +82,12 @@ def test_chain_force_field_shapes_and_gravity() -> None: config, rollout = _make_rollout() field = chain_force_field(config, rollout, _DT, frame_index=2) assert isinstance(field, ChainForceField) - for array in (field.midpoints_m, field.gravity_n, field.tension_n, field.net_force_n): + for array in ( + field.midpoints_m, + field.gravity_n, + field.tension_n, + field.net_force_n, + ): assert array.shape == (_SEGMENTS, 2) assert np.all(np.isfinite(array)) expected = config.link_mass_kg * config.gravity_m_s2 diff --git a/src/movement_optimizer/tests/test_cli.py b/src/movement_optimizer/tests/test_cli.py index f28bf66eb1..802601b5a6 100644 --- a/src/movement_optimizer/tests/test_cli.py +++ b/src/movement_optimizer/tests/test_cli.py @@ -139,7 +139,9 @@ def optimize(self): class TestMain: - def test_main_writes_output_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + def test_main_writes_output_file( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ): result = make_test_result(cost=12.3) _FakeOptimizer.init_calls = [] _FakeOptimizer.next_result = result @@ -150,7 +152,9 @@ def test_main_writes_output_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path np.ones(3), np.zeros((3, 2)), ) - monkeypatch.setitem(cli.EXERCISE_FACTORIES, "squat", lambda body, bar_mass: fake_config) + monkeypatch.setitem( + cli.EXERCISE_FACTORIES, "squat", lambda body, bar_mass: fake_config + ) monkeypatch.setattr(cli, "TrajectoryOptimizer", _FakeOptimizer) output_path = tmp_path / "result.json" @@ -164,7 +168,9 @@ def test_main_writes_output_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path assert _FakeOptimizer.init_calls[-1]["kwargs"]["duration"] == 2.0 assert _FakeOptimizer.init_calls[-1]["kwargs"]["q_via"] is None - def test_main_emits_summary_for_multiphase_lift(self, monkeypatch: pytest.MonkeyPatch): + def test_main_emits_summary_for_multiphase_lift( + self, monkeypatch: pytest.MonkeyPatch + ): result = make_test_result(cost=7.5) result.success = False _FakeOptimizer.init_calls = [] @@ -180,9 +186,13 @@ def test_main_emits_summary_for_multiphase_lift(self, monkeypatch: pytest.Monkey ) emitted: list[dict[str, Any]] = [] - monkeypatch.setitem(cli.EXERCISE_FACTORIES, "clean", lambda body, bar_mass: fake_config) + monkeypatch.setitem( + cli.EXERCISE_FACTORIES, "clean", lambda body, bar_mass: fake_config + ) monkeypatch.setattr(cli, "TrajectoryOptimizer", _FakeOptimizer) - monkeypatch.setattr(cli, "_emit_cli_summary", lambda summary: emitted.append(summary)) + monkeypatch.setattr( + cli, "_emit_cli_summary", lambda summary: emitted.append(summary) + ) exit_code = cli.main(["--exercise", "clean", "--duration", "1.0", "--verbose"]) diff --git a/src/movement_optimizer/tests/test_edge_cases.py b/src/movement_optimizer/tests/test_edge_cases.py index 3ececa19ad..e6d8794ec6 100644 --- a/src/movement_optimizer/tests/test_edge_cases.py +++ b/src/movement_optimizer/tests/test_edge_cases.py @@ -92,12 +92,12 @@ def _assert_result_finite(result: OptimizationResult, n_eval: int) -> None: def _assert_inner_bos(result: OptimizationResult, body: BodyModel) -> None: """COM must respect the inner-BOS hard constraint (with loose slack).""" com_x = result.com[:, 0] - assert np.all(com_x >= body.inner_heel - _BOS_TOL_M), ( - f"COM below inner_heel: min={com_x.min():.4f}, bound={body.inner_heel:.4f}" - ) - assert np.all(com_x <= body.inner_toe + _BOS_TOL_M), ( - f"COM above inner_toe: max={com_x.max():.4f}, bound={body.inner_toe:.4f}" - ) + assert np.all( + com_x >= body.inner_heel - _BOS_TOL_M + ), f"COM below inner_heel: min={com_x.min():.4f}, bound={body.inner_heel:.4f}" + assert np.all( + com_x <= body.inner_toe + _BOS_TOL_M + ), f"COM above inner_toe: max={com_x.max():.4f}, bound={body.inner_toe:.4f}" # --------------------------------------------------------------------------- @@ -248,9 +248,9 @@ def test_identical_start_and_end_angles(self) -> None: _assert_result_finite(result, opt.n_eval) # Each joint should travel less than ~3 degrees from the constant pose. max_travel_rad = float(np.max(np.abs(result.q - qs))) - assert max_travel_rad < np.radians(15.0), ( - f"Zero-ROM trajectory drifted {np.degrees(max_travel_rad):.2f} deg" - ) + assert max_travel_rad < np.radians( + 15.0 + ), f"Zero-ROM trajectory drifted {np.degrees(max_travel_rad):.2f} deg" # --------------------------------------------------------------------------- @@ -267,7 +267,9 @@ def test_single_start(self) -> None: _assert_result_finite(result, opt.n_eval) assert result.success - @pytest.mark.xfail(reason="SLSQP multistart convergence is unstable on some platforms") + @pytest.mark.xfail( + reason="SLSQP multistart convergence is unstable on some platforms" + ) def test_many_multistarts(self) -> None: """A larger n_starts exercises the parallel path and must succeed.""" body = BodyModel(75.0, 1.75) @@ -335,7 +337,16 @@ def test_too_few_waypoints_raises(self) -> None: dyn, qs, qe, qb = make_squat_config(body, 60.0) with pytest.raises(ValueError, match=r">= 4 waypoints"): TrajectoryOptimizer( - body, dyn, "squat", 60.0, qs, qe, qb, n_waypoints=3, n_eval=20, n_starts=1 + body, + dyn, + "squat", + 60.0, + qs, + qe, + qb, + n_waypoints=3, + n_eval=20, + n_starts=1, ) def test_minimum_waypoints_accepted(self) -> None: diff --git a/src/movement_optimizer/tests/test_exercise_tab.py b/src/movement_optimizer/tests/test_exercise_tab.py index 1416f607ea..07fc426e4a 100644 --- a/src/movement_optimizer/tests/test_exercise_tab.py +++ b/src/movement_optimizer/tests/test_exercise_tab.py @@ -239,7 +239,9 @@ def test_draw_anim_frame_passes_tab_name(self, mock_anim_renderer) -> None: assert "Deadlift" in call_args @patch("movement_optimizer.gui.exercise_tab.anim_renderer") - def test_draw_anim_frame_passes_correct_frame_index(self, mock_anim_renderer) -> None: + def test_draw_anim_frame_passes_correct_frame_index( + self, mock_anim_renderer + ) -> None: from movement_optimizer.gui.exercise_tab import ExerciseTab tab = ExerciseTab("Squat") diff --git a/src/movement_optimizer/tests/test_exercises.py b/src/movement_optimizer/tests/test_exercises.py index 864f1ea5b7..6a0842d0ca 100644 --- a/src/movement_optimizer/tests/test_exercises.py +++ b/src/movement_optimizer/tests/test_exercises.py @@ -85,7 +85,9 @@ def test_jerk_start_at_rack(self, default_body: BodyModel) -> None: def test_jerk_end_overhead(self, default_body: BodyModel) -> None: dyn, _qs, qe, _qb, _q_via = make_jerk_config(default_body, 60.0) # End: torso near vertical (bar overhead) - assert abs(qe[2]) < np.radians(10), "Jerk end: torso must be near vertical (overhead)" + assert abs(qe[2]) < np.radians( + 10 + ), "Jerk end: torso must be near vertical (overhead)" # Shoulder should be near standing height (overhead lockout) fk = dyn.forward_kinematics(qe) shoulder_h = fk["shoulder"][1] @@ -115,11 +117,15 @@ def test_snatch_start_near_floor(self, default_body: BodyModel) -> None: def test_snatch_end_overhead(self, default_body: BodyModel) -> None: dyn, _qs, qe, _qb, _q_via = make_snatch_config(default_body, 60.0) # End: torso near vertical (bar overhead) - assert abs(qe[2]) < np.radians(10), "Snatch end: torso must be near vertical (overhead)" + assert abs(qe[2]) < np.radians( + 10 + ), "Snatch end: torso must be near vertical (overhead)" fk = dyn.forward_kinematics(qe) shoulder_h = fk["shoulder"][1] total_h = default_body.L.sum() - assert shoulder_h > total_h * 0.90, "Snatch end: shoulder must be high (overhead)" + assert ( + shoulder_h > total_h * 0.90 + ), "Snatch end: shoulder must be high (overhead)" def test_snatch_has_via_points(self, default_body: BodyModel) -> None: _dyn, _qs, _qe, _qb, q_via = make_snatch_config(default_body, 60.0) @@ -131,7 +137,9 @@ def test_snatch_via_is_overhead_squat(self, default_body: BodyModel) -> None: assert q_via[1] < np.radians(-60), "Snatch via: should be deep squat" # Torso relatively upright for overhead position # balance_pose may adjust the torso angle to maintain COM balance - assert abs(q_via[2]) < np.radians(80), "Snatch via: torso should be reasonably upright" + assert abs(q_via[2]) < np.radians( + 80 + ), "Snatch via: torso should be reasonably upright" # ------------------------------------------------------------------ @@ -179,7 +187,9 @@ def test_bench_no_com_constraint(self, default_body: BodyModel) -> None: n_waypoints=8, ) constraints = opt._build_constraints() - assert len(constraints) == 1, "Bench press should keep only the joint-limit constraint" + assert ( + len(constraints) == 1 + ), "Bench press should keep only the joint-limit constraint" assert constraints[0]["fun"] is joint_limit_constraint_values @@ -208,8 +218,12 @@ def _check_com_in_inner_bos( def test_clean_endpoints_balanced(self, default_body: BodyModel) -> None: dyn, qs, qe, _qb, _q_via = make_clean_config(default_body, 60.0) - self._check_com_in_inner_bos(default_body, dyn, qs, "deadlift", 60.0, "clean start") - self._check_com_in_inner_bos(default_body, dyn, qe, "deadlift", 60.0, "clean end") + self._check_com_in_inner_bos( + default_body, dyn, qs, "deadlift", 60.0, "clean start" + ) + self._check_com_in_inner_bos( + default_body, dyn, qe, "deadlift", 60.0, "clean end" + ) def test_jerk_endpoints_balanced(self, default_body: BodyModel) -> None: dyn, qs, qe, _qb, _q_via = make_jerk_config(default_body, 60.0) @@ -218,5 +232,7 @@ def test_jerk_endpoints_balanced(self, default_body: BodyModel) -> None: def test_snatch_endpoints_balanced(self, default_body: BodyModel) -> None: dyn, qs, qe, _qb, _q_via = make_snatch_config(default_body, 60.0) - self._check_com_in_inner_bos(default_body, dyn, qs, "deadlift", 60.0, "snatch start") + self._check_com_in_inner_bos( + default_body, dyn, qs, "deadlift", 60.0, "snatch start" + ) self._check_com_in_inner_bos(default_body, dyn, qe, "squat", 60.0, "snatch end") diff --git a/src/movement_optimizer/tests/test_export.py b/src/movement_optimizer/tests/test_export.py index f3869d3793..00b44d35c6 100644 --- a/src/movement_optimizer/tests/test_export.py +++ b/src/movement_optimizer/tests/test_export.py @@ -257,7 +257,9 @@ def test_summary_contains_torque_statistics(self, tmp_path): export_to_excel(r, str(path)) wb = openpyxl.load_workbook(str(path)) ws = wb["Summary"] - all_values = [str(cell.value) for row in ws.iter_rows() for cell in row if cell.value] + all_values = [ + str(cell.value) for row in ws.iter_rows() for cell in row if cell.value + ] assert any("Peak" in v for v in all_values) def test_statistics_sheet_contains_recommendations(self, tmp_path): @@ -270,7 +272,9 @@ def test_statistics_sheet_contains_recommendations(self, tmp_path): export_to_excel(r, str(path)) wb = openpyxl.load_workbook(str(path)) ws = wb["Statistics"] - all_values = [str(cell.value) for row in ws.iter_rows() for cell in row if cell.value] + all_values = [ + str(cell.value) for row in ws.iter_rows() for cell in row if cell.value + ] assert "Recommendations" in all_values def test_raises_on_none_result(self, tmp_path): diff --git a/src/movement_optimizer/tests/test_export_excel.py b/src/movement_optimizer/tests/test_export_excel.py index 6f078ecf9e..64c5d9eb8b 100644 --- a/src/movement_optimizer/tests/test_export_excel.py +++ b/src/movement_optimizer/tests/test_export_excel.py @@ -36,7 +36,9 @@ def test_summary_sheet_has_non_empty_data(self, tmp_path): wb = openpyxl.load_workbook(str(path)) ws = wb["Summary"] non_empty_rows = [ - row for row in ws.iter_rows(values_only=True) if any(v is not None for v in row) + row + for row in ws.iter_rows(values_only=True) + if any(v is not None for v in row) ] assert len(non_empty_rows) > 0 @@ -92,7 +94,11 @@ def test_optional_metadata_written_to_summary(self, tmp_path): path = tmp_path / "meta.xlsx" export_to_excel( - result, path, exercise_name="Deadlift", body_mass_kg=80.0, body_height_m=1.82 + result, + path, + exercise_name="Deadlift", + body_mass_kg=80.0, + body_height_m=1.82, ) wb = openpyxl.load_workbook(str(path)) @@ -111,7 +117,9 @@ def test_statistics_sheet_contains_required_metrics(self, tmp_path): wb = openpyxl.load_workbook(str(path)) ws = wb["Statistics"] - values = [cell for row in ws.iter_rows(values_only=True) for cell in row if cell] + values = [ + cell for row in ws.iter_rows(values_only=True) for cell in row if cell + ] assert "Mean (N*m)" in values assert "Std dev (N*m)" in values assert "Min (N*m)" in values diff --git a/src/movement_optimizer/tests/test_gait_sts.py b/src/movement_optimizer/tests/test_gait_sts.py index 80ac6e244c..80706f0e6b 100644 --- a/src/movement_optimizer/tests/test_gait_sts.py +++ b/src/movement_optimizer/tests/test_gait_sts.py @@ -73,7 +73,9 @@ def test_spatiotemporal_basic(self, default_body: BodyModel) -> None: assert result["walking_speed_m_s"] == pytest.approx(0.7, rel=1e-6) assert result["cycle_duration_s"] == pytest.approx(1.0, rel=1e-6) assert 0.0 < result["stance_phase_pct"] < 100.0 - assert result["stance_phase_pct"] + result["swing_phase_pct"] == pytest.approx(100.0) + assert result["stance_phase_pct"] + result["swing_phase_pct"] == pytest.approx( + 100.0 + ) def test_symmetry_index_identical(self, default_body: BodyModel) -> None: analyzer = GaitAnalyzer(default_body) diff --git a/src/movement_optimizer/tests/test_help_dialog.py b/src/movement_optimizer/tests/test_help_dialog.py index c96cde95c7..c86ecbd842 100644 --- a/src/movement_optimizer/tests/test_help_dialog.py +++ b/src/movement_optimizer/tests/test_help_dialog.py @@ -16,9 +16,13 @@ def test_help_center_exposes_required_offline_topics(qapp) -> None: assert len(HELP_TOPICS) >= 5 assert dialog.tabs.count() >= 5 - assert {"getting_started", "parameters", "results", "troubleshooting", "glossary"} <= set( - HELP_TOPICS - ) + assert { + "getting_started", + "parameters", + "results", + "troubleshooting", + "glossary", + } <= set(HELP_TOPICS) def test_help_center_can_select_each_topic(qapp) -> None: @@ -34,10 +38,15 @@ def test_help_center_contains_glossary_terms(qapp) -> None: assert len(GLOSSARY) >= 7 assert {"COM", "BOS", "Torque", "ROM"} <= set(GLOSSARY) - assert dialog.tabs.tabText(dialog.tabs.currentIndex()) == HELP_TOPICS["glossary"].title + assert ( + dialog.tabs.tabText(dialog.tabs.currentIndex()) == HELP_TOPICS["glossary"].title + ) def test_parameter_help_dialog_opens_parameter_topic(qapp) -> None: dialog = ParameterHelpDialog() - assert dialog.tabs.tabText(dialog.tabs.currentIndex()) == HELP_TOPICS["parameters"].title + assert ( + dialog.tabs.tabText(dialog.tabs.currentIndex()) + == HELP_TOPICS["parameters"].title + ) diff --git a/src/movement_optimizer/tests/test_hypothesis.py b/src/movement_optimizer/tests/test_hypothesis.py index 166cac0cbd..a71413bfdc 100644 --- a/src/movement_optimizer/tests/test_hypothesis.py +++ b/src/movement_optimizer/tests/test_hypothesis.py @@ -24,7 +24,9 @@ build_splines, eval_trajectory, ) -from movement_optimizer.trajectory.optimizer_constraints import joint_limit_constraint_values +from movement_optimizer.trajectory.optimizer_constraints import ( + joint_limit_constraint_values, +) from movement_optimizer.trajectory.optimizer_cost import ( compute_torque_cost, compute_torque_rate_cost, @@ -86,7 +88,9 @@ def test_body_model_rejects_nonpositive_mass(self, body_mass: float): to=st.floats(min_value=0.5, max_value=2.0), ) @settings(max_examples=100) - def test_segment_multipliers_preserve_proportionality(self, ll: float, ul: float, to: float): + def test_segment_multipliers_preserve_proportionality( + self, ll: float, ul: float, to: float + ): """Segment lengths should scale linearly with multipliers.""" base = BodyModel(75.0, 1.75) scaled = BodyModel( @@ -222,7 +226,9 @@ def test_constant_in_bounds_spline_satisfies_joint_constraints( def build_splines_fn(flat_x: np.ndarray): return build_splines(flat_x, q, q, None, t_ctrl, n_waypoints, 3) - constraints = joint_limit_constraint_values(x, build_splines_fn, t_eval, q_bounds) + constraints = joint_limit_constraint_values( + x, build_splines_fn, t_eval, q_bounds + ) assert constraints.shape == (2 * len(t_eval) * 3,) assert np.all(constraints >= -1e-10) @@ -231,12 +237,18 @@ def build_splines_fn(flat_x: np.ndarray): class TestOptimizationCostProperties: @given( values=st.lists( - st.floats(min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False), + st.floats( + min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False + ), min_size=6, max_size=30, ), - dt=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), - scale=st.floats(min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False), + dt=st.floats( + min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False + ), + scale=st.floats( + min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False + ), ) @settings(max_examples=75) def test_torque_cost_scales_quadratically( @@ -252,17 +264,29 @@ def test_torque_cost_scales_quadratically( base_cost = compute_torque_cost(torques, dt) scaled_cost = compute_torque_cost(scale * torques, dt) - np.testing.assert_allclose(scaled_cost, scale**2 * base_cost, rtol=1e-12, atol=1e-9) + np.testing.assert_allclose( + scaled_cost, scale**2 * base_cost, rtol=1e-12, atol=1e-9 + ) @given( row=st.tuples( - st.floats(min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False), - st.floats(min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False), - st.floats(min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False), + st.floats( + min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False + ), + st.floats( + min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False + ), + st.floats( + min_value=-200.0, max_value=200.0, allow_nan=False, allow_infinity=False + ), ), n_eval=st.integers(min_value=2, max_value=20), - dt=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False), - weight=st.floats(min_value=0.0, max_value=10.0, allow_nan=False, allow_infinity=False), + dt=st.floats( + min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False + ), + weight=st.floats( + min_value=0.0, max_value=10.0, allow_nan=False, allow_infinity=False + ), ) @settings(max_examples=75) def test_torque_rate_cost_zero_for_constant_torque( @@ -320,7 +344,9 @@ class TestTrajectoryOptimizerProperties: bar_mass=st.floats(min_value=0.0, max_value=200.0), ) @settings(max_examples=50) - def test_optimizer_produces_finite_cost(self, body_mass: float, height: float, bar_mass: float): + def test_optimizer_produces_finite_cost( + self, body_mass: float, height: float, bar_mass: float + ): """Optimizer should always produce a finite cost for valid inputs.""" from movement_optimizer.models.exercise_configs import make_squat_config from movement_optimizer.trajectory import TrajectoryOptimizer @@ -350,7 +376,9 @@ def test_optimizer_produces_finite_cost(self, body_mass: float, height: float, b q2=st.floats(min_value=-1.0, max_value=1.0), ) @settings(max_examples=50) - def test_cost_at_start_equals_end_for_static_pose(self, q0: float, q1: float, q2: float): + def test_cost_at_start_equals_end_for_static_pose( + self, q0: float, q1: float, q2: float + ): """Cost should be consistent for static start/end poses.""" from movement_optimizer.models.exercise_configs import make_squat_config from movement_optimizer.trajectory import TrajectoryOptimizer diff --git a/src/movement_optimizer/tests/test_import.py b/src/movement_optimizer/tests/test_import.py index ffcdad7138..c6470a10c4 100644 --- a/src/movement_optimizer/tests/test_import.py +++ b/src/movement_optimizer/tests/test_import.py @@ -62,7 +62,9 @@ def test_legacy_file_without_format_version_emits_warning(self, tmp_path, caplog path = tmp_path / "legacy.json" path.write_text(json.dumps(data), encoding="utf-8") - with caplog.at_level(logging.WARNING, logger="movement_optimizer.import_results"): + with caplog.at_level( + logging.WARNING, logger="movement_optimizer.import_results" + ): result = import_result_from_json(path) assert result["cost"] == 99.0 diff --git a/src/movement_optimizer/tests/test_install_nightly_system_deps.py b/src/movement_optimizer/tests/test_install_nightly_system_deps.py index 56663837d2..c862abf9f0 100644 --- a/src/movement_optimizer/tests/test_install_nightly_system_deps.py +++ b/src/movement_optimizer/tests/test_install_nightly_system_deps.py @@ -13,7 +13,9 @@ def _completed_process( stdout: str = "", stderr: str = "", ) -> subprocess.CompletedProcess[str]: - return subprocess.CompletedProcess(command, returncode, stdout=stdout, stderr=stderr) + return subprocess.CompletedProcess( + command, returncode, stdout=stdout, stderr=stderr + ) def test_run_with_lock_retries_retries_until_dpkg_lock_clears() -> None: diff --git a/src/movement_optimizer/tests/test_issue_217_decompose.py b/src/movement_optimizer/tests/test_issue_217_decompose.py index 1c3ca99abe..c4bd21e455 100644 --- a/src/movement_optimizer/tests/test_issue_217_decompose.py +++ b/src/movement_optimizer/tests/test_issue_217_decompose.py @@ -178,7 +178,9 @@ def test_4tuple_unpacks_all_fields(self) -> None: qe = np.array([0.4, 0.5, 0.6]) qb = np.zeros((3, 2)) dyn = object() - out_dyn, out_qs, out_qe, out_qb, out_via = _unpack_exercise_config((dyn, qs, qe, qb)) + out_dyn, out_qs, out_qe, out_qb, out_via = _unpack_exercise_config( + (dyn, qs, qe, qb) + ) assert out_dyn is dyn assert np.array_equal(out_qs, qs) assert np.array_equal(out_qe, qe) diff --git a/src/movement_optimizer/tests/test_issue_222_decompose.py b/src/movement_optimizer/tests/test_issue_222_decompose.py index 0435f421c6..cccdd7d0e0 100644 --- a/src/movement_optimizer/tests/test_issue_222_decompose.py +++ b/src/movement_optimizer/tests/test_issue_222_decompose.py @@ -145,7 +145,9 @@ def test_writes_json_to_file(self, tmp_path: Path) -> None: assert written["exercise"] == "squat" assert written["cost"] == pytest.approx(5.0) - def test_emits_summary_when_no_output(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_emits_summary_when_no_output( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: from conftest import make_test_result result = make_test_result(cost=7.7) @@ -204,7 +206,9 @@ def test_consistent_with_inverse_dynamics_single_timestep(self) -> None: d01 = q[:, 0] - q[:, 1] d02 = q[:, 0] - q[:, 2] d12 = q[:, 1] - q[:, 2] - tau_inertia = dyn._batch_inertia_torques(qdd, np.cos(d01), np.cos(d02), np.cos(d12)) + tau_inertia = dyn._batch_inertia_torques( + qdd, np.cos(d01), np.cos(d02), np.cos(d12) + ) tau_gravity = dyn._batch_gravity_torques(q) tau_total = tau_inertia + tau_gravity tau_ref = dyn.inverse_dynamics_batch(q, qd, qdd) diff --git a/src/movement_optimizer/tests/test_issue_247_split_optimizer.py b/src/movement_optimizer/tests/test_issue_247_split_optimizer.py index 3c5085fcb7..20b5481e6c 100644 --- a/src/movement_optimizer/tests/test_issue_247_split_optimizer.py +++ b/src/movement_optimizer/tests/test_issue_247_split_optimizer.py @@ -12,10 +12,17 @@ import numpy as np import pytest -from movement_optimizer.models import BodyModel, make_bench_press_config, make_squat_config +from movement_optimizer.models import ( + BodyModel, + make_bench_press_config, + make_squat_config, +) from movement_optimizer.trajectory import TrajectoryOptimizer from movement_optimizer.trajectory.optimizer_bench import compute_bench_bar_cost -from movement_optimizer.trajectory.optimizer_spline import build_splines, eval_trajectory +from movement_optimizer.trajectory.optimizer_spline import ( + build_splines, + eval_trajectory, +) # --------------------------------------------------------------------------- # Fixtures @@ -105,12 +112,12 @@ def test_splines_satisfy_boundary_conditions(self, squat_spline_args) -> None: q_start_eval = spline(t0) q_end_eval = spline(tf) for j in range(a["n_dof"]): - assert abs(float(q_start_eval[j]) - a["q_start"][j]) < 1e-10, ( - f"DOF {j}: spline does not pass through q_start" - ) - assert abs(float(q_end_eval[j]) - a["q_end"][j]) < 1e-10, ( - f"DOF {j}: spline does not pass through q_end" - ) + assert ( + abs(float(q_start_eval[j]) - a["q_start"][j]) < 1e-10 + ), f"DOF {j}: spline does not pass through q_start" + assert ( + abs(float(q_end_eval[j]) - a["q_end"][j]) < 1e-10 + ), f"DOF {j}: spline does not pass through q_end" def test_splines_with_via_point(self) -> None: """Via-point variant must also honour boundary conditions.""" diff --git a/src/movement_optimizer/tests/test_joint_limits.py b/src/movement_optimizer/tests/test_joint_limits.py index 5aea81d59c..9b76604792 100644 --- a/src/movement_optimizer/tests/test_joint_limits.py +++ b/src/movement_optimizer/tests/test_joint_limits.py @@ -115,7 +115,9 @@ def test_custom_limits(self) -> None: def test_bench_press_limits(self) -> None: """Bench press joints should have their own limits.""" q = np.array([np.radians(100), 0.0, np.radians(20)]) - q_clamped = clamp_joint_angles(q, BENCH_PRESS_JOINT_LIMITS, BENCH_PRESS_JOINT_NAMES) + q_clamped = clamp_joint_angles( + q, BENCH_PRESS_JOINT_LIMITS, BENCH_PRESS_JOINT_NAMES + ) for i, name in enumerate(BENCH_PRESS_JOINT_NAMES): lo, hi = BENCH_PRESS_JOINT_LIMITS[name] assert lo - 1e-10 <= q_clamped[i] <= hi + 1e-10 @@ -296,7 +298,9 @@ def test_set_invalid_joint_raises(self, default_torque_set: JointTorqueSet) -> N with pytest.raises(ValueError, match="Unknown joint"): default_torque_set.set_max_torque("nonexistent", 100.0) - def test_set_negative_torque_raises(self, default_torque_set: JointTorqueSet) -> None: + def test_set_negative_torque_raises( + self, default_torque_set: JointTorqueSet + ) -> None: with pytest.raises(ValueError, match="tau_max"): default_torque_set.set_max_torque("knee", -10.0) @@ -307,7 +311,9 @@ def test_available_torques_shape(self, default_torque_set: JointTorqueSet) -> No assert result.shape == (3,) assert np.all(result > 0) - def test_available_torques_batch_shape(self, default_torque_set: JointTorqueSet) -> None: + def test_available_torques_batch_shape( + self, default_torque_set: JointTorqueSet + ) -> None: n = 10 q = np.tile([0.0, -0.5, 0.5], (n, 1)) qd = np.zeros((n, 3)) @@ -341,7 +347,9 @@ def test_find_sticking_point(self, default_torque_set: JointTorqueSet) -> None: torques = np.ones((n, 3)) * 10.0 torques[3, 1] = 500.0 # knee at step 3 - time_idx, joint_name, peak_util = default_torque_set.find_sticking_point(q, qd, torques) + time_idx, joint_name, peak_util = default_torque_set.find_sticking_point( + q, qd, torques + ) assert time_idx == 3 assert joint_name == "knee" assert peak_util > 1.0 # should be overloaded diff --git a/src/movement_optimizer/tests/test_main_window.py b/src/movement_optimizer/tests/test_main_window.py index b7e5924bca..2402dbcc93 100644 --- a/src/movement_optimizer/tests/test_main_window.py +++ b/src/movement_optimizer/tests/test_main_window.py @@ -147,7 +147,11 @@ def stall_label_set_visible(self, v: bool) -> None: pass def get_optimization_params(self) -> tuple[float, float, float]: - return (self.bar_slider.value(), self.dur_slider.value(), self.smooth_slider.value()) + return ( + self.bar_slider.value(), + self.dur_slider.value(), + self.smooth_slider.value(), + ) def get_segment_multipliers(self) -> dict[str, float]: return { @@ -201,7 +205,9 @@ def draw_all_plots( ) -> None: self.draw_all_plots_calls.append((result, body, bar, exercise_type)) - def draw_anim_frame(self, fi: int, result: Any, dyn: Any, body: Any, etype: str) -> None: + def draw_anim_frame( + self, fi: int, result: Any, dyn: Any, body: Any, etype: str + ) -> None: self.draw_anim_frame_calls.append((fi, result, dyn, body, etype)) @@ -219,7 +225,9 @@ def __init__(self) -> None: from movement_optimizer.gui.exercise_state import ExerciseRuntimeState from movement_optimizer.trajectory import SolutionCache - self.exercise_states = [ExerciseRuntimeState() for _name, _etype in self.EXERCISE_CONFIGS] + self.exercise_states = [ + ExerciseRuntimeState() for _name, _etype in self.EXERCISE_CONFIGS + ] self.sidebar = _FakeSidebar() self.status_label = _FakeLabel() self.exercise_tabs = [_FakeTab() for _ in self.EXERCISE_CONFIGS] @@ -493,7 +501,14 @@ def test_squat_returns_correct_etype(self) -> None: from movement_optimizer.gui.optimization_mixin import OptimizationMixin window = _FakeWindow() - _body, _dyn, etype, _bar, _dur, _smoothness = OptimizationMixin._resolve_exercise_params( + ( + _body, + _dyn, + etype, + _bar, + _dur, + _smoothness, + ) = OptimizationMixin._resolve_exercise_params( window, 0, # type: ignore ) # type: ignore[arg-type] @@ -503,7 +518,14 @@ def test_deadlift_returns_correct_etype(self) -> None: from movement_optimizer.gui.optimization_mixin import OptimizationMixin window = _FakeWindow() - _body, _dyn, etype, _bar, _dur, _smoothness = OptimizationMixin._resolve_exercise_params( + ( + _body, + _dyn, + etype, + _bar, + _dur, + _smoothness, + ) = OptimizationMixin._resolve_exercise_params( window, 2, # type: ignore ) # type: ignore[arg-type] @@ -528,7 +550,14 @@ def test_bar_value_from_slider(self) -> None: window = _FakeWindow() window.sidebar.bar_slider.current = 100.0 - _body, _dyn, _etype, bar, _dur, _smoothness = OptimizationMixin._resolve_exercise_params( + ( + _body, + _dyn, + _etype, + bar, + _dur, + _smoothness, + ) = OptimizationMixin._resolve_exercise_params( window, 0, # type: ignore ) # type: ignore[arg-type] @@ -540,7 +569,14 @@ def test_full_squat_minimum_duration_enforced(self) -> None: window = _FakeWindow() window.sidebar.dur_slider.current = 1.0 - _body, _dyn, _etype, _bar, dur, _smoothness = OptimizationMixin._resolve_exercise_params( + ( + _body, + _dyn, + _etype, + _bar, + dur, + _smoothness, + ) = OptimizationMixin._resolve_exercise_params( window, 1, # type: ignore ) # type: ignore[arg-type] diff --git a/src/movement_optimizer/tests/test_models.py b/src/movement_optimizer/tests/test_models.py index 6e00fd0c3e..1dee618029 100644 --- a/src/movement_optimizer/tests/test_models.py +++ b/src/movement_optimizer/tests/test_models.py @@ -59,9 +59,9 @@ def test_negative_bar_height_raises(self) -> None: def test_mass_fractions_sum_to_one(self) -> None: """MASS_FRAC values must sum to exactly 1.0 (issue #125).""" total = sum(MASS_FRAC.values()) - assert total == pytest.approx(1.0, abs=1e-9), ( - f"MASS_FRAC values sum to {total}, expected 1.0" - ) + assert total == pytest.approx( + 1.0, abs=1e-9 + ), f"MASS_FRAC values sum to {total}, expected 1.0" def test_mass_fractions_sum(self, default_body: BodyModel) -> None: total = default_body.m_feet + default_body.m_squat.sum() @@ -84,7 +84,9 @@ def test_inner_bos_is_60_percent(self, default_body: BodyModel) -> None: b = default_body full_span = b.toe_x - b.heel_x inner_span = b.inner_toe - b.inner_heel - np.testing.assert_allclose(inner_span / full_span, BOS_INNER_FRACTION, atol=1e-10) + np.testing.assert_allclose( + inner_span / full_span, BOS_INNER_FRACTION, atol=1e-10 + ) def test_inner_center_between_bounds(self, default_body: BodyModel) -> None: b = default_body @@ -213,7 +215,9 @@ def test_deadlift_bar_below_shoulder(self, deadlift_dynamics) -> None: bp = dyn.bar_position(qs, "deadlift") assert bp[1] < fk["shoulder"][1] - def test_deadlift_start_bar_near_ground(self, deadlift_dynamics, default_body) -> None: + def test_deadlift_start_bar_near_ground( + self, deadlift_dynamics, default_body + ) -> None: dyn, qs, _, _ = deadlift_dynamics bp = dyn.bar_position(qs, "deadlift") assert abs(bp[1] - PLATE_RADIUS_STD_M) < 0.15 @@ -237,7 +241,9 @@ def test_batch_torques_match_loop(self, squat_dynamics) -> None: qd = np.random.default_rng(43).normal(0, 0.5, (n, 3)) qdd = np.random.default_rng(44).normal(0, 1.0, (n, 3)) - loop_torques = np.array([dyn.inverse_dynamics(q[i], qd[i], qdd[i]) for i in range(n)]) + loop_torques = np.array( + [dyn.inverse_dynamics(q[i], qd[i], qdd[i]) for i in range(n)] + ) batch_torques = dyn.inverse_dynamics_batch(q, qd, qdd) np.testing.assert_allclose(batch_torques, loop_torques, rtol=1e-10) @@ -280,12 +286,12 @@ def test_squat_endpoints_com_in_inner_bos(self, default_body) -> None: com_start = dyn.com_position(qs, "squat", 60.0)[0] com_end = dyn.com_position(qe, "squat", 60.0)[0] b = default_body - assert b.inner_heel <= com_start <= b.inner_toe, ( - f"Start COM {com_start:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" - ) - assert b.inner_heel <= com_end <= b.inner_toe, ( - f"End COM {com_end:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" - ) + assert ( + b.inner_heel <= com_start <= b.inner_toe + ), f"Start COM {com_start:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" + assert ( + b.inner_heel <= com_end <= b.inner_toe + ), f"End COM {com_end:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" def test_full_squat_via_com_in_inner_bos(self, default_body) -> None: """Via-point should have COM in the inner 60% zone.""" @@ -294,9 +300,9 @@ def test_full_squat_via_com_in_inner_bos(self, default_body) -> None: dyn, _, _, _, q_via = make_full_squat_config(default_body, 60.0) com_via = dyn.com_position(q_via, "full_squat", 60.0)[0] b = default_body - assert b.inner_heel <= com_via <= b.inner_toe, ( - f"Via COM {com_via:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" - ) + assert ( + b.inner_heel <= com_via <= b.inner_toe + ), f"Via COM {com_via:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" def test_deadlift_endpoints_com_in_inner_bos(self, default_body) -> None: """Deadlift start and end should have COM in the inner 60% zone.""" @@ -306,12 +312,12 @@ def test_deadlift_endpoints_com_in_inner_bos(self, default_body) -> None: com_start = dyn.com_position(qs, "deadlift", 60.0)[0] com_end = dyn.com_position(qe, "deadlift", 60.0)[0] b = default_body - assert b.inner_heel <= com_start <= b.inner_toe, ( - f"Start COM {com_start:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" - ) - assert b.inner_heel <= com_end <= b.inner_toe, ( - f"End COM {com_end:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" - ) + assert ( + b.inner_heel <= com_start <= b.inner_toe + ), f"Start COM {com_start:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" + assert ( + b.inner_heel <= com_end <= b.inner_toe + ), f"End COM {com_end:.4f} outside inner BOS [{b.inner_heel:.4f}, {b.inner_toe:.4f}]" class TestLegAbductionCorrection: @@ -343,8 +349,12 @@ def test_standing_height_decreases(self) -> None: """FK shoulder height at standing decreases with abduction.""" body_0 = BodyModel(75.0, 1.75, abduction_angle=0.0) body_30 = BodyModel(75.0, 1.75, abduction_angle=30.0) - dyn_0 = LagrangianDynamics(body_0, body_0.m_squat.copy(), body_0.I_squat.copy(), 0.0) - dyn_30 = LagrangianDynamics(body_30, body_30.m_squat.copy(), body_30.I_squat.copy(), 0.0) + dyn_0 = LagrangianDynamics( + body_0, body_0.m_squat.copy(), body_0.I_squat.copy(), 0.0 + ) + dyn_30 = LagrangianDynamics( + body_30, body_30.m_squat.copy(), body_30.I_squat.copy(), 0.0 + ) fk_0 = dyn_0.forward_kinematics(np.zeros(3)) fk_30 = dyn_30.forward_kinematics(np.zeros(3)) # With abduction, projected leg lengths are shorter, so shoulder is lower @@ -354,8 +364,12 @@ def test_com_y_decreases(self) -> None: """COM y-position decreases with abduction at standing.""" body_0 = BodyModel(75.0, 1.75, abduction_angle=0.0) body_30 = BodyModel(75.0, 1.75, abduction_angle=30.0) - dyn_0 = LagrangianDynamics(body_0, body_0.m_squat.copy(), body_0.I_squat.copy(), 0.0) - dyn_30 = LagrangianDynamics(body_30, body_30.m_squat.copy(), body_30.I_squat.copy(), 0.0) + dyn_0 = LagrangianDynamics( + body_0, body_0.m_squat.copy(), body_0.I_squat.copy(), 0.0 + ) + dyn_30 = LagrangianDynamics( + body_30, body_30.m_squat.copy(), body_30.I_squat.copy(), 0.0 + ) com_0 = dyn_0.com_position(np.zeros(3), "squat", 0.0) com_30 = dyn_30.com_position(np.zeros(3), "squat", 0.0) assert com_30[1] < com_0[1] diff --git a/src/movement_optimizer/tests/test_motion_analysis_panel.py b/src/movement_optimizer/tests/test_motion_analysis_panel.py index 58f5a42ada..707273e734 100644 --- a/src/movement_optimizer/tests/test_motion_analysis_panel.py +++ b/src/movement_optimizer/tests/test_motion_analysis_panel.py @@ -164,8 +164,12 @@ def test_panel_mode_suppresses_data_axis_legends(self, chain_history) -> None: plotters = ( lambda ax: plot_chain_tension(ax, chain_history, legend=False), lambda ax: plot_chain_curvature(ax, chain_history, legend=False), - lambda ax: plot_chain_energy(ax, np.linspace(0, 1, _T), np.zeros(_T), legend=False), - lambda ax: plot_chain_tip_speed(ax, np.linspace(0, 1, _T), np.zeros(_T), legend=False), + lambda ax: plot_chain_energy( + ax, np.linspace(0, 1, _T), np.zeros(_T), legend=False + ), + lambda ax: plot_chain_tip_speed( + ax, np.linspace(0, 1, _T), np.zeros(_T), legend=False + ), ) for plotter in plotters: figure = Figure() diff --git a/src/movement_optimizer/tests/test_motion_analysis_panel_legends.py b/src/movement_optimizer/tests/test_motion_analysis_panel_legends.py index 8ee9f832c1..b037301b99 100644 --- a/src/movement_optimizer/tests/test_motion_analysis_panel_legends.py +++ b/src/movement_optimizer/tests/test_motion_analysis_panel_legends.py @@ -126,13 +126,17 @@ def test_swingset_minimum_layout_preserves_curve_height(qapp, swing_history) -> panel.draw() panel.canvas.draw() renderer = panel.canvas.get_renderer() - data_heights = [axes.get_window_extent(renderer).height for axes in panel.axes.values()] + data_heights = [ + axes.get_window_extent(renderer).height for axes in panel.axes.values() + ] assert min(data_heights) >= 210.0 _assert_panel_legends_do_not_cover_plots(panel) -def test_swingset_live_tab_layout_preserves_usable_plot_width(qapp, swing_history) -> None: +def test_swingset_live_tab_layout_preserves_usable_plot_width( + qapp, swing_history +) -> None: from movement_optimizer.gui.motion_analysis_panel import MotionAnalysisPanel panel = MotionAnalysisPanel( @@ -149,7 +153,9 @@ def test_swingset_live_tab_layout_preserves_usable_plot_width(qapp, swing_histor ) panel.draw() renderer = panel.canvas.get_renderer() - data_widths = [axes.get_window_extent(renderer).width for axes in panel.axes.values()] + data_widths = [ + axes.get_window_extent(renderer).width for axes in panel.axes.values() + ] assert min(data_widths) >= 300.0 _assert_panel_legends_do_not_cover_plots( @@ -181,7 +187,8 @@ def test_swingset_legends_are_docked_in_reserved_rows(qapp, swing_history) -> No assert legend_box.x0 >= figure_box.x0 - 1.0 assert legend_box.x1 <= figure_box.x1 + 1.0 assert not any( - legend_box.overlaps(axes.get_window_extent(renderer)) for axes in panel.axes.values() + legend_box.overlaps(axes.get_window_extent(renderer)) + for axes in panel.axes.values() ) @@ -217,7 +224,9 @@ def test_swingset_docked_legends_clear_minimum_plot_size(qapp, swing_history) -> assert all(axes.get_legend() is None for axes in panel.axes.values()) -def test_swingset_docked_legends_clear_compressed_plot_size(qapp, swing_history) -> None: +def test_swingset_docked_legends_clear_compressed_plot_size( + qapp, swing_history +) -> None: from movement_optimizer.gui.motion_analysis_panel import MotionAnalysisPanel panel = MotionAnalysisPanel( @@ -231,7 +240,9 @@ def test_swingset_docked_legends_clear_compressed_plot_size(qapp, swing_history) assert all(axes.get_legend() is None for axes in panel.axes.values()) -def test_draw_enforces_minimum_render_size_before_docking_legends(qapp, swing_history) -> None: +def test_draw_enforces_minimum_render_size_before_docking_legends( + qapp, swing_history +) -> None: from movement_optimizer.gui.motion_analysis_panel import MotionAnalysisPanel panel = MotionAnalysisPanel( @@ -252,7 +263,9 @@ def test_draw_enforces_minimum_render_size_before_docking_legends(qapp, swing_hi def test_chain_legends_are_docked_outside_data_axes(qapp, chain_history) -> None: from movement_optimizer.gui.motion_analysis_panel import MotionAnalysisPanel - panel = MotionAnalysisPanel(["tension", "curvature", "energy", "tip_speed"], rows=2, cols=2) + panel = MotionAnalysisPanel( + ["tension", "curvature", "energy", "tip_speed"], rows=2, cols=2 + ) plot_chain_tension(panel.axes["tension"], chain_history) plot_chain_curvature(panel.axes["curvature"], chain_history) plot_chain_energy(panel.axes["energy"], np.linspace(0, 1, _T), np.zeros(_T)) diff --git a/src/movement_optimizer/tests/test_motion_tabs.py b/src/movement_optimizer/tests/test_motion_tabs.py index 736efbe135..1940aa7abd 100644 --- a/src/movement_optimizer/tests/test_motion_tabs.py +++ b/src/movement_optimizer/tests/test_motion_tabs.py @@ -25,7 +25,10 @@ ) from movement_optimizer.gui import motion_tabs, motion_tabs_chain, policy_worker -from movement_optimizer.gui.app_icon import movement_optimizer_icon, movement_optimizer_icon_path +from movement_optimizer.gui.app_icon import ( + movement_optimizer_icon, + movement_optimizer_icon_path, +) from movement_optimizer.gui.main_window import MainWindow from movement_optimizer.gui.motion_tabs import ( ChainDynamicsTab, @@ -36,7 +39,9 @@ from movement_optimizer.gui.policy_trace_canvas import PolicyTraceCanvas -def _wait_for_policy_worker(qapp, swingset: SwingsetTab, timeout_s: float = 10.0) -> None: +def _wait_for_policy_worker( + qapp, swingset: SwingsetTab, timeout_s: float = 10.0 +) -> None: deadline = time.monotonic() + timeout_s while swingset._policy_worker is not None and time.monotonic() < deadline: qapp.processEvents() @@ -70,7 +75,9 @@ def _assert_reserved_legend_rows_do_not_cover_plots(panel) -> None: def test_main_window_preserves_barbell_tabs_and_adds_motion_tabs(qapp) -> None: window = MainWindow() - tab_names = [window.tabs.tabText(index).strip() for index in range(window.tabs.count())] + tab_names = [ + window.tabs.tabText(index).strip() for index in range(window.tabs.count()) + ] assert tab_names[:7] == [ "Bottoms Up Squat", @@ -216,7 +223,9 @@ def test_swingset_tab_exposes_policy_tuning_and_progress(qapp) -> None: "phase_samples", ): assert key in swingset._controls - swingset.iterative_checkbox.setChecked(False) # exercise the grid-search fallback path. + swingset.iterative_checkbox.setChecked( + False + ) # exercise the grid-search fallback path. swingset._controls["cycles"].set_value(1) swingset._controls["freq_samples"].set_value(2) swingset._controls["hip_samples"].set_value(1) @@ -240,7 +249,9 @@ def test_swingset_policy_terminology_is_not_walking(qapp) -> None: swingset = SwingsetTab() visible_text = " ".join( - widget.text() for widget in swingset.findChildren((QLabel, QPushButton)) if widget.text() + widget.text() + for widget in swingset.findChildren((QLabel, QPushButton)) + if widget.text() ) assert "walking" not in visible_text.lower() @@ -255,7 +266,9 @@ def test_motion_tab_parameter_panels_are_scrollable_and_not_compressed(qapp) -> assert scroll_area is not None assert scroll_area.widgetResizable() assert tab.control_panel_visible() - assert all(line_edit.minimumHeight() >= 28 for line_edit in tab.findChildren(QLineEdit)) + assert all( + line_edit.minimumHeight() >= 28 for line_edit in tab.findChildren(QLineEdit) + ) tab.set_control_panel_visible(False) assert not tab.control_panel_visible() @@ -272,7 +285,9 @@ def test_swingset_optimize_policy_action_is_sticky_above_scroll_area(qapp) -> No assert swingset.optimize_button.property("class") == "primary" assert swingset.optimize_button.minimumHeight() >= 48 assert swingset.optimize_button.minimumWidth() >= 220 - assert swingset.optimize_button not in scroll_area.widget().findChildren(QPushButton) + assert swingset.optimize_button not in scroll_area.widget().findChildren( + QPushButton + ) def test_swingset_autoplay_after_policy_optimization_is_configurable(qapp) -> None: @@ -298,7 +313,9 @@ def test_swingset_autoplay_after_policy_optimization_is_configurable(qapp) -> No def test_swingset_policy_trace_canvas_accepts_optimization_samples(qapp) -> None: swingset = SwingsetTab() swingset.autoplay_checkbox.setChecked(False) - swingset.iterative_checkbox.setChecked(False) # exercise the grid-search fallback path. + swingset.iterative_checkbox.setChecked( + False + ) # exercise the grid-search fallback path. swingset._controls["cycles"].set_value(1) swingset._controls["freq_samples"].set_value(2) swingset._controls["hip_samples"].set_value(1) @@ -325,7 +342,9 @@ def test_swingset_policy_trace_canvas_handles_sparse_series(qapp) -> None: pixmap = QPixmap(120, 80) painter = QPainter(pixmap) try: - swingset.policy_trace_canvas._draw_normalized_series(painter, "missing", QColor("white"), 1) + swingset.policy_trace_canvas._draw_normalized_series( + painter, "missing", QColor("white"), 1 + ) finally: painter.end() @@ -584,7 +603,9 @@ def test_chain_rollout_keeps_physical_anchor_fixed(qapp) -> None: np.testing.assert_allclose(chain._rollout.positions[:, 0, :], 0.0) -def test_chain_tab_reports_invalid_inputs_and_covers_playback_branches(qapp, monkeypatch) -> None: +def test_chain_tab_reports_invalid_inputs_and_covers_playback_branches( + qapp, monkeypatch +) -> None: chain = ChainDynamicsTab() chain.autoplay_checkbox.setChecked(False) chain.tie_segments.setChecked(False) @@ -649,10 +670,13 @@ def test_swingset_iterative_optimize_populates_panel_and_overlays(qapp) -> None: assert 0 < swingset.policy_trace_canvas.sample_count() <= 50 # Analysis plots populated. assert swingset.analysis_panel.axes["torques"].get_lines() - assert all(axes.get_legend() is None for axes in swingset.analysis_panel.axes.values()) + assert all( + axes.get_legend() is None for axes in swingset.analysis_panel.axes.values() + ) assert swingset.analysis_panel._figure_legend is None assert any( - axes.get_legend() is not None for axes in swingset.analysis_panel.legend_axes.values() + axes.get_legend() is not None + for axes in swingset.analysis_panel.legend_axes.values() ) # Force overlay drawn (all toggles default-on). assert swingset.canvas._overlay.arrows or swingset.canvas._overlay.com_markers @@ -712,7 +736,9 @@ def test_swingset_playback_uses_cached_force_fields(qapp, monkeypatch) -> None: _wait_for_policy_worker(qapp, swingset) def fail_recompute(*_args, **_kwargs): - raise AssertionError("playback must not recompute rollout-wide swing force fields") + raise AssertionError( + "playback must not recompute rollout-wide swing force fields" + ) monkeypatch.setattr(motion_tabs, "swing_force_fields", fail_recompute) @@ -739,7 +765,10 @@ def test_chain_simulate_populates_panel_and_overlays(qapp) -> None: assert chain.analysis_panel.axes["tension"].get_lines() assert all(axes.get_legend() is None for axes in chain.analysis_panel.axes.values()) assert chain.analysis_panel._figure_legend is None - assert any(axes.get_legend() is not None for axes in chain.analysis_panel.legend_axes.values()) + assert any( + axes.get_legend() is not None + for axes in chain.analysis_panel.legend_axes.values() + ) assert chain.canvas._overlay.arrows @@ -781,7 +810,9 @@ def test_chain_playback_uses_cached_force_fields(qapp, monkeypatch) -> None: chain._simulate() def fail_recompute(*_args, **_kwargs): - raise AssertionError("playback must not recompute rollout-wide chain force fields") + raise AssertionError( + "playback must not recompute rollout-wide chain force fields" + ) monkeypatch.setattr(motion_tabs_chain, "chain_force_fields", fail_recompute) @@ -974,6 +1005,8 @@ def test_policy_trace_iteration_label_stays_below_plot_area(qapp) -> None: label_rect = trace._iteration_label_rect() - assert label_rect.top() >= trace._plot_bottom() + trace._AXIS_LABEL_TOP_PADDING_PX - 1 + assert ( + label_rect.top() >= trace._plot_bottom() + trace._AXIS_LABEL_TOP_PADDING_PX - 1 + ) assert trace._plot_bottom() - trace._top_margin() >= trace._MINIMUM_PLOT_HEIGHT_PX trace.grab() # repaint with bottom-axis label must not raise diff --git a/src/movement_optimizer/tests/test_optimization_mixin.py b/src/movement_optimizer/tests/test_optimization_mixin.py index dfca8ca92e..93cc8c7d1c 100644 --- a/src/movement_optimizer/tests/test_optimization_mixin.py +++ b/src/movement_optimizer/tests/test_optimization_mixin.py @@ -65,7 +65,9 @@ def test_on_cancelled_resets_state(window) -> None: def test_on_err_with_structured_and_plain_errors(window) -> None: window._opt_running = True - window._on_err(OptimizationError("boom", error_code="OPT_X", suggestion="try again")) + window._on_err( + OptimizationError("boom", error_code="OPT_X", suggestion="try again") + ) assert "OPT_X" in window.status_label.text() window._on_err("plain failure") assert "plain failure" in window.status_label.text() @@ -116,7 +118,9 @@ def test_completed_single_exercise_autoplays_when_enabled(window, monkeypatch) - def test_finish_or_chain_advances_then_chain(window, monkeypatch) -> None: calls: list[tuple[int, list[int] | None]] = [] - monkeypatch.setattr(window, "_run_exercise", lambda idx, rest=None: calls.append((idx, rest))) + monkeypatch.setattr( + window, "_run_exercise", lambda idx, rest=None: calls.append((idx, rest)) + ) window._finish_or_chain([1, 2], "msg") assert calls == [(1, [2])] diff --git a/src/movement_optimizer/tests/test_parameter_sidebar.py b/src/movement_optimizer/tests/test_parameter_sidebar.py index ef7feeb259..8f49e55bd1 100644 --- a/src/movement_optimizer/tests/test_parameter_sidebar.py +++ b/src/movement_optimizer/tests/test_parameter_sidebar.py @@ -32,7 +32,9 @@ def test_action_handlers_connect_and_emit(sidebar) -> None: "compare_trials_requested", "clear_comparison_requested", ] - sidebar.connect_action_handlers({name: (lambda n=name: fired.append(n)) for name in names}) + sidebar.connect_action_handlers( + {name: (lambda n=name: fired.append(n)) for name in names} + ) for name in names: getattr(sidebar, name).emit() assert set(fired) == set(names) diff --git a/src/movement_optimizer/tests/test_plot_renderer.py b/src/movement_optimizer/tests/test_plot_renderer.py index 4de8313f64..49b2bcbadd 100644 --- a/src/movement_optimizer/tests/test_plot_renderer.py +++ b/src/movement_optimizer/tests/test_plot_renderer.py @@ -72,7 +72,9 @@ def test_plot_angles(self, mock_ax, dummy_result): plot_angles(mock_ax, dummy_result) assert mock_ax.plot.call_count == 3 mock_ax.set_title.assert_called_once_with( - "Joint Angles", color=mock_ax.set_title.call_args[1].get("color"), fontsize=10 + "Joint Angles", + color=mock_ax.set_title.call_args[1].get("color"), + fontsize=10, ) def test_plot_torques(self, mock_ax, dummy_result): @@ -117,7 +119,9 @@ def test_plot_com_balance(self, mock_ax, dummy_result, body): def test_plot_spine_loads(self, mock_ax, dummy_result, body): ax_comp = MagicMock() ax_shear = MagicMock() - plot_spine_loads(ax_comp, ax_shear, dummy_result, body, bar_mass=20.0, name="squat") + plot_spine_loads( + ax_comp, ax_shear, dummy_result, body, bar_mass=20.0, name="squat" + ) ax_comp.plot.assert_called_once() ax_comp.axhline.assert_called_once() @@ -200,6 +204,8 @@ def test_bottoms_up_squat_is_aliased_to_squat(self, dummy_result, body): ax_comp = MagicMock() ax_shear = MagicMock() - plot_spine_loads(ax_comp, ax_shear, dummy_result, body, 60.0, "Bottoms Up Squat") + plot_spine_loads( + ax_comp, ax_shear, dummy_result, body, 60.0, "Bottoms Up Squat" + ) assert ax_comp.plot.called assert ax_shear.plot.called diff --git a/src/movement_optimizer/tests/test_rust_parity_com_x.py b/src/movement_optimizer/tests/test_rust_parity_com_x.py index 962fff7670..a6ea9a0629 100644 --- a/src/movement_optimizer/tests/test_rust_parity_com_x.py +++ b/src/movement_optimizer/tests/test_rust_parity_com_x.py @@ -42,7 +42,9 @@ def _make_deadlift_dynamics() -> LagrangianDynamics: """Deadlift dynamics (arm mass folded into the load, no bar offset).""" body = BodyModel(75.0, 1.75) load = body.m_arms + 100.0 - return LagrangianDynamics(body, body.m_deadlift.copy(), body.I_deadlift.copy(), load) + return LagrangianDynamics( + body, body.m_deadlift.copy(), body.I_deadlift.copy(), load + ) def _random_q(rng: np.random.Generator, n: int) -> np.ndarray: diff --git a/src/movement_optimizer/tests/test_scipy_dependency_contract.py b/src/movement_optimizer/tests/test_scipy_dependency_contract.py index 8f43d5880d..3e1a4ff50b 100644 --- a/src/movement_optimizer/tests/test_scipy_dependency_contract.py +++ b/src/movement_optimizer/tests/test_scipy_dependency_contract.py @@ -14,8 +14,12 @@ def test_scipy_dependency_has_no_legacy_1_16_ceiling() -> None: - pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - scipy_specs = [dep for dep in pyproject["project"]["dependencies"] if dep.startswith("scipy")] + pyproject = tomllib.loads( + (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + scipy_specs = [ + dep for dep in pyproject["project"]["dependencies"] if dep.startswith("scipy") + ] assert scipy_specs == ["scipy>=1.10"] diff --git a/src/movement_optimizer/tests/test_shared_theme_dependency.py b/src/movement_optimizer/tests/test_shared_theme_dependency.py index 0472753200..b594050f12 100644 --- a/src/movement_optimizer/tests/test_shared_theme_dependency.py +++ b/src/movement_optimizer/tests/test_shared_theme_dependency.py @@ -34,7 +34,15 @@ def test_shared_theme_public_surface_is_importable() -> None: # The themes we map onto must exist with the keys the Palette consumes. assert "Dark" in BUILTIN_THEMES assert "Light" in BUILTIN_THEMES - required = {"bg", "group_bg", "input_bg", "text", "text_secondary", "accent", "button_hover"} + required = { + "bg", + "group_bg", + "input_bg", + "text", + "text_secondary", + "accent", + "button_hover", + } assert required.issubset(set(THEME_COLOR_KEYS)) assert required.issubset(set(BUILTIN_THEMES["Dark"])) diff --git a/src/movement_optimizer/tests/test_spine_loads.py b/src/movement_optimizer/tests/test_spine_loads.py index cdc0a367b4..81726deb01 100644 --- a/src/movement_optimizer/tests/test_spine_loads.py +++ b/src/movement_optimizer/tests/test_spine_loads.py @@ -28,7 +28,9 @@ def squat_dyn(default_body: BodyModel): class TestStandingCompression: """At standing (q=0, qd=0, qdd=0) compression should equal gravity on mass above L5.""" - def test_standing_compression_equals_gravity(self, default_body: BodyModel, squat_dyn) -> None: + def test_standing_compression_equals_gravity( + self, default_body: BodyModel, squat_dyn + ) -> None: q = np.zeros(3) qd = np.zeros(3) qdd = np.zeros(3) @@ -41,7 +43,9 @@ def test_standing_compression_equals_gravity(self, default_body: BodyModel, squa expected = (m_above + bar_mass) * default_body.g np.testing.assert_allclose(comp, expected, rtol=1e-6) - def test_standing_compression_no_bar(self, default_body: BodyModel, squat_dyn) -> None: + def test_standing_compression_no_bar( + self, default_body: BodyModel, squat_dyn + ) -> None: q = np.zeros(3) qd = np.zeros(3) qdd = np.zeros(3) @@ -68,7 +72,9 @@ def test_standing_shear_near_zero(self, default_body: BodyModel, squat_dyn) -> N class TestForwardLean: """With torso lean, shear increases and compression decreases.""" - def test_shear_increases_with_lean(self, default_body: BodyModel, squat_dyn) -> None: + def test_shear_increases_with_lean( + self, default_body: BodyModel, squat_dyn + ) -> None: qd = np.zeros(3) qdd = np.zeros(3) bar_mass = 60.0 @@ -76,12 +82,16 @@ def test_shear_increases_with_lean(self, default_body: BodyModel, squat_dyn) -> q_upright = np.array([0.0, 0.0, 0.0]) q_leaned = np.array([0.0, 0.0, np.radians(30)]) - shear_upright = spinal_shear(q_upright, qd, qdd, default_body, bar_mass, "squat") + shear_upright = spinal_shear( + q_upright, qd, qdd, default_body, bar_mass, "squat" + ) shear_leaned = spinal_shear(q_leaned, qd, qdd, default_body, bar_mass, "squat") assert abs(shear_leaned) > abs(shear_upright) # type: ignore - def test_shear_proportional_to_sin(self, default_body: BodyModel, squat_dyn) -> None: + def test_shear_proportional_to_sin( + self, default_body: BodyModel, squat_dyn + ) -> None: qd = np.zeros(3) qdd = np.zeros(3) bar_mass = 60.0 @@ -94,7 +104,9 @@ def test_shear_proportional_to_sin(self, default_body: BodyModel, squat_dyn) -> expected = (m_above + bar_mass) * default_body.g * np.sin(angle) np.testing.assert_allclose(shear, expected, rtol=1e-6) - def test_compression_decreases_with_lean(self, default_body: BodyModel, squat_dyn) -> None: + def test_compression_decreases_with_lean( + self, default_body: BodyModel, squat_dyn + ) -> None: qd = np.zeros(3) qdd = np.zeros(3) bar_mass = 60.0 @@ -102,12 +114,18 @@ def test_compression_decreases_with_lean(self, default_body: BodyModel, squat_dy q_upright = np.array([0.0, 0.0, 0.0]) q_leaned = np.array([0.0, 0.0, np.radians(30)]) - comp_upright = spinal_compression(q_upright, qd, qdd, default_body, bar_mass, "squat") - comp_leaned = spinal_compression(q_leaned, qd, qdd, default_body, bar_mass, "squat") + comp_upright = spinal_compression( + q_upright, qd, qdd, default_body, bar_mass, "squat" + ) + comp_leaned = spinal_compression( + q_leaned, qd, qdd, default_body, bar_mass, "squat" + ) assert comp_leaned < comp_upright - def test_compression_cos_component(self, default_body: BodyModel, squat_dyn) -> None: + def test_compression_cos_component( + self, default_body: BodyModel, squat_dyn + ) -> None: qd = np.zeros(3) qdd = np.zeros(3) bar_mass = 60.0 @@ -155,7 +173,10 @@ def test_batch_matches_loop(self, default_body: BodyModel, squat_dyn) -> None: batch_comp = spinal_compression(q, qd, qdd, default_body, 60.0, "squat") loop_comp = np.array( - [spinal_compression(q[i], qd[i], qdd[i], default_body, 60.0, "squat") for i in range(n)] + [ + spinal_compression(q[i], qd[i], qdd[i], default_body, 60.0, "squat") + for i in range(n) + ] ) np.testing.assert_allclose(batch_comp, loop_comp, rtol=1e-10) @@ -220,7 +241,9 @@ def test_shear_exceeds_static_during_motion(self, default_body: BodyModel) -> No q = np.array([0.0, 0.0, angle]) bar_mass = 60.0 - static_shear = spinal_shear(q, np.zeros(3), np.zeros(3), default_body, bar_mass, "squat") + static_shear = spinal_shear( + q, np.zeros(3), np.zeros(3), default_body, bar_mass, "squat" + ) dynamic_shear = spinal_shear( q, np.array([0.0, 0.0, 3.0]), diff --git a/src/movement_optimizer/tests/test_subprocess_usage.py b/src/movement_optimizer/tests/test_subprocess_usage.py index fcbeec55b8..71fedee661 100644 --- a/src/movement_optimizer/tests/test_subprocess_usage.py +++ b/src/movement_optimizer/tests/test_subprocess_usage.py @@ -6,7 +6,11 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] -PYTHON_SOURCES = (PROJECT_ROOT / "scripts", PROJECT_ROOT / "src", PROJECT_ROOT / "tests") +PYTHON_SOURCES = ( + PROJECT_ROOT / "scripts", + PROJECT_ROOT / "src", + PROJECT_ROOT / "tests", +) def _subprocess_calls(tree: ast.AST) -> list[ast.Call]: @@ -36,7 +40,9 @@ def test_subprocess_calls_do_not_use_shell_true() -> None: and isinstance(keyword.value, ast.Constant) and keyword.value.value is True ): - offenders.append(f"{source.relative_to(PROJECT_ROOT)}:{call.lineno}") + offenders.append( + f"{source.relative_to(PROJECT_ROOT)}:{call.lineno}" + ) assert offenders == [] @@ -50,7 +56,11 @@ def test_subprocess_calls_use_sequence_arguments() -> None: if not call.args: continue first_arg = call.args[0] - if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str): - offenders.append(f"{source.relative_to(PROJECT_ROOT)}:{call.lineno}") + if isinstance(first_arg, ast.Constant) and isinstance( + first_arg.value, str + ): + offenders.append( + f"{source.relative_to(PROJECT_ROOT)}:{call.lineno}" + ) assert offenders == [] diff --git a/src/movement_optimizer/tests/test_swingset_chain_models.py b/src/movement_optimizer/tests/test_swingset_chain_models.py index 87f0c9c747..b893ab6c67 100644 --- a/src/movement_optimizer/tests/test_swingset_chain_models.py +++ b/src/movement_optimizer/tests/test_swingset_chain_models.py @@ -108,7 +108,9 @@ def test_chain_simulation_damps_energy() -> None: assert len(rollout.states) == 25 assert rollout.positions.shape == (25, 7, 2) assert np.all(np.isfinite(rollout.energy_j)) - assert total_energy(config, rollout.states[-1]) == pytest.approx(rollout.energy_j[-1]) + assert total_energy(config, rollout.states[-1]) == pytest.approx( + rollout.energy_j[-1] + ) link_lengths = np.linalg.norm(np.diff(rollout.positions, axis=1), axis=2) np.testing.assert_allclose(link_lengths, config.segment_length_m) @@ -146,13 +148,15 @@ def test_chain_single_segment_gravity_matches_slender_rod_pendulum() -> None: ) angle = 0.2 dt_s = 1e-4 - state = ChainState(np.asarray([angle], dtype=np.float64), np.zeros(1, dtype=np.float64)) + state = ChainState( + np.asarray([angle], dtype=np.float64), np.zeros(1, dtype=np.float64) + ) stepped = step_chain(config, state, dt_s=dt_s) - expected_acceleration = -(3.0 * config.gravity_m_s2 / (2.0 * config.segment_length_m)) * np.sin( - angle - ) + expected_acceleration = -( + 3.0 * config.gravity_m_s2 / (2.0 * config.segment_length_m) + ) * np.sin(angle) observed_acceleration = stepped.angular_velocities_rad_s[0] / dt_s assert observed_acceleration == pytest.approx(expected_acceleration, rel=0.02) @@ -181,8 +185,12 @@ def test_chain_downstream_load_slows_top_link_gravity() -> None: stepped = step_chain(config, state, dt_s=dt_s) acceleration = stepped.angular_velocities_rad_s / dt_s - single_link = -(3.0 * config.gravity_m_s2 / (2.0 * config.segment_length_m)) * np.sin(angle) - assert acceleration[0] == pytest.approx(single_link / config.segment_count, rel=0.03) + single_link = -( + 3.0 * config.gravity_m_s2 / (2.0 * config.segment_length_m) + ) * np.sin(angle) + assert acceleration[0] == pytest.approx( + single_link / config.segment_count, rel=0.03 + ) assert acceleration[-1] == pytest.approx(single_link, rel=0.03) @@ -197,13 +205,17 @@ def test_chain_tip_kick_velocities_increase_toward_tip() -> None: def test_chain_random_wadded_start_is_deterministic_and_validated() -> None: config = ChainConfig(segment_count=5) - first = random_wadded_chain_state(config, angle_span_rad=np.pi, velocity_span_rad_s=0.4, seed=7) + first = random_wadded_chain_state( + config, angle_span_rad=np.pi, velocity_span_rad_s=0.4, seed=7 + ) second = random_wadded_chain_state( config, angle_span_rad=np.pi, velocity_span_rad_s=0.4, seed=7 ) np.testing.assert_allclose(first.angles_rad, second.angles_rad) - np.testing.assert_allclose(first.angular_velocities_rad_s, second.angular_velocities_rad_s) + np.testing.assert_allclose( + first.angular_velocities_rad_s, second.angular_velocities_rad_s + ) assert first.angles_rad.shape == (5,) assert np.max(np.abs(first.angles_rad)) <= np.pi with pytest.raises(ValueError, match="angle_span_rad"): @@ -232,7 +244,9 @@ def test_chain_simulation_validates_rollout_inputs() -> None: with pytest.raises(ValueError, match="dt_s"): step_chain(config, initial, dt_s=0.0) with pytest.raises(ValueError, match="incompatible"): - simulate_chain(config, initial, steps=2, dt_s=0.01, torque_history_nm=np.zeros((2, 2))) + simulate_chain( + config, initial, steps=2, dt_s=0.01, torque_history_nm=np.zeros((2, 2)) + ) def test_swingset_snapshot_models_body_chain_and_mass() -> None: @@ -276,7 +290,9 @@ def test_swingset_elbow_branch_does_not_mirror_when_control_crosses_zero() -> No elbow = snapshot.points["elbow"] hand_delta = hand - shoulder elbow_delta = elbow - shoulder - branch_signs.append(float(hand_delta[0] * elbow_delta[1] - hand_delta[1] * elbow_delta[0])) + branch_signs.append( + float(hand_delta[0] * elbow_delta[1] - hand_delta[1] * elbow_delta[0]) + ) elbow_points.append(elbow) # The elbow must never mirror to the far branch as the requested flexion @@ -284,7 +300,9 @@ def test_swingset_elbow_branch_does_not_mirror_when_control_crosses_zero() -> No assert min(branch_signs) > 0.0 # No discontinuous jump (a mirror flip would be a large step); the elbow # moves smoothly across the swept range. - max_step = max(float(np.linalg.norm(end - start)) for start, end in pairwise(elbow_points)) + max_step = max( + float(np.linalg.norm(end - start)) for start, end in pairwise(elbow_points) + ) assert max_step < 0.1 @@ -458,7 +476,9 @@ def test_cyclic_policy_controls_match_callback_policy() -> None: def test_swingset_cyclic_policy_search_selects_height_objective() -> None: result = optimize_cyclic_policy(SwingSetConfig(), steps=40, dt_s=0.02) - assert result.objective_height_m == pytest.approx(result.rollout.metrics.max_height_gain_m) + assert result.objective_height_m == pytest.approx( + result.rollout.metrics.max_height_gain_m + ) assert result.objective_height_m > 0.0 assert result.parameters.frequency_hz > 0.0 @@ -486,7 +506,9 @@ def test_swingset_policy_search_reports_progress_and_uses_cycles() -> None: cycles=2.0, dt_s=0.02, search_space=search_space, - progress_callback=lambda done, total, score, _params: progress.append((done, total, score)), + progress_callback=lambda done, total, score, _params: progress.append( + (done, total, score) + ), ) assert result.evaluated_candidates == 4 @@ -537,7 +559,9 @@ def test_swingset_joint_torque_estimator_validates_control_history() -> None: def test_swingset_rollout_validates_inputs() -> None: config = SwingSetConfig() with pytest.raises(ValueError, match="steps"): - simulate_swingset(config, SwingSetState.rest(), 0, 0.02, heuristic_pumping_policy) + simulate_swingset( + config, SwingSetState.rest(), 0, 0.02, heuristic_pumping_policy + ) with pytest.raises(ValueError, match="dt_s"): step_swingset(config, SwingSetState.rest(), SwingControlAction(), dt_s=0.0) with pytest.raises(ValueError, match="steps"): @@ -568,7 +592,9 @@ def test_iterative_optimizer_is_deterministic() -> None: first = optimize_cyclic_policy_iterative(config, steps=40, budget=60, seed=7) second = optimize_cyclic_policy_iterative(config, steps=40, budget=60, seed=7) assert first.objective_height_m == pytest.approx(second.objective_height_m) - assert first.parameters.frequency_hz == pytest.approx(second.parameters.frequency_hz) + assert first.parameters.frequency_hz == pytest.approx( + second.parameters.frequency_hz + ) assert first.parameters.phase_rad == pytest.approx(second.parameters.phase_rad) assert len(first.trace) == len(second.trace) @@ -583,7 +609,9 @@ def test_iterative_optimizer_honors_budget(budget: int) -> None: def test_iterative_optimizer_matches_or_beats_grid() -> None: config = SwingSetConfig() - grid = optimize_cyclic_policy(config, steps=80, search_space=CyclicPolicySearchSpace()) + grid = optimize_cyclic_policy( + config, steps=80, search_space=CyclicPolicySearchSpace() + ) iterative = optimize_cyclic_policy_iterative(config, steps=80, budget=400, seed=0) assert iterative.objective_height_m >= grid.objective_height_m - 0.05 @@ -602,7 +630,9 @@ def test_iterative_optimizer_progress_callback_contract() -> None: config = SwingSetConfig() calls: list[tuple[int, int, float]] = [] - def _record(completed: int, total: int, best: float, params: CyclicPolicyParameters) -> None: + def _record( + completed: int, total: int, best: float, params: CyclicPolicyParameters + ) -> None: calls.append((completed, total, best)) assert isinstance(params, CyclicPolicyParameters) diff --git a/src/movement_optimizer/tests/test_swingset_forces.py b/src/movement_optimizer/tests/test_swingset_forces.py index 5772a3403d..e68a4be29c 100644 --- a/src/movement_optimizer/tests/test_swingset_forces.py +++ b/src/movement_optimizer/tests/test_swingset_forces.py @@ -74,7 +74,9 @@ def test_swing_chain_tension_uses_acceleration_not_velocity() -> None: ) linear_com_rollout = dataclasses.replace(rollout, snapshots=snapshots) - field = swing_force_field(config, linear_com_rollout, DEFAULT_POLICY_DT_S, frame_index=10) + field = swing_force_field( + config, linear_com_rollout, DEFAULT_POLICY_DT_S, frame_index=10 + ) np.testing.assert_allclose(field.chain_tension_n, -field.gravity_n, atol=1e-9) diff --git a/src/movement_optimizer/tests/test_thread_safety.py b/src/movement_optimizer/tests/test_thread_safety.py index 032dc7e34a..a42c6ebd77 100644 --- a/src/movement_optimizer/tests/test_thread_safety.py +++ b/src/movement_optimizer/tests/test_thread_safety.py @@ -225,9 +225,9 @@ def runner() -> None: t.start() t.join(timeout=2.0) - assert not t.is_alive(), ( - "Re-entrant lock acquisition deadlocked -- _opt_lock must be an RLock" - ) + assert ( + not t.is_alive() + ), "Re-entrant lock acquisition deadlocked -- _opt_lock must be an RLock" assert not errors, f"Runner raised: {errors!r}" assert completed.is_set() assert harness.exercise_states[0].anim_frame == 7 diff --git a/src/movement_optimizer/tests/test_trajectory_generation.py b/src/movement_optimizer/tests/test_trajectory_generation.py index ce92e1a950..819c100d5e 100644 --- a/src/movement_optimizer/tests/test_trajectory_generation.py +++ b/src/movement_optimizer/tests/test_trajectory_generation.py @@ -96,7 +96,9 @@ def test_via_point_trajectory(self, full_squat_optimizer) -> None: splines = opt.build_splines(wp.flatten()) q, _, _, _ = opt.eval_trajectory(splines) mid = len(q) // 2 - assert q[mid, 1] < np.radians(-60), "Thigh should flex significantly at midpoint" + assert q[mid, 1] < np.radians( + -60 + ), "Thigh should flex significantly at midpoint" # ============================================================== @@ -158,7 +160,9 @@ def test_balance_cost_inside_is_centering_only(self, squat_optimizer) -> None: opt, body, _, _, _ = squat_optimizer center = body.inner_center com_x = np.full(20, center) - cost = compute_balance_cost(com_x, opt.inner_center, opt.dt, opt.balance_center_weight) + cost = compute_balance_cost( + com_x, opt.inner_center, opt.dt, opt.balance_center_weight + ) # Should be zero since COM == center assert cost < 1e-10 @@ -194,7 +198,9 @@ def test_total_cost_is_sum(self, squat_optimizer) -> None: + compute_endpoint_damping_cost( qd, qdd, opt.dt, opt.endpoint_weight, opt._n_damp, opt._damp_weights ) - + compute_balance_cost(com_x, opt.inner_center, opt.dt, opt.balance_center_weight) + + compute_balance_cost( + com_x, opt.inner_center, opt.dt, opt.balance_center_weight + ) ) computed = opt._compute_cost(x) np.testing.assert_allclose(computed, total, rtol=1e-10) diff --git a/src/movement_optimizer/tests/test_trajectory_optimization.py b/src/movement_optimizer/tests/test_trajectory_optimization.py index ef96e3e124..e704147c31 100644 --- a/src/movement_optimizer/tests/test_trajectory_optimization.py +++ b/src/movement_optimizer/tests/test_trajectory_optimization.py @@ -51,15 +51,17 @@ def test_precondition_objective_finite(self, squat_optimizer) -> None: opt, _, _, _, _ = squat_optimizer wp = opt._initial_guess() cost = opt._compute_cost(wp.flatten()) - assert cost < float("inf"), "Precondition violated: initial objective is not finite" + assert cost < float( + "inf" + ), "Precondition violated: initial objective is not finite" def test_postcondition_kkt_within_tol(self, squat_optimizer) -> None: opt, _, _, _, _ = squat_optimizer # We assume the optimization result includes 'success' which means KKT conditions are within tolerance result = opt.optimize() - assert result.success, ( - "Postcondition violated: optimization did not satisfy KKT within tolerance" - ) + assert ( + result.success + ), "Postcondition violated: optimization did not satisfy KKT within tolerance" def test_cost_decreases(self) -> None: """With enough waypoints, optimization should reduce cost.""" @@ -134,12 +136,12 @@ def test_com_stays_in_inner_bos(self) -> None: ) result = opt.optimize() com_x = result.com[:, 0] - assert np.all(com_x >= body.inner_heel - 0.01), ( - f"COM below inner_heel: min={com_x.min():.4f}, bound={body.inner_heel:.4f}" - ) - assert np.all(com_x <= body.inner_toe + 0.01), ( - f"COM above inner_toe: max={com_x.max():.4f}, bound={body.inner_toe:.4f}" - ) + assert np.all( + com_x >= body.inner_heel - 0.01 + ), f"COM below inner_heel: min={com_x.min():.4f}, bound={body.inner_heel:.4f}" + assert np.all( + com_x <= body.inner_toe + 0.01 + ), f"COM above inner_toe: max={com_x.max():.4f}, bound={body.inner_toe:.4f}" assert result.success, "Optimization should report success with COM in bounds" diff --git a/src/movement_optimizer/tests/test_vector_overlay.py b/src/movement_optimizer/tests/test_vector_overlay.py index e2ee9855a2..84560a1007 100644 --- a/src/movement_optimizer/tests/test_vector_overlay.py +++ b/src/movement_optimizer/tests/test_vector_overlay.py @@ -26,7 +26,9 @@ _MID = _SIZE // 2 -def _flipping_projector(scale: float = 20.0) -> Callable[[tuple[float, float]], QPointF]: +def _flipping_projector( + scale: float = 20.0, +) -> Callable[[tuple[float, float]], QPointF]: # Mimics the canvas projector's Y handling: larger world-y -> smaller screen-y. def _project(point: tuple[float, float]) -> QPointF: x, y = point @@ -89,14 +91,18 @@ def test_auto_scale_factor_rejects_nonpositive_target(style: VectorStyle) -> Non def test_draw_force_arrows_renders_pixels(qapp, style: VectorStyle) -> None: arrows = [ForceArrow((0.0, 0.0), (1.0, 0.0), style)] - image = _render(lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=1.0)) + image = _render( + lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=1.0) + ) assert _colored_pixels(image) def test_draw_force_arrows_respects_projector_y_flip(qapp, style: VectorStyle) -> None: # A +y world vector must render ABOVE the origin (smaller screen-y). up = [ForceArrow((0.0, 0.0), (0.0, 1.0), style)] - image = _render(lambda p: draw_force_arrows(p, _flipping_projector(), up, scale=1.0)) + image = _render( + lambda p: draw_force_arrows(p, _flipping_projector(), up, scale=1.0) + ) ys = [y for _x, y in _colored_pixels(image)] assert min(ys) < _MID # reached above the origin row @@ -104,31 +110,45 @@ def test_draw_force_arrows_respects_projector_y_flip(qapp, style: VectorStyle) - def test_draw_force_arrows_rejects_nonpositive_scale(qapp, style: VectorStyle) -> None: arrows = [ForceArrow((0.0, 0.0), (1.0, 0.0), style)] with pytest.raises(ValueError, match="scale"): - _render(lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=0.0)) + _render( + lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=0.0) + ) def test_draw_force_arrows_rejects_nonfinite_scale(qapp, style: VectorStyle) -> None: arrows = [ForceArrow((0.0, 0.0), (1.0, 0.0), style)] with pytest.raises(ValueError, match="scale"): - _render(lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=float("inf"))) + _render( + lambda p: draw_force_arrows( + p, _flipping_projector(), arrows, scale=float("inf") + ) + ) def test_draw_force_arrows_skips_zero_length(qapp, style: VectorStyle) -> None: arrows = [ForceArrow((0.0, 0.0), (0.0, 0.0), style)] - image = _render(lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=1.0)) + image = _render( + lambda p: draw_force_arrows(p, _flipping_projector(), arrows, scale=1.0) + ) assert not _colored_pixels(image) # no shaft, no head def test_draw_torque_arcs_renders(qapp, style: VectorStyle) -> None: arcs = [TorqueArc((0.0, 0.0), 12.0, style), TorqueArc((0.5, 0.0), -8.0, style)] - image = _render(lambda p: draw_torque_arcs(p, _flipping_projector(), arcs, reference_nm=12.0)) + image = _render( + lambda p: draw_torque_arcs(p, _flipping_projector(), arcs, reference_nm=12.0) + ) assert _colored_pixels(image) -def test_draw_torque_arcs_rejects_nonpositive_reference(qapp, style: VectorStyle) -> None: +def test_draw_torque_arcs_rejects_nonpositive_reference( + qapp, style: VectorStyle +) -> None: arcs = [TorqueArc((0.0, 0.0), 1.0, style)] with pytest.raises(ValueError, match="reference_nm"): - _render(lambda p: draw_torque_arcs(p, _flipping_projector(), arcs, reference_nm=0.0)) + _render( + lambda p: draw_torque_arcs(p, _flipping_projector(), arcs, reference_nm=0.0) + ) def test_draw_com_markers_renders(qapp, style: VectorStyle) -> None: diff --git a/src/movement_optimizer/theme_bridge.py b/src/movement_optimizer/theme_bridge.py index 7a318d69bd..ae31e15687 100644 --- a/src/movement_optimizer/theme_bridge.py +++ b/src/movement_optimizer/theme_bridge.py @@ -20,8 +20,12 @@ from shared.python.theme import BUILTIN_THEMES as _SHARED_THEMES from shared.python.theme import ThemedWindowMixin as _SharedThemedWindowMixin from shared.python.theme import get_theme_manager as _shared_get_theme_manager - from shared.python.theme.matplotlib_style import apply_plot_theme as _shared_apply_plot_theme - from shared.python.theme.matplotlib_style import get_chart_color as _shared_get_chart_color + from shared.python.theme.matplotlib_style import ( + apply_plot_theme as _shared_apply_plot_theme, + ) + from shared.python.theme.matplotlib_style import ( + get_chart_color as _shared_get_chart_color, + ) SHARED_THEME_AVAILABLE = True BUILTIN_THEMES: Mapping[str, Mapping[str, str]] = _SHARED_THEMES diff --git a/src/movement_optimizer/tool_pack.py b/src/movement_optimizer/tool_pack.py index eb59011f49..284f45dedf 100644 --- a/src/movement_optimizer/tool_pack.py +++ b/src/movement_optimizer/tool_pack.py @@ -42,7 +42,9 @@ def _load_manifest_text() -> str: repo_manifest = parent / _MANIFEST_FILENAME if repo_manifest.is_file(): return repo_manifest.read_text(encoding="utf-8") - raise FileNotFoundError(f"{_MANIFEST_FILENAME} not found alongside movement_optimizer.") + raise FileNotFoundError( + f"{_MANIFEST_FILENAME} not found alongside movement_optimizer." + ) def manifest() -> dict[str, Any]: diff --git a/src/movement_optimizer/trajectory/optimizer.py b/src/movement_optimizer/trajectory/optimizer.py index 0caf2b28f5..a251f9783f 100644 --- a/src/movement_optimizer/trajectory/optimizer.py +++ b/src/movement_optimizer/trajectory/optimizer.py @@ -129,7 +129,12 @@ def __init__( self.n_dof = n_dof self.body, self.dynamics = body, dynamics self.exercise_type, self.bar_mass = exercise_type, bar_mass - self.q_start, self.q_end, self.q_bounds, self.q_via = q_start, q_end, q_bounds, q_via + self.q_start, self.q_end, self.q_bounds, self.q_via = ( + q_start, + q_end, + q_bounds, + q_via, + ) self.duration, self.n_waypoints, self.n_eval = duration, n_waypoints, n_eval self.progress_cb, self.n_starts = progress_cb, n_starts self.cancel_event = cancel_event or threading.Event() @@ -144,7 +149,9 @@ def __init__( self.balance_center_weight = BALANCE_CENTER_WEIGHT self._setup_time_grids() self.dt = duration / (n_eval - 1) - self._n_damp = max(ENDPOINT_DAMP_MIN_SAMPLES, int(n_eval * ENDPOINT_DAMP_SAMPLE_FRACTION)) + self._n_damp = max( + ENDPOINT_DAMP_MIN_SAMPLES, int(n_eval * ENDPOINT_DAMP_SAMPLE_FRACTION) + ) self._damp_weights = 1.0 - np.arange(self._n_damp) / self._n_damp self._progress = ProgressTracker(progress_cb=progress_cb) self._progress_lock = self._progress.lock() @@ -175,7 +182,9 @@ def build_splines(self, x: NDArray) -> CubicSpline: self.n_dof, ) - def eval_trajectory(self, splines: CubicSpline) -> tuple[NDArray, NDArray, NDArray, NDArray]: + def eval_trajectory( + self, splines: CubicSpline + ) -> tuple[NDArray, NDArray, NDArray, NDArray]: """Evaluate position, velocity, acceleration, jerk at eval grid. Delegates to :func:`optimizer_spline.eval_trajectory`. @@ -345,7 +354,9 @@ def _optimize_single_start(self) -> OptimizationResult: """Run single-start path and package its result.""" self._progress.reset() wp0 = self._initial_guess() - out = self._minimize_single(wp0.flatten(), self.cost, max_iter=MAX_ITER_PER_START * 2) + out = self._minimize_single( + wp0.flatten(), self.cost, max_iter=MAX_ITER_PER_START * 2 + ) if self.cancel_event.is_set(): metrics.increment( "trajectory_optimization_cancelled_total", @@ -357,7 +368,9 @@ def _optimize_single_start(self) -> OptimizationResult: self._record_result_metrics(result, mode="single") return result - def _finalize_parallel_results(self, results: list[tuple[Any, int]]) -> OptimizationResult: + def _finalize_parallel_results( + self, results: list[tuple[Any, int]] + ) -> OptimizationResult: """Select the best result, log summary, and package output.""" if not results: raise CancelledError("All optimization starts were cancelled") @@ -385,11 +398,15 @@ def _record_result_metrics(self, result: OptimizationResult, *, mode: str) -> No exercise_type=self.exercise_type, mode=mode, ) - metrics.observe("trajectory_optimization_elapsed_seconds", result.elapsed_s, **labels) + metrics.observe( + "trajectory_optimization_elapsed_seconds", result.elapsed_s, **labels + ) metrics.observe("trajectory_optimization_cost", result.cost, **labels) metrics.observe("trajectory_optimization_evaluations", result.n_evals, **labels) - def _check_solution_feasibility(self, res: Any, q: NDArray, com_x: NDArray) -> tuple[bool, int]: + def _check_solution_feasibility( + self, res: Any, q: NDArray, com_x: NDArray + ) -> tuple[bool, int]: """Assess cost finiteness, COM bounds, and joint-limit violations. SLSQP can report ``success`` while sitting on a point that the diff --git a/src/movement_optimizer/trajectory/optimizer_cost.py b/src/movement_optimizer/trajectory/optimizer_cost.py index cd21a5b1a5..2ab1642331 100644 --- a/src/movement_optimizer/trajectory/optimizer_cost.py +++ b/src/movement_optimizer/trajectory/optimizer_cost.py @@ -111,7 +111,9 @@ def compute_endpoint_damping_cost( return weight * float(cost) * dt -def compute_balance_cost(com_x: NDArray, center: float, dt: float, weight: float) -> float: +def compute_balance_cost( + com_x: NDArray, center: float, dt: float, weight: float +) -> float: """Soft centering preference — penalise COM deviation from the inner BOS center. Preconditions: diff --git a/src/movement_optimizer/trajectory/optimizer_parallel.py b/src/movement_optimizer/trajectory/optimizer_parallel.py index dc4ac3d6c2..2b897cfd4f 100644 --- a/src/movement_optimizer/trajectory/optimizer_parallel.py +++ b/src/movement_optimizer/trajectory/optimizer_parallel.py @@ -113,7 +113,9 @@ def run_parallel_starts( optimizer work performed by each submitted start. """ with ThreadPoolExecutor(max_workers=n_workers) as pool: - pending: set[Future] = {pool.submit(run_single_fn, seed) for seed in range(n_starts)} + pending: set[Future] = { + pool.submit(run_single_fn, seed) for seed in range(n_starts) + } return collect_future_results(pending, cancel_check, record_progress) diff --git a/src/p1am_control_system/backend/modbus_client.py b/src/p1am_control_system/backend/modbus_client.py index 0d5daf4329..15049798fa 100644 --- a/src/p1am_control_system/backend/modbus_client.py +++ b/src/p1am_control_system/backend/modbus_client.py @@ -153,7 +153,9 @@ async def read_tags(self) -> dict[str, float] | None: high = response.registers[i * 2 + 1] tags[f"TAG_{i}"] = registers_to_float(low, high) return tags - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception during tag read: {e}") self._connected = False return None @@ -288,7 +290,9 @@ async def write_routing(self, config: RoutingConfig) -> bool: ) return True - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception writing configuration to PLC: {e}") self._connected = False return False @@ -313,7 +317,9 @@ async def save_to_flash(self) -> bool: return False logger.info("Triggered Save to Flash Modbus Coil.") return True - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception saving config to PLC flash: {e}") self._connected = False return False @@ -359,7 +365,9 @@ async def trigger_estop(self) -> bool: else: logger.error("E-stop: one or more zeroing writes FAILED — retry.") return all_ok - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception during E-stop Modbus execution: {e}") self._connected = False return False @@ -389,7 +397,9 @@ async def clear_estop(self) -> bool: return False logger.warning("E-stop reset coil written to PLC successfully.") return True - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception during E-stop reset Modbus execution: {e}") self._connected = False return False @@ -440,7 +450,9 @@ async def write_pid_setpoint(self, pid_index: int, value: float) -> bool: value, resp, ) - except Exception as exc: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as exc: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error( "write_pid_setpoint(%d, %f) exception: %s", pid_index, @@ -497,7 +509,9 @@ async def write_coil(self, address: int, value: bool) -> bool: if not resp.isError(): return True logger.error("write_coil(%d, %s) failed: %s", address, value, resp) - except Exception as exc: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as exc: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error( "write_coil(%d, %s) exception: %s", address, value, exc ) @@ -546,7 +560,9 @@ async def write_tag(self, tag_name: str, value: float) -> bool: f"Directly wrote {value} to tag {tag_name} at register {address}." ) return True - except Exception as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects + except ( + Exception + ) as e: # noqa: BLE001 - any I/O failure drops the connection; poll loop reconnects logger.error(f"Exception during direct tag write for {tag_name}: {e}") self._connected = False return False diff --git a/src/p1am_control_system/desktop/plot_compat.py b/src/p1am_control_system/desktop/plot_compat.py index dd27fb0768..d3e33c30a7 100644 --- a/src/p1am_control_system/desktop/plot_compat.py +++ b/src/p1am_control_system/desktop/plot_compat.py @@ -49,7 +49,9 @@ class _FallbackPyQtGraph: PlotWidget = _FallbackPlotWidget @staticmethod - def mkPen(*args: Any, **kwargs: Any) -> tuple[tuple[Any, ...], dict[str, Any]]: # noqa: N802 + def mkPen( + *args: Any, **kwargs: Any + ) -> tuple[tuple[Any, ...], dict[str, Any]]: # noqa: N802 return args, kwargs pg = _FallbackPyQtGraph() diff --git a/src/p1am_control_system/desktop/sidebar.py b/src/p1am_control_system/desktop/sidebar.py index 1e4c78159a..4b5b91ac45 100644 --- a/src/p1am_control_system/desktop/sidebar.py +++ b/src/p1am_control_system/desktop/sidebar.py @@ -293,12 +293,12 @@ def _apply_changes(self) -> None: # Update safety limits if tag_id < len(self.routing_config.interlocks): - self.routing_config.interlocks[ - tag_id - ].low_limit = self.spin_low_limit.value() - self.routing_config.interlocks[ - tag_id - ].high_limit = self.spin_high_limit.value() + self.routing_config.interlocks[tag_id].low_limit = ( + self.spin_low_limit.value() + ) + self.routing_config.interlocks[tag_id].high_limit = ( + self.spin_high_limit.value() + ) # Update PID loop configs if self.pid_group.isVisible() and self.pid_loop_index >= 0: diff --git a/src/pendulum_simulator/pendulum-core/python/physics_native.py b/src/pendulum_simulator/pendulum-core/python/physics_native.py index 3f5a7e21df..95fe803be4 100644 --- a/src/pendulum_simulator/pendulum-core/python/physics_native.py +++ b/src/pendulum_simulator/pendulum-core/python/physics_native.py @@ -152,7 +152,9 @@ def mass_matrix(self, q: np.ndarray) -> np.ndarray: raise ValueError(f"q must have shape (2,), got {q.shape}") if self.use_native: try: - result = pendulum_core.py_double_mass_matrix(q.tolist(), self.params.to_rust()) + result = pendulum_core.py_double_mass_matrix( + q.tolist(), self.params.to_rust() + ) return _float64_array(result) except (RuntimeError, AttributeError, TypeError) as e: logger.warning( @@ -318,7 +320,9 @@ def __init__( if not isinstance(val, (int, float)): raise TypeError(f"{name} must be a number, got {type(val).__name__}") if not isinstance(m_clubhead, (int, float)): - raise TypeError(f"m_clubhead must be a number, got {type(m_clubhead).__name__}") + raise TypeError( + f"m_clubhead must be a number, got {type(m_clubhead).__name__}" + ) if m_clubhead < 0: raise ValueError(f"m_clubhead must be non-negative, got {m_clubhead}") if not isinstance(g, (int, float)): @@ -438,7 +442,9 @@ def mass_matrix(self, q: np.ndarray) -> np.ndarray: raise ValueError(f"q must have shape (8,), got {q.shape}") if self.use_native: try: - result = pendulum_core.py_golfer_mass_matrix(q.tolist(), self.params.to_rust()) + result = pendulum_core.py_golfer_mass_matrix( + q.tolist(), self.params.to_rust() + ) return _float64_array(result) except (RuntimeError, AttributeError, TypeError) as e: logger.error( @@ -468,7 +474,9 @@ def gravity_vector(self, q: np.ndarray) -> np.ndarray: ) return _float64_array(result) except (RuntimeError, AttributeError, TypeError) as e: - logger.warning("Rust golfer gravity_vector call failed (%s)", type(e).__name__) + logger.warning( + "Rust golfer gravity_vector call failed (%s)", type(e).__name__ + ) # Golfer NumPy fallback is not implemented (see module docstring; native-only, GH#3294). raise NotImplementedError( diff --git a/src/pendulum_simulator/src/double_pendulum_golf/__main__.py b/src/pendulum_simulator/src/double_pendulum_golf/__main__.py index 2862dd911f..08d677b719 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/__main__.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/__main__.py @@ -30,7 +30,9 @@ class _WheelBlockFilter(QObject): range and the value survives across launches via QSettings. """ - def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # noqa: N802 + def eventFilter( + self, obj: QObject | None, event: QEvent | None + ) -> bool: # noqa: N802 if event is not None and event.type() == QEvent.Type.Wheel: wheel: QWheelEvent = event # type: ignore[assignment] # Ctrl+Wheel → font zoom (delegated to MainWindow for bounds + persist) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/constraint_solver.py b/src/pendulum_simulator/src/double_pendulum_golf/constraint_solver.py index 7d15f0bac6..0ef9192513 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/constraint_solver.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/constraint_solver.py @@ -111,7 +111,9 @@ def _solve_constrained_dynamics( if not (np.all(np.isfinite(qddot))): raise ValueError(f"qddot has non-finite values: {qddot}") if not (np.all(np.isfinite(lambda_forces))): - raise ValueError(f"Constraint forces have non-finite values: {lambda_forces}") + raise ValueError( + f"Constraint forces have non-finite values: {lambda_forces}" + ) return qddot, lambda_forces # Compute dynamic terms @@ -209,7 +211,9 @@ def constraint_forces( raise ValueError(f"state must have shape ({2 * N_DOF},), got {state.shape}") if not isinstance(t, (int, float)): raise TypeError(f"t must be a number, got {type(t).__name__}") - _, lambda_forces = _solve_constrained_dynamics(state, t, params, torque_func, alpha, beta) + _, lambda_forces = _solve_constrained_dynamics( + state, t, params, torque_func, alpha, beta + ) return lambda_forces @@ -334,7 +338,9 @@ def project_to_constraints( if not (tol > 0): raise ValueError(f"tol must be positive, got {tol}") - native_projection = _native_backend.golfer_project_to_constraints(q, params, max_iter, tol) + native_projection = _native_backend.golfer_project_to_constraints( + q, params, max_iter, tol + ) if native_projection is not None: residual = float(np.linalg.norm(constraint_vector(native_projection, params))) if residual < tol: @@ -347,7 +353,9 @@ def project_to_constraints( return q Phi_q = constraint_jacobian(q, params) # Use pseudoinverse for robustness - dq = Phi_q.T @ np.linalg.solve(Phi_q @ Phi_q.T + 1e-12 * np.eye(N_CONSTRAINTS), Phi) + dq = Phi_q.T @ np.linalg.solve( + Phi_q @ Phi_q.T + 1e-12 * np.eye(N_CONSTRAINTS), Phi + ) q -= dq residual = float(np.linalg.norm(constraint_vector(q, params))) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/counterfactual.py b/src/pendulum_simulator/src/double_pendulum_golf/counterfactual.py index c53961f119..d4ae823766 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/counterfactual.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/counterfactual.py @@ -129,7 +129,9 @@ def zero_torque_joint_forces_double( # --------------------------------------------------------------------------- -def _zero_torque_qddot_triple(state: np.ndarray, params: TriplePendulumParams) -> np.ndarray: +def _zero_torque_qddot_triple( + state: np.ndarray, params: TriplePendulumParams +) -> np.ndarray: """Compute angular accel under zero driving torque for triple pendulum. Preconditions diff --git a/src/pendulum_simulator/src/double_pendulum_golf/data_extractor.py b/src/pendulum_simulator/src/double_pendulum_golf/data_extractor.py index 544779cfb7..eddd81ac40 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/data_extractor.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/data_extractor.py @@ -79,7 +79,9 @@ def _make_velocity_extractor(key: str) -> Extractor: def _extract(result: Any) -> np.ndarray: n = result.n_steps - return np.array([result.joint_velocities_at(i)[key] for i in range(n)], dtype=float) + return np.array( + [result.joint_velocities_at(i)[key] for i in range(n)], dtype=float + ) return _extract @@ -127,7 +129,9 @@ def _make_base_force_extractor(component: str) -> Extractor: def _extract(result: Any) -> np.ndarray: n = result.n_steps - return np.array([result.base_force_at(i)[component] for i in range(n)], dtype=float) + return np.array( + [result.base_force_at(i)[component] for i in range(n)], dtype=float + ) return _extract diff --git a/src/pendulum_simulator/src/double_pendulum_golf/dynamics_quantities.py b/src/pendulum_simulator/src/double_pendulum_golf/dynamics_quantities.py index 5775e936b4..eeb8726d33 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/dynamics_quantities.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/dynamics_quantities.py @@ -107,7 +107,9 @@ def angular_power_series( if not (torques.ndim == 1): raise ValueError(f"torques must be 1-D, got {torques.ndim}-D") if not (torques.shape == angular_velocities.shape): - raise ValueError(f"Shape mismatch: {torques.shape} vs {angular_velocities.shape}") + raise ValueError( + f"Shape mismatch: {torques.shape} vs {angular_velocities.shape}" + ) if not (np.all(np.isfinite(torques))): raise ValueError("torques must be all finite") if not (np.all(np.isfinite(angular_velocities))): diff --git a/src/pendulum_simulator/src/double_pendulum_golf/golfer_dynamics.py b/src/pendulum_simulator/src/double_pendulum_golf/golfer_dynamics.py index 6e1053193b..32430a3af4 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/golfer_dynamics.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/golfer_dynamics.py @@ -16,7 +16,9 @@ from .physics_golfer import GolferParams, N_DOF, State -def _mass_point_positions(q: np.ndarray, p: GolferParams) -> list[tuple[float, Callable]]: +def _mass_point_positions( + q: np.ndarray, p: GolferParams +) -> list[tuple[float, Callable]]: """Return list of (mass, position_function) for all point masses.""" if not isinstance(q, np.ndarray): raise TypeError("q must be a numpy ndarray") @@ -133,7 +135,9 @@ def __init__(self, q: np.ndarray) -> None: self.cos_club = np.cos(q[7]) -def _hub_and_shoulder_jacobians(p: GolferParams, tc: _TrigCache) -> dict[str, np.ndarray]: +def _hub_and_shoulder_jacobians( + p: GolferParams, tc: _TrigCache +) -> dict[str, np.ndarray]: """Compute Jacobians for hub, right shoulder, and left shoulder.""" if p is None: raise ValueError("p must be provided") @@ -190,7 +194,9 @@ def _right_arm_chain_jacobian( return J_re, J_rh, J_rh -def _left_arm_chain_jacobian(p: GolferParams, tc: _TrigCache) -> tuple[np.ndarray, np.ndarray]: +def _left_arm_chain_jacobian( + p: GolferParams, tc: _TrigCache +) -> tuple[np.ndarray, np.ndarray]: """Compute Jacobians for LE, LH along the left arm kinematic chain.""" # LE (left elbow): depends on q[0], q[4] if p is None: @@ -482,4 +488,6 @@ def total_energy(state: State, p: GolferParams) -> float: q = state[:N_DOF] qdot = state[N_DOF:] - return total_energy_from_parts(kinetic_energy(q, qdot, p), potential_energy(state, p)) + return total_energy_from_parts( + kinetic_energy(q, qdot, p), potential_energy(state, p) + ) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/golfer_kinematics.py b/src/pendulum_simulator/src/double_pendulum_golf/golfer_kinematics.py index 0a0a6c4145..d37be07b94 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/golfer_kinematics.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/golfer_kinematics.py @@ -106,7 +106,9 @@ def _absolute_angles(theta_hub: float, relative_angles: list[float]) -> list[flo return result -def forward_kinematics(q: np.ndarray, p: GolferParams) -> dict[str, tuple[float, float]]: +def forward_kinematics( + q: np.ndarray, p: GolferParams +) -> dict[str, tuple[float, float]]: """Compute all joint positions in world frame. Parameters diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/analysis_tab.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/analysis_tab.py index 97c6add473..11ad32cb05 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/analysis_tab.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/analysis_tab.py @@ -319,8 +319,12 @@ def _on_plot_2d(self) -> None: from ..data_extractor import extract_series try: - x_vals, x_desc, x_unit = extract_series(self._result, x_key, self._model_type) - y_vals, y_desc, y_unit = extract_series(self._result, y_key, self._model_type) + x_vals, x_desc, x_unit = extract_series( + self._result, x_key, self._model_type + ) + y_vals, y_desc, y_unit = extract_series( + self._result, y_key, self._model_type + ) except (KeyError, AttributeError) as exc: logger.error("Failed to extract series: %s", exc) return @@ -510,7 +514,9 @@ def _evaluator_double(self, z_key: str) -> Any: if z_key == "potential_energy": def _eval(angles: dict) -> float: - state = np.array([angles.get("theta1", 0.0), angles.get("phi", 0.0), 0.0, 0.0]) + state = np.array( + [angles.get("theta1", 0.0), angles.get("phi", 0.0), 0.0, 0.0] + ) return potential_energy(state, params) return _eval diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/base_pendulum_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/base_pendulum_widget.py index 78f7dffe37..f9d0c4b1c6 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/base_pendulum_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/base_pendulum_widget.py @@ -402,9 +402,9 @@ def mousePressEvent(self, event: object) -> None: if not isinstance(event, QMouseEvent): return if event.button() == Qt.MouseButton.LeftButton: - if hasattr(self, "_handle_zoom_button_click") and self._handle_zoom_button_click( - event.pos() - ): + if hasattr( + self, "_handle_zoom_button_click" + ) and self._handle_zoom_button_click(event.pos()): return self._drag_start = event.pos() self._drag_pan_start = (self._pan_x, self._pan_y) @@ -536,7 +536,9 @@ def _world_to_pixel(self, x_world: float, y_world: float) -> QPointF: # Off-screen detection / recovery overlay # ------------------------------------------------------------------ - def _world_points_in_view(self, points: list[tuple[float, float]]) -> tuple[bool, QPointF]: + def _world_points_in_view( + self, points: list[tuple[float, float]] + ) -> tuple[bool, QPointF]: """Check if any of the given world points lies inside the widget. Returns ``(any_visible, centroid_pixel)`` where the centroid is @@ -558,7 +560,9 @@ def _world_points_in_view(self, points: list[tuple[float, float]]) -> tuple[bool any_visible = True return any_visible, QPointF(sum_x / n, sum_y / n) - def _draw_offscreen_indicator(self, painter: QPainter, system_centroid: QPointF) -> None: + def _draw_offscreen_indicator( + self, painter: QPainter, system_centroid: QPointF + ) -> None: """Draw a banner + arrow when the system is fully off-screen. Always-visible recovery affordance: tells the user where to look @@ -1020,7 +1024,9 @@ def _draw_shadow_projection( # Image export (#1779) # ------------------------------------------------------------------ - def export_image(self, file_path: str, width: int = 1920, height: int = 1080) -> None: + def export_image( + self, file_path: str, width: int = 1920, height: int = 1080 + ) -> None: """Export the current visualization as a high-resolution image. Supports PNG, SVG, and PDF formats based on file extension. diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/clipboard_utils.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/clipboard_utils.py index 602164a71c..4ae5fcccad 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/clipboard_utils.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/clipboard_utils.py @@ -36,7 +36,9 @@ def matrix_to_tsv(data: np.ndarray) -> str: return result -def series_to_tsv(x: np.ndarray, y: np.ndarray, x_label: str = "x", y_label: str = "y") -> str: +def series_to_tsv( + x: np.ndarray, y: np.ndarray, x_label: str = "x", y_label: str = "y" +) -> str: """Convert two 1D arrays to tab-separated text with header. Pre: x.shape == y.shape, both 1D diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_utils.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_utils.py index 2580b3598f..a65ca0ad1a 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_utils.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_utils.py @@ -26,7 +26,8 @@ # ── UnitAwareInput availability (DRY: single check shared by all widgets) ── HAS_UNIT_AWARE_INPUT = ( - importlib.util.find_spec("upstream_drift_tools.ui.widgets.unit_aware_input") is not None + importlib.util.find_spec("upstream_drift_tools.ui.widgets.unit_aware_input") + is not None ) # --------------------------------------------------------------------------- @@ -238,7 +239,9 @@ def parse_coeffs(widget: LabeledInput, name: str) -> list[float]: parts = widget.value.split(",") return [float(p.strip()) for p in parts if p.strip()] except ValueError: - raise ValueError(f"Cannot parse '{name}' coefficients: '{widget.value}'") from None + raise ValueError( + f"Cannot parse '{name}' coefficients: '{widget.value}'" + ) from None def parse_coeffs_lenient(widget: LabeledInput) -> list[float]: diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget.py index e7ac2f5bb8..709d6365a6 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget.py @@ -274,8 +274,12 @@ def _build_joint_limits_section(self) -> QGroupBox: self.chk_limits = QCheckBox("Enable joint limits") self.chk_limits.setStyleSheet(STYLE_CHECK) layout.addWidget(self.chk_limits) - self.inp_theta1_min = LabeledInput("θ1 min°", "-180", "Min shoulder angle (deg)", lw) - self.inp_theta1_max = LabeledInput("θ1 max°", "180", "Max shoulder angle (deg)", lw) + self.inp_theta1_min = LabeledInput( + "θ1 min°", "-180", "Min shoulder angle (deg)", lw + ) + self.inp_theta1_max = LabeledInput( + "θ1 max°", "180", "Max shoulder angle (deg)", lw + ) layout.addLayout(_row(self.inp_theta1_min, self.inp_theta1_max)) self.inp_phi_min = LabeledInput("φ min°", "-90", "Min wrist angle (deg)", lw) self.inp_phi_max = LabeledInput("φ max°", "90", "Max wrist angle (deg)", lw) @@ -382,7 +386,9 @@ def _build_ic_section(self) -> QGroupBox: row.addWidget(widget) layout.addLayout(row) else: - self.inp_dtheta1 = LabeledInput("dθ1", "0", "Arm angular velocity rad/s", lw) + self.inp_dtheta1 = LabeledInput( + "dθ1", "0", "Arm angular velocity rad/s", lw + ) self.inp_dphi = LabeledInput("dφ", "0", "Club angular velocity rad/s", lw) layout.addLayout(_row(self.inp_theta1, self.inp_phi)) layout.addLayout(_row(self.inp_dtheta1, self.inp_dphi)) @@ -394,7 +400,9 @@ def _build_torque_section(self) -> QGroupBox: layout = QVBoxLayout(box) layout.setContentsMargins(4, 12, 4, 4) layout.setSpacing(3) - self.inp_tau_shoulder = LabeledInput("Shoulder", "-25, 10", "τ(t)=c0+c1·t+…", 56) + self.inp_tau_shoulder = LabeledInput( + "Shoulder", "-25, 10", "τ(t)=c0+c1·t+…", 56 + ) self.inp_tau_wrist = LabeledInput("Wrist", "0", "τ(t)=c0+c1·t+…", 56) layout.addWidget(self.inp_tau_shoulder) layout.addWidget(self.inp_tau_wrist) @@ -512,7 +520,9 @@ def _apply_preset(self, name: str) -> None: raise ValueError("name must be provided") if name not in self.PRESETS: return - theta1, phi, dth, dph, tau_sh, tau_wr, tend, m1, m2, mClub, L1, L2 = self.PRESETS[name] + theta1, phi, dth, dph, tau_sh, tau_wr, tend, m1, m2, mClub, L1, L2 = ( + self.PRESETS[name] + ) self.inp_theta1.set_value(str(theta1)) self.inp_phi.set_value(str(phi)) self.inp_tau_shoulder.set_value(tau_sh) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_base.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_base.py index ccd4d5930e..2ccec6fd30 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_base.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_base.py @@ -77,9 +77,7 @@ "QPushButton:hover{background:#32326a;}" ) -STYLE_COMBO = ( - "background:#2a2a38;color:#e0e0f0;border:1px solid #505068;border-radius:3px;padding:4px;" -) +STYLE_COMBO = "background:#2a2a38;color:#e0e0f0;border:1px solid #505068;border-radius:3px;padding:4px;" class ControlsWidgetBase(QWidget): @@ -269,7 +267,10 @@ def _parse_torque_limits(self) -> list[float] | None: if not hasattr(self, "chk_clamp") or not self.chk_clamp.isChecked(): return None - return [parse_float(inp, f"Max torque {i}") for i, inp in enumerate(self.clamp_inputs)] + return [ + parse_float(inp, f"Max torque {i}") + for i, inp in enumerate(self.clamp_inputs) + ] def _parse_joint_limits(self) -> tuple[list[float], list[float], float] | None: """Parse joint limit values. @@ -361,7 +362,9 @@ def _on_torque_imported(self, joint: str, coeffs: list[float]) -> None: inputs = self._get_torque_inputs() key = joint.lower() valid_keys = {k.lower() for k in inputs} - assert key in valid_keys, f"Unknown joint '{joint}', expected one of {valid_keys}" + assert ( + key in valid_keys + ), f"Unknown joint '{joint}', expected one of {valid_keys}" assert len(coeffs) >= 1, "Coefficients list must not be empty" coeffs_str = ", ".join(f"{c:.4g}" for c in coeffs) @@ -391,9 +394,9 @@ def set_slider_range(self, max_val: int) -> None: def set_slider_value(self, val: int) -> None: """Pre: 0 <= val <= slider.maximum()""" - assert 0 <= val <= self.slider.maximum(), ( - f"Slider value {val} out of range [0, {self.slider.maximum()}]" - ) + assert ( + 0 <= val <= self.slider.maximum() + ), f"Slider value {val} out of range [0, {self.slider.maximum()}]" self.slider.blockSignals(True) self.slider.setValue(val) self.slider.blockSignals(False) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_golfer.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_golfer.py index 3eb228dc2d..5da57642d8 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_golfer.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_golfer.py @@ -220,7 +220,9 @@ def _build_mass_section(self) -> QGroupBox: row.addWidget(w) layout.addLayout(row) else: - self.inp_m_hub = LabeledInput("Standoff", "0.001", "Standoff mass (massless)") + self.inp_m_hub = LabeledInput( + "Standoff", "0.001", "Standoff mass (massless)" + ) self.inp_m_r_upper = LabeledInput("R Upper", "3.5", "Right upper arm") self.inp_m_r_fore = LabeledInput("R Fore", "2.0", "Right forearm") self.inp_m_l_upper = LabeledInput("L Upper", "3.5", "Left upper arm") @@ -280,7 +282,9 @@ def _build_length_section(self) -> QGroupBox: row.addWidget(w) layout.addLayout(row) else: - self.inp_L_hub = LabeledInput("Standoff", "0.15", "Standoff length (COM offset)") + self.inp_L_hub = LabeledInput( + "Standoff", "0.15", "Standoff length (COM offset)" + ) self.inp_L_r_upper = LabeledInput("R Upper", "0.35", "Right upper arm") self.inp_L_r_fore = LabeledInput("R Fore", "0.30", "Right forearm") self.inp_L_l_upper = LabeledInput("L Upper", "0.35", "Left upper arm") @@ -303,8 +307,12 @@ def _build_geometry_section(self) -> QGroupBox: layout = QVBoxLayout(box) layout.setContentsMargins(4, 12, 4, 4) layout.setSpacing(3) - self.inp_d_rs = LabeledInput("d_RS (m)", "0.20", "Hub bar to right shoulder offset") - self.inp_d_ls = LabeledInput("d_LS (m)", "0.20", "Hub bar to left shoulder offset") + self.inp_d_rs = LabeledInput( + "d_RS (m)", "0.20", "Hub bar to right shoulder offset" + ) + self.inp_d_ls = LabeledInput( + "d_LS (m)", "0.20", "Hub bar to left shoulder offset" + ) self.inp_grip_right = LabeledInput( "Grip R (m)", "0.05", "Right hand grip from club base" ) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_triple.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_triple.py index 6f62fc37f4..8f330f4bab 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_triple.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/controls_widget_triple.py @@ -211,8 +211,12 @@ def _build_physics_section(self) -> QGroupBox: self.inp_L1 = LabeledInput( "L1 (m) — Hub", "0.20", "Length of segment 1: Hub (sternum → shoulder)" ) - self.inp_L2 = LabeledInput("L2 (m) — Arm", "0.65", "Length of segment 2: Arm") - self.inp_L3 = LabeledInput("L3 (m) — Club", "1.10", "Length of segment 3: Club") + self.inp_L2 = LabeledInput( + "L2 (m) — Arm", "0.65", "Length of segment 2: Arm" + ) + self.inp_L3 = LabeledInput( + "L3 (m) — Club", "1.10", "Length of segment 3: Club" + ) for w in [ self.inp_m1, self.inp_m2, @@ -264,8 +268,12 @@ def _build_torque_section(self) -> QGroupBox: self.inp_tau_shoulder = LabeledInput( "Shoulder", "-25, 10", "τ(t) = c0 + c1*t + c2*t^2 + ..." ) - self.inp_tau_elbow = LabeledInput("Elbow", "0", "τ(t) = c0 + c1*t + c2*t^2 + ...") - self.inp_tau_wrist = LabeledInput("Wrist", "0", "τ(t) = c0 + c1*t + c2*t^2 + ...") + self.inp_tau_elbow = LabeledInput( + "Elbow", "0", "τ(t) = c0 + c1*t + c2*t^2 + ..." + ) + self.inp_tau_wrist = LabeledInput( + "Wrist", "0", "τ(t) = c0 + c1*t + c2*t^2 + ..." + ) layout.addWidget(self.inp_tau_shoulder) layout.addWidget(self.inp_tau_elbow) layout.addWidget(self.inp_tau_wrist) @@ -364,12 +372,24 @@ def get_params(self) -> dict: L1 = self._uai_or_parse(self.inp_L1, "L1") L2 = self._uai_or_parse(self.inp_L2, "L2") L3 = self._uai_or_parse(self.inp_L3, "L3") - b1 = require_non_negative(parse_float(getattr(self, "inp_b1", None), "b1"), "b1") - b2 = require_non_negative(parse_float(getattr(self, "inp_b2", None), "b2"), "b2") - b3 = require_non_negative(parse_float(getattr(self, "inp_b3", None), "b3"), "b3") - mu1 = require_non_negative(parse_float(getattr(self, "inp_mu1", None), "μ1"), "μ1") - mu2 = require_non_negative(parse_float(getattr(self, "inp_mu2", None), "μ2"), "μ2") - mu3 = require_non_negative(parse_float(getattr(self, "inp_mu3", None), "μ3"), "μ3") + b1 = require_non_negative( + parse_float(getattr(self, "inp_b1", None), "b1"), "b1" + ) + b2 = require_non_negative( + parse_float(getattr(self, "inp_b2", None), "b2"), "b2" + ) + b3 = require_non_negative( + parse_float(getattr(self, "inp_b3", None), "b3"), "b3" + ) + mu1 = require_non_negative( + parse_float(getattr(self, "inp_mu1", None), "μ1"), "μ1" + ) + mu2 = require_non_negative( + parse_float(getattr(self, "inp_mu2", None), "μ2"), "μ2" + ) + mu3 = require_non_negative( + parse_float(getattr(self, "inp_mu3", None), "μ3"), "μ3" + ) require_positive(m1, "m1") require_positive(m2, "m2") require_positive(m3, "m3") diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/diagnostics.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/diagnostics.py index ed22743d43..ccba5b4f5a 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/diagnostics.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/diagnostics.py @@ -403,9 +403,13 @@ def _populate(self) -> None: error_count = self._tracker.error_count total = len(self._tracker.events) - self._count_label.setText(f"{total} events total • {error_count} errors/critical") + self._count_label.setText( + f"{total} events total • {error_count} errors/critical" + ) - def _on_row_selected(self, row: int, _col: int, _prev_row: int, _prev_col: int) -> None: + def _on_row_selected( + self, row: int, _col: int, _prev_row: int, _prev_col: int + ) -> None: """Show details for the selected event.""" # Events are displayed newest-first (reversed) if 0 <= row < len(self._displayed_events): diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/golfer_pendulum_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/golfer_pendulum_widget.py index e5e09ba7fc..2c088f0ed8 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/golfer_pendulum_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/golfer_pendulum_widget.py @@ -326,7 +326,9 @@ def _draw_golfer(self, painter: QPainter) -> None: self._draw_3d_segment(painter, ls, le, 12, 9, self.COLOR_LEFT_ARM) self._draw_3d_segment(painter, le, lh, 9, 6, self.COLOR_LEFT_ARM) # Club shaft — tapered from grip to head - self._draw_3d_segment(painter, club_base, club_tip, 10, 4, self.COLOR_CLUB_SHAFT) + self._draw_3d_segment( + painter, club_base, club_tip, 10, 4, self.COLOR_CLUB_SHAFT + ) else: # Original flat-line rendering # Standoff (origin -> hub) — massless, COM offset adjustment @@ -542,7 +544,10 @@ def _draw_torque_vectors(self, painter: QPainter) -> None: for i, jname in enumerate(joint_keys): if i >= len(torque_list): break - if self._visible_segments is not None and jname not in self._visible_segments: + if ( + self._visible_segments is not None + and jname not in self._visible_segments + ): continue jp = pos.get(jname) if jp is None: @@ -675,7 +680,10 @@ def _draw_ellipsoids_at_frame(self, painter: QPainter) -> None: } for name, ell in data.items(): - if self._visible_segments is not None and name not in self._visible_segments: + if ( + self._visible_segments is not None + and name not in self._visible_segments + ): continue world_pos = endpoint_map.get(name) if world_pos is None: @@ -725,7 +733,9 @@ def _draw_ellipsoids_at_frame(self, painter: QPainter) -> None: QPointF(cx_px + dx_line, cy_px + dy_line), ) painter.setFont(QFont("Monospace", 7)) - painter.drawText(QPointF(cx_px + dx_line + 4, cy_px + dy_line), "F\u221e") + painter.drawText( + QPointF(cx_px + dx_line + 4, cy_px + dy_line), "F\u221e" + ) def _draw_ellipse_axes( self, diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/main_window.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/main_window.py index b902064918..8136da00f9 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/main_window.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/main_window.py @@ -256,7 +256,9 @@ def adjust_global_font_zoom(cls, delta_steps: int) -> int: Returns the resulting (clamped) offset. """ try: - current = int(QSettings(_SETTINGS_ORG, _SETTINGS_APP).value("font_zoom_pt", 0)) + current = int( + QSettings(_SETTINGS_ORG, _SETTINGS_APP).value("font_zoom_pt", 0) + ) except (TypeError, ValueError): current = 0 return cls._apply_offset_to_app_font(current + int(delta_steps)) @@ -440,7 +442,9 @@ def _on_shortcut_toggle_3d(self) -> None: widget = panel.pendulum_widget new_state = not widget._3d_mode widget.set_3d_mode(new_state) - self.statusBar().showMessage(f"3D mode {'enabled' if new_state else 'disabled'}", 2000) + self.statusBar().showMessage( + f"3D mode {'enabled' if new_state else 'disabled'}", 2000 + ) def _on_shortcut_toggle_forces(self) -> None: """F key: toggle force vector display.""" @@ -448,7 +452,9 @@ def _on_shortcut_toggle_forces(self) -> None: widget = panel.pendulum_widget new_state = not widget._show_forces widget.set_show_forces(new_state) - self.statusBar().showMessage(f"Forces {'shown' if new_state else 'hidden'}", 2000) + self.statusBar().showMessage( + f"Forces {'shown' if new_state else 'hidden'}", 2000 + ) def _on_shortcut_toggle_gravity(self) -> None: """G key: toggle gravity display indicator.""" @@ -517,7 +523,9 @@ def _wire_analysis_tab(self) -> None: for idx, panel in enumerate(self._panels): model_type = model_map[idx] - def _on_finished(_p: SimulationPanel = panel, _mt: str = model_type) -> None: + def _on_finished( + _p: SimulationPanel = panel, _mt: str = model_type + ) -> None: result = _p._result if result is not None: self._analysis_tab.set_result(result, model_type=_mt) @@ -724,7 +732,11 @@ def _on_theme_changed(self, name: str) -> None: self.status.showMessage(f"Theme changed to: {name}", 3000) def _open_theme_manager(self) -> None: - if not _THEME_AVAILABLE or self._theme_manager is None or ThemeManagerDialog is None: + if ( + not _THEME_AVAILABLE + or self._theme_manager is None + or ThemeManagerDialog is None + ): from PyQt6.QtWidgets import QMessageBox QMessageBox.information( diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/matrix_widget_base.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/matrix_widget_base.py index 8eb1dfd9f7..9f399135d1 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/matrix_widget_base.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/matrix_widget_base.py @@ -114,7 +114,9 @@ def paintEvent(self, event: object) -> None: if self._result is None: painter.setPen(self.COLOR_LABEL) painter.setFont(QFont("Sans", 11)) - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "No simulation loaded") + painter.drawText( + self.rect(), Qt.AlignmentFlag.AlignCenter, "No simulation loaded" + ) painter.end() return diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/optimization_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/optimization_widget.py index 4246abe63e..5208bf4b2a 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/optimization_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/optimization_widget.py @@ -135,10 +135,14 @@ def _cmaes_step( # Learning rates c_sigma = (mu_eff + 2.0) / (n + mu_eff + 5.0) - d_sigma = 1.0 + 2.0 * max(0.0, math.sqrt((mu_eff - 1.0) / (n + 1.0)) - 1.0) + c_sigma + d_sigma = ( + 1.0 + 2.0 * max(0.0, math.sqrt((mu_eff - 1.0) / (n + 1.0)) - 1.0) + c_sigma + ) c_c = (4.0 + mu_eff / n) / (n + 4.0 + 2.0 * mu_eff / n) c1 = 2.0 / ((n + 1.3) ** 2 + mu_eff) - c_mu_lr = min(1.0 - c1, 2.0 * (mu_eff - 2.0 + 1.0 / mu_eff) / ((n + 2.0) ** 2 + mu_eff)) + c_mu_lr = min( + 1.0 - c1, 2.0 * (mu_eff - 2.0 + 1.0 / mu_eff) / ((n + 2.0) ** 2 + mu_eff) + ) # Sample population try: @@ -186,9 +190,9 @@ def _cmaes_step( else 0.0 ) - p_c_new = (1.0 - c_c) * state.p_c + h_sigma * math.sqrt(c_c * (2.0 - c_c) * mu_eff) * ( - new_mean - old_mean - ) / state.sigma + p_c_new = (1.0 - c_c) * state.p_c + h_sigma * math.sqrt( + c_c * (2.0 - c_c) * mu_eff + ) * (new_mean - old_mean) / state.sigma # Update covariance matrix artmp = (selected - old_mean) / state.sigma @@ -262,7 +266,9 @@ def __init__( self._n_iterations = n_iterations self._method = method self._warm_start = warm_start - self._population_size = population_size or max(10, 4 + int(3 * np.log(n_params))) + self._population_size = population_size or max( + 10, 4 + int(3 * np.log(n_params)) + ) self._plateau_patience = plateau_patience self._use_native_batch = use_native_batch self._native_config = native_batch_config or {} @@ -333,7 +339,9 @@ def _run_cmaes(self) -> None: self.finished.emit( { "coeffs": ( - state.best_solution if state.best_solution is not None else state.mean + state.best_solution + if state.best_solution is not None + else state.mean ), "speed": -state.best_fitness, "history": history, @@ -491,7 +499,9 @@ def _build_ui_header(self, layout: QVBoxLayout) -> None: layout.addWidget(title) backend_lbl = QLabel( - "[Rust] parallel batch enabled" if _HAS_NATIVE_BATCH else "[Python] sequential" + "[Rust] parallel batch enabled" + if _HAS_NATIVE_BATCH + else "[Python] sequential" ) backend_lbl.setStyleSheet( f"color:{'#60c060' if _HAS_NATIVE_BATCH else '#c0a060'};font-size:9px;" @@ -509,7 +519,9 @@ def _build_ui_config_group(self) -> QGroupBox: obj_row = QHBoxLayout() obj_row.addWidget(QLabel("Objective:")) self._cmb_objective = QComboBox() - self._cmb_objective.addItems(["Max Tip Speed", "Max Height", "Min Control Effort"]) + self._cmb_objective.addItems( + ["Max Tip Speed", "Max Height", "Min Control Effort"] + ) obj_row.addWidget(self._cmb_objective) cfg_lay.addLayout(obj_row) @@ -558,7 +570,9 @@ def _build_ui_config_group(self) -> QGroupBox: self._spin_patience = QSpinBox() self._spin_patience.setRange(5, 200) self._spin_patience.setValue(20) - self._spin_patience.setToolTip("Stop if no improvement for this many generations") + self._spin_patience.setToolTip( + "Stop if no improvement for this many generations" + ) pat_row.addWidget(self._spin_patience) cfg_lay.addLayout(pat_row) @@ -698,7 +712,9 @@ def _on_run(self) -> None: if not self._refresh_bound_objective(): return if self._objective_fn is None: - self.append_status_message("⚠ No objective function set. Run a simulation first.") + self.append_status_message( + "⚠ No objective function set. Run a simulation first." + ) return n_params = self._n_torque_params * self._spin_degree.value() @@ -716,7 +732,9 @@ def _on_run(self) -> None: self._log.clear() self._log.append(f"Starting {method} optimization...") - self._log.append(f" Params: {n_params}, Generations: {n_iters}, Pop: {pop_size}") + self._log.append( + f" Params: {n_params}, Generations: {n_iters}, Pop: {pop_size}" + ) if _HAS_NATIVE_BATCH and self._chk_native.isChecked(): self._log.append(" Backend: [Rust] parallel (rayon)") else: @@ -796,7 +814,9 @@ def _on_finished(self, result: Any) -> None: if self._convergence_history: n_gens = len(self._convergence_history) best = min(self._convergence_history) - self.append_status_message(f" Generations: {n_gens}, Best loss: {best:.6f}") + self.append_status_message( + f" Generations: {n_gens}, Best loss: {best:.6f}" + ) if coeffs is not None: self.append_status_message( @@ -818,4 +838,6 @@ def _on_error(self, msg: str) -> None: def _on_apply(self) -> None: if self._result is not None: self.optimized_coefficients.emit(self._result) - self.append_status_message("\n✓ Applied optimized coefficients to controls.") + self.append_status_message( + "\n✓ Applied optimized coefficients to controls." + ) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/overlay_state.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/overlay_state.py index f0be7905e1..41cb6d75b2 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/overlay_state.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/overlay_state.py @@ -117,7 +117,9 @@ def apply_toolstrip_overlay_state( for src_attr, dst_setter, extract in _OVERLAY_BINDINGS: src = getattr(toolstrip, src_attr, None) if src is None: - logger.debug("toolstrip has no attribute %r; skipping %s", src_attr, dst_setter) + logger.debug( + "toolstrip has no attribute %r; skipping %s", src_attr, dst_setter + ) continue setter = getattr(pendulum, dst_setter, None) if setter is None: diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/panel_builders.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/panel_builders.py index e3ed5aaff2..c0b6e29bea 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/panel_builders.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/panel_builders.py @@ -377,7 +377,9 @@ def _wire_panel_sim_signals( ) # Reset toolstrip play button when playback ends panel.playback_ended.connect( - lambda _p=panel: ts.btn_play.setChecked(False) if _p is active_panel_fn() else None + lambda _p=panel: ( + ts.btn_play.setChecked(False) if _p is active_panel_fn() else None + ) ) @@ -977,12 +979,18 @@ def _fwd_overlay(attr: str, value: object) -> None: if hasattr(pw, attr): getattr(pw, attr)(value) - ts.torque_vectors_toggled.connect(lambda v: _fwd_overlay("set_show_torque_vectors", v)) - ts.moment_of_force_toggled.connect(lambda v: _fwd_overlay("set_show_moment_of_force", v)) + ts.torque_vectors_toggled.connect( + lambda v: _fwd_overlay("set_show_torque_vectors", v) + ) + ts.moment_of_force_toggled.connect( + lambda v: _fwd_overlay("set_show_moment_of_force", v) + ) ts.sum_moments_toggled.connect(lambda v: _fwd_overlay("set_show_sum_moments", v)) ts.force_scale_changed.connect(lambda v: _fwd_overlay("set_force_scale", v)) ts.mob_scale_changed.connect(lambda v: _fwd_overlay("set_mob_ellipsoid_scale", v)) - ts.force_ell_scale_changed.connect(lambda v: _fwd_overlay("set_force_ellipsoid_scale", v)) + ts.force_ell_scale_changed.connect( + lambda v: _fwd_overlay("set_force_ellipsoid_scale", v) + ) ts.azimuth_changed.connect(lambda v: _fwd_overlay("set_view_azimuth", v)) ts.tilt_changed.connect(lambda v: _fwd_overlay("set_tilt_angle", v)) ts.reset_view_requested.connect( @@ -1024,7 +1032,9 @@ def wire_toolstrip(main_window: Any) -> None: ) # ── Simulation action signals → active panel only ────────────── - ts.run_requested.connect(lambda: main_window._active_panel().controls.run_requested.emit()) + ts.run_requested.connect( + lambda: main_window._active_panel().controls.run_requested.emit() + ) ts.reset_requested.connect( lambda: main_window._active_panel().controls.reset_requested.emit() ) @@ -1034,7 +1044,9 @@ def wire_toolstrip(main_window: Any) -> None: ts.speed_changed.connect( lambda val: main_window._active_panel().controls.speed_changed.emit(val) ) - ts.frame_scrubbed.connect(lambda idx: main_window._active_panel().scrub_to_frame(idx)) + ts.frame_scrubbed.connect( + lambda idx: main_window._active_panel().scrub_to_frame(idx) + ) # ── Export actions (#1141) → active panel's controls ────────── ts.export_data_requested.connect( @@ -1121,9 +1133,15 @@ def _fwd_overlay(attr: str, value: object) -> None: getattr(pw, attr)(value) ts.forces_toggled.connect(lambda v: _fwd_overlay("set_show_forces", v)) - ts.zero_torque_toggled.connect(lambda v: _fwd_overlay("set_show_zero_torque_forces", v)) - ts.mob_ellipsoid_toggled.connect(lambda v: _fwd_overlay("set_show_mob_ellipsoids", v)) - ts.force_ellipsoid_toggled.connect(lambda v: _fwd_overlay("set_show_force_ellipsoids", v)) + ts.zero_torque_toggled.connect( + lambda v: _fwd_overlay("set_show_zero_torque_forces", v) + ) + ts.mob_ellipsoid_toggled.connect( + lambda v: _fwd_overlay("set_show_mob_ellipsoids", v) + ) + ts.force_ellipsoid_toggled.connect( + lambda v: _fwd_overlay("set_show_force_ellipsoids", v) + ) ts.com_toggled.connect(lambda v: _fwd_overlay("set_show_com", v)) # ── 3D segment rendering (#1155) ────────────────────────────── diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/pendulum_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/pendulum_widget.py index 9a5ca00b39..d5604f6c49 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/pendulum_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/pendulum_widget.py @@ -582,7 +582,10 @@ def _draw_torque_vectors(self, painter: QPainter) -> None: for i, jname in enumerate(joint_names): if i >= len(torque_list): break - if self._visible_segments is not None and jname not in self._visible_segments: + if ( + self._visible_segments is not None + and jname not in self._visible_segments + ): continue jp = pos.get(jname) if jp is None: @@ -661,7 +664,10 @@ def _draw_moment_of_force(self, painter: QPainter) -> None: joint_names.append("wrist") for jname in joint_names: - if self._visible_segments is not None and jname not in self._visible_segments: + if ( + self._visible_segments is not None + and jname not in self._visible_segments + ): continue jp = pos.get(jname) if jp is None: @@ -738,7 +744,10 @@ def _draw_ellipsoids_at_frame(self, painter: QPainter) -> None: } for name, ell in data.items(): - if self._visible_segments is not None and name not in self._visible_segments: + if ( + self._visible_segments is not None + and name not in self._visible_segments + ): continue world_pos = endpoint_map.get(name) if world_pos is None: @@ -790,7 +799,9 @@ def _draw_ellipsoids_at_frame(self, painter: QPainter) -> None: QPointF(cx_px + dx_line, cy_px + dy_line), ) painter.setFont(QFont("Monospace", 7)) - painter.drawText(QPointF(cx_px + dx_line + 4, cy_px + dy_line), "F∞") + painter.drawText( + QPointF(cx_px + dx_line + 4, cy_px + dy_line), "F∞" + ) def _draw_ellipse_axes( self, diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/side_panel_tabs.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/side_panel_tabs.py index 6741c1529d..6a8b843407 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/side_panel_tabs.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/side_panel_tabs.py @@ -73,7 +73,9 @@ def __init__( parent: QWidget | None = None, ) -> None: if not settings_key or not settings_key.strip(): - raise ValueError(f"settings_key must be a non-empty string, got {settings_key!r}") + raise ValueError( + f"settings_key must be a non-empty string, got {settings_key!r}" + ) super().__init__(parent) self._settings_key: str = settings_key # Insertion-ordered: label → wrapped scroll area @@ -124,7 +126,9 @@ def add_panel( if widget is None: raise ValueError("widget must not be None") if label in self._panels: - raise ValueError(f"duplicate label {label!r} — already used by another panel") + raise ValueError( + f"duplicate label {label!r} — already used by another panel" + ) wrapper = self._wrap(widget) index = self.addTab(wrapper, label) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel.py index 1d5f7c10f5..fd1a5cf32a 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel.py @@ -270,7 +270,9 @@ def _connect_signals(self) -> None: self.controls.force_scale_changed.connect(self.pendulum.set_force_scale) # Wire real-time rotation controls (#1146) - if hasattr(self.controls, "tilt_changed") and hasattr(self.pendulum, "set_tilt_angle"): + if hasattr(self.controls, "tilt_changed") and hasattr( + self.pendulum, "set_tilt_angle" + ): self.controls.tilt_changed.connect(self.pendulum.set_tilt_angle) if hasattr(self.controls, "azimuth_changed") and hasattr( self.pendulum, "set_view_azimuth" @@ -306,7 +308,9 @@ def _on_run(self) -> None: p = self.controls.get_params() except ValueError as e: logger.warning("Parameter validation failed: %s", e) - get_tracker().record_exception("simulation", e, context="Parameter validation") + get_tracker().record_exception( + "simulation", e, context="Parameter validation" + ) QMessageBox.warning(self, "Input Error", str(e)) return @@ -327,7 +331,9 @@ def _on_run(self) -> None: torque_func = self._torque_builder(p) except (ValueError, TypeError, KeyError) as e: logger.warning("State/torque build failed: %s", e, exc_info=True) - get_tracker().record_exception("simulation", e, context="State/torque build") + get_tracker().record_exception( + "simulation", e, context="State/torque build" + ) QMessageBox.warning(self, "Build Error", str(e)) return @@ -646,7 +652,9 @@ def _fmt_coeffs(arr: np.ndarray) -> str: # Triple: split into 3 groups (shoulder, elbow, wrist) n_third = len(coeffs) // 3 self.controls.inp_tau_shoulder.set_value(_fmt_coeffs(coeffs[:n_third])) - self.controls.inp_tau_elbow.set_value(_fmt_coeffs(coeffs[n_third : 2 * n_third])) + self.controls.inp_tau_elbow.set_value( + _fmt_coeffs(coeffs[n_third : 2 * n_third]) + ) self.controls.inp_tau_wrist.set_value(_fmt_coeffs(coeffs[2 * n_third :])) logger.info("Applied triple pendulum optimizer coefficients") diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_lifecycle_mixin.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_lifecycle_mixin.py index 8c8162a12c..829292c4ce 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_lifecycle_mixin.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_lifecycle_mixin.py @@ -65,7 +65,9 @@ def _on_run(self) -> None: p = self.controls.get_params() except ValueError as e: logger.warning("Parameter validation failed: %s", e) - get_tracker().record_exception("simulation", e, context="Parameter validation") + get_tracker().record_exception( + "simulation", e, context="Parameter validation" + ) QMessageBox.warning(self, "Input Error", str(e)) # type: ignore[arg-type] return @@ -86,7 +88,9 @@ def _on_run(self) -> None: torque_func = self._torque_builder(p) except (ValueError, TypeError, KeyError) as e: logger.warning("State/torque build failed: %s", e, exc_info=True) - get_tracker().record_exception("simulation", e, context="State/torque build") + get_tracker().record_exception( + "simulation", e, context="State/torque build" + ) QMessageBox.warning(self, "Build Error", str(e)) # type: ignore[arg-type] return @@ -277,7 +281,9 @@ def _fmt_coeffs(arr: np.ndarray) -> str: # Triple: split into 3 groups (shoulder, elbow, wrist) n_third = len(coeffs) // 3 self.controls.inp_tau_shoulder.set_value(_fmt_coeffs(coeffs[:n_third])) - self.controls.inp_tau_elbow.set_value(_fmt_coeffs(coeffs[n_third : 2 * n_third])) + self.controls.inp_tau_elbow.set_value( + _fmt_coeffs(coeffs[n_third : 2 * n_third]) + ) self.controls.inp_tau_wrist.set_value(_fmt_coeffs(coeffs[2 * n_third :])) _log.info("Applied triple pendulum optimizer coefficients") diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_simulation_panel.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_simulation_panel.py index 7339a52a4b..c499874dc0 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_simulation_panel.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/simulation_panel/_simulation_panel.py @@ -235,7 +235,9 @@ def _connect_signals(self) -> None: self.controls.force_scale_changed.connect(self.pendulum.set_force_scale) # Wire real-time rotation controls (#1146) - if hasattr(self.controls, "tilt_changed") and hasattr(self.pendulum, "set_tilt_angle"): + if hasattr(self.controls, "tilt_changed") and hasattr( + self.pendulum, "set_tilt_angle" + ): self.controls.tilt_changed.connect(self.pendulum.set_tilt_angle) if hasattr(self.controls, "azimuth_changed") and hasattr( self.pendulum, "set_view_azimuth" diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/theme_defaults.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/theme_defaults.py index 0a0cf95b2d..8cc7a66044 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/theme_defaults.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/theme_defaults.py @@ -77,7 +77,9 @@ def ensure_default_theme_seeded() -> str: if not has_initial_flag: settings.setValue(_INITIAL_FLAG_KEY, "1") settings.sync() - active = str(existing_theme) if existing_theme is not None else DEFAULT_THEME_NAME + active = ( + str(existing_theme) if existing_theme is not None else DEFAULT_THEME_NAME + ) logger.debug( "Theme already initialised (theme=%s, flag=%s); not seeding", active, diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/toolstrip_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/toolstrip_widget.py index 0fc7373900..93ea6ae256 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/toolstrip_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/toolstrip_widget.py @@ -38,7 +38,9 @@ # Stylesheet constants # --------------------------------------------------------------------------- -_STYLE_STRIP = "QWidget#toolstrip {background: #16162e;border-bottom: 1px solid #2a2a50;}" +_STYLE_STRIP = ( + "QWidget#toolstrip {background: #16162e;border-bottom: 1px solid #2a2a50;}" +) _BTN_RUN = ( "QPushButton{" "background:#1e5c30;color:#a8f0b8;border:none;border-radius:5px;" @@ -203,9 +205,9 @@ def _make_scale_slider( if style is None: raise ValueError("style must be provided") assert divisor > 0, f"divisor must be > 0, got {divisor}" - assert max_val > 0 and default > 0 and default <= max_val, ( - f"invalid slider bounds: default={default}, max_val={max_val}" - ) + assert ( + max_val > 0 and default > 0 and default <= max_val + ), f"invalid slider bounds: default={default}, max_val={max_val}" s = QSlider(Qt.Orientation.Horizontal) s.setRange(1, max_val) s.setValue(default) @@ -638,7 +640,9 @@ def _build_mobility_ellipsoids_row(self) -> QHBoxLayout: # Mobility ellipsoids: divisor=100 → raw 1..1000 maps to 0.01×..10× # so the user can shrink them to 1/100th of unity when joints crowd. - self._sld_mob = _make_scale_slider(_SLIDER_MOB, default=100, max_val=1000, divisor=100) + self._sld_mob = _make_scale_slider( + _SLIDER_MOB, default=100, max_val=1000, divisor=100 + ) self._sld_mob.setToolTip("Mobility ellipsoid display scale (0.01× – 10×)") self._sld_mob.valueChanged.connect(self._on_mob_scale) @@ -667,7 +671,9 @@ def _build_force_ellipsoids_row(self) -> QHBoxLayout: self._lbl_force_ell_scale = QLabel("1.0×") self._lbl_force_ell_scale.setStyleSheet(_VAL_LBL) - return _overlay_row(self.chk_force_ell, self._sld_force_ell, self._lbl_force_ell_scale) + return _overlay_row( + self.chk_force_ell, self._sld_force_ell, self._lbl_force_ell_scale + ) def _build_segment_visibility_row(self) -> QHBoxLayout: """Row D: Per-segment visibility sub-checkboxes (#1100, #1101, #1102).""" @@ -919,7 +925,9 @@ def _on_segment_toggled(self) -> None: If all segments are checked, emit None (show all). Otherwise emit the set of checked segment names. """ - checked = {name for name, chk in self._segment_checks.items() if chk.isChecked()} + checked = { + name for name, chk in self._segment_checks.items() if chk.isChecked() + } if len(checked) == len(self._segment_checks): self.segment_visibility_changed.emit(None) # all visible else: diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_history_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_history_widget.py index 5a4851cd85..15bf0e8c20 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_history_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_history_widget.py @@ -161,7 +161,9 @@ def _build_ui(self) -> None: self._outer_layout.addWidget(title) if not _HAS_PYQTGRAPH: - fallback = QLabel("Install pyqtgraph for torque plots:\n pip install pyqtgraph") + fallback = QLabel( + "Install pyqtgraph for torque plots:\n pip install pyqtgraph" + ) fallback.setAlignment(Qt.AlignmentFlag.AlignCenter) fallback.setStyleSheet("color: #808090; font-size: 11px;") self._outer_layout.addWidget(fallback) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_preview_widget.py b/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_preview_widget.py index e66f66eb8a..10832718ea 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_preview_widget.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/gui/torque_preview_widget.py @@ -45,7 +45,9 @@ def set_profiles( """ if profiles is None: raise ValueError("profiles must be provided") - self._profiles = [(name, list(coeffs), color) for name, coeffs, color in profiles] + self._profiles = [ + (name, list(coeffs), color) for name, coeffs, color in profiles + ] self._clamp_limits = list(clamp_limits) if clamp_limits else [] self.update() @@ -62,7 +64,9 @@ def paintEvent(self, event: object) -> None: if not self._profiles: painter.setPen(self.COLOR_TEXT) painter.setFont(QFont("Sans", 9)) - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "Torque preview") + painter.drawText( + self.rect(), Qt.AlignmentFlag.AlignCenter, "Torque preview" + ) painter.end() return @@ -103,7 +107,9 @@ def paintEvent(self, event: object) -> None: for lv in [limit, -limit]: y = qrect.bottom() - (lv - v_min) / (v_max - v_min) * qrect.height() if qrect.top() <= y <= qrect.bottom(): - painter.drawLine(QPointF(qrect.left(), y), QPointF(qrect.right(), y)) + painter.drawLine( + QPointF(qrect.left(), y), QPointF(qrect.right(), y) + ) for idx, ((_, values), (__, ___, color)) in enumerate( zip(series, self._profiles, strict=True) @@ -117,7 +123,10 @@ def paintEvent(self, event: object) -> None: points: list[QPointF] = [] for i, val in enumerate(values): x = qrect.left() + (t[i] / self._t_end) * qrect.width() - y = qrect.bottom() - (val - v_min) / (v_max - v_min) * qrect.height() + y = ( + qrect.bottom() + - (val - v_min) / (v_max - v_min) * qrect.height() + ) points.append(QPointF(x, y)) for i in range(1, len(points)): painter.drawLine(points[i - 1], points[i]) @@ -128,7 +137,10 @@ def paintEvent(self, event: object) -> None: points = [] for i, val in enumerate(clamped): x = qrect.left() + (t[i] / self._t_end) * qrect.width() - y = qrect.bottom() - (val - v_min) / (v_max - v_min) * qrect.height() + y = ( + qrect.bottom() + - (val - v_min) / (v_max - v_min) * qrect.height() + ) points.append(QPointF(x, y)) for i in range(1, len(points)): painter.drawLine(points[i - 1], points[i]) @@ -139,7 +151,10 @@ def paintEvent(self, event: object) -> None: points = [] for i, val in enumerate(values): x = qrect.left() + (t[i] / self._t_end) * qrect.width() - y = qrect.bottom() - (val - v_min) / (v_max - v_min) * qrect.height() + y = ( + qrect.bottom() + - (val - v_min) / (v_max - v_min) * qrect.height() + ) points.append(QPointF(x, y)) for i in range(1, len(points)): painter.drawLine(points[i - 1], points[i]) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/jacobians_golfer.py b/src/pendulum_simulator/src/double_pendulum_golf/jacobians_golfer.py index 6d6256fa7d..f1275d58c6 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/jacobians_golfer.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/jacobians_golfer.py @@ -123,7 +123,9 @@ def delta_matrix(q: np.ndarray, p: GolferParams) -> np.ndarray: return np.linalg.pinv(M) -def ztcf_matrix(q: np.ndarray, p: GolferParams, joint_name: str = "club_tip") -> np.ndarray: +def ztcf_matrix( + q: np.ndarray, p: GolferParams, joint_name: str = "club_tip" +) -> np.ndarray: """Compute the Zero-Torque Constraint Force transfer matrix. Maps applied joint torques to endpoint forces via: diff --git a/src/pendulum_simulator/src/double_pendulum_golf/joint_moments.py b/src/pendulum_simulator/src/double_pendulum_golf/joint_moments.py index 5797f53a60..c875896afb 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/joint_moments.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/joint_moments.py @@ -68,7 +68,9 @@ def moment_of_force( """ if joint_position is None: raise ValueError("joint_position must be provided") - r = np.asarray(distal_com_position, dtype=float) - np.asarray(joint_position, dtype=float) + r = np.asarray(distal_com_position, dtype=float) - np.asarray( + joint_position, dtype=float + ) return cross_2d(r, np.asarray(net_force, dtype=float)) @@ -139,7 +141,9 @@ def double_pendulum_moments( # Shoulder: moment about arm COM m_shoulder = moment_of_force(shoulder, arm_com, f_shoulder) - total_shoulder = total_moment_at_joint(applied_torques[0], shoulder, arm_com, f_shoulder) + total_shoulder = total_moment_at_joint( + applied_torques[0], shoulder, arm_com, f_shoulder + ) # Wrist: moment about shaft COM m_wrist = moment_of_force(wrist, shaft_com, f_wrist) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/model_registry.py b/src/pendulum_simulator/src/double_pendulum_golf/model_registry.py index 8b07ff191d..29cbe096e9 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/model_registry.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/model_registry.py @@ -88,7 +88,9 @@ def get_model(name: str) -> ModelConfig: Raises: KeyError if not found. """ if name not in _registry: - raise KeyError(f"Model {name!r} not registered. Available: {list(_registry.keys())}") + raise KeyError( + f"Model {name!r} not registered. Available: {list(_registry.keys())}" + ) return _registry[name] diff --git a/src/pendulum_simulator/src/double_pendulum_golf/native_backend.py b/src/pendulum_simulator/src/double_pendulum_golf/native_backend.py index 9b0de07bf5..083ccd4933 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/native_backend.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/native_backend.py @@ -579,7 +579,9 @@ def golfer_constrained_dynamics( """Return native golfer accelerations and multipliers when supported.""" if q is None: raise ValueError("q must be provided") - if not golfer_native_enabled() or not golfer_native_constraint_dynamics_supported(params): + if not golfer_native_enabled() or not golfer_native_constraint_dynamics_supported( + params + ): return None try: @@ -693,17 +695,21 @@ def batch_evaluate_double( """ if params is None: raise ValueError("params must be provided") - if _pendulum_core is None or not hasattr(_pendulum_core, "py_batch_evaluate_double"): + if _pendulum_core is None or not hasattr( + _pendulum_core, "py_batch_evaluate_double" + ): return None try: - result: list[tuple[float, float, bool]] = _pendulum_core.py_batch_evaluate_double( - _to_rust_double_params(params), - coeffs_batch, - n_coeffs_per_joint, - q0, - qdot0, - t_end, + result: list[tuple[float, float, bool]] = ( + _pendulum_core.py_batch_evaluate_double( + _to_rust_double_params(params), + coeffs_batch, + n_coeffs_per_joint, + q0, + qdot0, + t_end, + ) ) return result except (RuntimeError, AttributeError, TypeError) as exc: # pragma: no cover diff --git a/src/pendulum_simulator/src/double_pendulum_golf/optimizer_gpu.py b/src/pendulum_simulator/src/double_pendulum_golf/optimizer_gpu.py index 6704a7a918..2b7165f17b 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/optimizer_gpu.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/optimizer_gpu.py @@ -224,9 +224,9 @@ def loss_fn(coeffs): # type: ignore[no-untyped-def] logger.info("Iteration %d/%d: loss = %.6f", i + 1, n_iterations, loss_val) optimal_coeffs = torque_coeffs.reshape(7, n_coeffs_per_joint) - assert len(history) == n_iterations, ( - f"Expected {n_iterations} history entries, got {len(history)}" - ) + assert ( + len(history) == n_iterations + ), f"Expected {n_iterations} history entries, got {len(history)}" assert optimal_coeffs.shape == (7, n_coeffs_per_joint) return optimal_coeffs, history @@ -287,7 +287,9 @@ def optimize_simple_torque_profile( @jax.jit @jax.value_and_grad def loss_fn(coeffs): # type: ignore[no-untyped-def] - return clubhead_speed_objective(coeffs, params, initial_state, t_end, alpha, beta, dt) + return clubhead_speed_objective( + coeffs, params, initial_state, t_end, alpha, beta, dt + ) history = [] @@ -340,7 +342,9 @@ def compute_gradient_via_finite_difference( assert eps > 0, f"eps must be positive, got {eps}" grad = jnp.zeros(7) - f0 = clubhead_speed_objective(torque_coeffs, params, initial_state, t_end, alpha, beta, dt) + f0 = clubhead_speed_objective( + torque_coeffs, params, initial_state, t_end, alpha, beta, dt + ) for i in range(7): torque_plus = torque_coeffs.at[i].add(eps) # type: ignore[attr-defined] diff --git a/src/pendulum_simulator/src/double_pendulum_golf/perturbation_analysis.py b/src/pendulum_simulator/src/double_pendulum_golf/perturbation_analysis.py index a6f6fb608d..2b465310d1 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/perturbation_analysis.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/perturbation_analysis.py @@ -110,7 +110,9 @@ def generate_noise( f"Unknown noise type: {noise_type!r}. Must be 'white', 'pink', or 'brown'." ) - assert noise.shape == (n_samples,), f"Expected shape ({n_samples},), got {noise.shape}" + assert noise.shape == ( + n_samples, + ), f"Expected shape ({n_samples},), got {noise.shape}" return noise @@ -150,7 +152,9 @@ def perturb_torque_coeffs( if not (noise_amplitude >= 0): raise ValueError("DbC Blocked: Precondition failed.") if noise_type not in {"white", "pink", "brown"}: - raise ValueError(f"noise_type must be 'white', 'pink', or 'brown'; got {noise_type!r}") + raise ValueError( + f"noise_type must be 'white', 'pink', or 'brown'; got {noise_type!r}" + ) if noise_amplitude == 0.0: return [list(c) for c in coeffs] @@ -196,9 +200,9 @@ class PerturbationConfig: def __post_init__(self) -> None: assert self.n_trials > 0, f"n_trials must be positive, got {self.n_trials}" - assert self.noise_amplitude >= 0, ( - f"noise_amplitude must be non-negative, got {self.noise_amplitude}" - ) + assert ( + self.noise_amplitude >= 0 + ), f"noise_amplitude must be non-negative, got {self.noise_amplitude}" assert self.noise_type in { "white", "pink", diff --git a/src/pendulum_simulator/src/double_pendulum_golf/physics.py b/src/pendulum_simulator/src/double_pendulum_golf/physics.py index 46948b294a..4d0656dac4 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/physics.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/physics.py @@ -261,7 +261,9 @@ def gravity_vector(theta1: float, phi: float, params: PendulumParams) -> np.ndar # --------------------------------------------------------------------------- -def friction_torque_vector(dtheta1: float, dphi: float, params: PendulumParams) -> np.ndarray: +def friction_torque_vector( + dtheta1: float, dphi: float, params: PendulumParams +) -> np.ndarray: """Compute dissipative torque vector (viscous + Coulomb). Pre: dtheta1, dphi finite. @@ -494,7 +496,9 @@ def equations_of_motion( tau_limits = np.zeros(2) if limits is not None: - tau_limits = joint_limit_torque(phi, dphi, limits, theta1=theta1, dtheta1=dtheta1) + tau_limits = joint_limit_torque( + phi, dphi, limits, theta1=theta1, dtheta1=dtheta1 + ) rhs = tau_drive + tau_friction + tau_limits - C - G cond = np.linalg.cond(M) @@ -595,15 +599,28 @@ def base_force(state: State, qddot: np.ndarray, params: PendulumParams) -> dict: awy = params.L1 * (np.sin(theta1) * qdd1 + np.cos(theta1) * dtheta1**2) # Tip acceleration (clubhead) - atx = awx + params.L2 * (np.cos(abs_angle2) * ddabs2 - np.sin(abs_angle2) * dabs2**2) - aty = awy + params.L2 * (np.sin(abs_angle2) * ddabs2 + np.cos(abs_angle2) * dabs2**2) + atx = awx + params.L2 * ( + np.cos(abs_angle2) * ddabs2 - np.sin(abs_angle2) * dabs2**2 + ) + aty = awy + params.L2 * ( + np.sin(abs_angle2) * ddabs2 + np.cos(abs_angle2) * dabs2**2 + ) # Shaft COM at L2/2 from wrist - asx = awx + (params.L2 / 2) * (np.cos(abs_angle2) * ddabs2 - np.sin(abs_angle2) * dabs2**2) - asy = awy + (params.L2 / 2) * (np.sin(abs_angle2) * ddabs2 + np.cos(abs_angle2) * dabs2**2) + asx = awx + (params.L2 / 2) * ( + np.cos(abs_angle2) * ddabs2 - np.sin(abs_angle2) * dabs2**2 + ) + asy = awy + (params.L2 / 2) * ( + np.sin(abs_angle2) * ddabs2 + np.cos(abs_angle2) * dabs2**2 + ) fx = params.m1 * ax1 + params.m2 * asx + params.mClub * atx - fy = params.m1 * ay1 + params.m2 * asy + params.mClub * aty - (params.m1 + me) * params.g + fy = ( + params.m1 * ay1 + + params.m2 * asy + + params.mClub * aty + - (params.m1 + me) * params.g + ) return { "fx": float(fx), @@ -664,7 +681,9 @@ def control_vector( # --------------------------------------------------------------------------- -def linear_accelerations(state: State, qddot: np.ndarray, params: PendulumParams) -> dict: +def linear_accelerations( + state: State, qddot: np.ndarray, params: PendulumParams +) -> dict: """Compute linear accelerations of joints in world coordinates.""" if not (state.shape == (4,) and qddot.shape == (2,)): raise ValueError("state must be (4,) and qddot must be (2,)") diff --git a/src/pendulum_simulator/src/double_pendulum_golf/physics_golfer_jax.py b/src/pendulum_simulator/src/double_pendulum_golf/physics_golfer_jax.py index 0e116e286a..6cdb13fdb1 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/physics_golfer_jax.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/physics_golfer_jax.py @@ -287,8 +287,12 @@ def forward_kinematics_jax(q: JaxArray, p: GolferParamsJAX) -> dict[str, JaxArra perp_x = jnp.cos(th_hub) perp_y = jnp.sin(th_hub) - rs, re, rh = _right_arm_fk_jax(p, hub_x, hub_y, perp_x, perp_y, th_hub, alpha_rs, alpha_re) - ls, le, lh = _left_arm_fk_jax(p, hub_x, hub_y, perp_x, perp_y, th_hub, alpha_ls, alpha_le) + rs, re, rh = _right_arm_fk_jax( + p, hub_x, hub_y, perp_x, perp_y, th_hub, alpha_rs, alpha_re + ) + ls, le, lh = _left_arm_fk_jax( + p, hub_x, hub_y, perp_x, perp_y, th_hub, alpha_ls, alpha_le + ) club_base, grip_left, club_tip = _club_fk_jax(p, rh[0], rh[1], th_club) return { @@ -337,18 +341,28 @@ def _right_arm_jacobians_jax( # RE (Right Elbow): from RS along right upper arm J_re = jnp.zeros((2, N_DOF)) - J_re = J_re.at[0, 0].set(p.L_hub * cos_hub - p.d_rs * sin_hub + p.L_r_upper * cos_rs) - J_re = J_re.at[1, 0].set(p.L_hub * sin_hub + p.d_rs * cos_hub + p.L_r_upper * sin_rs) + J_re = J_re.at[0, 0].set( + p.L_hub * cos_hub - p.d_rs * sin_hub + p.L_r_upper * cos_rs + ) + J_re = J_re.at[1, 0].set( + p.L_hub * sin_hub + p.d_rs * cos_hub + p.L_r_upper * sin_rs + ) J_re = J_re.at[0, 1].set(p.L_r_upper * cos_rs) J_re = J_re.at[1, 1].set(p.L_r_upper * sin_rs) # RH (Right Hand): from RS along right upper + forearm J_rh = jnp.zeros((2, N_DOF)) J_rh = J_rh.at[0, 0].set( - p.L_hub * cos_hub - p.d_rs * sin_hub + p.L_r_upper * cos_rs + p.L_r_fore * cos_re + p.L_hub * cos_hub + - p.d_rs * sin_hub + + p.L_r_upper * cos_rs + + p.L_r_fore * cos_re ) J_rh = J_rh.at[1, 0].set( - p.L_hub * sin_hub + p.d_rs * cos_hub + p.L_r_upper * sin_rs + p.L_r_fore * sin_re + p.L_hub * sin_hub + + p.d_rs * cos_hub + + p.L_r_upper * sin_rs + + p.L_r_fore * sin_re ) J_rh = J_rh.at[0, 1].set(p.L_r_upper * cos_rs + p.L_r_fore * cos_re) J_rh = J_rh.at[1, 1].set(p.L_r_upper * sin_rs + p.L_r_fore * sin_re) @@ -379,18 +393,28 @@ def _left_arm_jacobians_jax( # LE (Left Elbow): from LS along left upper arm J_le = jnp.zeros((2, N_DOF)) - J_le = J_le.at[0, 0].set(p.L_hub * cos_hub + p.d_ls * sin_hub + p.L_l_upper * cos_ls) - J_le = J_le.at[1, 0].set(p.L_hub * sin_hub - p.d_ls * cos_hub + p.L_l_upper * sin_ls) + J_le = J_le.at[0, 0].set( + p.L_hub * cos_hub + p.d_ls * sin_hub + p.L_l_upper * cos_ls + ) + J_le = J_le.at[1, 0].set( + p.L_hub * sin_hub - p.d_ls * cos_hub + p.L_l_upper * sin_ls + ) J_le = J_le.at[0, 4].set(p.L_l_upper * cos_ls) J_le = J_le.at[1, 4].set(p.L_l_upper * sin_ls) # LH (Left Hand): from LS along left upper + forearm J_lh = jnp.zeros((2, N_DOF)) J_lh = J_lh.at[0, 0].set( - p.L_hub * cos_hub + p.d_ls * sin_hub + p.L_l_upper * cos_ls + p.L_l_fore * cos_le + p.L_hub * cos_hub + + p.d_ls * sin_hub + + p.L_l_upper * cos_ls + + p.L_l_fore * cos_le ) J_lh = J_lh.at[1, 0].set( - p.L_hub * sin_hub - p.d_ls * cos_hub + p.L_l_upper * sin_ls + p.L_l_fore * sin_le + p.L_hub * sin_hub + - p.d_ls * cos_hub + + p.L_l_upper * sin_ls + + p.L_l_fore * sin_le ) J_lh = J_lh.at[0, 4].set(p.L_l_upper * cos_ls + p.L_l_fore * cos_le) J_lh = J_lh.at[1, 4].set(p.L_l_upper * sin_ls + p.L_l_fore * sin_le) @@ -418,10 +442,16 @@ def _club_jacobians_jax( """ # Shared right-hand column values rh_col0_x = ( - p.L_hub * cos_hub - p.d_rs * sin_hub + p.L_r_upper * cos_rs + p.L_r_fore * cos_re + p.L_hub * cos_hub + - p.d_rs * sin_hub + + p.L_r_upper * cos_rs + + p.L_r_fore * cos_re ) rh_col0_y = ( - p.L_hub * sin_hub + p.d_rs * cos_hub + p.L_r_upper * sin_rs + p.L_r_fore * sin_re + p.L_hub * sin_hub + + p.d_rs * cos_hub + + p.L_r_upper * sin_rs + + p.L_r_fore * sin_re ) rh_col1_x = p.L_r_upper * cos_rs + p.L_r_fore * cos_re rh_col1_y = p.L_r_upper * sin_rs + p.L_r_fore * sin_re @@ -492,10 +522,16 @@ def _right_arm_base_jacobian( J = jnp.zeros((2, N_DOF)) # DOF 0: hub rotation affects the entire chain J = J.at[0, 0].set( - p.L_hub * cos_hub - p.d_rs * sin_hub + p.L_r_upper * cos_rs + p.L_r_fore * cos_re + p.L_hub * cos_hub + - p.d_rs * sin_hub + + p.L_r_upper * cos_rs + + p.L_r_fore * cos_re ) J = J.at[1, 0].set( - p.L_hub * sin_hub + p.d_rs * cos_hub + p.L_r_upper * sin_rs + p.L_r_fore * sin_re + p.L_hub * sin_hub + + p.d_rs * cos_hub + + p.L_r_upper * sin_rs + + p.L_r_fore * sin_re ) # DOF 1: right-shoulder flexion/extension J = J.at[0, 1].set(p.L_r_upper * cos_rs + p.L_r_fore * cos_re) @@ -524,10 +560,16 @@ def _left_arm_base_jacobian( J = jnp.zeros((2, N_DOF)) # DOF 0: hub rotation affects the entire left chain J = J.at[0, 0].set( - p.L_hub * cos_hub + p.d_ls * sin_hub + p.L_l_upper * cos_ls + p.L_l_fore * cos_le + p.L_hub * cos_hub + + p.d_ls * sin_hub + + p.L_l_upper * cos_ls + + p.L_l_fore * cos_le ) J = J.at[1, 0].set( - p.L_hub * sin_hub - p.d_ls * cos_hub + p.L_l_upper * sin_ls + p.L_l_fore * sin_le + p.L_hub * sin_hub + - p.d_ls * cos_hub + + p.L_l_upper * sin_ls + + p.L_l_fore * sin_le ) # DOF 4: left-shoulder flexion/extension J = J.at[0, 4].set(p.L_l_upper * cos_ls + p.L_l_fore * cos_le) @@ -664,12 +706,14 @@ def coriolis_jax(q: JaxArray, qdot: JaxArray, p: GolferParamsJAX) -> JaxArray: M0 = mass_matrix_jax(q, p) basis = jnp.eye(N_DOF) - dM = jax.vmap(lambda direction: (mass_matrix_jax(q + eps * direction, p) - M0) / eps)( - basis - ) + dM = jax.vmap( + lambda direction: (mass_matrix_jax(q + eps * direction, p) - M0) / eps + )(basis) dM = jnp.transpose(dM, (1, 2, 0)) - christoffel = 0.5 * (dM + jnp.transpose(dM, (0, 2, 1)) - jnp.transpose(dM, (1, 2, 0))) + christoffel = 0.5 * ( + dM + jnp.transpose(dM, (0, 2, 1)) - jnp.transpose(dM, (1, 2, 0)) + ) return jnp.einsum("ijk,j,k->i", christoffel, qdot, qdot) @@ -810,7 +854,8 @@ def constraint_jacobian_jax(q: JaxArray, p: GolferParamsJAX) -> JaxArray: # dPhi[2]/dq: perpendicular distance constraint Phi_q = Phi_q.at[2, :].set( - club_perp[0] * (J_lh[0, :] - J_rh[0, :]) + club_perp[1] * (J_lh[1, :] - J_rh[1, :]) + club_perp[0] * (J_lh[0, :] - J_rh[0, :]) + + club_perp[1] * (J_lh[1, :] - J_rh[1, :]) ) # d(club_perp)/dq_7: (-sin(th_club), cos(th_club)) d_club_perp_dth = jnp.array([-sin_club, cos_club]) @@ -818,7 +863,8 @@ def constraint_jacobian_jax(q: JaxArray, p: GolferParamsJAX) -> JaxArray: # dPhi[3]/dq: along-club distance constraint Phi_q = Phi_q.at[3, :].set( - club_dir[0] * (J_lh[0, :] - J_rh[0, :]) + club_dir[1] * (J_lh[1, :] - J_rh[1, :]) + club_dir[0] * (J_lh[0, :] - J_rh[0, :]) + + club_dir[1] * (J_lh[1, :] - J_rh[1, :]) ) # d(club_dir)/dq_7: (cos(th_club), sin(th_club)) d_club_dir_dth = jnp.array([cos_club, sin_club]) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/physics_triple.py b/src/pendulum_simulator/src/double_pendulum_golf/physics_triple.py index 3e7c7dec84..54899b41d0 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/physics_triple.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/physics_triple.py @@ -185,7 +185,9 @@ def mass_matrix(phi1: float, phi2: float, params: TriplePendulumParams) -> np.nd return M -def mass_matrix_components(phi1: float, phi2: float, params: TriplePendulumParams) -> dict: +def mass_matrix_components( + phi1: float, phi2: float, params: TriplePendulumParams +) -> dict: """Return individual mass matrix terms with labels. Returns @@ -476,7 +478,9 @@ def forward_kinematics( """ if theta1 is None: raise ValueError("theta1 must be provided") - native_positions = _native_backend.triple_forward_kinematics(theta1, phi1, phi2, params) + native_positions = _native_backend.triple_forward_kinematics( + theta1, phi1, phi2, params + ) if native_positions is not None: return native_positions @@ -559,7 +563,9 @@ def linear_accelerations( } -def net_joint_forces(state: State, qddot: np.ndarray, params: TriplePendulumParams) -> dict: +def net_joint_forces( + state: State, qddot: np.ndarray, params: TriplePendulumParams +) -> dict: """Compute net joint forces (proximal on distal) in world coordinates. Returns @@ -621,7 +627,9 @@ def potential_energy(state: State, params: TriplePendulumParams) -> float: V = ( -m1 * g * L1 * np.cos(theta1) - m2 * g * (L1 * np.cos(theta1) + L2 * np.cos(abs_angle2)) - - m3 * g * (L1 * np.cos(theta1) + L2 * np.cos(abs_angle2) + L3 * np.cos(abs_angle3)) + - m3 + * g + * (L1 * np.cos(theta1) + L2 * np.cos(abs_angle2) + L3 * np.cos(abs_angle3)) ) return float(V) diff --git a/src/pendulum_simulator/src/double_pendulum_golf/simulation.py b/src/pendulum_simulator/src/double_pendulum_golf/simulation.py index 4cead3b042..88bfe3c629 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/simulation.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/simulation.py @@ -224,7 +224,9 @@ def run_simulation( qdot0 = initial_state[2:4].tolist() t_span = (0.0, t_end) max_steps = int(max(t_end / dt * 10, 100000)) - res = simulate_double(params, q0, qdot0, coeffs, n_coeffs_per_joint, t_span, max_steps) + res = simulate_double( + params, q0, qdot0, coeffs, n_coeffs_per_joint, t_span, max_steps + ) if res is not None: t_res, states_res = res if len(t_res) >= 2: diff --git a/src/pendulum_simulator/src/double_pendulum_golf/simulation_golfer.py b/src/pendulum_simulator/src/double_pendulum_golf/simulation_golfer.py index 734853c7d7..bca4f507aa 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/simulation_golfer.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/simulation_golfer.py @@ -98,7 +98,9 @@ def positions_at(self, idx: int) -> dict: self._check_idx(idx) return forward_kinematics(self.q_at(idx), self.params) # type: ignore[no-any-return] - def torques_at(self, idx: int) -> tuple[float, float, float, float, float, float, float]: + def torques_at( + self, idx: int + ) -> tuple[float, float, float, float, float, float, float]: """Applied driving torques at time index.""" if idx is None: raise ValueError("idx must be provided") @@ -129,7 +131,9 @@ def constraint_forces_at(self, idx: int) -> np.ndarray: if idx is None: raise ValueError("idx must be provided") self._check_idx(idx) - return constraint_forces(self.states[idx], self.t[idx], self.params, self.torque_func) + return constraint_forces( + self.states[idx], self.t[idx], self.params, self.torque_func + ) def constraint_violation_at(self, idx: int) -> float: """Constraint violation magnitude at time index.""" diff --git a/src/pendulum_simulator/src/double_pendulum_golf/simulation_result_base.py b/src/pendulum_simulator/src/double_pendulum_golf/simulation_result_base.py index 29459e5f02..15dbc08896 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/simulation_result_base.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/simulation_result_base.py @@ -77,13 +77,17 @@ def all_energies(self) -> dict[str, np.ndarray]: energy_at = getattr(self, "energy_at") first = energy_at(0) return { - key: np.asarray([energy_at(i)[key] for i in range(self.n_steps)], dtype=float) + key: np.asarray( + [energy_at(i)[key] for i in range(self.n_steps)], dtype=float + ) for key in first } def all_accelerations(self) -> np.ndarray: accelerations_at = getattr(self, "accelerations_at") - return np.asarray([accelerations_at(i) for i in range(self.n_steps)], dtype=float) + return np.asarray( + [accelerations_at(i) for i in range(self.n_steps)], dtype=float + ) def all_torques(self) -> np.ndarray: torques_at = getattr(self, "torques_at") diff --git a/src/pendulum_simulator/src/double_pendulum_golf/torque_utils.py b/src/pendulum_simulator/src/double_pendulum_golf/torque_utils.py index 5a3f5726bd..cc3813440c 100644 --- a/src/pendulum_simulator/src/double_pendulum_golf/torque_utils.py +++ b/src/pendulum_simulator/src/double_pendulum_golf/torque_utils.py @@ -56,7 +56,9 @@ def make_polynomial_torque( polys: list[np.ndarray] = [] for i, coeffs in enumerate(coeffs_per_joint): if not (len(coeffs) >= 1): - raise ValueError(f"Need at least one coefficient for joint {i}, got {len(coeffs)}") + raise ValueError( + f"Need at least one coefficient for joint {i}, got {len(coeffs)}" + ) # Reverse: our convention is [c0, c1, c2, ...] (ascending), # np.polyval expects [cN, ..., c1, c0] (descending). polys.append(np.array(coeffs[::-1])) diff --git a/src/pendulum_simulator/tests/test_analysis_tab.py b/src/pendulum_simulator/tests/test_analysis_tab.py index ead705920b..6a4f99748b 100644 --- a/src/pendulum_simulator/tests/test_analysis_tab.py +++ b/src/pendulum_simulator/tests/test_analysis_tab.py @@ -30,7 +30,9 @@ def test_det_of_identity(self) -> None: def test_det_of_known_matrix(self) -> None: """det([[2,0],[0,3]]) = 6.0.""" - evaluator = _make_det_evaluator(lambda angles: np.array([[2.0, 0.0], [0.0, 3.0]])) + evaluator = _make_det_evaluator( + lambda angles: np.array([[2.0, 0.0], [0.0, 3.0]]) + ) assert evaluator({}) == pytest.approx(6.0) def test_det_passes_angles_to_fn(self) -> None: @@ -56,7 +58,9 @@ def test_cond_of_identity(self) -> None: def test_cond_of_diagonal(self) -> None: """cond(diag(1, 10)) = 10.0.""" - evaluator = _make_cond_evaluator(lambda angles: np.array([[1.0, 0.0], [0.0, 10.0]])) + evaluator = _make_cond_evaluator( + lambda angles: np.array([[1.0, 0.0], [0.0, 10.0]]) + ) assert evaluator({}) == pytest.approx(10.0, rel=1e-6) def test_cond_passes_angles_to_fn(self) -> None: @@ -253,5 +257,7 @@ def test_analysis_tab_plot_2d_errors(qapp, monkeypatch) -> Any: def mock_extract(*args) -> Any: raise KeyError() - monkeypatch.setattr("double_pendulum_golf.data_extractor.extract_series", mock_extract) + monkeypatch.setattr( + "double_pendulum_golf.data_extractor.extract_series", mock_extract + ) tab._on_plot_2d() diff --git a/src/pendulum_simulator/tests/test_analytical_jacobians.py b/src/pendulum_simulator/tests/test_analytical_jacobians.py index 500c249e93..b5f0ad9e95 100644 --- a/src/pendulum_simulator/tests/test_analytical_jacobians.py +++ b/src/pendulum_simulator/tests/test_analytical_jacobians.py @@ -101,9 +101,9 @@ def hub_pos(qq): J_numerical = _numerical_jacobian_point(hub_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"Hub Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"Hub Jacobian mismatch at q={q}" def test_re_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: """RE Jacobian (depends on q[0], q[1]).""" @@ -118,9 +118,9 @@ def re_pos(qq): J_numerical = _numerical_jacobian_point(re_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"RE Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"RE Jacobian mismatch at q={q}" def test_rh_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: """RH Jacobian (depends on q[0], q[1], q[2]).""" @@ -135,9 +135,9 @@ def rh_pos(qq): J_numerical = _numerical_jacobian_point(rh_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"RH Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"RH Jacobian mismatch at q={q}" def test_le_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: """LE Jacobian (depends on q[0], q[4]).""" @@ -152,9 +152,9 @@ def le_pos(qq): J_numerical = _numerical_jacobian_point(le_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"LE Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"LE Jacobian mismatch at q={q}" def test_lh_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: """LH Jacobian (depends on q[0], q[4], q[5]).""" @@ -169,11 +169,13 @@ def lh_pos(qq): J_numerical = _numerical_jacobian_point(lh_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"LH Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"LH Jacobian mismatch at q={q}" - def test_club_com_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: + def test_club_com_jacobian_vs_numerical( + self, test_configs: list[np.ndarray] + ) -> None: """Club COM Jacobian (depends on q[0], q[1], q[2], q[3], q[7]).""" from double_pendulum_golf.physics_golfer import analytical_fk_jacobians @@ -188,11 +190,13 @@ def club_com_pos(qq): J_numerical = _numerical_jacobian_point(club_com_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"Club COM Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"Club COM Jacobian mismatch at q={q}" - def test_club_tip_jacobian_vs_numerical(self, test_configs: list[np.ndarray]) -> None: + def test_club_tip_jacobian_vs_numerical( + self, test_configs: list[np.ndarray] + ) -> None: """Club tip Jacobian (depends on q[0], q[1], q[2], q[3], q[7]).""" from double_pendulum_golf.physics_golfer import analytical_fk_jacobians @@ -205,9 +209,9 @@ def club_tip_pos(qq): J_numerical = _numerical_jacobian_point(club_tip_pos, q) - assert np.allclose(J_analytical, J_numerical, atol=1e-5, rtol=1e-4), ( - f"Club tip Jacobian mismatch at q={q}" - ) + assert np.allclose( + J_analytical, J_numerical, atol=1e-5, rtol=1e-4 + ), f"Club tip Jacobian mismatch at q={q}" class TestAnalyticalMassMatrix: @@ -220,7 +224,9 @@ def test_module_exports_analytical_mass_matrix(self) -> None: assert hasattr(physics_golfer, "analytical_mass_matrix") assert callable(physics_golfer.analytical_mass_matrix) - def test_analytical_mass_matrix_parity(self, test_configs: list[np.ndarray]) -> None: + def test_analytical_mass_matrix_parity( + self, test_configs: list[np.ndarray] + ) -> None: """Analytical mass matrix matches numerical at 20 configs.""" from double_pendulum_golf.physics_golfer import analytical_mass_matrix @@ -228,9 +234,9 @@ def test_analytical_mass_matrix_parity(self, test_configs: list[np.ndarray]) -> M_analytical = analytical_mass_matrix(q, _PARAMS) M_numerical = numerical_mass_matrix(q, _PARAMS) - assert np.allclose(M_analytical, M_numerical, atol=1e-6, rtol=1e-4), ( - f"Mass matrix mismatch at q={q}" - ) + assert np.allclose( + M_analytical, M_numerical, atol=1e-6, rtol=1e-4 + ), f"Mass matrix mismatch at q={q}" def test_mass_matrix_symmetric(self, test_configs: list[np.ndarray]) -> None: """Analytical mass matrix is symmetric.""" @@ -270,11 +276,13 @@ def test_analytical_coriolis_parity(self, test_configs: list[np.ndarray]) -> Non C_analytical = analytical_coriolis(q, qdot, _PARAMS) C_numerical = numerical_coriolis(q, qdot, _PARAMS) - assert np.allclose(C_analytical, C_numerical, atol=1e-5, rtol=1e-3), ( - f"Coriolis mismatch at q={q}, qdot={qdot}" - ) + assert np.allclose( + C_analytical, C_numerical, atol=1e-5, rtol=1e-3 + ), f"Coriolis mismatch at q={q}, qdot={qdot}" - def test_coriolis_zero_at_zero_velocity(self, test_configs: list[np.ndarray]) -> None: + def test_coriolis_zero_at_zero_velocity( + self, test_configs: list[np.ndarray] + ) -> None: """Coriolis is zero when velocity is zero.""" from double_pendulum_golf.physics_golfer import analytical_coriolis @@ -302,9 +310,9 @@ def test_analytical_gravity_parity(self, test_configs: list[np.ndarray]) -> None G_analytical = analytical_gravity_vector(q, _PARAMS) G_numerical = numerical_gravity(q, _PARAMS) - assert np.allclose(G_analytical, G_numerical, atol=1e-5, rtol=1e-4), ( - f"Gravity mismatch at q={q}" - ) + assert np.allclose( + G_analytical, G_numerical, atol=1e-5, rtol=1e-4 + ), f"Gravity mismatch at q={q}" class TestAnalyticalConstraintJacobian: @@ -317,7 +325,9 @@ def test_module_exports_analytical_constraint_jac(self) -> None: assert hasattr(physics_golfer, "analytical_constraint_jacobian") assert callable(physics_golfer.analytical_constraint_jacobian) - def test_analytical_constraint_jac_parity(self, test_configs: list[np.ndarray]) -> None: + def test_analytical_constraint_jac_parity( + self, test_configs: list[np.ndarray] + ) -> None: """Analytical constraint Jacobian matches numerical at 20 configs.""" from double_pendulum_golf.physics_golfer import ( analytical_constraint_jacobian, @@ -327,9 +337,9 @@ def test_analytical_constraint_jac_parity(self, test_configs: list[np.ndarray]) Phi_q_analytical = analytical_constraint_jacobian(q, _PARAMS) Phi_q_numerical = numerical_constraint_jac(q, _PARAMS) - assert np.allclose(Phi_q_analytical, Phi_q_numerical, atol=1e-5, rtol=1e-4), ( - f"Constraint Jacobian mismatch at q={q}" - ) + assert np.allclose( + Phi_q_analytical, Phi_q_numerical, atol=1e-5, rtol=1e-4 + ), f"Constraint Jacobian mismatch at q={q}" def test_constraint_jac_shape(self) -> None: """Constraint Jacobian has shape (4, 8).""" @@ -364,9 +374,9 @@ def test_analytical_bias_parity(self, test_configs: list[np.ndarray]) -> None: gamma_analytical = analytical_constraint_acceleration_bias(q, qdot, _PARAMS) gamma_numerical = numerical_bias(q, qdot, _PARAMS) - assert np.allclose(gamma_analytical, gamma_numerical, atol=1e-5, rtol=1e-3), ( - f"Bias mismatch at q={q}, qdot={qdot}" - ) + assert np.allclose( + gamma_analytical, gamma_numerical, atol=1e-5, rtol=1e-3 + ), f"Bias mismatch at q={q}, qdot={qdot}" def test_bias_zero_at_zero_velocity(self, test_configs: list[np.ndarray]) -> None: """Bias is zero when velocity is zero.""" diff --git a/src/pendulum_simulator/tests/test_club_forces.py b/src/pendulum_simulator/tests/test_club_forces.py index f3c43d7e34..11ff2c37bb 100644 --- a/src/pendulum_simulator/tests/test_club_forces.py +++ b/src/pendulum_simulator/tests/test_club_forces.py @@ -370,7 +370,9 @@ def test_delta_zero_torque_zero_forces(self, default_params): # So F = m*0 - m*(0, -g) = (0, m*g) # Net force should be +(m_rh + m_lh)*g in the y direction net_fy = result["net_force"][1] - expected_fy = (default_params.m_r_fore + default_params.m_l_fore) * default_params.g + expected_fy = ( + default_params.m_r_fore + default_params.m_l_fore + ) * default_params.g assert net_fy == pytest.approx(expected_fy, rel=0.01) @@ -431,16 +433,22 @@ def test_delta_state_wrong_type(self, default_params): from double_pendulum_golf.club_forces import delta_club_decomposition with pytest.raises(TypeError, match="state must be a numpy ndarray"): - delta_club_decomposition(state=list(range(16)), tau=np.zeros(8), p=default_params) + delta_club_decomposition( + state=list(range(16)), tau=np.zeros(8), p=default_params + ) def test_delta_tau_wrong_type(self, default_params): from double_pendulum_golf.club_forces import delta_club_decomposition with pytest.raises(TypeError, match="tau must be a numpy ndarray"): - delta_club_decomposition(state=np.zeros(16), tau=[0.0] * 8, p=default_params) + delta_club_decomposition( + state=np.zeros(16), tau=[0.0] * 8, p=default_params + ) def test_delta_tau_wrong_shape(self, default_params): from double_pendulum_golf.club_forces import delta_club_decomposition with pytest.raises(ValueError, match="tau must have shape"): - delta_club_decomposition(state=np.zeros(16), tau=np.zeros(4), p=default_params) + delta_club_decomposition( + state=np.zeros(16), tau=np.zeros(4), p=default_params + ) diff --git a/src/pendulum_simulator/tests/test_club_forces_extended.py b/src/pendulum_simulator/tests/test_club_forces_extended.py index 035e8ce763..b047bba2fd 100644 --- a/src/pendulum_simulator/tests/test_club_forces_extended.py +++ b/src/pendulum_simulator/tests/test_club_forces_extended.py @@ -66,7 +66,9 @@ class TestOverallClubDecomposition: Uses real constrained dynamics with zero torques — simplest valid case. """ - def test_returns_required_keys(self, params: GolferParams, zero_state: np.ndarray) -> None: + def test_returns_required_keys( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: result = overall_club_decomposition(zero_state, 0.0, params, zero_torque) for key in ( "net_force", @@ -78,7 +80,9 @@ def test_returns_required_keys(self, params: GolferParams, zero_state: np.ndarra ): assert key in result, f"Missing key: {key}" - def test_net_force_is_array(self, params: GolferParams, zero_state: np.ndarray) -> None: + def test_net_force_is_array( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: result = overall_club_decomposition(zero_state, 0.0, params, zero_torque) assert isinstance(result["net_force"], np.ndarray) assert result["net_force"].shape == (2,) @@ -89,11 +93,15 @@ def test_action_point_is_finite( result = overall_club_decomposition(zero_state, 0.0, params, zero_torque) assert np.all(np.isfinite(result["action_point"])) - def test_couple_is_finite(self, params: GolferParams, zero_state: np.ndarray) -> None: + def test_couple_is_finite( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: result = overall_club_decomposition(zero_state, 0.0, params, zero_torque) assert np.isfinite(result["couple"]) - def test_all_values_finite(self, params: GolferParams, zero_state: np.ndarray) -> None: + def test_all_values_finite( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: result = overall_club_decomposition(zero_state, 0.0, params, zero_torque) for key, val in result.items(): if isinstance(val, np.ndarray): @@ -103,15 +111,25 @@ def test_all_values_finite(self, params: GolferParams, zero_state: np.ndarray) - def test_alpha_midpoint(self, params: GolferParams, zero_state: np.ndarray) -> None: """alpha=0 gives midpoint between grip positions.""" - result = overall_club_decomposition(zero_state, 0.0, params, zero_torque, alpha=0.0) + result = overall_club_decomposition( + zero_state, 0.0, params, zero_torque, alpha=0.0 + ) assert result["action_point"].shape == (2,) - def test_alpha_right_grip(self, params: GolferParams, zero_state: np.ndarray) -> None: - result = overall_club_decomposition(zero_state, 0.0, params, zero_torque, alpha=-1.0) + def test_alpha_right_grip( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: + result = overall_club_decomposition( + zero_state, 0.0, params, zero_torque, alpha=-1.0 + ) assert all(np.isfinite(result["action_point"])) - def test_alpha_left_grip(self, params: GolferParams, zero_state: np.ndarray) -> None: - result = overall_club_decomposition(zero_state, 0.0, params, zero_torque, alpha=1.0) + def test_alpha_left_grip( + self, params: GolferParams, zero_state: np.ndarray + ) -> None: + result = overall_club_decomposition( + zero_state, 0.0, params, zero_torque, alpha=1.0 + ) assert all(np.isfinite(result["action_point"])) @@ -181,7 +199,9 @@ def test_applied_torques_preserved( ) joints = ["hub", "rs", "re", "rh", "ls", "le", "lh"] for i, joint in enumerate(joints): - assert result[f"{joint}_applied_torque"] == pytest.approx(applied_torques[i]) + assert result[f"{joint}_applied_torque"] == pytest.approx( + applied_torques[i] + ) def test_all_values_finite( self, full_positions: dict, full_forces: dict, applied_torques: tuple @@ -218,15 +238,21 @@ def test_fewer_than_7_torques_raises( self, full_positions: dict, full_forces: dict ) -> None: with pytest.raises((ValueError, TypeError, AssertionError), match="Need >= 7"): - golfer_pendulum_moments(full_positions, full_forces, (1.0, 2.0, 3.0), object()) + golfer_pendulum_moments( + full_positions, full_forces, (1.0, 2.0, 3.0), object() + ) - def test_exactly_7_torques_ok(self, full_positions: dict, full_forces: dict) -> None: + def test_exactly_7_torques_ok( + self, full_positions: dict, full_forces: dict + ) -> None: torques = (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0) result = golfer_pendulum_moments(full_positions, full_forces, torques, object()) assert len(result) == 21 def test_zero_forces_moment_of_force_is_zero(self, full_positions: dict) -> None: - forces = {joint: (0.0, 0.0) for joint in ("hub", "rs", "re", "rh", "ls", "le", "lh")} + forces = { + joint: (0.0, 0.0) for joint in ("hub", "rs", "re", "rh", "ls", "le", "lh") + } torques = (1.0,) * 7 result = golfer_pendulum_moments(full_positions, forces, torques, object()) for joint in ("hub", "rs", "re", "rh", "ls", "le", "lh"): diff --git a/src/pendulum_simulator/tests/test_constraint_solver.py b/src/pendulum_simulator/tests/test_constraint_solver.py index c61c15ff71..7e46f97b85 100644 --- a/src/pendulum_simulator/tests/test_constraint_solver.py +++ b/src/pendulum_simulator/tests/test_constraint_solver.py @@ -56,7 +56,9 @@ def golfer_params() -> GolferParams: @pytest.fixture -def zero_torque() -> Callable[[float], tuple[float, float, float, float, float, float, float]]: +def zero_torque() -> ( + Callable[[float], tuple[float, float, float, float, float, float, float]] +): """Zero torque function for all joints.""" return lambda t: (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0) @@ -77,18 +79,18 @@ def test_zero_config_projects(self, golfer_params: GolferParams) -> None: q = np.zeros(N_DOF) q_proj = project_to_constraints(q, golfer_params) phi = constraint_vector(q_proj, golfer_params) - assert np.linalg.norm(phi) < 1e-6, ( - f"Constraint violation after projection: {np.linalg.norm(phi)}" - ) + assert ( + np.linalg.norm(phi) < 1e-6 + ), f"Constraint violation after projection: {np.linalg.norm(phi)}" def test_arbitrary_config_projects(self, golfer_params: GolferParams) -> None: rng = np.random.default_rng(123) q = rng.uniform(-0.5, 0.5, size=N_DOF) q_proj = project_to_constraints(q, golfer_params) phi = constraint_vector(q_proj, golfer_params) - assert np.linalg.norm(phi) < 1e-4, ( - f"Constraint violation after projection: {np.linalg.norm(phi)}" - ) + assert ( + np.linalg.norm(phi) < 1e-4 + ), f"Constraint violation after projection: {np.linalg.norm(phi)}" def test_idempotent(self, golfer_params: GolferParams) -> None: q = np.zeros(N_DOF) @@ -107,7 +109,9 @@ def stuck_constraint(_q: np.ndarray, _params: GolferParams) -> np.ndarray: def constant_jacobian(_q: np.ndarray, _params: GolferParams) -> np.ndarray: return np.eye(N_CONSTRAINTS, N_DOF) - monkeypatch.setattr(constraint_solver_module, "constraint_vector", stuck_constraint) + monkeypatch.setattr( + constraint_solver_module, "constraint_vector", stuck_constraint + ) monkeypatch.setattr( constraint_solver_module, "constraint_jacobian", @@ -133,9 +137,9 @@ def test_velocity_satisfies_constraint(self, golfer_params: GolferParams) -> Non qdot_proj = project_velocity(q, qdot, golfer_params) Phi_q = constraint_jacobian(q, golfer_params) violation = Phi_q @ qdot_proj - assert np.linalg.norm(violation) < 1e-6, ( - f"Velocity constraint violation: {np.linalg.norm(violation)}" - ) + assert ( + np.linalg.norm(violation) < 1e-6 + ), f"Velocity constraint violation: {np.linalg.norm(violation)}" class TestConstrainedAccelerations: @@ -144,7 +148,9 @@ class TestConstrainedAccelerations: def test_finite_at_rest( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], ) -> None: state = _make_consistent_state(golfer_params) qddot = constrained_accelerations(state, 0.0, golfer_params, zero_torque) @@ -154,7 +160,9 @@ def test_finite_at_rest( def test_shape( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], ) -> None: state = _make_consistent_state(golfer_params) qddot = constrained_accelerations(state, 0.0, golfer_params, zero_torque) @@ -167,7 +175,9 @@ class TestConstraintForces: def test_shape( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], ) -> None: state = _make_consistent_state(golfer_params) lam = constraint_forces(state, 0.0, golfer_params, zero_torque) @@ -181,7 +191,9 @@ class TestNativeConstraintBackend: def test_constrained_dynamics_prefers_native_backend( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], monkeypatch: pytest.MonkeyPatch, ) -> None: native_qddot = np.full(N_DOF, 3.0) @@ -255,7 +267,9 @@ class TestEquationsOfMotion: def test_shape( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], ) -> None: state = _make_consistent_state(golfer_params) state_dot = equations_of_motion(state, 0.0, golfer_params, zero_torque) @@ -265,7 +279,9 @@ def test_shape( def test_velocity_in_derivative( self, golfer_params: GolferParams, - zero_torque: Callable[[float], tuple[float, float, float, float, float, float, float]], + zero_torque: Callable[ + [float], tuple[float, float, float, float, float, float, float] + ], ) -> None: state = _make_consistent_state(golfer_params) state_dot = equations_of_motion(state, 0.0, golfer_params, zero_torque) diff --git a/src/pendulum_simulator/tests/test_counterfactual.py b/src/pendulum_simulator/tests/test_counterfactual.py index fdebb5b74e..d8ea10b859 100644 --- a/src/pendulum_simulator/tests/test_counterfactual.py +++ b/src/pendulum_simulator/tests/test_counterfactual.py @@ -47,7 +47,9 @@ def double_state() -> np.ndarray: @pytest.fixture() def triple_state() -> np.ndarray: """Triple pendulum state: [theta1, phi1, phi2, dtheta1, dphi1, dphi2].""" - return np.array([np.radians(45.0), np.radians(-30.0), np.radians(20.0), 0.5, -0.3, 0.2]) + return np.array( + [np.radians(45.0), np.radians(-30.0), np.radians(20.0), 0.5, -0.3, 0.2] + ) # --------------------------------------------------------------------------- @@ -83,9 +85,9 @@ def test_zero_velocity_zero_torque_matches_static_gravity( fx, fy = result["shoulder"] expected_fy = (double_params.m1 + double_params.m2) * double_params.g assert abs(fx) < 1e-8, f"No horizontal force at rest, got fx={fx}" - assert abs(fy - expected_fy) < 1e-4, ( - f"Shoulder fy={fy:.4f}, expected {expected_fy:.4f}" - ) + assert ( + abs(fy - expected_fy) < 1e-4 + ), f"Shoulder fy={fy:.4f}, expected {expected_fy:.4f}" def test_differs_from_driven_forces_when_torque_nonzero( self, double_state: np.ndarray, double_params: PendulumParams @@ -110,9 +112,9 @@ def torque_func(t: float) -> tuple[float, float]: # With 50 Nm at shoulder, forces should differ meaningfully diff_shoulder = abs(actual["shoulder"][1] - counterfactual["shoulder"][1]) - assert diff_shoulder > 1.0, ( - f"Expected driven vs zero-torque to differ; got diff={diff_shoulder:.3f}" - ) + assert ( + diff_shoulder > 1.0 + ), f"Expected driven vs zero-torque to differ; got diff={diff_shoulder:.3f}" def test_zero_gravity_hanging_position(self, double_params: PendulumParams) -> None: """With g=0, zero-torque counterfactual gives near-zero forces at rest.""" @@ -127,9 +129,9 @@ def test_zero_gravity_hanging_position(self, double_params: PendulumParams) -> N result = zero_torque_joint_forces_double(state, params_no_g) for key in ("shoulder", "wrist"): fx, fy = result[key] - assert abs(fx) < 1e-8 and abs(fy) < 1e-8, ( - f"No gravity + no motion → zero force at {key}, got ({fx:.2e},{fy:.2e})" - ) + assert ( + abs(fx) < 1e-8 and abs(fy) < 1e-8 + ), f"No gravity + no motion → zero force at {key}, got ({fx:.2e},{fy:.2e})" def test_invalid_state_shape_raises(self, double_params: PendulumParams) -> None: """Non-(4,) state must raise AssertionError.""" @@ -139,7 +141,9 @@ def test_invalid_state_shape_raises(self, double_params: PendulumParams) -> None def test_nonfinite_state_raises(self, double_params: PendulumParams) -> None: """NaN state must raise AssertionError.""" with pytest.raises((ValueError, TypeError)): - zero_torque_joint_forces_double(np.array([np.nan, 0.0, 0.0, 0.0]), double_params) + zero_torque_joint_forces_double( + np.array([np.nan, 0.0, 0.0, 0.0]), double_params + ) # --------------------------------------------------------------------------- @@ -163,11 +167,13 @@ def test_forces_are_finite( result = zero_torque_joint_forces_triple(triple_state, triple_params) for key in ("shoulder", "wrist1", "wrist2"): fx, fy = result[key] - assert np.isfinite(fx) and np.isfinite(fy), ( - f"{key} forces not finite: ({fx}, {fy})" - ) + assert np.isfinite(fx) and np.isfinite( + fy + ), f"{key} forces not finite: ({fx}, {fy})" - def test_static_hanging_shoulder_force(self, triple_params: TriplePendulumParams) -> None: + def test_static_hanging_shoulder_force( + self, triple_params: TriplePendulumParams + ) -> None: """At rest hanging straight down, shoulder force ≈ (m1+m2+m3)*g.""" state = np.zeros(6) result = zero_torque_joint_forces_triple(state, triple_params) @@ -178,6 +184,8 @@ def test_static_hanging_shoulder_force(self, triple_params: TriplePendulumParams assert abs(fx) < 1e-8 assert abs(fy - expected_fy) < 1e-4, f"fy={fy}, expected {expected_fy}" - def test_invalid_state_shape_raises(self, triple_params: TriplePendulumParams) -> None: + def test_invalid_state_shape_raises( + self, triple_params: TriplePendulumParams + ) -> None: with pytest.raises((ValueError, TypeError)): zero_torque_joint_forces_triple(np.zeros(5), triple_params) diff --git a/src/pendulum_simulator/tests/test_default_dark_theme_and_button_width.py b/src/pendulum_simulator/tests/test_default_dark_theme_and_button_width.py index f291a46172..f7945a4bba 100644 --- a/src/pendulum_simulator/tests/test_default_dark_theme_and_button_width.py +++ b/src/pendulum_simulator/tests/test_default_dark_theme_and_button_width.py @@ -66,12 +66,12 @@ def test_first_launch_writes_dark_default(self, qapp) -> None: ensure_default_theme_seeded() s = QSettings("D-sorganization", "PendulumSimulator") - assert s.value(_INITIAL_FLAG) is not None, ( - "first_launch_initialized flag should be set after seeding" - ) - assert s.value(_THEME_KEY) == "Dark", ( - f"Default theme should be 'Dark', got {s.value(_THEME_KEY)!r}" - ) + assert ( + s.value(_INITIAL_FLAG) is not None + ), "first_launch_initialized flag should be set after seeding" + assert ( + s.value(_THEME_KEY) == "Dark" + ), f"Default theme should be 'Dark', got {s.value(_THEME_KEY)!r}" def test_existing_user_preference_is_not_overwritten(self, qapp) -> None: """If a user already chose 'Light', do not stomp it.""" @@ -86,9 +86,9 @@ def test_existing_user_preference_is_not_overwritten(self, qapp) -> None: ensure_default_theme_seeded() - assert s.value(_THEME_KEY) == "Light", ( - "User-chosen 'Light' theme must not be overwritten" - ) + assert ( + s.value(_THEME_KEY) == "Light" + ), "User-chosen 'Light' theme must not be overwritten" def test_seeding_is_idempotent(self, qapp) -> None: """Calling ensure_default_theme_seeded twice does not flip diff --git a/src/pendulum_simulator/tests/test_diagnostics.py b/src/pendulum_simulator/tests/test_diagnostics.py index 990e480f12..1c954e8d88 100644 --- a/src/pendulum_simulator/tests/test_diagnostics.py +++ b/src/pendulum_simulator/tests/test_diagnostics.py @@ -34,7 +34,9 @@ def test_singleton_get_tracker(self) -> Any: assert t1 is t2 def test_record_event(self, temp_tracker) -> Any: - temp_tracker.record("test_cat", "test msg", severity="warning", extra={"k": "v"}) + temp_tracker.record( + "test_cat", "test msg", severity="warning", extra={"k": "v"} + ) assert len(temp_tracker.events) == 1 event = temp_tracker.events[0] assert event.category == "test_cat" @@ -147,7 +149,9 @@ def test_copy_details(self, temp_tracker, qtbot) -> Any: viewer._table.setCurrentCell(0, 0) - with patch("double_pendulum_golf.gui.diagnostics.QApplication.clipboard") as mock_clip: + with patch( + "double_pendulum_golf.gui.diagnostics.QApplication.clipboard" + ) as mock_clip: mock_cb = MagicMock() mock_clip.return_value = mock_cb viewer._copy_details() @@ -179,7 +183,9 @@ def test_hook_records_event(self, temp_tracker) -> Any: class TestDiagnosticsGaps: def test_show_viewer(self, temp_tracker) -> Any: - with patch("double_pendulum_golf.gui.diagnostics.DiagnosticsViewer.exec") as mock_exec: + with patch( + "double_pendulum_golf.gui.diagnostics.DiagnosticsViewer.exec" + ) as mock_exec: temp_tracker.show_viewer() mock_exec.assert_called_once() diff --git a/src/pendulum_simulator/tests/test_dynamics_quantities.py b/src/pendulum_simulator/tests/test_dynamics_quantities.py index 07a9685d92..93d6efdff5 100644 --- a/src/pendulum_simulator/tests/test_dynamics_quantities.py +++ b/src/pendulum_simulator/tests/test_dynamics_quantities.py @@ -58,19 +58,19 @@ class TestLinearPowerAt: """Unit tests for single-timestep linear power.""" def test_aligned_force_velocity(self): - assert linear_power_at(np.array([1.0, 0.0]), np.array([3.0, 0.0])) == pytest.approx( - 3.0 - ) + assert linear_power_at( + np.array([1.0, 0.0]), np.array([3.0, 0.0]) + ) == pytest.approx(3.0) def test_orthogonal_force_velocity(self): - assert linear_power_at(np.array([1.0, 0.0]), np.array([0.0, 1.0])) == pytest.approx( - 0.0 - ) + assert linear_power_at( + np.array([1.0, 0.0]), np.array([0.0, 1.0]) + ) == pytest.approx(0.0) def test_2d_dot_product(self): - assert linear_power_at(np.array([2.0, 3.0]), np.array([4.0, 5.0])) == pytest.approx( - 23.0 - ) + assert linear_power_at( + np.array([2.0, 3.0]), np.array([4.0, 5.0]) + ) == pytest.approx(23.0) def test_wrong_shape_raises(self): with pytest.raises((ValueError, TypeError), match="force must be shape"): diff --git a/src/pendulum_simulator/tests/test_ellipsoid_scale_and_emoji.py b/src/pendulum_simulator/tests/test_ellipsoid_scale_and_emoji.py index 4aefacdc45..548a19e279 100644 --- a/src/pendulum_simulator/tests/test_ellipsoid_scale_and_emoji.py +++ b/src/pendulum_simulator/tests/test_ellipsoid_scale_and_emoji.py @@ -67,9 +67,9 @@ def test_force_ell_slider_min_emits_one_hundredth(self, qapp) -> None: ts.force_ell_scale_changed.connect(captured.append) ts._sld_force_ell.setValue(ts._sld_force_ell.minimum()) assert captured, "force_ell_scale_changed never fired" - assert captured[-1] <= 0.01 + 1e-9, ( - f"Force ellipsoid slider floor is {captured[-1]}; should be ≤ 0.01" - ) + assert ( + captured[-1] <= 0.01 + 1e-9 + ), f"Force ellipsoid slider floor is {captured[-1]}; should be ≤ 0.01" def test_default_value_still_emits_one_x(self, qapp) -> None: """The default slider position must still emit 1.0×, so existing @@ -84,9 +84,9 @@ def test_default_value_still_emits_one_x(self, qapp) -> None: ts._sld_mob.setValue(default + 1) ts._sld_mob.setValue(default) assert captured, "mob_scale_changed did not fire on default" - assert captured[-1] == pytest.approx(1.0, abs=0.05), ( - f"Default mob scale should be ~1.0×, got {captured[-1]}" - ) + assert captured[-1] == pytest.approx( + 1.0, abs=0.05 + ), f"Default mob scale should be ~1.0×, got {captured[-1]}" # ────────────────────────────────────────────────────────────────────── diff --git a/src/pendulum_simulator/tests/test_friction.py b/src/pendulum_simulator/tests/test_friction.py index f5ad7a6015..f7087e0087 100644 --- a/src/pendulum_simulator/tests/test_friction.py +++ b/src/pendulum_simulator/tests/test_friction.py @@ -125,25 +125,33 @@ def test_viscous_magnitude_linear(self, damped_params: PendulumParams) -> None: expected_tau_f1 = -damped_params.b1 * dtheta1 assert np.isclose(tf[0], expected_tau_f1) - def test_coulomb_has_constant_magnitude(self, frictional_params: PendulumParams) -> None: + def test_coulomb_has_constant_magnitude( + self, frictional_params: PendulumParams + ) -> None: """Coulomb friction magnitude is mu regardless of velocity magnitude.""" for speed in [0.1, 1.0, 10.0, 100.0]: - tf = friction_torque_vector(dtheta1=speed, dphi=speed, params=frictional_params) - assert np.isclose(abs(tf[0]), frictional_params.mu1), ( - f"Expected |tau_f1|={frictional_params.mu1}, got {abs(tf[0])} at speed={speed}" + tf = friction_torque_vector( + dtheta1=speed, dphi=speed, params=frictional_params ) + assert np.isclose( + abs(tf[0]), frictional_params.mu1 + ), f"Expected |tau_f1|={frictional_params.mu1}, got {abs(tf[0])} at speed={speed}" def test_coulomb_zero_at_rest(self, frictional_params: PendulumParams) -> None: """np.sign(0) == 0, so Coulomb friction is zero when stationary.""" tf = friction_torque_vector(dtheta1=0.0, dphi=0.0, params=frictional_params) assert np.allclose(tf, [0.0, 0.0]) - def test_combined_friction_superposition(self, combined_params: PendulumParams) -> None: + def test_combined_friction_superposition( + self, combined_params: PendulumParams + ) -> None: """Combined damping+friction = viscous + Coulomb separately.""" dtheta1, dphi = 1.5, -0.8 tf = friction_torque_vector(dtheta1, dphi, combined_params) - expected_1 = -combined_params.b1 * dtheta1 - combined_params.mu1 * np.sign(dtheta1) + expected_1 = -combined_params.b1 * dtheta1 - combined_params.mu1 * np.sign( + dtheta1 + ) expected_2 = -combined_params.b2 * dphi - combined_params.mu2 * np.sign(dphi) assert np.isclose(tf[0], expected_1) assert np.isclose(tf[1], expected_2) @@ -182,9 +190,9 @@ def test_undamped_conserves_energy_approximately( e_start = total_energy(result.states[0], base_params) e_end = total_energy(result.states[-1], base_params) # Allow ~1% drift from numerical integration - assert abs(e_end - e_start) / max(abs(e_start), 1e-9) < 0.01, ( - f"Energy drift too large: {e_start:.4f} → {e_end:.4f}" - ) + assert ( + abs(e_end - e_start) / max(abs(e_start), 1e-9) < 0.01 + ), f"Energy drift too large: {e_start:.4f} → {e_end:.4f}" def test_damped_pendulum_loses_energy(self, damped_params: PendulumParams) -> None: """With viscous damping, total energy must decrease over time.""" @@ -203,9 +211,9 @@ def test_damped_pendulum_loses_energy(self, damped_params: PendulumParams) -> No e_start = total_energy(result.states[0], damped_params) e_end = total_energy(result.states[-1], damped_params) - assert e_end < e_start, ( - f"Damped pendulum energy should decrease: {e_start:.4f} → {e_end:.4f}" - ) + assert ( + e_end < e_start + ), f"Damped pendulum energy should decrease: {e_start:.4f} → {e_end:.4f}" def test_friction_does_not_blow_up(self, combined_params: PendulumParams) -> None: """Simulation with both friction types must remain numerically stable.""" @@ -221,9 +229,9 @@ def test_friction_does_not_blow_up(self, combined_params: PendulumParams) -> Non ) assert result.n_steps >= 2 - assert all(np.isfinite(result.states.flatten())), ( - "Simulation with combined friction/damping produced non-finite states" - ) + assert all( + np.isfinite(result.states.flatten()) + ), "Simulation with combined friction/damping produced non-finite states" # --------------------------------------------------------------------------- @@ -259,7 +267,9 @@ def test_total_torques_equals_drive_plus_friction( total = friction_result.total_torques_at(idx) assert np.allclose(total, drive + friction) - def test_no_dissipation_zero_friction_torques(self, base_params: PendulumParams) -> None: + def test_no_dissipation_zero_friction_torques( + self, base_params: PendulumParams + ) -> None: state0 = np.array([np.radians(45), 0.0, 1.0, 0.0]) result = run_simulation( params=base_params, @@ -270,6 +280,6 @@ def test_no_dissipation_zero_friction_torques(self, base_params: PendulumParams) ) for i in range(0, result.n_steps, 20): tf = result.friction_torques_at(i) - assert np.allclose(tf, [0.0, 0.0]), ( - f"Expected zero friction torques at step {i}, got {tf}" - ) + assert np.allclose( + tf, [0.0, 0.0] + ), f"Expected zero friction torques at step {i}, got {tf}" diff --git a/src/pendulum_simulator/tests/test_friction_triple.py b/src/pendulum_simulator/tests/test_friction_triple.py index 1c25429db4..ae300a72ac 100644 --- a/src/pendulum_simulator/tests/test_friction_triple.py +++ b/src/pendulum_simulator/tests/test_friction_triple.py @@ -295,7 +295,9 @@ def test_combined_friction_superposition( dtheta1, dphi1, dphi2 = 1.5, -0.8, 0.3 tf = friction_torque_vector(dtheta1, dphi1, dphi2, combined_params) - expected_1 = -combined_params.b1 * dtheta1 - combined_params.mu1 * np.sign(dtheta1) + expected_1 = -combined_params.b1 * dtheta1 - combined_params.mu1 * np.sign( + dtheta1 + ) expected_2 = -combined_params.b2 * dphi1 - combined_params.mu2 * np.sign(dphi1) expected_3 = -combined_params.b3 * dphi2 - combined_params.mu3 * np.sign(dphi2) assert np.isclose(tf[0], expected_1) @@ -345,9 +347,9 @@ def test_undamped_conserves_energy_approximately( e_start = total_energy(result.states[0], base_params) e_end = total_energy(result.states[-1], base_params) # Allow ~2% drift for chaotic triple pendulum - assert abs(e_end - e_start) / max(abs(e_start), 1e-9) < 0.02, ( - f"Energy drift too large: {e_start:.4f} → {e_end:.4f}" - ) + assert ( + abs(e_end - e_start) / max(abs(e_start), 1e-9) < 0.02 + ), f"Energy drift too large: {e_start:.4f} → {e_end:.4f}" def test_damped_pendulum_loses_energy( self, @@ -369,16 +371,18 @@ def test_damped_pendulum_loses_energy( e_start = total_energy(result.states[0], damped_params) e_end = total_energy(result.states[-1], damped_params) - assert e_end < e_start, ( - f"Damped pendulum energy should decrease: {e_start:.4f} → {e_end:.4f}" - ) + assert ( + e_end < e_start + ), f"Damped pendulum energy should decrease: {e_start:.4f} → {e_end:.4f}" def test_friction_does_not_blow_up( self, combined_params: TriplePendulumParams, ) -> None: """Simulation with both friction types must remain numerically stable.""" - state0 = np.array([np.radians(90), np.radians(-45), np.radians(30), 0.0, 0.0, 0.0]) + state0 = np.array( + [np.radians(90), np.radians(-45), np.radians(30), 0.0, 0.0, 0.0] + ) torque_func = make_polynomial_torque([-15.0, 5.0], [0.0], [0.0]) result = run_simulation( @@ -390,9 +394,9 @@ def test_friction_does_not_blow_up( ) assert result.n_steps >= 2 - assert all(np.isfinite(result.states.flatten())), ( - "Simulation with combined friction/damping produced non-finite states" - ) + assert all( + np.isfinite(result.states.flatten()) + ), "Simulation with combined friction/damping produced non-finite states" # --------------------------------------------------------------------------- @@ -472,7 +476,9 @@ class TestMassMatrixCorrectness: def equal_params(self) -> TriplePendulumParams: return TriplePendulumParams(m1=1.0, m2=1.0, m3=1.0, L1=1.0, L2=1.0, L3=1.0) - def test_symmetric_at_random_angles(self, equal_params: TriplePendulumParams) -> None: + def test_symmetric_at_random_angles( + self, equal_params: TriplePendulumParams + ) -> None: rng = np.random.default_rng(42) for _ in range(20): phi1, phi2 = rng.uniform(-np.pi, np.pi, size=2) @@ -487,7 +493,9 @@ def test_positive_definite_at_random_angles( phi1, phi2 = rng.uniform(-np.pi, np.pi, size=2) M = mass_matrix(phi1, phi2, equal_params) eigvals = np.linalg.eigvalsh(M) - assert all(eigvals > 0), f"Not positive definite at phi1={phi1}, phi2={phi2}" + assert all( + eigvals > 0 + ), f"Not positive definite at phi1={phi1}, phi2={phi2}" def test_aligned_configuration_known_value(self) -> None: """When phi1=phi2=0 (all segments aligned), M has a known closed form.""" @@ -550,9 +558,11 @@ def test_conservative_energy_conservation(self, state0: np.ndarray) -> None: rtol=1e-10, atol=1e-12, ) - energies = [total_energy(result.states[i], params) for i in range(result.n_steps)] + energies = [ + total_energy(result.states[i], params) for i in range(result.n_steps) + ] e0 = energies[0] max_drift = max(abs(e - e0) for e in energies) - assert max_drift < 1e-6, ( - f"Energy drift {max_drift:.2e} exceeds 1e-6 for state0={state0}" - ) + assert ( + max_drift < 1e-6 + ), f"Energy drift {max_drift:.2e} exceeds 1e-6 for state0={state0}" diff --git a/src/pendulum_simulator/tests/test_golfer_dynamics_extended.py b/src/pendulum_simulator/tests/test_golfer_dynamics_extended.py index 263abcab6b..fe1598a17e 100644 --- a/src/pendulum_simulator/tests/test_golfer_dynamics_extended.py +++ b/src/pendulum_simulator/tests/test_golfer_dynamics_extended.py @@ -293,7 +293,9 @@ def test_symmetric(self, params: GolferParams) -> None: M = analytical_mass_matrix(q, params) np.testing.assert_allclose(M, M.T, atol=1e-8) - def test_positive_semidefinite(self, params: GolferParams, zero_q: np.ndarray) -> None: + def test_positive_semidefinite( + self, params: GolferParams, zero_q: np.ndarray + ) -> None: with patch( "double_pendulum_golf.golfer_dynamics._native_backend.golfer_mass_matrix", return_value=None, @@ -440,7 +442,9 @@ def test_zero_at_rest( T = kinetic_energy(zero_q, zero_qdot, params) assert T == pytest.approx(0.0, abs=1e-12) - def test_positive_with_velocity(self, params: GolferParams, zero_q: np.ndarray) -> None: + def test_positive_with_velocity( + self, params: GolferParams, zero_q: np.ndarray + ) -> None: qdot = np.ones(N_DOF) * 0.5 with patch( "double_pendulum_golf.golfer_dynamics._native_backend.golfer_mass_matrix", @@ -462,7 +466,9 @@ def test_scales_quadratically_with_speed( T2 = kinetic_energy(zero_q, 2 * qdot, params) assert T2 == pytest.approx(4 * T1, rel=1e-6) - def test_type_error_non_array_q(self, params: GolferParams, zero_qdot: np.ndarray) -> None: + def test_type_error_non_array_q( + self, params: GolferParams, zero_qdot: np.ndarray + ) -> None: with pytest.raises(TypeError): kinetic_energy([0.0] * N_DOF, zero_qdot, params) @@ -490,7 +496,9 @@ def test_returns_float(self, params: GolferParams, full_state: np.ndarray) -> No V = potential_energy(full_state, params) assert isinstance(V, float) - def test_different_configurations_give_different_pe(self, params: GolferParams) -> None: + def test_different_configurations_give_different_pe( + self, params: GolferParams + ) -> None: q1 = np.zeros(N_DOF) state1 = np.concatenate([q1, np.zeros(N_DOF)]) q2 = np.zeros(N_DOF) @@ -554,7 +562,9 @@ def test_total_is_T_plus_V(self, params: GolferParams) -> None: class TestMassPointPositions: - def test_returns_seven_points(self, params: GolferParams, zero_q: np.ndarray) -> None: + def test_returns_seven_points( + self, params: GolferParams, zero_q: np.ndarray + ) -> None: points = _mass_point_positions(zero_q, params) assert len(points) == 7 @@ -564,14 +574,18 @@ def test_all_callable(self, params: GolferParams, zero_q: np.ndarray) -> None: result = pos_func(zero_q) assert len(result) == 2 - def test_masses_match_params(self, params: GolferParams, zero_q: np.ndarray) -> None: + def test_masses_match_params( + self, params: GolferParams, zero_q: np.ndarray + ) -> None: points = _mass_point_positions(zero_q, params) masses = [m for m, _ in points] assert params.m_hub in masses assert params.m_r_upper in masses assert params.m_club in masses - def test_all_positions_finite(self, params: GolferParams, zero_q: np.ndarray) -> None: + def test_all_positions_finite( + self, params: GolferParams, zero_q: np.ndarray + ) -> None: points = _mass_point_positions(zero_q, params) for _, pos_func in points: x, y = pos_func(zero_q) diff --git a/src/pendulum_simulator/tests/test_golfer_ellipsoids.py b/src/pendulum_simulator/tests/test_golfer_ellipsoids.py index 4d3ec77f67..514803bd00 100644 --- a/src/pendulum_simulator/tests/test_golfer_ellipsoids.py +++ b/src/pendulum_simulator/tests/test_golfer_ellipsoids.py @@ -86,9 +86,9 @@ def test_nonzero_configuration(self, default_golfer_params): result = ellipsoids_golfer(q, default_golfer_params) assert len(result) > 0 for name, ell in result.items(): - assert np.all(np.isfinite(ell["mob_semi_axes"])), ( - f"{name}: non-finite mob_semi_axes" - ) + assert np.all( + np.isfinite(ell["mob_semi_axes"]) + ), f"{name}: non-finite mob_semi_axes" def test_handles_full_state_vector(self, default_golfer_params): """Should accept q with shape (16,) and use only first 8.""" diff --git a/src/pendulum_simulator/tests/test_golfer_kinematics.py b/src/pendulum_simulator/tests/test_golfer_kinematics.py index bc7bd40382..c9e1a7dfa6 100644 --- a/src/pendulum_simulator/tests/test_golfer_kinematics.py +++ b/src/pendulum_simulator/tests/test_golfer_kinematics.py @@ -120,7 +120,9 @@ def test_pi_half_hub_to_the_left(self, sym_params: GolferParams) -> None: assert x == pytest.approx(-sym_params.L_hub, abs=1e-10) assert abs(y) < 1e-10 - def test_hub_distance_from_origin_equals_L_hub(self, sym_params: GolferParams) -> None: + def test_hub_distance_from_origin_equals_L_hub( + self, sym_params: GolferParams + ) -> None: """Distance |hub| must equal L_hub for all angles.""" for theta in np.linspace(-np.pi, np.pi, 20): x, y = _hub_position(theta, sym_params) @@ -141,7 +143,9 @@ def test_returns_tuple_of_two_floats(self, sym_params: GolferParams) -> None: class TestShoulderPosition: - def test_distance_from_hub_equals_d_shoulder(self, sym_params: GolferParams) -> None: + def test_distance_from_hub_equals_d_shoulder( + self, sym_params: GolferParams + ) -> None: hub = (0.0, sym_params.L_hub) for d in [0.15, 0.20, 0.25]: rs = _shoulder_position(hub, 0.0, d, +1.0) @@ -274,9 +278,9 @@ def test_all_positions_finite(self, sym_params: GolferParams) -> None: q = rng.uniform(-np.pi / 2, np.pi / 2, N_DOF) pos = self._fk(q, sym_params) for key, (x, y) in pos.items(): - assert np.isfinite(x) and np.isfinite(y), ( - f"Non-finite position for joint {key!r}: ({x}, {y})" - ) + assert np.isfinite(x) and np.isfinite( + y + ), f"Non-finite position for joint {key!r}: ({x}, {y})" def test_origin_always_zero(self, sym_params: GolferParams) -> None: for q in [np.zeros(N_DOF), np.ones(N_DOF) * 0.3]: @@ -355,7 +359,9 @@ def test_extended_state_is_truncated(self, sym_params: GolferParams) -> None: assert pos_ext[key][0] == pytest.approx(pos_short[key][0], abs=1e-10) assert pos_ext[key][1] == pytest.approx(pos_short[key][1], abs=1e-10) - def test_scapula_keys_present_when_nonzero(self, scapula_params: GolferParams) -> None: + def test_scapula_keys_present_when_nonzero( + self, scapula_params: GolferParams + ) -> None: """When L_rscap > 0, 'rscap' and 'lscap' should appear in the result.""" q = np.zeros(N_DOF) pos = self._fk(q, scapula_params) diff --git a/src/pendulum_simulator/tests/test_golfer_model.py b/src/pendulum_simulator/tests/test_golfer_model.py index 604cef1692..adfad92267 100644 --- a/src/pendulum_simulator/tests/test_golfer_model.py +++ b/src/pendulum_simulator/tests/test_golfer_model.py @@ -197,9 +197,9 @@ def test_friction_opposes_velocity(self, default_params: GolferParams) -> None: # For each DOF with nonzero damping, sign(tau) = -sign(qdot) for i in range(N_DOF - 1): # Skip club DOF (no damping) if abs(qdot[i]) > 0 and abs(tau[i]) > 0: - assert np.sign(tau[i]) == -np.sign(qdot[i]), ( - f"Friction at DOF {i} does not oppose velocity" - ) + assert np.sign(tau[i]) == -np.sign( + qdot[i] + ), f"Friction at DOF {i} does not oppose velocity" def test_zero_velocity_zero_friction(self, default_params: GolferParams) -> None: """Zero velocity must produce zero friction torque.""" @@ -257,9 +257,9 @@ def test_mass_matrix_psd( M = analytical_mass_matrix(random_state, default_params) eigenvalues = np.linalg.eigvalsh(M) - assert np.all(eigenvalues >= -1e-10), ( - f"Negative eigenvalue in mass matrix: {eigenvalues}" - ) + assert np.all( + eigenvalues >= -1e-10 + ), f"Negative eigenvalue in mass matrix: {eigenvalues}" def test_mass_matrix_shape( self, default_params: GolferParams, zero_state: np.ndarray diff --git a/src/pendulum_simulator/tests/test_golfer_moments.py b/src/pendulum_simulator/tests/test_golfer_moments.py index 49ceb17b72..8b28b93221 100644 --- a/src/pendulum_simulator/tests/test_golfer_moments.py +++ b/src/pendulum_simulator/tests/test_golfer_moments.py @@ -74,9 +74,9 @@ def test_total_equals_applied_plus_moment(self, sample_positions, sample_forces) applied = result[f"{jname}_applied_torque"] moment = result[f"{jname}_moment_of_force"] total = result[f"{jname}_total_moment"] - assert total == pytest.approx(applied + moment), ( - f"{jname}: total {total} != applied {applied} + moment {moment}" - ) + assert total == pytest.approx( + applied + moment + ), f"{jname}: total {total} != applied {applied} + moment {moment}" def test_too_few_torques_raises(self, sample_positions, sample_forces): """Must have at least 7 applied torques.""" diff --git a/src/pendulum_simulator/tests/test_golfer_topology.py b/src/pendulum_simulator/tests/test_golfer_topology.py index 69d854e889..f2c25e1ff4 100644 --- a/src/pendulum_simulator/tests/test_golfer_topology.py +++ b/src/pendulum_simulator/tests/test_golfer_topology.py @@ -62,9 +62,9 @@ class TestStandoffMassless: def test_standoff_mass_near_zero(self, address_params: GolferParams) -> None: """Standoff mass must be near zero (< 0.01 kg).""" - assert address_params.m_hub < 0.01, ( - f"Standoff mass should be near-zero, got {address_params.m_hub}" - ) + assert ( + address_params.m_hub < 0.01 + ), f"Standoff mass should be near-zero, got {address_params.m_hub}" def test_standoff_mass_positive(self, address_params: GolferParams) -> None: """Standoff mass must be positive (required by solver numerics).""" @@ -78,7 +78,9 @@ def test_standoff_has_length(self, address_params: GolferParams) -> None: class TestUpperBodyMass: """Upper body (scapula) segments should have significant mass (~2x arms).""" - def test_right_upper_body_heavier_than_arms(self, address_params: GolferParams) -> None: + def test_right_upper_body_heavier_than_arms( + self, address_params: GolferParams + ) -> None: """Right upper body mass should be >= right arm total.""" right_arm_total = address_params.m_r_upper + address_params.m_r_fore assert address_params.m_rscap >= right_arm_total, ( @@ -86,7 +88,9 @@ def test_right_upper_body_heavier_than_arms(self, address_params: GolferParams) f"right arm total ({right_arm_total} kg)" ) - def test_left_upper_body_heavier_than_arms(self, address_params: GolferParams) -> None: + def test_left_upper_body_heavier_than_arms( + self, address_params: GolferParams + ) -> None: """Left upper body mass should be >= left arm total.""" left_arm_total = address_params.m_l_upper + address_params.m_l_fore assert address_params.m_lscap >= left_arm_total, ( @@ -124,7 +128,9 @@ def test_total_mass_reasonable(self, address_params: GolferParams) -> None: + address_params.m_clubhead ) # Upper body + arms + club: roughly 10-40 kg is reasonable - assert 10.0 < total < 40.0, f"Total mass {total:.1f} kg should be in 10-40 kg range" + assert ( + 10.0 < total < 40.0 + ), f"Total mass {total:.1f} kg should be in 10-40 kg range" def test_standoff_negligible_fraction(self, address_params: GolferParams) -> None: """Standoff mass should be < 0.1% of total system mass.""" @@ -140,7 +146,9 @@ def test_standoff_negligible_fraction(self, address_params: GolferParams) -> Non + address_params.m_clubhead ) fraction = address_params.m_hub / total - assert fraction < 0.001, f"Standoff mass fraction {fraction:.4f} should be < 0.001" + assert ( + fraction < 0.001 + ), f"Standoff mass fraction {fraction:.4f} should be < 0.001" def test_upper_body_dominates(self, address_params: GolferParams) -> None: """Upper body segments should be the heaviest components.""" @@ -153,12 +161,12 @@ def test_upper_body_dominates(self, address_params: GolferParams) -> None: address_params.m_club, address_params.m_clubhead, ] - assert address_params.m_rscap >= max(all_masses), ( - "Right upper body should be the heaviest individual segment" - ) - assert address_params.m_lscap >= max(all_masses), ( - "Left upper body should be the heaviest individual segment" - ) + assert address_params.m_rscap >= max( + all_masses + ), "Right upper body should be the heaviest individual segment" + assert address_params.m_lscap >= max( + all_masses + ), "Left upper body should be the heaviest individual segment" class TestGolferParamsValidation: @@ -256,7 +264,9 @@ def test_positions_finite(self, address_params: GolferParams) -> None: q = np.zeros(8) pos = forward_kinematics(q, address_params) for name, xy in pos.items(): - assert np.all(np.isfinite(xy)), f"Position {name} has non-finite values: {xy}" + assert np.all( + np.isfinite(xy) + ), f"Position {name} has non-finite values: {xy}" def test_scapula_positions_present(self, address_params: GolferParams) -> None: """When scapula lengths are nonzero, scapula positions must be in FK.""" diff --git a/src/pendulum_simulator/tests/test_gui_utilities.py b/src/pendulum_simulator/tests/test_gui_utilities.py index ec3366b33a..30e146b23f 100644 --- a/src/pendulum_simulator/tests/test_gui_utilities.py +++ b/src/pendulum_simulator/tests/test_gui_utilities.py @@ -317,7 +317,9 @@ def test_all_factors_positive(self) -> None: for cat, options in _UNIT_OPTIONS.items(): for label, factor in options: - assert factor > 0, f"Non-positive factor for {cat.value}/{label}: {factor}" + assert ( + factor > 0 + ), f"Non-positive factor for {cat.value}/{label}: {factor}" class TestToSiFromSi: diff --git a/src/pendulum_simulator/tests/test_hub_and_geometry.py b/src/pendulum_simulator/tests/test_hub_and_geometry.py index d500a211e9..7a9e347ba4 100644 --- a/src/pendulum_simulator/tests/test_hub_and_geometry.py +++ b/src/pendulum_simulator/tests/test_hub_and_geometry.py @@ -176,7 +176,9 @@ def test_finite(self) -> None: def test_negative_radius_raises(self) -> None: with pytest.raises((ValueError, TypeError)): - cylinder_cross_section(np.array([0.0, 0.0]), np.array([1.0, 0.0]), radius=-0.1) + cylinder_cross_section( + np.array([0.0, 0.0]), np.array([1.0, 0.0]), radius=-0.1 + ) def test_degenerate_segment(self) -> None: """Zero-length segment should not crash.""" @@ -233,7 +235,9 @@ class TestTaperedCylinderCrossSection: def test_shape(self) -> None: start = np.array([0.0, 0.0]) end = np.array([0.0, 1.0]) - corners = tapered_cylinder_cross_section(start, end, radius_start=0.2, radius_end=0.05) + corners = tapered_cylinder_cross_section( + start, end, radius_start=0.2, radius_end=0.05 + ) assert corners.shape == (4, 2) def test_finite(self) -> None: diff --git a/src/pendulum_simulator/tests/test_hypothesis_physics.py b/src/pendulum_simulator/tests/test_hypothesis_physics.py index eb8b6153e5..ae1a7741db 100644 --- a/src/pendulum_simulator/tests/test_hypothesis_physics.py +++ b/src/pendulum_simulator/tests/test_hypothesis_physics.py @@ -137,13 +137,17 @@ def test_kinetic_energy_non_negative( @given(params=double_params(), state=double_state()) @settings(max_examples=50) - def test_total_energy_finite(self, params: PendulumParams, state: np.ndarray) -> None: + def test_total_energy_finite( + self, params: PendulumParams, state: np.ndarray + ) -> None: E = total_energy(state, params) assert np.isfinite(E), f"Non-finite total energy: {E}" @given(params=double_params(), state=double_state()) @settings(max_examples=50) - def test_total_energy_is_sum(self, params: PendulumParams, state: np.ndarray) -> None: + def test_total_energy_is_sum( + self, params: PendulumParams, state: np.ndarray + ) -> None: T = kinetic_energy(state, params) V = potential_energy(state, params) E = total_energy(state, params) @@ -161,15 +165,15 @@ def test_fk_segment_lengths( # Shoulder at origin, wrist distance = L1 wrist_dist = np.linalg.norm(wrist) - assert np.isclose(wrist_dist, params.L1, atol=1e-8), ( - f"Wrist distance {wrist_dist} != L1 {params.L1}" - ) + assert np.isclose( + wrist_dist, params.L1, atol=1e-8 + ), f"Wrist distance {wrist_dist} != L1 {params.L1}" # Wrist-to-tip distance = L2 tip_dist = np.linalg.norm(tip - wrist) - assert np.isclose(tip_dist, params.L2, atol=1e-8), ( - f"Tip distance {tip_dist} != L2 {params.L2}" - ) + assert np.isclose( + tip_dist, params.L2, atol=1e-8 + ), f"Tip distance {tip_dist} != L2 {params.L2}" # --------------------------------------------------------------------------- @@ -218,7 +222,9 @@ def test_total_energy_is_sum( @given(params=triple_params(), state=triple_state()) @settings(max_examples=30) - def test_fk_segment_lengths(self, params: TriplePendulumParams, state: np.ndarray) -> None: + def test_fk_segment_lengths( + self, params: TriplePendulumParams, state: np.ndarray + ) -> None: """FK inter-joint distances must match segment lengths.""" pos = triple_fk(state[0], state[1], state[2], params) shoulder = np.array(pos["shoulder"]) diff --git a/src/pendulum_simulator/tests/test_issue_fixes.py b/src/pendulum_simulator/tests/test_issue_fixes.py index 0f1ce9fa7e..0523ddad58 100644 --- a/src/pendulum_simulator/tests/test_issue_fixes.py +++ b/src/pendulum_simulator/tests/test_issue_fixes.py @@ -164,7 +164,9 @@ def test_plot_data_stores(self) -> None: # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _has_pyqt6(), reason="PyQt6 not available in headless environment") +@pytest.mark.skipif( + not _has_pyqt6(), reason="PyQt6 not available in headless environment" +) class TestBasePendulumWidget3D: """3D segment rendering base class methods must exist and be callable.""" @@ -227,7 +229,9 @@ def test_tilt_foreshortens_y(self) -> None: # --------------------------------------------------------------------------- -@pytest.mark.skipif(not _has_pyqt6(), reason="PyQt6 not available in headless environment") +@pytest.mark.skipif( + not _has_pyqt6(), reason="PyQt6 not available in headless environment" +) class TestFunctionGeneratorDialog: """Function generator dialog must be importable with correct structure.""" @@ -280,9 +284,9 @@ def test_no_print_in_optimizer_gpu(self) -> None: stripped = line.strip() if stripped.startswith("#") or stripped.startswith('"'): continue - assert "print(" not in stripped, ( - f"optimizer_gpu.py line {i}: found print() call" - ) + assert ( + "print(" not in stripped + ), f"optimizer_gpu.py line {i}: found print() call" except ImportError: pytest.skip("optimizer_gpu not available") @@ -468,7 +472,9 @@ def test_simulation_rejects_nonfinite_state(self) -> None: def test_noise_generator_rejects_negative_amplitude(self) -> None: from double_pendulum_golf.perturbation_analysis import generate_noise - with pytest.raises((ValueError, TypeError), match="amplitude must be non-negative"): + with pytest.raises( + (ValueError, TypeError), match="amplitude must be non-negative" + ): generate_noise("white", 100, -1.0) def test_noise_generator_rejects_zero_samples(self) -> None: diff --git a/src/pendulum_simulator/tests/test_jacobians.py b/src/pendulum_simulator/tests/test_jacobians.py index 44637867bf..f93a4b0033 100644 --- a/src/pendulum_simulator/tests/test_jacobians.py +++ b/src/pendulum_simulator/tests/test_jacobians.py @@ -136,9 +136,9 @@ def test_phi_only_affects_tip_not_wrist(self, L: tuple[float, float]) -> None: L1, L2 = L for phi in [0.0, 0.3, 1.0, -0.8]: J_wrist = jacobian_double(0.5, phi, L1, L2)["wrist"] - assert np.isclose(J_wrist[0, 1], 0.0), ( - f"J_wrist[:,1] should be zero for any phi, got {J_wrist[:, 1]}" - ) + assert np.isclose( + J_wrist[0, 1], 0.0 + ), f"J_wrist[:,1] should be zero for any phi, got {J_wrist[:, 1]}" class TestJacobianDoubleContinuity: @@ -150,9 +150,9 @@ def test_continuity_at_various_angles(self, L: tuple[float, float]) -> None: for theta1 in np.linspace(-1.0, 1.0, 10): J0 = jacobian_double(theta1, 0.5, L1, L2)["tip"] J1 = jacobian_double(theta1 + eps, 0.5, L1, L2)["tip"] - assert np.allclose(J0, J1, atol=(L1 + L2) * eps * 2), ( - f"Jacobian discontinuity at theta1={theta1}" - ) + assert np.allclose( + J0, J1, atol=(L1 + L2) * eps * 2 + ), f"Jacobian discontinuity at theta1={theta1}" # ============================================================================ @@ -174,7 +174,9 @@ def test_all_jacobians_shape(self, L3: tuple[float, float, float]) -> None: class TestJacobianTripleAnalytic: """Known values at canonical configurations.""" - def test_straight_down_wrist1_jacobian(self, L3: tuple[float, float, float]) -> None: + def test_straight_down_wrist1_jacobian( + self, L3: tuple[float, float, float] + ) -> None: """theta1=phi1=phi2=0 → wrist1: [[L1, 0, 0], [0, 0, 0]].""" L1, L2, L3_ = L3 J = jacobian_triple(0.0, 0.0, 0.0, L1, L2, L3_)["wrist1"] @@ -329,15 +331,17 @@ def test_each_endpoint_has_required_keys(self, L: tuple[float, float]) -> None: "singular_values", } for name, data in result.items(): - assert set(data.keys()) == expected_keys, ( - f"Missing keys in '{name}': {expected_keys - set(data.keys())}" - ) + assert ( + set(data.keys()) == expected_keys + ), f"Missing keys in '{name}': {expected_keys - set(data.keys())}" def test_mob_axes_positive_full_rank(self, L: tuple[float, float]) -> None: L1, L2 = L result = ellipsoids_double(1.0, 0.5, L1, L2) for name, data in result.items(): - assert np.all(data["mob_semi_axes"] >= 0), f"Negative mobility axis in '{name}'" + assert np.all( + data["mob_semi_axes"] >= 0 + ), f"Negative mobility axis in '{name}'" class TestEllipsoidsTriple: @@ -348,7 +352,9 @@ def test_returns_three_endpoints(self, L3: tuple[float, float, float]) -> None: result = ellipsoids_triple(0.3, 0.2, 0.1, L1, L2, L3_) assert set(result.keys()) == {"wrist1", "wrist2", "tip"} - def test_each_endpoint_has_required_keys(self, L3: tuple[float, float, float]) -> None: + def test_each_endpoint_has_required_keys( + self, L3: tuple[float, float, float] + ) -> None: L1, L2, L3_ = L3 result = ellipsoids_triple(0.3, 0.2, 0.1, L1, L2, L3_) required = { diff --git a/src/pendulum_simulator/tests/test_jacobians_extended.py b/src/pendulum_simulator/tests/test_jacobians_extended.py index 37a675285d..cbc5419ecf 100644 --- a/src/pendulum_simulator/tests/test_jacobians_extended.py +++ b/src/pendulum_simulator/tests/test_jacobians_extended.py @@ -224,11 +224,15 @@ def test_singular_values_non_negative(self) -> None: class TestJacobianGolfer: - def test_returns_dict(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: + def test_returns_dict( + self, golfer_params: GolferParams, zero_q: np.ndarray + ) -> None: J = jacobian_golfer(zero_q, golfer_params) assert isinstance(J, dict) - def test_joint_key_shapes(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: + def test_joint_key_shapes( + self, golfer_params: GolferParams, zero_q: np.ndarray + ) -> None: J = jacobian_golfer(zero_q, golfer_params) for name, mat in J.items(): assert mat.shape == (2, N_DOF), f"Wrong shape for joint {name}" @@ -240,7 +244,9 @@ def test_finite(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: class TestEllipsoidsGolfer: - def test_returns_dict(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: + def test_returns_dict( + self, golfer_params: GolferParams, zero_q: np.ndarray + ) -> None: result = ellipsoids_golfer(zero_q, golfer_params) assert isinstance(result, dict) @@ -272,7 +278,9 @@ def test_finite(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: Z = ztcf_matrix(zero_q, golfer_params) assert np.all(np.isfinite(Z)) - def test_different_joint(self, golfer_params: GolferParams, zero_q: np.ndarray) -> None: + def test_different_joint( + self, golfer_params: GolferParams, zero_q: np.ndarray + ) -> None: Z_tip = ztcf_matrix(zero_q, golfer_params, joint_name="club_tip") Z_rh = ztcf_matrix(zero_q, golfer_params, joint_name="rh") # Different joints should give different matrices diff --git a/src/pendulum_simulator/tests/test_jacobians_golfer.py b/src/pendulum_simulator/tests/test_jacobians_golfer.py index 7ace63191b..3fbaad7f62 100644 --- a/src/pendulum_simulator/tests/test_jacobians_golfer.py +++ b/src/pendulum_simulator/tests/test_jacobians_golfer.py @@ -241,17 +241,17 @@ def test_ellipsoid_data_finite(self): result = ellipsoids_golfer(q, p) for name, data in result.items(): assert np.all(np.isfinite(data["jacobian"])), f"{name} Jacobian non-finite" - assert np.all(np.isfinite(data["singular_values"])), ( - f"{name} singular values non-finite" - ) - assert np.all(np.isfinite(data["mob_semi_axes"])), ( - f"{name} mobility semi-axes non-finite" - ) + assert np.all( + np.isfinite(data["singular_values"]) + ), f"{name} singular values non-finite" + assert np.all( + np.isfinite(data["mob_semi_axes"]) + ), f"{name} mobility semi-axes non-finite" # force_semi_axes may be None at singular configurations if data["force_semi_axes"] is not None: - assert np.all(np.isfinite(data["force_semi_axes"])), ( - f"{name} force semi-axes non-finite" - ) + assert np.all( + np.isfinite(data["force_semi_axes"]) + ), f"{name} force semi-axes non-finite" def test_singular_values_descending(self): """Singular values should be in descending order.""" @@ -262,7 +262,9 @@ def test_singular_values_descending(self): for name, data in result.items(): svs = data["singular_values"] # Check descending order - assert np.all(np.diff(svs) <= 0), f"{name} singular values not in descending order" + assert np.all( + np.diff(svs) <= 0 + ), f"{name} singular values not in descending order" def test_directions_orthonormal(self): """Ellipsoid directions should be orthonormal.""" @@ -275,9 +277,9 @@ def test_directions_orthonormal(self): # Check columns are unit vectors for i in range(dirs.shape[1]): col_norm = np.linalg.norm(dirs[:, i]) - assert np.isclose(col_norm, 1.0, atol=1e-10), ( - f"{name} direction {i} not unit norm" - ) + assert np.isclose( + col_norm, 1.0, atol=1e-10 + ), f"{name} direction {i} not unit norm" def test_semi_axes_positive(self): """Semi-axes lengths should be positive.""" diff --git a/src/pendulum_simulator/tests/test_joint_moments.py b/src/pendulum_simulator/tests/test_joint_moments.py index d96aa27589..47c7b20baf 100644 --- a/src/pendulum_simulator/tests/test_joint_moments.py +++ b/src/pendulum_simulator/tests/test_joint_moments.py @@ -23,15 +23,21 @@ class TestCross2D: def test_unit_vectors(self): """x × y = +1 (CCW).""" - assert cross_2d(np.array([1.0, 0.0]), np.array([0.0, 1.0])) == pytest.approx(1.0) + assert cross_2d(np.array([1.0, 0.0]), np.array([0.0, 1.0])) == pytest.approx( + 1.0 + ) def test_antiparallel(self): """y × x = -1 (CW).""" - assert cross_2d(np.array([0.0, 1.0]), np.array([1.0, 0.0])) == pytest.approx(-1.0) + assert cross_2d(np.array([0.0, 1.0]), np.array([1.0, 0.0])) == pytest.approx( + -1.0 + ) def test_parallel(self): """Parallel vectors → zero cross product.""" - assert cross_2d(np.array([3.0, 0.0]), np.array([5.0, 0.0])) == pytest.approx(0.0) + assert cross_2d(np.array([3.0, 0.0]), np.array([5.0, 0.0])) == pytest.approx( + 0.0 + ) def test_wrong_shape_raises(self): with pytest.raises((ValueError, TypeError), match="r must be shape"): diff --git a/src/pendulum_simulator/tests/test_main_window.py b/src/pendulum_simulator/tests/test_main_window.py index d98597c7e3..f5c366cf9a 100644 --- a/src/pendulum_simulator/tests/test_main_window.py +++ b/src/pendulum_simulator/tests/test_main_window.py @@ -156,7 +156,9 @@ def get_selection(self) -> Any: # mock extract_series mock_extract = MagicMock(side_effect=[([1], "X", "m"), ([2], "Y", "m")]) - monkeypatch.setattr("double_pendulum_golf.data_extractor.extract_series", mock_extract) + monkeypatch.setattr( + "double_pendulum_golf.data_extractor.extract_series", mock_extract + ) # mock PopOutChart mock_chart_class = MagicMock() @@ -199,7 +201,9 @@ def get_selection(self) -> Any: def mock_extract(*args) -> Any: raise KeyError("bad") - monkeypatch.setattr("double_pendulum_golf.data_extractor.extract_series", mock_extract) + monkeypatch.setattr( + "double_pendulum_golf.data_extractor.extract_series", mock_extract + ) mock_msg = MagicMock() monkeypatch.setattr("PyQt6.QtWidgets.QMessageBox.warning", mock_msg) diff --git a/src/pendulum_simulator/tests/test_model_registry_gaps.py b/src/pendulum_simulator/tests/test_model_registry_gaps.py index 4d2c839427..f12abb9856 100644 --- a/src/pendulum_simulator/tests/test_model_registry_gaps.py +++ b/src/pendulum_simulator/tests/test_model_registry_gaps.py @@ -55,7 +55,9 @@ def test_overwrites_and_warns(self, caplog: pytest.LogCaptureFixture) -> None: cfg2 = _make_config("Second Version", n_dof=3) register_model("__test_overwrite__", cfg1) - with caplog.at_level(logging.WARNING, logger="double_pendulum_golf.model_registry"): + with caplog.at_level( + logging.WARNING, logger="double_pendulum_golf.model_registry" + ): register_model("__test_overwrite__", cfg2) assert "Overwriting existing model registration" in caplog.text @@ -64,7 +66,9 @@ def test_overwrites_and_warns(self, caplog: pytest.LogCaptureFixture) -> None: def test_no_warn_first_registration(self, caplog: pytest.LogCaptureFixture) -> None: """First registration should not warn.""" - with caplog.at_level(logging.WARNING, logger="double_pendulum_golf.model_registry"): + with caplog.at_level( + logging.WARNING, logger="double_pendulum_golf.model_registry" + ): register_model("__test_first__", _make_config()) assert "Overwriting" not in caplog.text @@ -90,7 +94,9 @@ def test_import_error_branches( monkeypatch.setitem(sys.modules, "double_pendulum_golf.physics_triple", None) monkeypatch.setitem(sys.modules, "double_pendulum_golf.physics_golfer", None) - with caplog.at_level(logging.DEBUG, logger="double_pendulum_golf.model_registry"): + with caplog.at_level( + logging.DEBUG, logger="double_pendulum_golf.model_registry" + ): model_registry._register_builtins() # All 3 modules should fail to import and log at DEBUG level diff --git a/src/pendulum_simulator/tests/test_native_backend.py b/src/pendulum_simulator/tests/test_native_backend.py index b346780d38..5261861af9 100644 --- a/src/pendulum_simulator/tests/test_native_backend.py +++ b/src/pendulum_simulator/tests/test_native_backend.py @@ -307,7 +307,9 @@ def py_double_mass_matrix( return [[5.0, 2.0], [2.0, 1.0]] @staticmethod - def py_double_gravity_vector(q: list[float], params: tuple[float, ...]) -> list[float]: + def py_double_gravity_vector( + q: list[float], params: tuple[float, ...] + ) -> list[float]: del q, params return [3.0, 1.0] @@ -366,7 +368,9 @@ def py_triple_mass_matrix( return [[14.0, 8.0, 3.0], [8.0, 5.0, 2.0], [3.0, 2.0, 1.0]] @staticmethod - def py_triple_gravity_vector(q: list[float], params: tuple[float, ...]) -> list[float]: + def py_triple_gravity_vector( + q: list[float], params: tuple[float, ...] + ) -> list[float]: del q, params return [1.0, 2.0, 3.0] @@ -399,7 +403,9 @@ def py_triple_forward_kinematics( mass = native_backend.triple_mass_matrix(0.0, 0.0, triple_params) gravity = native_backend.triple_gravity_vector(0.0, 0.0, 0.0, triple_params) - coriolis = native_backend.triple_coriolis_vector(0.0, 0.0, 0.0, 0.0, 0.0, triple_params) + coriolis = native_backend.triple_coriolis_vector( + 0.0, 0.0, 0.0, 0.0, 0.0, triple_params + ) fk = native_backend.triple_forward_kinematics(0.0, 0.0, 0.0, triple_params) assert mass is not None @@ -505,7 +511,9 @@ def py_golfer_project_velocity( q_proj = native_backend.golfer_project_to_constraints( np.zeros(8), golfer_params, max_iters=5, tol=1e-6 ) - qdot_proj = native_backend.golfer_project_velocity(np.zeros(8), np.zeros(8), golfer_params) + qdot_proj = native_backend.golfer_project_velocity( + np.zeros(8), np.zeros(8), golfer_params + ) assert q_proj is not None assert qdot_proj is not None diff --git a/src/pendulum_simulator/tests/test_native_backend_gaps.py b/src/pendulum_simulator/tests/test_native_backend_gaps.py index 472bccce65..7ac3a2e3cf 100644 --- a/src/pendulum_simulator/tests/test_native_backend_gaps.py +++ b/src/pendulum_simulator/tests/test_native_backend_gaps.py @@ -118,7 +118,9 @@ def test_with_zero_b_returns_true(self, golfer_params: GolferParams) -> None: assert golfer_native_constraint_dynamics_supported(golfer_params) is True - def test_with_nonzero_b_hub_returns_false(self, golfer_params: GolferParams) -> None: + def test_with_nonzero_b_hub_returns_false( + self, golfer_params: GolferParams + ) -> None: from double_pendulum_golf.native_backend import ( golfer_native_constraint_dynamics_supported, ) diff --git a/src/pendulum_simulator/tests/test_optimizer_advanced.py b/src/pendulum_simulator/tests/test_optimizer_advanced.py index 92f9f588aa..0a39f2d548 100644 --- a/src/pendulum_simulator/tests/test_optimizer_advanced.py +++ b/src/pendulum_simulator/tests/test_optimizer_advanced.py @@ -19,7 +19,9 @@ def _has_optimizer() -> bool: return False -pytestmark = pytest.mark.skipif(not _has_optimizer(), reason="PyQt6/optimizer not available") +pytestmark = pytest.mark.skipif( + not _has_optimizer(), reason="PyQt6/optimizer not available" +) class TestCMAESStep: @@ -111,7 +113,9 @@ def test_warm_start_advantage(self) -> None: state_cold, _ = _cmaes_step(state_cold, self._sphere, pop_size=10, rng=rng) rng_w = np.random.default_rng(42) for _ in range(20): - state_warm, _ = _cmaes_step(state_warm, self._sphere, pop_size=10, rng=rng_w) + state_warm, _ = _cmaes_step( + state_warm, self._sphere, pop_size=10, rng=rng_w + ) assert state_warm.best_fitness < state_cold.best_fitness diff --git a/src/pendulum_simulator/tests/test_optimizer_gpu.py b/src/pendulum_simulator/tests/test_optimizer_gpu.py index 6ce9db1111..6328e290b9 100644 --- a/src/pendulum_simulator/tests/test_optimizer_gpu.py +++ b/src/pendulum_simulator/tests/test_optimizer_gpu.py @@ -97,7 +97,9 @@ def test_gradient_via_autodiff_vs_finite_difference( # Compute gradient via autodiff def loss_fn(coeffs): - return clubhead_speed_objective(coeffs, _PARAMS, state_jax, t_end=0.5, dt=0.01) + return clubhead_speed_objective( + coeffs, _PARAMS, state_jax, t_end=0.5, dt=0.01 + ) grad_autodiff = jax.grad(loss_fn)(torque_jax) @@ -114,7 +116,9 @@ def loss_fn(coeffs): # Normalize by max absolute value to avoid scale issues max_grad = np.max(np.abs(grad_fd_np)) if max_grad > 1e-10: - rel_error = np.linalg.norm(grad_autodiff_np - grad_fd_np) / (max_grad + 1e-12) + rel_error = np.linalg.norm(grad_autodiff_np - grad_fd_np) / ( + max_grad + 1e-12 + ) assert rel_error < 0.5, f"Relative error in gradient: {rel_error}" @@ -196,7 +200,9 @@ def test_clubhead_speed_is_positive( assert float(speed) >= 0.0 @pytest.mark.slow - def test_clubhead_speed_increases_with_torque(self, initial_state: np.ndarray) -> None: + def test_clubhead_speed_increases_with_torque( + self, initial_state: np.ndarray + ) -> None: """Clubhead speed is higher with positive torques.""" state_jax = jnp.array(initial_state) @@ -245,4 +251,6 @@ def test_fd_gradient_is_finite( ) grad_np = np.array(grad) - assert np.all(np.isfinite(grad_np)), f"Gradient has non-finite values: {grad_np}" + assert np.all( + np.isfinite(grad_np) + ), f"Gradient has non-finite values: {grad_np}" diff --git a/src/pendulum_simulator/tests/test_overlay_state_sync.py b/src/pendulum_simulator/tests/test_overlay_state_sync.py index af99b0b916..a766eb0ce5 100644 --- a/src/pendulum_simulator/tests/test_overlay_state_sync.py +++ b/src/pendulum_simulator/tests/test_overlay_state_sync.py @@ -178,7 +178,9 @@ def test_force_scale_pushed(qapp) -> None: apply_toolstrip_overlay_state(ts, pw) - assert pw.calls.get("set_force_scale") == pytest.approx(_expected_scale(ts._sld_force)) + assert pw.calls.get("set_force_scale") == pytest.approx( + _expected_scale(ts._sld_force) + ) def test_mob_ellipsoid_scale_pushed(qapp) -> None: diff --git a/src/pendulum_simulator/tests/test_panel_builders.py b/src/pendulum_simulator/tests/test_panel_builders.py index 2d64440472..cced2348ff 100644 --- a/src/pendulum_simulator/tests/test_panel_builders.py +++ b/src/pendulum_simulator/tests/test_panel_builders.py @@ -181,7 +181,9 @@ def test_build_triple_panel(mock_run, mock_set_perturb, qapp) -> Any: real_perturb._get_coeffs_for_preset_fn("Default") - panel.controls.PRESETS = {"Default": ["0", "0", "0", "0", "0", "0", "1.0, 2.0", "3.0", ""]} + panel.controls.PRESETS = { + "Default": ["0", "0", "0", "0", "0", "0", "1.0, 2.0", "3.0", ""] + } parsed = real_perturb._get_coeffs_for_preset_fn("Default") assert len(parsed) == 3 diff --git a/src/pendulum_simulator/tests/test_perturbation_analysis.py b/src/pendulum_simulator/tests/test_perturbation_analysis.py index e0ba5632f5..3eb0382fb2 100644 --- a/src/pendulum_simulator/tests/test_perturbation_analysis.py +++ b/src/pendulum_simulator/tests/test_perturbation_analysis.py @@ -119,7 +119,9 @@ def test_defaults(self): assert cfg.seed is None def test_custom(self): - cfg = PerturbationConfig(n_trials=50, noise_type="pink", noise_amplitude=0.2, seed=42) + cfg = PerturbationConfig( + n_trials=50, noise_type="pink", noise_amplitude=0.2, seed=42 + ) assert cfg.n_trials == 50 assert cfg.noise_type == "pink" @@ -199,7 +201,9 @@ def extract_fn(result): "tip_position_final": np.array([1.0, -0.5]), } - results = batch_perturb_and_simulate(base_coeffs, config, simulate_fn, extract_fn) + results = batch_perturb_and_simulate( + base_coeffs, config, simulate_fn, extract_fn + ) assert len(results) == 5 def test_handles_failures_gracefully(self): @@ -222,7 +226,9 @@ def extract_fn(result): "tip_position_final": np.array([0.0, 0.0]), } - results = batch_perturb_and_simulate(base_coeffs, config, simulate_fn, extract_fn) + results = batch_perturb_and_simulate( + base_coeffs, config, simulate_fn, extract_fn + ) assert len(results) == 2 # 3 trials, 1 failed @@ -277,7 +283,9 @@ def extract_fn(_result): "tip_position_final": np.array([0.5, -0.3]), } - results = batch_perturb_and_simulate(base_coeffs, config, simulate_fn, extract_fn) + results = batch_perturb_and_simulate( + base_coeffs, config, simulate_fn, extract_fn + ) assert len(results) > 0 for r in results: assert np.isfinite(r["tip_speed_final"]), f"Non-finite tip_speed: {r}" diff --git a/src/pendulum_simulator/tests/test_physics.py b/src/pendulum_simulator/tests/test_physics.py index aaa302a4d8..2a04a2028b 100644 --- a/src/pendulum_simulator/tests/test_physics.py +++ b/src/pendulum_simulator/tests/test_physics.py @@ -46,25 +46,29 @@ def test_symmetric_at_arbitrary_angle(self, default_params: PendulumParams) -> N class TestMassMatrixPositiveDefinite: """The mass matrix must be positive definite (all eigenvalues > 0).""" - def test_positive_definite_at_various_angles(self, default_params: PendulumParams) -> None: + def test_positive_definite_at_various_angles( + self, default_params: PendulumParams + ) -> None: for phi in np.linspace(-np.pi, np.pi, 50): M = mass_matrix(phi, default_params) eigenvalues = np.linalg.eigvalsh(M) - assert all(ev > 0 for ev in eigenvalues), ( - f"Not positive definite at phi={phi}: eigenvalues={eigenvalues}" - ) + assert all( + ev > 0 for ev in eigenvalues + ), f"Not positive definite at phi={phi}: eigenvalues={eigenvalues}" class TestMassMatrixCouplingMaximum: """Off-diagonal coupling |M12| should be maximized when segments are aligned (phi=0).""" - def test_coupling_maximized_at_alignment(self, default_params: PendulumParams) -> None: + def test_coupling_maximized_at_alignment( + self, default_params: PendulumParams + ) -> None: M12_at_zero = abs(mass_matrix(0.0, default_params)[0, 1]) for phi in np.linspace(0.1, np.pi, 30): M12 = abs(mass_matrix(phi, default_params)[0, 1]) - assert M12 <= M12_at_zero + 1e-10, ( - f"|M12| at phi={phi:.2f} ({M12:.4f}) exceeds value at phi=0 ({M12_at_zero:.4f})" - ) + assert ( + M12 <= M12_at_zero + 1e-10 + ), f"|M12| at phi={phi:.2f} ({M12:.4f}) exceeds value at phi=0 ({M12_at_zero:.4f})" class TestMassMatrixDiagonalConstant: @@ -74,7 +78,9 @@ def test_m22_independent_of_phi(self, default_params: PendulumParams) -> None: M22_ref = mass_matrix(0.0, default_params)[1, 1] for phi in np.linspace(-np.pi, np.pi, 30): M22 = mass_matrix(phi, default_params)[1, 1] - assert np.isclose(M22, M22_ref), f"M22 changed at phi={phi}: {M22} vs {M22_ref}" + assert np.isclose( + M22, M22_ref + ), f"M22 changed at phi={phi}: {M22} vs {M22_ref}" def test_m22_equals_expected(self, default_params: PendulumParams) -> None: """M22 = m2 * L2^2 for point mass at tip.""" @@ -117,7 +123,9 @@ def test_perpendicular_equal_segments(self, equal_params: PendulumParams) -> Non class TestCoriolisVector: """Tests for the Coriolis/centrifugal force computation.""" - def test_zero_velocity_gives_zero_coriolis(self, default_params: PendulumParams) -> None: + def test_zero_velocity_gives_zero_coriolis( + self, default_params: PendulumParams + ) -> None: """No velocity => no velocity-dependent forces.""" C = coriolis_vector(0.5, 0.0, 0.0, default_params) assert np.allclose(C, [0.0, 0.0]) @@ -322,15 +330,21 @@ def test_full_penetration_no_blend(self): # pen >= transition → blend=1 → smooth=1 → full penalty pen = 0.05 # exactly at transition - result = _hermite_penalty(pen, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0) + result = _hermite_penalty( + pen, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0 + ) assert result == pytest.approx(500.0 * 0.05, rel=1e-9) def test_large_penetration_clamps_blend(self): from double_pendulum_golf.physics import _hermite_penalty # pen >> transition → blend clamped at 1 → same as full penalty - r1 = _hermite_penalty(0.05, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0) - r2 = _hermite_penalty(1.0, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0) + r1 = _hermite_penalty( + 0.05, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0 + ) + r2 = _hermite_penalty( + 1.0, vel=0.0, transition=0.05, stiffness=500.0, damping=0.0 + ) # Both have blend=1; r2 has larger pen so larger result assert r2 > r1 @@ -339,9 +353,13 @@ def test_damping_only_when_velocity_into_limit(self): # vel > 0 means moving into the limit → damping adds pen = 0.05 - r_into = _hermite_penalty(pen, vel=1.0, transition=0.05, stiffness=0.0, damping=20.0) + r_into = _hermite_penalty( + pen, vel=1.0, transition=0.05, stiffness=0.0, damping=20.0 + ) # vel = 0 means no damping contribution - r_zero = _hermite_penalty(pen, vel=0.0, transition=0.05, stiffness=0.0, damping=20.0) + r_zero = _hermite_penalty( + pen, vel=0.0, transition=0.05, stiffness=0.0, damping=20.0 + ) assert r_into > r_zero @@ -364,7 +382,9 @@ def limits(self): def test_within_limits_gives_zero(self, limits): from double_pendulum_golf.physics import joint_limit_torque - tau = joint_limit_torque(phi=0.0, dphi=0.0, limits=limits, theta1=0.0, dtheta1=0.0) + tau = joint_limit_torque( + phi=0.0, dphi=0.0, limits=limits, theta1=0.0, dtheta1=0.0 + ) np.testing.assert_allclose(tau, [0.0, 0.0], atol=1e-12) def test_exactly_at_lower_phi_limit_gives_zero(self, limits): @@ -401,9 +421,9 @@ def test_segment_lengths_arbitrary_angle(self, default_params: PendulumParams): tx, ty = pos["tip"] wrist_dist = np.hypot(wx - sx, wy - sy) tip_dist = np.hypot(tx - wx, ty - wy) - assert abs(wrist_dist - default_params.L1) < 1e-9, ( - f"theta1={theta1:.2f}, phi={phi:.2f}: wrist_dist={wrist_dist:.9f}" - ) - assert abs(tip_dist - default_params.L2) < 1e-9, ( - f"theta1={theta1:.2f}, phi={phi:.2f}: tip_dist={tip_dist:.9f}" - ) + assert ( + abs(wrist_dist - default_params.L1) < 1e-9 + ), f"theta1={theta1:.2f}, phi={phi:.2f}: wrist_dist={wrist_dist:.9f}" + assert ( + abs(tip_dist - default_params.L2) < 1e-9 + ), f"theta1={theta1:.2f}, phi={phi:.2f}: tip_dist={tip_dist:.9f}" diff --git a/src/pendulum_simulator/tests/test_physics_extended.py b/src/pendulum_simulator/tests/test_physics_extended.py index c0eed5f10b..2dfb034aad 100644 --- a/src/pendulum_simulator/tests/test_physics_extended.py +++ b/src/pendulum_simulator/tests/test_physics_extended.py @@ -211,7 +211,9 @@ def test_shape(self, wide_limits: JointLimitsNDOF) -> None: assert tau.shape == (2,) def test_finite(self, wide_limits: JointLimitsNDOF) -> None: - tau = joint_limit_torque_ndof(np.array([1.0, -0.5]), np.array([0.5, 0.1]), wide_limits) + tau = joint_limit_torque_ndof( + np.array([1.0, -0.5]), np.array([0.5, 0.1]), wide_limits + ) assert np.all(np.isfinite(tau)) @@ -246,7 +248,9 @@ def test_returns_dict(self, params: PendulumParams, rest_state: np.ndarray) -> N result = joint_velocities(rest_state, params) assert isinstance(result, dict) - def test_has_speed_keys(self, params: PendulumParams, rest_state: np.ndarray) -> None: + def test_has_speed_keys( + self, params: PendulumParams, rest_state: np.ndarray + ) -> None: result = joint_velocities(rest_state, params) assert "wrist_speed" in result assert "tip_speed" in result @@ -281,7 +285,9 @@ def test_returns_dict(self, params: PendulumParams, rest_state: np.ndarray) -> N result = base_force(rest_state, qddot, params) assert isinstance(result, dict) - def test_has_required_keys(self, params: PendulumParams, rest_state: np.ndarray) -> None: + def test_has_required_keys( + self, params: PendulumParams, rest_state: np.ndarray + ) -> None: qddot = np.zeros(2) result = base_force(rest_state, qddot, params) assert "fx" in result @@ -317,7 +323,9 @@ def test_finite(self, params: PendulumParams, moving_state: np.ndarray) -> None: qddot = ztcf_accelerations(moving_state, params) assert np.all(np.isfinite(qddot)) - def test_zero_at_equilibrium(self, params: PendulumParams, rest_state: np.ndarray) -> None: + def test_zero_at_equilibrium( + self, params: PendulumParams, rest_state: np.ndarray + ) -> None: """At equilibrium with no velocity, ZTCF accel should be zero.""" qddot = ztcf_accelerations(rest_state, params) np.testing.assert_allclose(qddot, 0.0, atol=1e-10) @@ -334,7 +342,9 @@ def test_returns_dict(self, params: PendulumParams, rest_state: np.ndarray) -> N result = linear_accelerations(rest_state, qddot, params) assert isinstance(result, dict) - def test_has_wrist_and_tip(self, params: PendulumParams, rest_state: np.ndarray) -> None: + def test_has_wrist_and_tip( + self, params: PendulumParams, rest_state: np.ndarray + ) -> None: qddot = np.zeros(2) result = linear_accelerations(rest_state, qddot, params) assert "wrist" in result or "ax_wrist" in result or len(result) >= 2 @@ -360,13 +370,17 @@ def test_finite(self, params: PendulumParams, rest_state: np.ndarray) -> None: E = total_energy(rest_state, params) assert np.isfinite(E) - def test_equals_T_plus_V(self, params: PendulumParams, moving_state: np.ndarray) -> None: + def test_equals_T_plus_V( + self, params: PendulumParams, moving_state: np.ndarray + ) -> None: E = total_energy(moving_state, params) T = kinetic_energy(moving_state, params) V = potential_energy(moving_state, params) assert E == pytest.approx(T + V, rel=1e-9) - def test_rest_equals_pe_only(self, params: PendulumParams, rest_state: np.ndarray) -> None: + def test_rest_equals_pe_only( + self, params: PendulumParams, rest_state: np.ndarray + ) -> None: E = total_energy(rest_state, params) V = potential_energy(rest_state, params) assert E == pytest.approx(V, abs=1e-10) diff --git a/src/pendulum_simulator/tests/test_physics_golfer.py b/src/pendulum_simulator/tests/test_physics_golfer.py index 8274687bcd..23d8c4448b 100644 --- a/src/pendulum_simulator/tests/test_physics_golfer.py +++ b/src/pendulum_simulator/tests/test_physics_golfer.py @@ -197,9 +197,9 @@ def test_positive_semi_definite(self, golfer_params: GolferParams) -> None: q = np.zeros(N_DOF) M = mass_matrix(q, golfer_params) eigenvalues = np.linalg.eigvalsh(M) - assert np.all(eigenvalues >= -1e-10), ( - f"M must be positive semi-definite, got eigenvalues {eigenvalues}" - ) + assert np.all( + eigenvalues >= -1e-10 + ), f"M must be positive semi-definite, got eigenvalues {eigenvalues}" def test_depends_on_configuration(self, golfer_params: GolferParams) -> None: q1 = np.zeros(N_DOF) diff --git a/src/pendulum_simulator/tests/test_physics_golfer_jax.py b/src/pendulum_simulator/tests/test_physics_golfer_jax.py index 9ab09af6b3..5cfabaab46 100644 --- a/src/pendulum_simulator/tests/test_physics_golfer_jax.py +++ b/src/pendulum_simulator/tests/test_physics_golfer_jax.py @@ -244,7 +244,9 @@ def test_gravity_vector_shape(self, random_config: np.ndarray) -> None: G_jax = gravity_vector_jax(q_jax, _PARAMS_JAX) assert G_jax.shape == (N_DOF,) - def test_gravity_vector_parity_random_configs(self, random_config: np.ndarray) -> None: + def test_gravity_vector_parity_random_configs( + self, random_config: np.ndarray + ) -> None: """JAX gravity vector matches numpy.""" q_jax = jnp.array(random_config) diff --git a/src/pendulum_simulator/tests/test_physics_native_dbc.py b/src/pendulum_simulator/tests/test_physics_native_dbc.py index 87c4cf3ad5..c8868f17bf 100644 --- a/src/pendulum_simulator/tests/test_physics_native_dbc.py +++ b/src/pendulum_simulator/tests/test_physics_native_dbc.py @@ -202,9 +202,13 @@ def test_no_misleading_fallback_log_for_golfer(self) -> None: # The double-pendulum path legitimately falls back; the golfer path must # not claim a fallback that does not exist. Assert the specific stale # golfer log string is gone. - assert "golfer mass_matrix call failed (%s), falling back to NumPy" not in source + assert ( + "golfer mass_matrix call failed (%s), falling back to NumPy" not in source + ) - @pytest.mark.skipif(not physics_native.HAS_NATIVE, reason="native pendulum_core not built") + @pytest.mark.skipif( + not physics_native.HAS_NATIVE, reason="native pendulum_core not built" + ) def test_construction_succeeds_with_native(self) -> None: golfer = physics_native.Golfer(**_GOLFER_KWARGS) assert golfer.use_native is True diff --git a/src/pendulum_simulator/tests/test_physics_triple.py b/src/pendulum_simulator/tests/test_physics_triple.py index 302426717e..758d635046 100644 --- a/src/pendulum_simulator/tests/test_physics_triple.py +++ b/src/pendulum_simulator/tests/test_physics_triple.py @@ -57,15 +57,17 @@ def test_symmetric_at_zero(self, triple_params: TriplePendulumParams) -> None: for j in range(3): assert np.isclose(M[i, j], M[j, i]), f"M[{i},{j}] != M[{j},{i}]" - def test_symmetric_at_arbitrary_angles(self, triple_params: TriplePendulumParams) -> None: + def test_symmetric_at_arbitrary_angles( + self, triple_params: TriplePendulumParams + ) -> None: for phi1 in np.linspace(-np.pi, np.pi, 10): for phi2 in np.linspace(-np.pi, np.pi, 10): M = mass_matrix_triple(phi1, phi2, triple_params) for i in range(3): for j in range(3): - assert np.isclose(M[i, j], M[j, i]), ( - f"Not symmetric at phi1={phi1}, phi2={phi2}" - ) + assert np.isclose( + M[i, j], M[j, i] + ), f"Not symmetric at phi1={phi1}, phi2={phi2}" class TestTripleMassMatrixPositiveDefinite: @@ -79,9 +81,9 @@ def test_positive_definite_at_various_angles( for phi2 in test_angles: M = mass_matrix_triple(phi1, phi2, triple_params) eigenvalues = np.linalg.eigvalsh(M) - assert all(ev > 0 for ev in eigenvalues), ( - f"Not positive definite at phi1={phi1}, phi2={phi2}" - ) + assert all( + ev > 0 for ev in eigenvalues + ), f"Not positive definite at phi1={phi1}, phi2={phi2}" class TestTripleCoriolisZeroAtRest: @@ -159,7 +161,9 @@ def test_eom_produces_valid_state_derivative( ) -> None: # State: [theta1, phi1, phi2, dtheta1, dphi1, dphi2] state = np.array([0.1, 0.05, -0.05, 0.0, 0.0, 0.0]) - state_dot = equations_of_motion_triple(state, 0.0, triple_params, triple_torque_func) + state_dot = equations_of_motion_triple( + state, 0.0, triple_params, triple_torque_func + ) assert state_dot.shape == (6,) assert all(np.isfinite(state_dot)), f"Invalid values: {state_dot}" @@ -171,7 +175,9 @@ def test_eom_at_rest_at_equilibrium( ) -> None: # At equilibrium with zero velocity, acceleration should be zero state = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - state_dot = equations_of_motion_triple(state, 0.0, triple_params, triple_torque_func) + state_dot = equations_of_motion_triple( + state, 0.0, triple_params, triple_torque_func + ) # Velocities should match input (first 3 elements should be all zeros) assert np.isclose(state_dot[0], 0.0) # dtheta1 @@ -219,10 +225,14 @@ def test_coriolis_scales_with_velocity_squared( phi1, phi2 = 0.5, -0.3 dtheta1_small = 0.1 - C_small = coriolis_vector_triple(phi1, phi2, dtheta1_small, 0.1, 0.1, triple_params) + C_small = coriolis_vector_triple( + phi1, phi2, dtheta1_small, 0.1, 0.1, triple_params + ) dtheta1_large = 0.2 # 2x larger - C_large = coriolis_vector_triple(phi1, phi2, dtheta1_large, 0.1, 0.1, triple_params) + C_large = coriolis_vector_triple( + phi1, phi2, dtheta1_large, 0.1, 0.1, triple_params + ) # The change should not be linear (quadratic in velocity) ratio = np.linalg.norm(C_large) / np.linalg.norm(C_small) diff --git a/src/pendulum_simulator/tests/test_physics_triple_extended.py b/src/pendulum_simulator/tests/test_physics_triple_extended.py index 98848e1aec..43d0714e71 100644 --- a/src/pendulum_simulator/tests/test_physics_triple_extended.py +++ b/src/pendulum_simulator/tests/test_physics_triple_extended.py @@ -68,7 +68,9 @@ def moving_state() -> np.ndarray: class TestMassMatrixComponents: - def test_returns_dict_with_required_keys(self, params: TriplePendulumParams) -> None: + def test_returns_dict_with_required_keys( + self, params: TriplePendulumParams + ) -> None: result = mass_matrix_components(0.0, 0.0, params) assert isinstance(result, dict) for key in ("M11", "M22", "M33", "M_full"): @@ -178,7 +180,9 @@ def test_finite_with_motion( class TestKineticEnergy: - def test_zero_at_rest(self, params: TriplePendulumParams, rest_state: np.ndarray) -> None: + def test_zero_at_rest( + self, params: TriplePendulumParams, rest_state: np.ndarray + ) -> None: T = kinetic_energy(rest_state, params) assert T == pytest.approx(0.0, abs=1e-12) @@ -187,7 +191,9 @@ def test_positive_with_velocity(self, params: TriplePendulumParams) -> None: T = kinetic_energy(state, params) assert T > 0 - def test_scales_quadratically_with_velocity(self, params: TriplePendulumParams) -> None: + def test_scales_quadratically_with_velocity( + self, params: TriplePendulumParams + ) -> None: """Doubling velocity should roughly quadruple KE.""" state_slow = np.array([0.0, 0.0, 0.0, 0.5, 0.0, 0.0]) state_fast = np.array([0.0, 0.0, 0.0, 1.0, 0.0, 0.0]) @@ -195,7 +201,9 @@ def test_scales_quadratically_with_velocity(self, params: TriplePendulumParams) T_fast = kinetic_energy(state_fast, params) assert T_fast == pytest.approx(4 * T_slow, rel=1e-6) - def test_finite(self, params: TriplePendulumParams, moving_state: np.ndarray) -> None: + def test_finite( + self, params: TriplePendulumParams, moving_state: np.ndarray + ) -> None: assert np.isfinite(kinetic_energy(moving_state, params)) @@ -249,7 +257,9 @@ def test_equals_T_plus_V_with_motion( V = potential_energy(moving_state, params) assert E == pytest.approx(T + V, rel=1e-8) - def test_finite(self, params: TriplePendulumParams, moving_state: np.ndarray) -> None: + def test_finite( + self, params: TriplePendulumParams, moving_state: np.ndarray + ) -> None: assert np.isfinite(total_energy(moving_state, params)) def test_more_than_potential_alone( diff --git a/src/pendulum_simulator/tests/test_physics_triple_gaps.py b/src/pendulum_simulator/tests/test_physics_triple_gaps.py index 54b157734c..76f7bcaf2b 100644 --- a/src/pendulum_simulator/tests/test_physics_triple_gaps.py +++ b/src/pendulum_simulator/tests/test_physics_triple_gaps.py @@ -43,11 +43,15 @@ def test_with_torque_limits_clamps( def huge_torque(t): return (1e6, 1e6, 1e6) - state_dot = equations_of_motion(state, 0.0, params, huge_torque, torque_limits=limits) + state_dot = equations_of_motion( + state, 0.0, params, huge_torque, torque_limits=limits + ) assert state_dot.shape == (6,) assert np.all(np.isfinite(state_dot)) - def test_with_large_limits_passes_through(self, params: TriplePendulumParams) -> None: + def test_with_large_limits_passes_through( + self, params: TriplePendulumParams + ) -> None: """With infinite limits, torques pass through unchanged.""" state = np.array([0.1, 0.05, -0.05, 0.0, 0.0, 0.0]) limits = np.array([np.inf, np.inf, np.inf]) @@ -55,7 +59,9 @@ def test_with_large_limits_passes_through(self, params: TriplePendulumParams) -> def tau_fn(t): return (5.0, -3.0, 2.0) - state_dot = equations_of_motion(state, 0.0, params, tau_fn, torque_limits=limits) + state_dot = equations_of_motion( + state, 0.0, params, tau_fn, torque_limits=limits + ) assert state_dot.shape == (6,) assert np.all(np.isfinite(state_dot)) @@ -64,7 +70,9 @@ def test_no_torque_limits_same_as_none( ) -> None: """Without limits, result should match None path.""" state = np.array([0.1, 0.05, -0.05, 0.0, 0.0, 0.0]) - sd_no_limits = equations_of_motion(state, 0.0, params, zero_torque, torque_limits=None) + sd_no_limits = equations_of_motion( + state, 0.0, params, zero_torque, torque_limits=None + ) assert np.all(np.isfinite(sd_no_limits)) diff --git a/src/pendulum_simulator/tests/test_side_panel_tabs.py b/src/pendulum_simulator/tests/test_side_panel_tabs.py index cdcaee6c6b..b5e8cb304a 100644 --- a/src/pendulum_simulator/tests/test_side_panel_tabs.py +++ b/src/pendulum_simulator/tests/test_side_panel_tabs.py @@ -98,9 +98,9 @@ def test_each_panel_is_wrapped_in_scroll_area(qapp) -> Any: tabs.add_panel("Plots", QLabel("b")) for i in range(tabs.count()): wrapper = tabs.widget(i) - assert isinstance(wrapper, QScrollArea), ( - f"Tab {i} is {type(wrapper).__name__}, expected QScrollArea" - ) + assert isinstance( + wrapper, QScrollArea + ), f"Tab {i} is {type(wrapper).__name__}, expected QScrollArea" def test_added_widget_reachable_through_panel_widget(qapp) -> Any: @@ -184,7 +184,9 @@ def test_restore_state_with_no_saved_value_is_noop(qapp) -> Any: def test_restore_state_with_obsolete_label_falls_back(qapp) -> Any: """Saved label that no longer exists keeps the default tab.""" - QSettings("D-sorganization", "PendulumSimulator").setValue(_TEST_KEY, "ObsoleteLabel") + QSettings("D-sorganization", "PendulumSimulator").setValue( + _TEST_KEY, "ObsoleteLabel" + ) tabs = SidePanelTabs(settings_key=_TEST_KEY) tabs.add_panel("Setup", QLabel("a")) tabs.add_panel("Plots", QLabel("b")) diff --git a/src/pendulum_simulator/tests/test_simulation.py b/src/pendulum_simulator/tests/test_simulation.py index 97beaf2c31..5e10f3aa97 100644 --- a/src/pendulum_simulator/tests/test_simulation.py +++ b/src/pendulum_simulator/tests/test_simulation.py @@ -134,7 +134,9 @@ def test_native_backend_integration(self, default_params: PendulumParams) -> Non assert len(result.t) == 10 assert np.isclose(result.t[1] - result.t[0], 0.1) - def test_native_backend_too_few_points(self, default_params: PendulumParams) -> None: + def test_native_backend_too_few_points( + self, default_params: PendulumParams + ) -> None: import unittest.mock as mock with ( @@ -209,13 +211,16 @@ def test_energy_conserved_free_pendulum( ) E0 = total_energy(result.states[0], equal_params) energies = np.array( - [total_energy(result.states[i], equal_params) for i in range(result.n_steps)] + [ + total_energy(result.states[i], equal_params) + for i in range(result.n_steps) + ] ) max_drift = np.max(np.abs(energies - E0)) relative_drift = max_drift / abs(E0) if abs(E0) > 1e-10 else max_drift - assert relative_drift < 1e-3, ( - f"Energy drift {relative_drift:.2e} exceeds 0.1% threshold" - ) + assert ( + relative_drift < 1e-3 + ), f"Energy drift {relative_drift:.2e} exceeds 0.1% threshold" class TestSimulationAccessors: diff --git a/src/pendulum_simulator/tests/test_simulation_gaps.py b/src/pendulum_simulator/tests/test_simulation_gaps.py index 7c3ccdd151..3a515ead95 100644 --- a/src/pendulum_simulator/tests/test_simulation_gaps.py +++ b/src/pendulum_simulator/tests/test_simulation_gaps.py @@ -124,7 +124,9 @@ def golfer_params() -> GolferParams: class TestGolferSimulationWithJointLimits: - def test_limits_code_path_via_direct_call(self, golfer_params: GolferParams) -> None: + def test_limits_code_path_via_direct_call( + self, golfer_params: GolferParams + ) -> None: """Directly test the limits branch in the ode_rhs closure. Instead of running the full simulation (which can hit singular matrices @@ -186,7 +188,9 @@ def test_simulation_runs_below_abort_threshold( ) -> None: """Normal simulation should not trigger constraint abort logging.""" initial_state = np.zeros(2 * N_DOF) - with caplog.at_level(logging.WARNING, logger="double_pendulum_golf.simulation_golfer"): + with caplog.at_level( + logging.WARNING, logger="double_pendulum_golf.simulation_golfer" + ): result = run_golfer_sim( golfer_params, initial_state, diff --git a/src/pendulum_simulator/tests/test_simulation_golfer.py b/src/pendulum_simulator/tests/test_simulation_golfer.py index 80bb734b21..17d6cf47c3 100644 --- a/src/pendulum_simulator/tests/test_simulation_golfer.py +++ b/src/pendulum_simulator/tests/test_simulation_golfer.py @@ -100,7 +100,9 @@ def test_constant_torque(self) -> None: assert result == (1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0) def test_linear_torque(self) -> None: - tf = make_polynomial_torque([0.0, 1.0], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0]) + tf = make_polynomial_torque( + [0.0, 1.0], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0] + ) result = tf(2.0) assert abs(result[0] - 2.0) < 1e-10 @@ -121,7 +123,9 @@ def test_states_shape(self, sim_result: GolferSimulationResult) -> None: assert sim_result.states.shape[1] == 2 * N_DOF def test_time_monotonic(self, sim_result: GolferSimulationResult) -> None: - assert np.all(np.diff(sim_result.t) > 0), "Time must be monotonically increasing" + assert np.all( + np.diff(sim_result.t) > 0 + ), "Time must be monotonically increasing" def test_constraint_bounded(self, sim_result: GolferSimulationResult) -> None: for i in range(sim_result.n_steps): @@ -154,16 +158,20 @@ def test_run_with_joint_limits(self) -> None: class TestConstraintViolationPostcondition: """Constraint monitoring postcondition: drift must stay within abort threshold.""" - def test_violation_below_abort_threshold(self, sim_result: GolferSimulationResult) -> None: + def test_violation_below_abort_threshold( + self, sim_result: GolferSimulationResult + ) -> None: """All trajectory steps must have constraint violation below abort threshold.""" abort_tol = 1e-2 for i in range(sim_result.n_steps): v = constraint_violation(sim_result.states[i], _GOLFER_PARAMS) - assert v < abort_tol, ( - f"Constraint violation {v:.3e} at step {i} exceeds abort threshold {abort_tol:.3e}" - ) + assert ( + v < abort_tol + ), f"Constraint violation {v:.3e} at step {i} exceeds abort threshold {abort_tol:.3e}" - def test_violation_finite_at_all_steps(self, sim_result: GolferSimulationResult) -> None: + def test_violation_finite_at_all_steps( + self, sim_result: GolferSimulationResult + ) -> None: """Constraint violation must be finite at every trajectory step.""" for i in range(sim_result.n_steps): v = constraint_violation(sim_result.states[i], _GOLFER_PARAMS) @@ -220,7 +228,9 @@ def test_mass_matrix_at(self, sim_result: GolferSimulationResult) -> None: M = sim_result.mass_matrix_at(0) assert M.shape == (N_DOF, N_DOF) - def test_all_positions_and_energies(self, sim_result: GolferSimulationResult) -> None: + def test_all_positions_and_energies( + self, sim_result: GolferSimulationResult + ) -> None: positions = sim_result.all_positions() energies = sim_result.all_energies() assert len(positions) == sim_result.n_steps diff --git a/src/pendulum_simulator/tests/test_simulation_golfer_drift.py b/src/pendulum_simulator/tests/test_simulation_golfer_drift.py index 9a10f2f8ba..100266389e 100644 --- a/src/pendulum_simulator/tests/test_simulation_golfer_drift.py +++ b/src/pendulum_simulator/tests/test_simulation_golfer_drift.py @@ -55,7 +55,9 @@ def test_warn_during_integration_line266( initial_state = np.concatenate([q0, np.zeros(N_DOF)]) # Always return a value above warn threshold → exercises line 266 - with caplog.at_level(logging.WARNING, logger="double_pendulum_golf.simulation_golfer"): + with caplog.at_level( + logging.WARNING, logger="double_pendulum_golf.simulation_golfer" + ): with patch( "double_pendulum_golf.simulation_golfer.constraint_violation", return_value=1e-3, # > _CONSTRAINT_WARN_TOL (1e-4) @@ -82,7 +84,9 @@ def test_abort_threshold_log_line296( initial_state = np.concatenate([q0, np.zeros(N_DOF)]) # Always return a value above abort threshold - with caplog.at_level(logging.ERROR, logger="double_pendulum_golf.simulation_golfer"): + with caplog.at_level( + logging.ERROR, logger="double_pendulum_golf.simulation_golfer" + ): with patch( "double_pendulum_golf.simulation_golfer.constraint_violation", return_value=0.5, # >> _CONSTRAINT_ABORT_TOL (1e-2) @@ -108,7 +112,9 @@ def test_warn_threshold_postcondition_line302( initial_state = np.concatenate([q0, np.zeros(N_DOF)]) # Always return a value between warn and abort thresholds - with caplog.at_level(logging.WARNING, logger="double_pendulum_golf.simulation_golfer"): + with caplog.at_level( + logging.WARNING, logger="double_pendulum_golf.simulation_golfer" + ): with patch( "double_pendulum_golf.simulation_golfer.constraint_violation", return_value=5e-3, # > WARN (1e-4), < ABORT (1e-2) diff --git a/src/pendulum_simulator/tests/test_simulation_golfer_extended.py b/src/pendulum_simulator/tests/test_simulation_golfer_extended.py index 16bef8e3d8..1098ea3f52 100644 --- a/src/pendulum_simulator/tests/test_simulation_golfer_extended.py +++ b/src/pendulum_simulator/tests/test_simulation_golfer_extended.py @@ -143,7 +143,9 @@ def test_constraint_forces_at_finite(self, result: GolferSimulationResult) -> No cf = result.constraint_forces_at(0) assert np.all(np.isfinite(cf)) - def test_constraint_violation_at_finite(self, result: GolferSimulationResult) -> None: + def test_constraint_violation_at_finite( + self, result: GolferSimulationResult + ) -> None: cv = result.constraint_violation_at(0) assert np.isfinite(cv) @@ -184,7 +186,9 @@ def test_friction_torques_at_shape(self, result: GolferSimulationResult) -> None tf = result.friction_torques_at(0) assert tf.shape == (N_DOF,) - def test_friction_torques_zero_at_rest(self, result: GolferSimulationResult) -> None: + def test_friction_torques_zero_at_rest( + self, result: GolferSimulationResult + ) -> None: """At zero velocity, friction should be zero.""" tf = result.friction_torques_at(0) np.testing.assert_allclose(tf, 0.0, atol=1e-14) diff --git a/src/pendulum_simulator/tests/test_simulation_panel.py b/src/pendulum_simulator/tests/test_simulation_panel.py index d94865ce5b..dd28724c74 100644 --- a/src/pendulum_simulator/tests/test_simulation_panel.py +++ b/src/pendulum_simulator/tests/test_simulation_panel.py @@ -264,7 +264,9 @@ def test_export_data(qapp, mock_sim_kwargs, tmp_path) -> Any: panel = SimulationPanel(**mock_sim_kwargs) # show message if no result - with patch("double_pendulum_golf.gui.simulation_panel.QMessageBox.information") as info: + with patch( + "double_pendulum_golf.gui.simulation_panel.QMessageBox.information" + ) as info: panel._on_export_data() info.assert_called_once() @@ -358,7 +360,9 @@ def test_apply_optimized_coefficients(qapp, mock_sim_kwargs) -> Any: panel_triple.controls.inp_tau_shoulder = MagicMock() panel_triple.controls.inp_tau_elbow = MagicMock() panel_triple.controls.inp_tau_wrist = MagicMock() - panel_triple._apply_optimized_coefficients({"coeffs": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]}) + panel_triple._apply_optimized_coefficients( + {"coeffs": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]} + ) # Test golfer mock_sim_kwargs["controls"] = MockControlsGolfer() @@ -374,7 +378,9 @@ def test_patched_on_run_optimizer(qapp, mock_sim_kwargs) -> Any: panel = SimulationPanel(**mock_sim_kwargs) panel.optimizer.bind_objective_builder.assert_called_once() - params_getter, objective_builder = panel.optimizer.bind_objective_builder.call_args[0] + params_getter, objective_builder = panel.optimizer.bind_objective_builder.call_args[ + 0 + ] assert params_getter is panel.controls.get_params assert objective_builder is panel.objective_builder @@ -426,7 +432,9 @@ def test_plots_tab_present_when_torque_history_supplied(qapp, mock_sim_kwargs) - panel = SimulationPanel(**mock_sim_kwargs) labels = panel._side_tabs.panel_labels() assert SimulationPanel.TAB_PLOTS in labels - assert panel._side_tabs.panel_widget(SimulationPanel.TAB_PLOTS) is panel.torque_history + assert ( + panel._side_tabs.panel_widget(SimulationPanel.TAB_PLOTS) is panel.torque_history + ) def test_plots_tab_absent_when_torque_history_omitted(qapp, mock_sim_kwargs) -> Any: diff --git a/src/pendulum_simulator/tests/test_simulation_triple.py b/src/pendulum_simulator/tests/test_simulation_triple.py index 8280e633ac..a2ea8c4df1 100644 --- a/src/pendulum_simulator/tests/test_simulation_triple.py +++ b/src/pendulum_simulator/tests/test_simulation_triple.py @@ -142,7 +142,9 @@ def test_energy_conserved_free_pendulum( and that energy drift stays below 2% for a 1-second free-pendulum run. The 2% bound is appropriate for DOP853 on a chaotic triple pendulum. """ - state0 = np.array([np.radians(45), np.radians(30), np.radians(-15), 0.0, 0.0, 0.0]) + state0 = np.array( + [np.radians(45), np.radians(30), np.radians(-15), 0.0, 0.0, 0.0] + ) result = run_simulation( triple_params, state0, diff --git a/src/pendulum_simulator/tests/test_simulation_triple_extended.py b/src/pendulum_simulator/tests/test_simulation_triple_extended.py index b592b9d3ba..fb6e41c056 100644 --- a/src/pendulum_simulator/tests/test_simulation_triple_extended.py +++ b/src/pendulum_simulator/tests/test_simulation_triple_extended.py @@ -46,7 +46,9 @@ def result( ) -> TripleSimulationResult: """Run a short simulation and cache the result for all tests in the module.""" initial_state = np.array([0.1, 0.05, -0.05, 0.0, 0.0, 0.0]) - return run_simulation(params, initial_state, t_end=0.1, torque_func=torque_func, dt=0.01) + return run_simulation( + params, initial_state, t_end=0.1, torque_func=torque_func, dt=0.01 + ) class TestRunSimulation: diff --git a/src/pendulum_simulator/tests/test_swing_comparison_dialog.py b/src/pendulum_simulator/tests/test_swing_comparison_dialog.py index 39b77ec6ed..74abe147b5 100644 --- a/src/pendulum_simulator/tests/test_swing_comparison_dialog.py +++ b/src/pendulum_simulator/tests/test_swing_comparison_dialog.py @@ -270,7 +270,9 @@ def test_run_flow(self, dialog): } dialog._on_preset_done("Preset A", summary) - with patch("double_pendulum_golf.gui.swing_comparison_dialog._HAS_MPL", False): + with patch( + "double_pendulum_golf.gui.swing_comparison_dialog._HAS_MPL", False + ): dialog._on_all_done([("Preset A", summary)]) from double_pendulum_golf.gui.swing_comparison_dialog import _HAS_MPL @@ -300,7 +302,9 @@ def test_export(self, dialog, tmp_path): dialog._results = [("Preset A", summary)] # no path - with patch("PyQt6.QtWidgets.QFileDialog.getSaveFileName", return_value=("", "")): + with patch( + "PyQt6.QtWidgets.QFileDialog.getSaveFileName", return_value=("", "") + ): dialog._on_export() csv_file = tmp_path / "test.csv" diff --git a/src/pendulum_simulator/tests/test_toolstrip_elements.py b/src/pendulum_simulator/tests/test_toolstrip_elements.py index 896edcad1f..5d5971351b 100644 --- a/src/pendulum_simulator/tests/test_toolstrip_elements.py +++ b/src/pendulum_simulator/tests/test_toolstrip_elements.py @@ -65,19 +65,19 @@ class TestPlaybackSlider: def test_frame_slider_exists(self, toolstrip: ToolStrip) -> None: """ToolStrip must have a _frame_slider attribute that is a QSlider.""" - assert hasattr(toolstrip, "_frame_slider"), ( - "ToolStrip is missing _frame_slider attribute" - ) - assert isinstance(toolstrip._frame_slider, QSlider), ( - f"_frame_slider is {type(toolstrip._frame_slider)}, expected QSlider" - ) + assert hasattr( + toolstrip, "_frame_slider" + ), "ToolStrip is missing _frame_slider attribute" + assert isinstance( + toolstrip._frame_slider, QSlider + ), f"_frame_slider is {type(toolstrip._frame_slider)}, expected QSlider" def test_frame_slider_is_child(self, toolstrip: ToolStrip) -> None: """Frame slider must be a descendant widget of the ToolStrip.""" all_sliders = toolstrip.findChildren(QSlider) - assert toolstrip._frame_slider in all_sliders, ( - "Frame slider is not a child widget of ToolStrip" - ) + assert ( + toolstrip._frame_slider in all_sliders + ), "Frame slider is not a child widget of ToolStrip" def test_frame_slider_has_minimum_width(self, toolstrip: ToolStrip) -> None: """Frame slider must have a minimum width >= 200px for visibility.""" @@ -131,7 +131,9 @@ def test_moment_of_force_checkbox_exists(self, toolstrip: ToolStrip) -> None: def test_sum_moments_checkbox_exists(self, toolstrip: ToolStrip) -> None: """ToolStrip must have a chk_sum_moments checkbox.""" - assert hasattr(toolstrip, "chk_sum_moments"), "ToolStrip missing chk_sum_moments" + assert hasattr( + toolstrip, "chk_sum_moments" + ), "ToolStrip missing chk_sum_moments" assert isinstance(toolstrip.chk_sum_moments, QCheckBox) def test_torque_signal_connected(self, toolstrip: ToolStrip) -> None: @@ -166,15 +168,15 @@ class TestNoGravityCheckbox: def test_no_gravity_checkbox_in_toolstrip(self, toolstrip: ToolStrip) -> None: """ToolStrip must NOT have a chk_gravity attribute.""" - assert not hasattr(toolstrip, "chk_gravity"), ( - "chk_gravity still exists in ToolStrip — it must be removed (#1209)" - ) + assert not hasattr( + toolstrip, "chk_gravity" + ), "chk_gravity still exists in ToolStrip — it must be removed (#1209)" def test_no_gravity_toggled_signal(self, toolstrip: ToolStrip) -> None: """ToolStrip must NOT have gravity_toggled signal.""" - assert not hasattr(toolstrip, "gravity_toggled"), ( - "gravity_toggled signal still exists — must be removed (#1209)" - ) + assert not hasattr( + toolstrip, "gravity_toggled" + ), "gravity_toggled signal still exists — must be removed (#1209)" # --------------------------------------------------------------------------- diff --git a/src/pendulum_simulator/tests/test_torque_utils.py b/src/pendulum_simulator/tests/test_torque_utils.py index 2d9aa7a3c0..bd4fd4dcfb 100644 --- a/src/pendulum_simulator/tests/test_torque_utils.py +++ b/src/pendulum_simulator/tests/test_torque_utils.py @@ -57,7 +57,9 @@ def test_zero_joints_raises(self): def test_empty_coefficients_raises(self): """Each joint needs at least one coefficient.""" - with pytest.raises((ValueError, TypeError), match="Need at least one coefficient"): + with pytest.raises( + (ValueError, TypeError), match="Need at least one coefficient" + ): make_polynomial_torque([]) def test_returns_tuple(self): diff --git a/src/pendulum_simulator/tests/test_ui_enhancements.py b/src/pendulum_simulator/tests/test_ui_enhancements.py index 4bfd4aea13..2ade43aeaa 100644 --- a/src/pendulum_simulator/tests/test_ui_enhancements.py +++ b/src/pendulum_simulator/tests/test_ui_enhancements.py @@ -131,7 +131,9 @@ def test_hub_rotates_correctly(self, golfer_params: GolferParams) -> None: assert pos["hub"][0] < 0, "Hub should be on left side at π/2" assert abs(pos["hub"][1]) < 1e-10 - def test_analytical_jacobians_match_numerical(self, golfer_params: GolferParams) -> None: + def test_analytical_jacobians_match_numerical( + self, golfer_params: GolferParams + ) -> None: """Analytical Jacobians must match numerical finite-diff after hub reversal.""" rng = np.random.default_rng(42) eps = 1e-7 @@ -149,9 +151,9 @@ def test_analytical_jacobians_match_numerical(self, golfer_params: GolferParams) J_hub_num[0, j] = (fkp["hub"][0] - fk0["hub"][0]) / eps J_hub_num[1, j] = (fkp["hub"][1] - fk0["hub"][1]) / eps - assert np.allclose(jacs["hub"], J_hub_num, atol=1e-4), ( - f"Hub Jacobian mismatch:\nAnalytical:\n{jacs['hub']}\nNumerical:\n{J_hub_num}" - ) + assert np.allclose( + jacs["hub"], J_hub_num, atol=1e-4 + ), f"Hub Jacobian mismatch:\nAnalytical:\n{jacs['hub']}\nNumerical:\n{J_hub_num}" def test_all_analytical_jacobians_match_numerical( self, golfer_params: GolferParams @@ -253,9 +255,9 @@ def test_scapula_position_is_at_bar_endpoint( # Scapula position should be at the original shoulder bar endpoint rscap = np.array(pos_scap["rscap"]) rs_orig = np.array(pos_no["rs"]) - assert np.allclose(rscap, rs_orig, atol=1e-10), ( - "Scapula joint should be at original shoulder bar endpoint" - ) + assert np.allclose( + rscap, rs_orig, atol=1e-10 + ), "Scapula joint should be at original shoulder bar endpoint" def test_mass_matrix_still_valid_with_scapula( self, @@ -310,9 +312,9 @@ def test_tilt_reduces_potential_energy(self, golfer_params: GolferParams) -> Non V_tilted = potential_energy_from_q(q, params_tilted) # PE should be smaller with reduced gravity - assert abs(V_tilted) < abs(V_full), ( - f"Tilted PE ({V_tilted}) should be smaller than full ({V_full})" - ) + assert abs(V_tilted) < abs( + V_full + ), f"Tilted PE ({V_tilted}) should be smaller than full ({V_full})" # --------------------------------------------------------------------------- diff --git a/src/pendulum_simulator/tests/test_ui_polish_fixes.py b/src/pendulum_simulator/tests/test_ui_polish_fixes.py index dd3bfe4427..eada9fddd1 100644 --- a/src/pendulum_simulator/tests/test_ui_polish_fixes.py +++ b/src/pendulum_simulator/tests/test_ui_polish_fixes.py @@ -125,9 +125,9 @@ def test_each_label_has_a_visible_symbol_prefix(self) -> None: assert stripped, f"Empty label: {label!r}" first = stripped[0] # First non-space char must be non-ASCII (a symbol/icon) - assert not first.isascii(), ( - f"Label {label!r} should start with a symbol prefix, not {first!r}" - ) + assert ( + not first.isascii() + ), f"Label {label!r} should start with a symbol prefix, not {first!r}" # ────────────────────────────────────────────────────────────────────── diff --git a/src/pendulum_simulator/tests/test_unit_converter.py b/src/pendulum_simulator/tests/test_unit_converter.py index 40440f99d9..28b80001a3 100644 --- a/src/pendulum_simulator/tests/test_unit_converter.py +++ b/src/pendulum_simulator/tests/test_unit_converter.py @@ -82,7 +82,9 @@ def test_imperial_foot_pound_units_use_shared_constants() -> None: prefs = UnitPreferences() prefs.set_unit(UnitCategory.TORQUE, "lbf·ft") - assert to_si(1.0, UnitCategory.TORQUE, prefs) == pytest.approx(FOOT_POUND_TO_NEWTON_METER) + assert to_si(1.0, UnitCategory.TORQUE, prefs) == pytest.approx( + FOOT_POUND_TO_NEWTON_METER + ) assert from_si( to_si(1.0, UnitCategory.TORQUE, prefs), UnitCategory.TORQUE, prefs ) == pytest.approx(1.0, rel=1e-12) diff --git a/src/pendulum_simulator/tests/test_v2_comprehensive.py b/src/pendulum_simulator/tests/test_v2_comprehensive.py index 72e73020ec..a1bc8dc4fe 100644 --- a/src/pendulum_simulator/tests/test_v2_comprehensive.py +++ b/src/pendulum_simulator/tests/test_v2_comprehensive.py @@ -268,9 +268,9 @@ def zero_torque(t: float) -> tuple[float, float, float]: E0 = total_energy(state0, params) E_final = total_energy(result.states[-1], params) # Energy should be conserved within integration tolerance - assert abs(E_final - E0) / max(abs(E0), 1e-10) < 0.01, ( - f"Energy drift: E0={E0:.4f}, E_final={E_final:.4f}" - ) + assert ( + abs(E_final - E0) / max(abs(E0), 1e-10) < 0.01 + ), f"Energy drift: E0={E0:.4f}, E_final={E_final:.4f}" class TestUnitConversionModule: @@ -492,4 +492,6 @@ def test_no_print_statements_in_physics_triple(self) -> None: source = inspect.getsource(phys_t) matches = re.findall(r"^\s*print\s*\(", source, re.MULTILINE) - assert len(matches) == 0, f"Found {len(matches)} print() calls in physics_triple.py" + assert ( + len(matches) == 0 + ), f"Found {len(matches)} print() calls in physics_triple.py" diff --git a/src/python/src/utils/error_handling.py b/src/python/src/utils/error_handling.py index 4dac92801c..142246417b 100644 --- a/src/python/src/utils/error_handling.py +++ b/src/python/src/utils/error_handling.py @@ -92,7 +92,9 @@ def safe_execute( """ try: return func(*args, **kwargs) - except Exception as e: # noqa: BLE001 — intentional catch-all; safe_execute must not propagate + except ( + Exception + ) as e: # noqa: BLE001 — intentional catch-all; safe_execute must not propagate if log_error: logger.error(f"Error executing {func.__name__}: {e}") return default diff --git a/src/python/tests/test_python_dbc_lod.py b/src/python/tests/test_python_dbc_lod.py index 292daecc2c..0cdc88dd5a 100644 --- a/src/python/tests/test_python_dbc_lod.py +++ b/src/python/tests/test_python_dbc_lod.py @@ -119,7 +119,9 @@ def _import_help_handlers() -> Any: module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module - except Exception as e: # noqa: BLE001 — test isolation: any import failure skips suite + except ( + Exception + ) as e: # noqa: BLE001 — test isolation: any import failure skips suite pytest.skip(f"help_system not importable: {e}") diff --git a/src/rotation_converter/ui/pyqt6/main_window.py b/src/rotation_converter/ui/pyqt6/main_window.py index f9d12248de..d372f18579 100644 --- a/src/rotation_converter/ui/pyqt6/main_window.py +++ b/src/rotation_converter/ui/pyqt6/main_window.py @@ -156,7 +156,9 @@ def _get_plot_colors() -> dict[str, Any]: "surface": colors.get("group_bg", _DARK_SURFACE), "axes": CHART_COLORS[:3] if CHART_COLORS else _AXIS_COLORS, } - except Exception: # noqa: BLE001 — theme import is optional; fall back to defaults + except ( + Exception + ): # noqa: BLE001 — theme import is optional; fall back to defaults pass return { "bg": _DARK_BG, @@ -338,7 +340,9 @@ def _update_outputs(self) -> None: rot = Rotation.from_rotation_matrix(R) else: return - except Exception as e: # noqa: BLE001 — user input can raise any error; display it + except ( + Exception + ) as e: # noqa: BLE001 — user input can raise any error; display it self._output_text.setPlainText(f"Error: {e}") return @@ -368,7 +372,9 @@ def _update_main_result(self, rot: Rotation) -> None: else: res = "" self._main_result.setText(res) - except Exception as e: # noqa: BLE001 — rotation conversion may raise any arithmetic error + except ( + Exception + ) as e: # noqa: BLE001 — rotation conversion may raise any arithmetic error self._main_result.setText(f"Error: {e}") def _display_all(self, rot: Rotation, conv: str) -> None: @@ -390,7 +396,9 @@ def _display_all(self, rot: Rotation, conv: str) -> None: e = rot.as_euler(c) marker = " ◀" if c == conv else "" lines.append(f" {c}: {e[0]: .6f} {e[1]: .6f} {e[2]: .6f}{marker}") - except Exception: # noqa: BLE001 — Euler conversion may fail for degenerate rotations + except ( + Exception + ): # noqa: BLE001 — Euler conversion may fail for degenerate rotations lines.append(f" {c}: (error)") lines += [ "", @@ -573,7 +581,9 @@ def _update(self) -> None: T = RigidTransform.from_matrix(v.reshape(4, 4), source=src, target=tgt) else: return - except Exception as e: # noqa: BLE001 — user input can raise any error; display it + except ( + Exception + ) as e: # noqa: BLE001 — user input can raise any error; display it self._tf_output.setPlainText(f"Error: {e}") return @@ -631,7 +641,9 @@ def _display_transform(self, T: RigidTransform) -> None: f" pitch: {screw['pitch']:.6f}", f" theta: {screw['theta']:.6f} rad", ] - except Exception: # noqa: BLE001 — screw decomposition is optional display; skip on error + except ( + Exception + ): # noqa: BLE001 — screw decomposition is optional display; skip on error pass self._tf_output.setPlainText("\n".join(lines)) diff --git a/src/rotation_converter/ui/pyqt6/reference_frame_tab.py b/src/rotation_converter/ui/pyqt6/reference_frame_tab.py index 560979e028..31b46f3535 100644 --- a/src/rotation_converter/ui/pyqt6/reference_frame_tab.py +++ b/src/rotation_converter/ui/pyqt6/reference_frame_tab.py @@ -145,7 +145,9 @@ def _compute(self) -> None: self._results.setPlainText(json.dumps(result.results, indent=2)) self._markdown.setPlainText(result.explanation_markdown) self._latex.setPlainText(result.explanation_latex) - except Exception as error: # noqa: BLE001 — user input can raise any error; display it + except ( + Exception + ) as error: # noqa: BLE001 — user input can raise any error; display it self._results.setPlainText(f"Error: {error}") self._markdown.clear() self._latex.clear() diff --git a/src/rrt_path_planner/python/src/star_wars_rrt.py b/src/rrt_path_planner/python/src/star_wars_rrt.py index 611793c819..f0537eb8e4 100644 --- a/src/rrt_path_planner/python/src/star_wars_rrt.py +++ b/src/rrt_path_planner/python/src/star_wars_rrt.py @@ -785,7 +785,9 @@ def _load_ship_models(self) -> dict[str, Any]: try: models["falcon"] = trimesh.load(model_path) logging.info("Loaded ship model from %s", model_path) - except Exception as exc: # noqa: BLE001 # pragma: no cover - visualization-only fallback + except ( + Exception + ) as exc: # noqa: BLE001 # pragma: no cover - visualization-only fallback logging.warning("Could not load STL model %s: %s", model_path, exc) return models diff --git a/src/shared/python/chat/_chat_dock_widget_qt.py b/src/shared/python/chat/_chat_dock_widget_qt.py index 3794a1cf81..8f57dcd8b5 100644 --- a/src/shared/python/chat/_chat_dock_widget_qt.py +++ b/src/shared/python/chat/_chat_dock_widget_qt.py @@ -1091,12 +1091,12 @@ def switch_provider( history_before = self._message_history snapshot_before = list(history_before) self._ai_settings_controller().switch_provider(name, model, thinking_level) - assert self._message_history is history_before, ( - "switch_provider invariant: _message_history must remain the same list" - ) - assert self._message_history == snapshot_before, ( - "switch_provider invariant: _message_history contents must not change" - ) + assert ( + self._message_history is history_before + ), "switch_provider invariant: _message_history must remain the same list" + assert ( + self._message_history == snapshot_before + ), "switch_provider invariant: _message_history contents must not change" # ── Terminal mode ─────────────────────────────────────────────── diff --git a/src/shared/python/chat/_qt/ai_dropdowns.py b/src/shared/python/chat/_qt/ai_dropdowns.py index 28b57c60f1..d6daf2c54c 100644 --- a/src/shared/python/chat/_qt/ai_dropdowns.py +++ b/src/shared/python/chat/_qt/ai_dropdowns.py @@ -255,9 +255,9 @@ def switch_provider( history_before = dock._message_history snapshot_before = list(history_before) _controller_for(dock).switch_provider(name, model, thinking_level) - assert dock._message_history is history_before, ( - "switch_provider invariant: _message_history must remain the same list" - ) - assert dock._message_history == snapshot_before, ( - "switch_provider invariant: _message_history contents must not change" - ) + assert ( + dock._message_history is history_before + ), "switch_provider invariant: _message_history must remain the same list" + assert ( + dock._message_history == snapshot_before + ), "switch_provider invariant: _message_history contents must not change" diff --git a/src/shared/python/chat/_qt/styling.py b/src/shared/python/chat/_qt/styling.py index 5635556aa6..9dc0a39de7 100644 --- a/src/shared/python/chat/_qt/styling.py +++ b/src/shared/python/chat/_qt/styling.py @@ -23,6 +23,8 @@ def get_theme_colors( try: colors: dict[str, str] = provider.get_current_colors() return colors - except Exception: # noqa: BLE001 - defensive: a misbehaving provider must not crash the widget + except ( + Exception + ): # noqa: BLE001 - defensive: a misbehaving provider must not crash the widget colors = _DefaultDarkTheme().get_current_colors() return colors diff --git a/src/shared/python/chat/condensation/condenser.py b/src/shared/python/chat/condensation/condenser.py index e02b4d553c..25b6e9135b 100644 --- a/src/shared/python/chat/condensation/condenser.py +++ b/src/shared/python/chat/condensation/condenser.py @@ -74,9 +74,9 @@ def condense( preserved_anchors=_count_anchors(condensed), ) - assert result.condensed_message_count >= 1, ( - "Condenser postcondition violated: must preserve at least one message" - ) + assert ( + result.condensed_message_count >= 1 + ), "Condenser postcondition violated: must preserve at least one message" return result def condense_to_session( diff --git a/src/shared/python/humanoid_character_builder/core/model.py b/src/shared/python/humanoid_character_builder/core/model.py index f4f8eeca21..df4925a0ac 100644 --- a/src/shared/python/humanoid_character_builder/core/model.py +++ b/src/shared/python/humanoid_character_builder/core/model.py @@ -97,7 +97,9 @@ def distance_to_edge(self, point: tuple[float, float]) -> float: if point is None: raise ValueError("point must be provided") if not self.contains(point): - return -1.0 # Or positive distance to polygon? Convention usually margin > 0 is stable. + return ( + -1.0 + ) # Or positive distance to polygon? Convention usually margin > 0 is stable. # If outside, negative margin. px, py = point diff --git a/src/shared/python/model_generation/library/model_library.py b/src/shared/python/model_generation/library/model_library.py index 6c82c309dc..45dcbd4a83 100644 --- a/src/shared/python/model_generation/library/model_library.py +++ b/src/shared/python/model_generation/library/model_library.py @@ -678,7 +678,9 @@ def _fetch_github_models( ) continue - with urllib.request.urlopen(subdir_url) as sub_response: # nosec B310 + with urllib.request.urlopen( + subdir_url + ) as sub_response: # nosec B310 sub_contents = json.loads(sub_response.read().decode()) for sub_item in sub_contents: if sub_item["type"] != "file": diff --git a/src/shared/python/model_generation/tests/test_unified_loader.py b/src/shared/python/model_generation/tests/test_unified_loader.py index 18e96a64d6..9245b3d968 100644 --- a/src/shared/python/model_generation/tests/test_unified_loader.py +++ b/src/shared/python/model_generation/tests/test_unified_loader.py @@ -789,8 +789,8 @@ def test_urdf_uses_bounded_precision(self) -> None: stripped = part.lstrip("-").lstrip("0").replace(".", "") stripped = stripped.lstrip("0") # :.6g can produce up to 6 sig figs - assert len(stripped) <= 6, ( - f"Value '{part}' has more than 6 significant digits" - ) + assert ( + len(stripped) <= 6 + ), f"Value '{part}' has more than 6 significant digits" except ValueError: pass # non-numeric attribute value diff --git a/src/shared/python/plot_theme/tests/test_plot_theme.py b/src/shared/python/plot_theme/tests/test_plot_theme.py index 56081a8f3e..870403e4ee 100644 --- a/src/shared/python/plot_theme/tests/test_plot_theme.py +++ b/src/shared/python/plot_theme/tests/test_plot_theme.py @@ -165,9 +165,9 @@ def test_all_themes_to_rcparams_succeeds(self): for key, theme in PLOT_THEMES.items(): params = theme.to_rcparams() - assert "figure.facecolor" in params, ( - f"Theme '{key}' missing figure.facecolor" - ) + assert ( + "figure.facecolor" in params + ), f"Theme '{key}' missing figure.facecolor" # ────────────────────────────────────────────────────────────────────────────── diff --git a/src/shared/python/scripting/scripting_env.py b/src/shared/python/scripting/scripting_env.py index 75750f9af9..12247d93db 100644 --- a/src/shared/python/scripting/scripting_env.py +++ b/src/shared/python/scripting/scripting_env.py @@ -560,7 +560,10 @@ def refresh_user_functions(self) -> None: _screen_source_for_escapes(code) # Execute within current namespace so imports/functions are persistent exec(code, self.namespace) # nosec B102 - except (SecurityError, *USER_CODE_ERROR_TYPES) as e: # noqa: BLE001 — user library code may raise anything; report and continue + except ( + SecurityError, + *USER_CODE_ERROR_TYPES, + ) as e: # noqa: BLE001 — user library code may raise anything; report and continue sys.stderr.write(f"Error loading user library: {e}\n") sys.stderr.flush() diff --git a/src/shared/python/sidekick/calculators/mechanical/trc_geometry.py b/src/shared/python/sidekick/calculators/mechanical/trc_geometry.py index c5bc513428..5c9a6182a1 100644 --- a/src/shared/python/sidekick/calculators/mechanical/trc_geometry.py +++ b/src/shared/python/sidekick/calculators/mechanical/trc_geometry.py @@ -316,12 +316,12 @@ def calculate_geometry( VesselGeometryResult containing detailed calculations """ # DbC preconditions - assert dimensions.cylinder_diameter > 0, ( - f"cylinder_diameter must be positive, got {dimensions.cylinder_diameter}" - ) - assert dimensions.cylinder_height > 0, ( - f"cylinder_height must be positive, got {dimensions.cylinder_height}" - ) + assert ( + dimensions.cylinder_diameter > 0 + ), f"cylinder_diameter must be positive, got {dimensions.cylinder_diameter}" + assert ( + dimensions.cylinder_height > 0 + ), f"cylinder_height must be positive, got {dimensions.cylinder_height}" results = VesselGeometryResult() if not layers: diff --git a/src/shared/python/sidekick/process_calculators/psa_package/psa_gui.py b/src/shared/python/sidekick/process_calculators/psa_package/psa_gui.py index a3b2043dd8..b19445472d 100644 --- a/src/shared/python/sidekick/process_calculators/psa_package/psa_gui.py +++ b/src/shared/python/sidekick/process_calculators/psa_package/psa_gui.py @@ -119,7 +119,9 @@ def __init__( self.fig = Figure(figsize=(width, height), dpi=100) # noqa: F821 super().__init__(self.fig) self.setParent(parent) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # noqa: F821 + self.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) # noqa: F821 class InputPanel(QWidget): # noqa: F811, F821 @@ -174,7 +176,9 @@ def _setup_ui(self) -> None: layout.addWidget(op_group) # Component Data Group - comp_group = QGroupBox("Component Data (Feed % | S1 Removal % | S2 Removal %)") # noqa: F821 + comp_group = QGroupBox( + "Component Data (Feed % | S1 Removal % | S2 Removal %)" + ) # noqa: F821 comp_layout = QVBoxLayout() # noqa: F821 self.component_table = QTableWidget(7, 4) # noqa: F821 @@ -186,8 +190,12 @@ def _setup_ui(self) -> None: header.setVisible(False) for i, comp in enumerate(DEFAULT_COMPONENTS): # noqa: F821 - self.component_table.setItem(i, 0, QTableWidgetItem(comp["name"])) # noqa: F821 - self.component_table.setItem(i, 1, QTableWidgetItem(str(comp["feed_pct"]))) # noqa: F821 + self.component_table.setItem( + i, 0, QTableWidgetItem(comp["name"]) + ) # noqa: F821 + self.component_table.setItem( + i, 1, QTableWidgetItem(str(comp["feed_pct"])) + ) # noqa: F821 self.component_table.setItem( i, 2, @@ -229,7 +237,9 @@ def _reset_defaults(self) -> None: self.prod_recycle_slider.setValue(0) for i, comp in enumerate(DEFAULT_COMPONENTS): # noqa: F821 - self.component_table.setItem(i, 1, QTableWidgetItem(str(comp["feed_pct"]))) # noqa: F821 + self.component_table.setItem( + i, 1, QTableWidgetItem(str(comp["feed_pct"])) + ) # noqa: F821 self.component_table.setItem( i, 2, @@ -408,7 +418,9 @@ def _update_safety_metrics(self, results: PSAResults) -> None: # noqa: F821 self.s2_tail_h2_label.setText(f"{results.s2_tail_h2_pct:.2f}%") self.s2_tail_o2_label.setText(f"{results.s2_tail_o2_pct:.2f}%") - status = get_flammability_status(results.s2_tail_h2_pct, results.s2_tail_o2_pct) # noqa: F821 + status = get_flammability_status( + results.s2_tail_h2_pct, results.s2_tail_o2_pct + ) # noqa: F821 self.flammability_label.setText(status) if "CRITICAL" in status or "FLAMMABLE" in status or "DANGEROUS" in status: @@ -680,7 +692,9 @@ def _plot_o2_safety(self) -> None: """Plot O2 safety analysis.""" num_points = min(self.num_points_spin.value(), 51) # Cap at 51 for O2 analysis inlet_o2_values = np.array([0.5, 1.0, 2.0, 5.0], dtype=np.float64) # noqa: F821 - s1_removal_range = np.linspace(50.0, 95.0, num_points, dtype=np.float64) # noqa: F821 + s1_removal_range = np.linspace( + 50.0, 95.0, num_points, dtype=np.float64 + ) # noqa: F821 o2_analysis = calculate_o2_safety_analysis( # noqa: F821 inlet_o2_pcts=inlet_o2_values, @@ -949,7 +963,8 @@ def _launch_colab(self) -> None: "3. Copy the notebook content manually" ) msg.setStandardButtons( - QMessageBox.StandardButton.Open | QMessageBox.StandardButton.Cancel # noqa: F821 + QMessageBox.StandardButton.Open + | QMessageBox.StandardButton.Cancel # noqa: F821 ) msg.setDefaultButton(QMessageBox.StandardButton.Open) # noqa: F821 @@ -1079,7 +1094,9 @@ def _calculate(self) -> None: self.sensitivity_widget.set_components(components) except ValueError as e: - QMessageBox.warning(self, "Input Error", f"Invalid input: {e}") # noqa: F821 + QMessageBox.warning( + self, "Input Error", f"Invalid input: {e}" + ) # noqa: F821 except (RuntimeError, AttributeError) as e: QMessageBox.critical(self, "Calculation Error", f"Error: {e}") # noqa: F821 diff --git a/src/shared/python/sidekick/standalone/preferences.py b/src/shared/python/sidekick/standalone/preferences.py index 1ee43ecc31..b791e9324f 100644 --- a/src/shared/python/sidekick/standalone/preferences.py +++ b/src/shared/python/sidekick/standalone/preferences.py @@ -79,9 +79,9 @@ class StandalonePreferences: def __init__(self, store: Any = None) -> None: if store is None: store = _default_store() - assert hasattr(store, "get") and hasattr(store, "set"), ( - "store must implement get() and set()" - ) + assert hasattr(store, "get") and hasattr( + store, "set" + ), "store must implement get() and set()" self._store = store # ------------------------------------------------------------------ @@ -184,9 +184,9 @@ def apply_tokens(self, theme_colors: dict[str, str]) -> dict[str, str]: Postcondition: every key in ``COLOR_TOKEN_MAP`` that maps to a key present in ``theme_colors`` appears in the result. """ - assert isinstance(theme_colors, dict) and theme_colors, ( - "theme_colors must be a non-empty dict" - ) + assert ( + isinstance(theme_colors, dict) and theme_colors + ), "theme_colors must be a non-empty dict" from theme.sidekick_tokens import COLOR_TOKEN_MAP, DEFAULT_SIDEKICK_TOKENS tokens: dict[str, str] = dict(DEFAULT_SIDEKICK_TOKENS) @@ -194,9 +194,9 @@ def apply_tokens(self, theme_colors: dict[str, str]) -> dict[str, str]: if theme_key in theme_colors: tokens[token_name] = theme_colors[theme_key] - assert all(isinstance(v, str) for v in tokens.values()), ( - "postcondition: all token values must be strings" - ) + assert all( + isinstance(v, str) for v in tokens.values() + ), "postcondition: all token values must be strings" return tokens diff --git a/src/shared/python/sidekick/standalone/runner.py b/src/shared/python/sidekick/standalone/runner.py index fd7bc80254..7e672fbeb0 100644 --- a/src/shared/python/sidekick/standalone/runner.py +++ b/src/shared/python/sidekick/standalone/runner.py @@ -246,9 +246,9 @@ def run_calculator( *calculator* must be a non-empty string. *inputs_path* must point to a readable JSON file. """ - assert isinstance(calculator, str) and calculator, ( - "calculator name must be non-empty" - ) + assert ( + isinstance(calculator, str) and calculator + ), "calculator name must be non-empty" assert isinstance(inputs_path, str) and inputs_path, "inputs_path must be non-empty" _ensure_registered() diff --git a/src/shared/python/sidekick/tests/process_calculators/test_psa_model.py b/src/shared/python/sidekick/tests/process_calculators/test_psa_model.py index 839da96d03..2cc8b507d9 100644 --- a/src/shared/python/sidekick/tests/process_calculators/test_psa_model.py +++ b/src/shared/python/sidekick/tests/process_calculators/test_psa_model.py @@ -107,9 +107,9 @@ def test_s2_tail_vent_flow(self, base_results) -> None: def test_mass_balance(self, base_results) -> None: """Test mass balance closure.""" - assert abs(base_results.mass_balance_error) < 1e-10, ( - f"Mass balance error too large: {base_results.mass_balance_error}" - ) + assert ( + abs(base_results.mass_balance_error) < 1e-10 + ), f"Mass balance error too large: {base_results.mass_balance_error}" def test_s2_tail_h2_pct(self, base_results) -> None: """Test S2 tail H2 percentage matches Excel.""" @@ -463,9 +463,9 @@ def test_flow_conservation_per_component(self) -> None: - results.flows.s2_tail_vent[i] - results.flows.net_product[i] ) - assert abs(balance) < 1e-10, ( - f"Mass balance error for {results.component_names[i]}: {balance}" - ) + assert ( + abs(balance) < 1e-10 + ), f"Mass balance error for {results.component_names[i]}: {balance}" def test_mixed_feed_balance(self) -> None: """Test mixed feed balance.""" diff --git a/src/shared/python/sidekick/tests/process_calculators/test_syngas_compression_dedup.py b/src/shared/python/sidekick/tests/process_calculators/test_syngas_compression_dedup.py index 718c929928..2542d1a77d 100644 --- a/src/shared/python/sidekick/tests/process_calculators/test_syngas_compression_dedup.py +++ b/src/shared/python/sidekick/tests/process_calculators/test_syngas_compression_dedup.py @@ -41,9 +41,9 @@ def test_single_syngas_compression_engine_definition() -> None: def test_dead_syngas_compression_subpackage_removed() -> None: """The empty placeholder ``syngas_compression/`` subpackage is gone.""" dead_dir = _PROCESS_CALCULATORS / "syngas_compression" - assert not dead_dir.exists(), ( - "Dead placeholder subpackage should have been deleted (#3183)" - ) + assert ( + not dead_dir.exists() + ), "Dead placeholder subpackage should have been deleted (#3183)" def test_root_calculator_exposes_real_engine() -> None: diff --git a/src/shared/python/sidekick/tests/test_json_io_boundary_3333.py b/src/shared/python/sidekick/tests/test_json_io_boundary_3333.py index d51377e34c..c2a45172d6 100644 --- a/src/shared/python/sidekick/tests/test_json_io_boundary_3333.py +++ b/src/shared/python/sidekick/tests/test_json_io_boundary_3333.py @@ -32,9 +32,9 @@ def test_state_manager_has_no_cross_tree_imports() -> None: root = node.module.split(".")[0] if root in {"utils", "compatibility"}: offending.append(node.module) - assert offending == [], ( - f"state_manager still imports across the tool-tree boundary: {offending}" - ) + assert ( + offending == [] + ), f"state_manager still imports across the tool-tree boundary: {offending}" @pytest.mark.unit diff --git a/src/shared/python/sidekick/ui/tools_sidebar/registry.py b/src/shared/python/sidekick/ui/tools_sidebar/registry.py index 70ffb40077..679264717e 100644 --- a/src/shared/python/sidekick/ui/tools_sidebar/registry.py +++ b/src/shared/python/sidekick/ui/tools_sidebar/registry.py @@ -212,7 +212,9 @@ def _notify(self, event: WorkspaceEvent, name: str) -> None: continue try: subscription.callback(queued_event, queued_name) - except Exception: # noqa: BLE001 - subscribers must not break notify + except ( + Exception + ): # noqa: BLE001 - subscribers must not break notify _logger.exception( "Workspace subscriber raised on %s '%s'", queued_event, diff --git a/src/shared/python/sidekick/ui/tools_sidebar/sidebar.py b/src/shared/python/sidekick/ui/tools_sidebar/sidebar.py index a7bdb28a35..c58d7ad1f7 100644 --- a/src/shared/python/sidekick/ui/tools_sidebar/sidebar.py +++ b/src/shared/python/sidekick/ui/tools_sidebar/sidebar.py @@ -769,7 +769,9 @@ def _persist_visible_tabs(self) -> None: self._vis_persistence.save(self._tab_collection.visible_ids()) def _apply_tab_state(self, state: SidebarState) -> None: - self._state = sanitize_tab_state(state, self._tab_collection._tab_definitions) # noqa: SLF001 + self._state = sanitize_tab_state( + state, self._tab_collection._tab_definitions + ) # noqa: SLF001 state = self._state for tab_id in list(self._tab_collection.visible_ids()): if tab_id in state.hidden_tabs: diff --git a/src/shared/python/tests/test_god_class_guard.py b/src/shared/python/tests/test_god_class_guard.py index b6f6c4f050..a15f3518d5 100644 --- a/src/shared/python/tests/test_god_class_guard.py +++ b/src/shared/python/tests/test_god_class_guard.py @@ -124,9 +124,10 @@ def test_no_god_classes_in_monitored_files() -> None: "Refactor or add to KNOWN_CLASSES with justification (GH1692)." ) - assert not violations, ( - "God class ceiling exceeded in monitored files:\n" - + "\n".join(f" - {v}" for v in violations) + assert ( + not violations + ), "God class ceiling exceeded in monitored files:\n" + "\n".join( + f" - {v}" for v in violations ) @@ -150,9 +151,9 @@ def test_calculator_state_mixin_reduced() -> None: ) # Also verify sub-mixins exist and are bounded - assert "_SplitterStateMixin" in counts, ( - "_SplitterStateMixin sub-mixin missing from calculator_state_mixin.py" - ) - assert "_ClipboardMixin" in counts, ( - "_ClipboardMixin sub-mixin missing from calculator_state_mixin.py" - ) + assert ( + "_SplitterStateMixin" in counts + ), "_SplitterStateMixin sub-mixin missing from calculator_state_mixin.py" + assert ( + "_ClipboardMixin" in counts + ), "_ClipboardMixin sub-mixin missing from calculator_state_mixin.py" diff --git a/src/shared/python/theme/zoom.py b/src/shared/python/theme/zoom.py index 7b12cf40d8..e50b0a36bc 100644 --- a/src/shared/python/theme/zoom.py +++ b/src/shared/python/theme/zoom.py @@ -142,7 +142,9 @@ def reset_zoom(self) -> None: """Reset application zoom to the configured default.""" self.set_zoom_percent(self._config.default_percent) - def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool: # noqa: N802 + def eventFilter( + self, obj: QObject | None, event: QEvent | None + ) -> bool: # noqa: N802 """Handle Ctrl+wheel and Ctrl+shortcut app zoom events.""" if event is None: return False diff --git a/src/web_applications/urdf_viewer/tests/test_urdf_viewer.py b/src/web_applications/urdf_viewer/tests/test_urdf_viewer.py index a65550442e..83acebd114 100644 --- a/src/web_applications/urdf_viewer/tests/test_urdf_viewer.py +++ b/src/web_applications/urdf_viewer/tests/test_urdf_viewer.py @@ -15,7 +15,9 @@ # (CI may lack python-multipart, cors deps, etc.) try: from app import app -except Exception as _exc: # noqa: BLE001 — CI may lack optional deps; skip entire module +except ( + Exception +) as _exc: # noqa: BLE001 — CI may lack optional deps; skip entire module pytest.skip( f"Skipping urdf_viewer tests — app import failed: {_exc}", allow_module_level=True, diff --git a/tests/architecture/test_gh1696_god_modules.py b/tests/architecture/test_gh1696_god_modules.py index 90155f871b..f2fb7c223c 100644 --- a/tests/architecture/test_gh1696_god_modules.py +++ b/tests/architecture/test_gh1696_god_modules.py @@ -241,15 +241,15 @@ def test_signal_toolkit_uses_lazy_import_pattern() -> None: "signal_toolkit must contain a LAZY dispatch table in __init__.py " "or _lazy_map.py" ) - assert SIGNAL_TOOLKIT_LAZY_MAP.exists(), ( - "_lazy_map.py must exist alongside __init__.py (issue #1696 refactor)" - ) - assert "def __getattr__" in init_source, ( - "signal_toolkit/__init__.py must define __getattr__ for lazy loading" - ) - assert "importlib.import_module" in init_source, ( - "signal_toolkit/__init__.py must use importlib.import_module in __getattr__" - ) + assert ( + SIGNAL_TOOLKIT_LAZY_MAP.exists() + ), "_lazy_map.py must exist alongside __init__.py (issue #1696 refactor)" + assert ( + "def __getattr__" in init_source + ), "signal_toolkit/__init__.py must define __getattr__ for lazy loading" + assert ( + "importlib.import_module" in init_source + ), "signal_toolkit/__init__.py must use importlib.import_module in __getattr__" @pytest.mark.unit @@ -270,9 +270,9 @@ def test_signal_toolkit_lazy_attribute_loads_on_access() -> None: assert obj is not None, "signal_toolkit.SeriesExpansion should not be None" # After access, should be cached in globals - assert "SeriesExpansion" in signal_toolkit.__dict__, ( - "After access, SeriesExpansion must be cached in signal_toolkit.__dict__" - ) + assert ( + "SeriesExpansion" in signal_toolkit.__dict__ + ), "After access, SeriesExpansion must be cached in signal_toolkit.__dict__" @pytest.mark.unit @@ -284,9 +284,9 @@ def test_signal_toolkit_all_exports_accessible() -> None: attr = getattr(signal_toolkit, name, None) # HAS_* flags and optional widgets may be None (no PyQt6 in CI) if name not in {"PolynomialGeneratorWidget", "SignalToolkitWidget"}: - assert attr is not None, ( - f"signal_toolkit.{name} is None — lazy import may be broken" - ) + assert ( + attr is not None + ), f"signal_toolkit.{name} is None — lazy import may be broken" @pytest.mark.unit diff --git a/tests/architecture/test_sidekick_external_imports_3316.py b/tests/architecture/test_sidekick_external_imports_3316.py index 9bc0009438..b28742fe66 100644 --- a/tests/architecture/test_sidekick_external_imports_3316.py +++ b/tests/architecture/test_sidekick_external_imports_3316.py @@ -96,8 +96,7 @@ def test_legacy_sidekick_aliases_share_canonical_module_objects() -> None: "-W", "ignore::DeprecationWarning", "-c", - textwrap.dedent( - """ + textwrap.dedent(""" import importlib canonical = importlib.import_module( @@ -114,8 +113,7 @@ def test_legacy_sidekick_aliases_share_canonical_module_objects() -> None: "src.shared.python.sidekick.ui.tools_sidebar.registry" ) assert src_alias is None or src_alias is canonical - """ - ), + """), ], cwd=REPO_ROOT, env=env, diff --git a/tests/calc_backend/test_wgs_reactor_headless_import_3317.py b/tests/calc_backend/test_wgs_reactor_headless_import_3317.py index fc51b50c2a..203c0eb5b9 100644 --- a/tests/calc_backend/test_wgs_reactor_headless_import_3317.py +++ b/tests/calc_backend/test_wgs_reactor_headless_import_3317.py @@ -22,8 +22,7 @@ # Program run in a clean subprocess: block PyQt6 (and the theme layer that wraps # it), then import the engine and assert success + no Qt/theme leakage. -_PROGRAM = textwrap.dedent( - """ +_PROGRAM = textwrap.dedent(""" import importlib.abc import importlib.machinery import sys @@ -57,8 +56,7 @@ def find_spec(self, fullname, path, target=None): assert "PyQt6" not in sys.modules print("HEADLESS_OK") - """ -) + """) @pytest.mark.unit diff --git a/tests/conftest.py b/tests/conftest.py index ee83891375..e8ef7cfe07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,6 +131,7 @@ def qapp(): if app is None: app = QApplication(sys.argv) yield app + except ImportError: pass diff --git a/tests/data_processing/data_processor/test_script_generator_hardening.py b/tests/data_processing/data_processor/test_script_generator_hardening.py index 101e6bae2a..14c6f33d49 100644 --- a/tests/data_processing/data_processor/test_script_generator_hardening.py +++ b/tests/data_processing/data_processor/test_script_generator_hardening.py @@ -91,9 +91,9 @@ def test_batch_script_path_metacharacters_are_safely_serialized() -> None: and node.targets[0].id == "input_patterns" ): value = ast.literal_eval(node.value) - assert value == [dangerous_path], ( - f"Path did not round-trip safely: got {value!r}" - ) + assert value == [ + dangerous_path + ], f"Path did not round-trip safely: got {value!r}" found = True break assert found, "input_patterns assignment not found in generated script" diff --git a/tests/heavy_integration/test_tools_contracts.py b/tests/heavy_integration/test_tools_contracts.py index f809601d08..e2606e3e09 100644 --- a/tests/heavy_integration/test_tools_contracts.py +++ b/tests/heavy_integration/test_tools_contracts.py @@ -30,9 +30,9 @@ def test_box_mesh_is_watertight_and_valid_volume(self) -> None: box = trimesh.creation.box((1.0, 2.0, 3.0)) assert box.is_watertight, "Box mesh must be watertight for URDF/physics use" - assert box.volume == pytest.approx(6.0, rel=1e-4), ( - f"Expected volume 6.0, got {box.volume}" - ) + assert box.volume == pytest.approx( + 6.0, rel=1e-4 + ), f"Expected volume 6.0, got {box.volume}" assert len(box.vertices) > 0 assert len(box.faces) > 0 @@ -75,16 +75,16 @@ def test_butterworth_filter_attenuation(self) -> None: freq = w / (2 * np.pi) # Passband gain at DC should be ~1.0 dc_gain = abs(h[0]) - assert dc_gain == pytest.approx(1.0, abs=0.01), ( - f"DC gain should be 1.0, got {dc_gain}" - ) + assert dc_gain == pytest.approx( + 1.0, abs=0.01 + ), f"DC gain should be 1.0, got {dc_gain}" # Stopband attenuation at 0.5 should be < -20 dB stop_idx = int(0.5 * len(freq)) stop_gain_db = 20 * np.log10(abs(h[stop_idx]) + 1e-12) - assert stop_gain_db < -20, ( - f"Expected > 20 dB attenuation, got {stop_gain_db:.1f} dB" - ) + assert ( + stop_gain_db < -20 + ), f"Expected > 20 dB attenuation, got {stop_gain_db:.1f} dB" def test_fft_roundtrip(self) -> None: """FFT→IFFT roundtrip preserves signal — fundamental DSP contract.""" diff --git a/tests/integration/test_cross_repo_contracts.py b/tests/integration/test_cross_repo_contracts.py index f2c503e518..dadb4aa6c4 100644 --- a/tests/integration/test_cross_repo_contracts.py +++ b/tests/integration/test_cross_repo_contracts.py @@ -127,13 +127,13 @@ def test_validate_composition_signature(self) -> None: sig = inspect.signature(InputValidator.validate_composition) params = sig.parameters - assert "composition" in params, ( - "validate_composition must have 'composition' param" - ) + assert ( + "composition" in params + ), "validate_composition must have 'composition' param" assert "tolerance" in params, "validate_composition must have 'tolerance' param" - assert params["tolerance"].default is not inspect.Parameter.empty, ( - "validate_composition 'tolerance' must have a default value" - ) + assert ( + params["tolerance"].default is not inspect.Parameter.empty + ), "validate_composition 'tolerance' must have a default value" # --------------------------------------------------------------------------- @@ -271,18 +271,18 @@ def test_baghouse_calculator_has_calculate_method(self) -> None: """BaghouseCalculator must expose a 'calculate' method.""" from upstream_drift_tools.process_calculators import BaghouseCalculator - assert hasattr(BaghouseCalculator, "calculate"), ( - "BaghouseCalculator.calculate() is missing" - ) + assert hasattr( + BaghouseCalculator, "calculate" + ), "BaghouseCalculator.calculate() is missing" assert callable(BaghouseCalculator.calculate) def test_financial_calculator_has_calculate_method(self) -> None: """FinancialCalculator must expose a 'calculate' method.""" from upstream_drift_tools.process_calculators import FinancialCalculator - assert hasattr(FinancialCalculator, "calculate"), ( - "FinancialCalculator.calculate() is missing" - ) + assert hasattr( + FinancialCalculator, "calculate" + ), "FinancialCalculator.calculate() is missing" assert callable(FinancialCalculator.calculate) def test_flare_design_is_dataclass_or_namedtuple(self) -> None: @@ -377,9 +377,9 @@ def test_all_exceptions_are_exception_subclasses(self) -> None: FitError, UnsupportedOperationError, ): - assert issubclass(exc_cls, Exception), ( - f"{exc_cls.__name__} must be a subclass of Exception" - ) + assert issubclass( + exc_cls, Exception + ), f"{exc_cls.__name__} must be a subclass of Exception" def test_specific_exceptions_are_subclass_of_base(self) -> None: """Specific exceptions must be catchable via the base DataProcessingError. @@ -466,9 +466,9 @@ def test_symbol_importable_from_contracts(self, symbol: str) -> None: import importlib mod = importlib.import_module("contracts") - assert hasattr(mod, symbol), ( - f"contracts.{symbol} is missing — downstream repos import it by name" - ) + assert hasattr( + mod, symbol + ), f"contracts.{symbol} is missing — downstream repos import it by name" def test_require_is_callable(self) -> None: """require must be callable (function or callable class).""" @@ -486,14 +486,14 @@ def test_contract_level_has_off_variant(self) -> None: """ContractLevel must have an OFF member — used to disable checks in prod.""" from contracts import ContractLevel - assert hasattr(ContractLevel, "OFF"), ( - "ContractLevel.OFF is required — downstream repos set it in production" - ) + assert hasattr( + ContractLevel, "OFF" + ), "ContractLevel.OFF is required — downstream repos set it in production" def test_contract_level_has_enforce_variant(self) -> None: """ContractLevel must have an ENFORCE member — used in test environments.""" from contracts import ContractLevel - assert hasattr(ContractLevel, "ENFORCE"), ( - "ContractLevel.ENFORCE is required — downstream test suites activate it" - ) + assert hasattr( + ContractLevel, "ENFORCE" + ), "ContractLevel.ENFORCE is required — downstream test suites activate it" diff --git a/tests/ode_solver/test_ode_solver_timeout.py b/tests/ode_solver/test_ode_solver_timeout.py index c6dcafb884..6af1081763 100644 --- a/tests/ode_solver/test_ode_solver_timeout.py +++ b/tests/ode_solver/test_ode_solver_timeout.py @@ -162,9 +162,9 @@ def test_exponential_decay_completes_in_budget(self, ode_solver: type) -> None: elapsed = time.perf_counter() - start assert sol is not None - assert elapsed < 5.0, ( - f"Exponential decay solve took {elapsed:.3f} s, expected < 5 s" - ) + assert ( + elapsed < 5.0 + ), f"Exponential decay solve took {elapsed:.3f} s, expected < 5 s" def test_harmonic_oscillator_completes_in_budget(self, ode_solver: type) -> None: """Harmonic oscillator solve completes in < 5 s (typical: < 0.1 s). @@ -182,9 +182,9 @@ def test_harmonic_oscillator_completes_in_budget(self, ode_solver: type) -> None elapsed = time.perf_counter() - start assert sol is not None - assert elapsed < 5.0, ( - f"Harmonic oscillator solve took {elapsed:.3f} s, expected < 5 s" - ) + assert ( + elapsed < 5.0 + ), f"Harmonic oscillator solve took {elapsed:.3f} s, expected < 5 s" def test_lotka_volterra_completes_in_budget(self, ode_solver: type) -> None: """Lotka-Volterra (predator-prey) solve completes in < 5 s. @@ -205,9 +205,9 @@ def test_lotka_volterra_completes_in_budget(self, ode_solver: type) -> None: elapsed = time.perf_counter() - start assert sol is not None - assert elapsed < 5.0, ( - f"Lotka-Volterra solve took {elapsed:.3f} s, expected < 5 s" - ) + assert ( + elapsed < 5.0 + ), f"Lotka-Volterra solve took {elapsed:.3f} s, expected < 5 s" def test_with_timeout_overhead_is_negligible(self) -> None: """with_timeout wrapper adds < 100 ms overhead for fast operations. @@ -226,6 +226,6 @@ def trivial() -> int: assert result == 42 avg_ms = (elapsed / 100) * 1000 - assert avg_ms < 100, ( - f"with_timeout average overhead {avg_ms:.1f} ms/call, expected < 100 ms" - ) + assert ( + avg_ms < 100 + ), f"with_timeout average overhead {avg_ms:.1f} ms/call, expected < 100 ms" diff --git a/tests/ops/test_detect_secrets_baseline.py b/tests/ops/test_detect_secrets_baseline.py index 97825ff5d7..2df7ec0e4a 100644 --- a/tests/ops/test_detect_secrets_baseline.py +++ b/tests/ops/test_detect_secrets_baseline.py @@ -141,9 +141,9 @@ def test_workflow_invokes_installed_python_module(self) -> None: def test_baseline_file_exists(self) -> None: """Precondition: .secrets.baseline must exist in repo root.""" - assert BASELINE_PATH.exists(), ( - ".secrets.baseline is missing. Run: detect-secrets scan > .secrets.baseline" - ) + assert ( + BASELINE_PATH.exists() + ), ".secrets.baseline is missing. Run: detect-secrets scan > .secrets.baseline" def test_baseline_is_valid_json(self) -> None: """Baseline must be parseable JSON.""" @@ -352,9 +352,9 @@ def test_all_entries_have_required_field(self, required_field: str) -> None: for i, entry in enumerate(entries): if required_field not in entry: missing.append(f"{file_key}[{i}]") - assert not missing, ( - f"Baseline entries missing field {required_field!r}: {missing[:10]}" - ) + assert ( + not missing + ), f"Baseline entries missing field {required_field!r}: {missing[:10]}" def test_hashed_secrets_are_40_char_hex(self) -> None: """All hashed_secret values must be 40-char hex strings (SHA1).""" diff --git a/tests/p1am_control_system/test_backend_security.py b/tests/p1am_control_system/test_backend_security.py index 52f639a0da..dba5e33191 100644 --- a/tests/p1am_control_system/test_backend_security.py +++ b/tests/p1am_control_system/test_backend_security.py @@ -268,9 +268,9 @@ def test_import_failure_leaves_db_intact(monkeypatch: pytest.MonkeyPatch) -> Non with Session(_test_engine) as s: tags = s.exec(select(TagDefinitionDb)).all() - assert any(t.name == "EXISTING_TAG" for t in tags), ( - "import failure wiped the existing plant DB" - ) + assert any( + t.name == "EXISTING_TAG" for t in tags + ), "import failure wiped the existing plant DB" def test_safe_extract_rejects_path_traversal(tmp_path) -> None: diff --git a/tests/p1am_control_system/test_backend_security_import_guard.py b/tests/p1am_control_system/test_backend_security_import_guard.py index e2cec7adfa..7f81a19ea0 100644 --- a/tests/p1am_control_system/test_backend_security_import_guard.py +++ b/tests/p1am_control_system/test_backend_security_import_guard.py @@ -40,9 +40,9 @@ def test_backend_import_guard_only_catches_module_not_found() -> None: "bare 'except:' would swallow real backend defects and skip the " "security suite" ) - assert isinstance(exc_type, ast.Name), ( - "import guard must catch a single named exception, not a tuple/attr" - ) + assert isinstance( + exc_type, ast.Name + ), "import guard must catch a single named exception, not a tuple/attr" assert exc_type.id == "ModuleNotFoundError", ( "backend import guard must narrow to ModuleNotFoundError so that a " "NameError/SyntaxError/ImportError in the backend fails loudly " diff --git a/tests/p1am_control_system/test_event_logger_filter_error_logging.py b/tests/p1am_control_system/test_event_logger_filter_error_logging.py index 2008094278..ef1391b94d 100644 --- a/tests/p1am_control_system/test_event_logger_filter_error_logging.py +++ b/tests/p1am_control_system/test_event_logger_filter_error_logging.py @@ -50,8 +50,8 @@ def _boom() -> list[str]: with caplog.at_level(logging.ERROR, logger=event_logger.__name__): event_logger.EventLogViewerWidget.update_event_types_combobox(widget) - assert any("event-type filter" in rec.getMessage() for rec in caplog.records), ( - "DB failure must be logged" - ) + assert any( + "event-type filter" in rec.getMessage() for rec in caplog.records + ), "DB failure must be logged" # Combobox still has the default 'All' entry and did not raise. assert widget.event_type_combo._items == ["All"] diff --git a/tests/programmatic_pid/test_equipment.py b/tests/programmatic_pid/test_equipment.py index f286c32eb2..aa31a25476 100644 --- a/tests/programmatic_pid/test_equipment.py +++ b/tests/programmatic_pid/test_equipment.py @@ -108,9 +108,9 @@ def test_draw_equipment_symbol_uses_registry(): for eq_type in ["hopper", "fan", "gate_valve", "control_valve", "pump"]: initial_count = len(list(msp)) draw_equipment_symbol(msp, _eq(etype=eq_type), "EQUIPMENT") - assert len(list(msp)) > initial_count, ( - f"{eq_type} should add entities to modelspace" - ) + assert ( + len(list(msp)) > initial_count + ), f"{eq_type} should add entities to modelspace" def test_draw_equipment_symbol_fallback_to_box(): diff --git a/tests/programmatic_pid/test_profiles_extra.py b/tests/programmatic_pid/test_profiles_extra.py index 514a26fb5a..ab9a2a8b6c 100644 --- a/tests/programmatic_pid/test_profiles_extra.py +++ b/tests/programmatic_pid/test_profiles_extra.py @@ -190,9 +190,9 @@ def test_all_presets_share_same_layout_keys(self) -> None: keysets = [ set(p["layout"].keys()) for p in PROFILE_PRESETS.values() if "layout" in p ] - assert all(k == keysets[0] for k in keysets), ( - "all presets must declare the same layout keys for predictable merging" - ) + assert all( + k == keysets[0] for k in keysets + ), "all presets must declare the same layout keys for predictable merging" def test_presentation_has_no_defaults_section(self) -> None: # presentation preset deliberately omits a defaults block. diff --git a/tests/project_packer_fixes/test_build_exe_lod.py b/tests/project_packer_fixes/test_build_exe_lod.py index 570ebb996b..27501ab365 100644 --- a/tests/project_packer_fixes/test_build_exe_lod.py +++ b/tests/project_packer_fixes/test_build_exe_lod.py @@ -39,9 +39,9 @@ def test_check_pyinstaller_uses_find_spec_directly(self, build_exe_module) -> No import inspect source = inspect.getsource(build_exe_module.check_pyinstaller) - assert "importlib.util.find_spec" not in source, ( - "LoD violation: should use find_spec directly, not importlib.util.find_spec" - ) + assert ( + "importlib.util.find_spec" not in source + ), "LoD violation: should use find_spec directly, not importlib.util.find_spec" assert "find_spec" in source, "check_pyinstaller should call find_spec" def test_check_pyinstaller_available(self, build_exe_module) -> None: @@ -60,9 +60,9 @@ def test_check_pyinstaller_not_available(self, build_exe_module) -> None: def test_find_spec_import_at_module_level(self, build_exe_module) -> None: """Verify find_spec is imported at module level (not accessed via importlib.util).""" - assert hasattr(build_exe_module, "find_spec"), ( - "find_spec must be imported at module level in build_exe" - ) + assert hasattr( + build_exe_module, "find_spec" + ), "find_spec must be imported at module level in build_exe" def test_install_pyinstaller_success(self, build_exe_module) -> None: """Test successful PyInstaller installation.""" diff --git a/tests/project_packer_fixes/test_build_lod.py b/tests/project_packer_fixes/test_build_lod.py index 0b18cec042..c04ae7d7cf 100644 --- a/tests/project_packer_fixes/test_build_lod.py +++ b/tests/project_packer_fixes/test_build_lod.py @@ -66,34 +66,34 @@ class TestBuildLoDFix: def test_main_no_chained_path_parent_absolute(self, build_module) -> None: """Verify main() does not chain Path().parent.absolute() directly.""" source = inspect.getsource(build_module.main) - assert "Path(__file__).parent.absolute()" not in source, ( - "LoD violation: build.py must not chain Path(__file__).parent.absolute()" - ) + assert ( + "Path(__file__).parent.absolute()" not in source + ), "LoD violation: build.py must not chain Path(__file__).parent.absolute()" def test_main_no_chained_stderr_write(self, build_module) -> None: """Verify main() does not chain sys.stderr.write() directly.""" source = inspect.getsource(build_module.main) - assert "sys.stderr.write" not in source, ( - "LoD violation: build.py must not chain sys.stderr.write() directly" - ) + assert ( + "sys.stderr.write" not in source + ), "LoD violation: build.py must not chain sys.stderr.write() directly" def test_main_extracts_stderr_to_variable(self, build_module) -> None: """Verify main() extracts sys.stderr to a local variable.""" source = inspect.getsource(build_module.main) - assert "stderr = sys.stderr" in source, ( - "build.py main() should extract sys.stderr to a local variable" - ) + assert ( + "stderr = sys.stderr" in source + ), "build.py main() should extract sys.stderr to a local variable" def test_main_extracts_path_parent(self, build_module) -> None: """Verify main() extracts Path().parent to an intermediate variable.""" source = inspect.getsource(build_module.main) # Should use script_parent or similar intermediate variable - assert "Path(__file__).parent" in source, ( - "build.py should still use Path(__file__).parent but assign to intermediate" - ) - assert ".absolute()" in source, ( - "build.py should call .absolute() on the intermediate variable" - ) + assert ( + "Path(__file__).parent" in source + ), "build.py should still use Path(__file__).parent but assign to intermediate" + assert ( + ".absolute()" in source + ), "build.py should call .absolute() on the intermediate variable" def test_no_print_calls_in_source(self, build_module) -> None: """Verify no print() calls exist in build module source.""" diff --git a/tests/project_packer_fixes/test_folder_packer_gui_lod.py b/tests/project_packer_fixes/test_folder_packer_gui_lod.py index e7d808756c..46bbb239d4 100644 --- a/tests/project_packer_fixes/test_folder_packer_gui_lod.py +++ b/tests/project_packer_fixes/test_folder_packer_gui_lod.py @@ -112,18 +112,18 @@ def test_should_include_file_no_chained_suffix_lower(self, gui_module) -> None: import inspect source = inspect.getsource(gui_module.FolderPackerGUI.should_include_file) - assert "file_path.suffix.lower()" not in source, ( - "LoD violation: should_include_file must not chain .suffix.lower()" - ) + assert ( + "file_path.suffix.lower()" not in source + ), "LoD violation: should_include_file must not chain .suffix.lower()" def test_should_include_directory_no_chained_name_lower(self, gui_module) -> None: """Verify should_include_directory does not use dir_path.name.lower() chain.""" import inspect source = inspect.getsource(gui_module.FolderPackerGUI.should_include_directory) - assert "dir_path.name.lower()" not in source, ( - "LoD violation: should_include_directory must not chain .name.lower()" - ) + assert ( + "dir_path.name.lower()" not in source + ), "LoD violation: should_include_directory must not chain .name.lower()" def test_should_include_file_python_file(self, gui_instance) -> None: """Test that .py files are included.""" @@ -203,9 +203,9 @@ def test_no_print_calls_in_source(self, gui_module) -> None: for i, line in enumerate(lines) if "print(" in line and not line.strip().startswith("#") ] - assert not print_lines, ( - f"Found print() calls in folder_packer_gui.py: {print_lines}" - ) + assert ( + not print_lines + ), f"Found print() calls in folder_packer_gui.py: {print_lines}" class TestFolderPackerGuiDbCContracts: diff --git a/tests/rust_bindings/test_math_primitives_bindings.py b/tests/rust_bindings/test_math_primitives_bindings.py index 6b2d483c2a..06bc214ed3 100644 --- a/tests/rust_bindings/test_math_primitives_bindings.py +++ b/tests/rust_bindings/test_math_primitives_bindings.py @@ -64,9 +64,9 @@ def test_orthonormality(self) -> None: for j in range(3): dot = sum(r[k][i] * r[k][j] for k in range(3)) expected = 1.0 if i == j else 0.0 - assert abs(dot - expected) < 1e-10, ( - f"Orthogonality violated at ({i},{j}): {dot}" - ) + assert ( + abs(dot - expected) < 1e-10 + ), f"Orthogonality violated at ({i},{j}): {dot}" class TestRotationMatrixToEuler: @@ -86,9 +86,9 @@ def test_roundtrip(self, euler: list[float]) -> None: r = mp.euler_to_rotation_matrix(euler) recovered = mp.rotation_matrix_to_euler(r) for i in range(3): - assert abs(recovered[i] - euler[i]) < 1e-10, ( - f"Roundtrip failed at index {i}: {recovered[i]} != {euler[i]}" - ) + assert ( + abs(recovered[i] - euler[i]) < 1e-10 + ), f"Roundtrip failed at index {i}: {recovered[i]} != {euler[i]}" # --------------------------------------------------------------------------- diff --git a/tests/scripts/test_generate_tools_json.py b/tests/scripts/test_generate_tools_json.py index 4a57d59510..f137b94df8 100644 --- a/tests/scripts/test_generate_tools_json.py +++ b/tests/scripts/test_generate_tools_json.py @@ -250,9 +250,9 @@ def test_contract_tool_id_format(self, manifest_gen_module, mock_repo_root): pattern = re.compile(r"^[a-z0-9_]+$") for tool in contract["tools"]: - assert pattern.match(tool["id"]), ( - f"Tool ID '{tool['id']}' is not snake_case" - ) + assert pattern.match( + tool["id"] + ), f"Tool ID '{tool['id']}' is not snake_case" def test_contract_surfaces_structure(self, manifest_gen_module, mock_repo_root): """Each tool's surfaces dict must have exactly pyqt6 and web booleans.""" @@ -334,9 +334,9 @@ def test_contract_schema_compliance(self, manifest_gen_module, mock_repo_root): expected_surface_keys = {"pyqt6", "web", "legacy_gui"} for tool in contract["tools"]: - assert set(tool.keys()) == expected_tool_keys, ( - f"Unexpected keys in tool entry: {set(tool.keys()) - expected_tool_keys}" - ) + assert ( + set(tool.keys()) == expected_tool_keys + ), f"Unexpected keys in tool entry: {set(tool.keys()) - expected_tool_keys}" assert set(tool["surfaces"].keys()) == expected_surface_keys diff --git a/tests/shared/python/ai/integrations/test_linear_client.py b/tests/shared/python/ai/integrations/test_linear_client.py index 4008c3117d..6e32bc22ff 100644 --- a/tests/shared/python/ai/integrations/test_linear_client.py +++ b/tests/shared/python/ai/integrations/test_linear_client.py @@ -111,9 +111,11 @@ def with_token(): """Set a dummy token for tests that need one.""" set_linear_api_token("test-token-abc") yield - set_linear_api_token.__wrapped__ if hasattr( - set_linear_api_token, "__wrapped__" - ) else None + ( + set_linear_api_token.__wrapped__ + if hasattr(set_linear_api_token, "__wrapped__") + else None + ) # --------------------------------------------------------------------------- diff --git a/tests/shared/python/ai/test_adapter_contract.py b/tests/shared/python/ai/test_adapter_contract.py index 86fec72c1d..03245efa57 100644 --- a/tests/shared/python/ai/test_adapter_contract.py +++ b/tests/shared/python/ai/test_adapter_contract.py @@ -44,18 +44,18 @@ def _assert_canonical_usage(usage: dict[str, int], adapter_name: str) -> None: f"got {set(usage.keys())!r}" ) for key in _CANONICAL_USAGE_KEYS: - assert isinstance(usage[key], int), ( - f"{adapter_name}: usage['{key}'] must be int, got {type(usage[key])!r}" - ) + assert isinstance( + usage[key], int + ), f"{adapter_name}: usage['{key}'] must be int, got {type(usage[key])!r}" def _assert_stream_terminates(chunks: Iterator[AgentChunk], adapter_name: str) -> None: """Consume *chunks* and assert at least one has ``is_final=True``.""" chunk_list = list(chunks) finals = [c for c in chunk_list if c.is_final] - assert finals, ( - f"{adapter_name}: stream_response did not emit any chunk with is_final=True" - ) + assert ( + finals + ), f"{adapter_name}: stream_response did not emit any chunk with is_final=True" # --------------------------------------------------------------------------- diff --git a/tests/shared/python/ai/test_adapter_factory.py b/tests/shared/python/ai/test_adapter_factory.py index 4a66c45536..75adf3238c 100644 --- a/tests/shared/python/ai/test_adapter_factory.py +++ b/tests/shared/python/ai/test_adapter_factory.py @@ -94,9 +94,9 @@ def test_create_different_configs_returns_different_instances() -> None: adapter_a = AdapterFactory.create("ollama", model="llama3") adapter_b = AdapterFactory.create("ollama", model="mistral") - assert adapter_a is not adapter_b, ( - "Different model configurations must produce distinct adapter instances." - ) + assert ( + adapter_a is not adapter_b + ), "Different model configurations must produce distinct adapter instances." def test_create_different_hosts_returns_different_instances() -> None: @@ -111,9 +111,9 @@ def test_create_different_hosts_returns_different_instances() -> None: adapter_a = AdapterFactory.create("ollama", host="http://host-a:11434") adapter_b = AdapterFactory.create("ollama", host="http://host-b:11434") - assert adapter_a is not adapter_b, ( - "Different host configurations must produce distinct adapter instances." - ) + assert ( + adapter_a is not adapter_b + ), "Different host configurations must produce distinct adapter instances." # --------------------------------------------------------------------------- @@ -134,9 +134,9 @@ def test_clear_cache_causes_fresh_construction() -> None: AdapterFactory.clear_cache() second = AdapterFactory.create("ollama", model="llama3") - assert first is not second, ( - "After clear_cache(), create() must construct a fresh adapter instance." - ) + assert ( + first is not second + ), "After clear_cache(), create() must construct a fresh adapter instance." def test_clear_cache_empties_internal_dict() -> None: @@ -149,9 +149,9 @@ def test_clear_cache_empties_internal_dict() -> None: ): AdapterFactory.create("ollama") - assert len(AdapterFactory._cache) == 1, ( - "Cache should have one entry after create()." - ) + assert ( + len(AdapterFactory._cache) == 1 + ), "Cache should have one entry after create()." AdapterFactory.clear_cache() assert len(AdapterFactory._cache) == 0, "Cache should be empty after clear_cache()." @@ -171,6 +171,6 @@ def test_constructor_called_once_for_repeated_create() -> None: AdapterFactory.create("ollama", model="llama3") AdapterFactory.create("ollama", model="llama3") - assert mock_cls.call_count == 1, ( - f"OllamaAdapter constructor should be called once, got {mock_cls.call_count}." - ) + assert ( + mock_cls.call_count == 1 + ), f"OllamaAdapter constructor should be called once, got {mock_cls.call_count}." diff --git a/tests/shared/python/ai/test_cli_provider_setup.py b/tests/shared/python/ai/test_cli_provider_setup.py index f9d076282d..54f097f9c8 100644 --- a/tests/shared/python/ai/test_cli_provider_setup.py +++ b/tests/shared/python/ai/test_cli_provider_setup.py @@ -27,9 +27,9 @@ class TestCatalogue: def test_all_cli_providers_covered(self) -> None: """Every CLI-shaped provider must have an install/auth card.""" expected = {"claude_code", "codex_cli", "gemini_cli", "cline"} - assert expected.issubset(CLI_PROVIDERS.keys()), ( - f"Missing CLI providers: {expected - set(CLI_PROVIDERS.keys())}" - ) + assert expected.issubset( + CLI_PROVIDERS.keys() + ), f"Missing CLI providers: {expected - set(CLI_PROVIDERS.keys())}" @pytest.mark.parametrize( "provider", ["claude_code", "codex_cli", "gemini_cli", "cline"] @@ -38,12 +38,12 @@ def test_each_spec_has_required_fields(self, provider: str) -> None: spec = CLI_PROVIDERS[provider] assert spec.display_name, f"{provider}: empty display_name" assert spec.install_command, f"{provider}: empty install_command" - assert spec.install_url.startswith(("http://", "https://")), ( - f"{provider}: install_url not a URL: {spec.install_url!r}" - ) - assert len(spec.auth_instructions) > 20, ( - f"{provider}: auth_instructions too short to be useful" - ) + assert spec.install_url.startswith( + ("http://", "https://") + ), f"{provider}: install_url not a URL: {spec.install_url!r}" + assert ( + len(spec.auth_instructions) > 20 + ), f"{provider}: auth_instructions too short to be useful" class TestStatusProbe: diff --git a/tests/shared/python/ai/test_onnx_preflight.py b/tests/shared/python/ai/test_onnx_preflight.py index b71630c01b..253f1d2abc 100644 --- a/tests/shared/python/ai/test_onnx_preflight.py +++ b/tests/shared/python/ai/test_onnx_preflight.py @@ -117,9 +117,9 @@ def test_error_message_includes_os_error( with pytest.raises(RuntimeError) as exc_info: check_ort_loadable() - assert exc_info.value.__cause__ is not None, ( - "RuntimeError should chain the underlying OSError" - ) + assert ( + exc_info.value.__cause__ is not None + ), "RuntimeError should chain the underlying OSError" def test_raises_for_nonexistent_explicit_path(self) -> None: """Explicit dylib_path argument is used instead of env var.""" diff --git a/tests/shared/python/ai/test_provider_config_registry.py b/tests/shared/python/ai/test_provider_config_registry.py index 8bec7bda35..0ede248454 100644 --- a/tests/shared/python/ai/test_provider_config_registry.py +++ b/tests/shared/python/ai/test_provider_config_registry.py @@ -14,9 +14,9 @@ def test_default_registrations_cover_all_providers(qapp) -> None: for provider in AIProvider: - assert ProviderConfigRegistry.is_registered(provider.name), ( - f"missing registration for {provider}" - ) + assert ProviderConfigRegistry.is_registered( + provider.name + ), f"missing registration for {provider}" def test_get_widget_returns_distinct_instances(qapp) -> None: diff --git a/tests/shared/python/ai/test_rust_adapter_fallback.py b/tests/shared/python/ai/test_rust_adapter_fallback.py index 43421036a2..28caa076f7 100644 --- a/tests/shared/python/ai/test_rust_adapter_fallback.py +++ b/tests/shared/python/ai/test_rust_adapter_fallback.py @@ -74,9 +74,9 @@ def test_warning_references_distribution_doc( ) all_text = " ".join(str(r.message) for r in caplog.records) - assert "rust_distribution.md" in all_text, ( - f"Expected 'rust_distribution.md' in log output, got: {all_text!r}" - ) + assert ( + "rust_distribution.md" in all_text + ), f"Expected 'rust_distribution.md' in log output, got: {all_text!r}" class TestGracefulDegradation: diff --git a/tests/shared/python/calculators/conversion/test_service.py b/tests/shared/python/calculators/conversion/test_service.py index 2f3d9a3b0c..918390040d 100644 --- a/tests/shared/python/calculators/conversion/test_service.py +++ b/tests/shared/python/calculators/conversion/test_service.py @@ -76,9 +76,9 @@ def test_factor_table_round_trip_exactness(service: UnitConversionService) -> No back = service.convert(forward, other, base).value except (IncompatibleUnitsError, TypeError, ValueError, UnknownUnitError): continue - assert back == pytest.approx(1.0, rel=1e-9), ( - f"{category}: {base}->{other}->{base} lost precision" - ) + assert back == pytest.approx( + 1.0, rel=1e-9 + ), f"{category}: {base}->{other}->{base} lost precision" checked += 1 assert checked > 0 diff --git a/tests/shared/python/chat/test_chat_agent_label.py b/tests/shared/python/chat/test_chat_agent_label.py index 320f2210cb..2d69bd05b2 100644 --- a/tests/shared/python/chat/test_chat_agent_label.py +++ b/tests/shared/python/chat/test_chat_agent_label.py @@ -29,7 +29,9 @@ # --------------------------------------------------------------------------- -def test_user_bubble_always_labelled_you(qapp) -> None: # noqa: F811 - qapp is conftest fixture +def test_user_bubble_always_labelled_you( + qapp, +) -> None: # noqa: F811 - qapp is conftest fixture from src.shared.python.chat._qt.bubbles import ChatMessageBubble bubble = ChatMessageBubble("user", "hi", agent_label="Agent (gpt-4o)") diff --git a/tests/shared/python/chat/test_chat_session_helpers.py b/tests/shared/python/chat/test_chat_session_helpers.py index 21dbff32a9..53c740ea51 100644 --- a/tests/shared/python/chat/test_chat_session_helpers.py +++ b/tests/shared/python/chat/test_chat_session_helpers.py @@ -103,9 +103,9 @@ def writer(sid: str) -> None: # No leftover .tmp file after atomic replaces. assert not (path.parent / f"{path.name}.tmp").exists() final = path.read_text(encoding="utf-8") - assert final in candidates, ( - f"expected exactly one of {candidates!r}, got {final!r}" - ) + assert ( + final in candidates + ), f"expected exactly one of {candidates!r}, got {final!r}" def test_atomic_write_leaves_no_tmp_file(self, tmp_path: Path) -> None: """Atomic write cleans up the .tmp file after replace.""" diff --git a/tests/shared/python/chat/test_quick_bar.py b/tests/shared/python/chat/test_quick_bar.py index 89b3ae3d91..c2cada99a3 100644 --- a/tests/shared/python/chat/test_quick_bar.py +++ b/tests/shared/python/chat/test_quick_bar.py @@ -116,12 +116,14 @@ def test_all_canonical_keys_present(self) -> None: def test_fallback_provider_produces_coherent_palette(self) -> None: c = _resolve_colors(_FallbackThemeProvider()) for key, value in c.items(): - assert len(value) in (4, 7, 9), ( - f"Key {key!r} resolved to non-standard color: {value!r}" - ) - assert value.startswith("#"), ( - f"Key {key!r} resolved to non-hex color: {value!r}" - ) + assert len(value) in ( + 4, + 7, + 9, + ), f"Key {key!r} resolved to non-standard color: {value!r}" + assert value.startswith( + "#" + ), f"Key {key!r} resolved to non-hex color: {value!r}" def test_partial_theme_uses_fallback_for_missing_keys(self) -> None: """A partial palette should not break resolution for missing tokens.""" diff --git a/tests/shared/python/chat/test_router_error_logging.py b/tests/shared/python/chat/test_router_error_logging.py index c6f4fca2ee..87fe0c2c9b 100644 --- a/tests/shared/python/chat/test_router_error_logging.py +++ b/tests/shared/python/chat/test_router_error_logging.py @@ -281,7 +281,9 @@ def test_index_codebase_error_logging( with caplog.at_level(logging.WARNING, logger="chat.router_factory"): with client.websocket_connect("/api/ws/chat/new") as ws: ws.receive_json() - ws.send_json({"action": "index_codebase", "root_path": "/tmp"}) # nosec B108 + ws.send_json( + {"action": "index_codebase", "root_path": "/tmp"} + ) # nosec B108 payload = ws.receive_json() assert payload == {"type": "error", "detail": "disk full"} diff --git a/tests/shared/python/chat/test_terminal_runtime.py b/tests/shared/python/chat/test_terminal_runtime.py index 06f1d0c078..f98848b6c3 100644 --- a/tests/shared/python/chat/test_terminal_runtime.py +++ b/tests/shared/python/chat/test_terminal_runtime.py @@ -246,9 +246,9 @@ def test_default_session_env_excludes_credential_variables( env = _build_default_session_env() - assert var_name not in env, ( - f"{var_name!r} must not appear in the default session env" - ) + assert ( + var_name not in env + ), f"{var_name!r} must not appear in the default session env" def test_default_session_env_includes_path(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/shared/python/model_generation/test_gh1694_xml_security.py b/tests/shared/python/model_generation/test_gh1694_xml_security.py index 297b5c4b7e..5bab798579 100644 --- a/tests/shared/python/model_generation/test_gh1694_xml_security.py +++ b/tests/shared/python/model_generation/test_gh1694_xml_security.py @@ -265,6 +265,6 @@ def test_validate_mjcf_no_stdlib_et_parse_in_fallback(self) -> None: source = inspect.getsource(format_utils) # The fallback branch must not use StdET.ParseError - assert "StdET" not in source, ( - "format_utils.py must not reference StdET — use DefusedET.ParseError instead" - ) + assert ( + "StdET" not in source + ), "format_utils.py must not reference StdET — use DefusedET.ParseError instead" diff --git a/tests/shared/python/theme/test_fallback_drift.py b/tests/shared/python/theme/test_fallback_drift.py index ae77ae3b90..dceddf1f61 100644 --- a/tests/shared/python/theme/test_fallback_drift.py +++ b/tests/shared/python/theme/test_fallback_drift.py @@ -25,9 +25,9 @@ def _json_themes() -> dict[str, dict[str, str]]: def test_fallback_theme_names_match_json() -> None: """The fallback exposes exactly the themes defined in themes.json.""" json_themes = _json_themes() - assert set(colors._HARDCODED_BUILTIN_THEMES) == set(json_themes), ( - "Hardcoded fallback theme set drifted from themes.json" - ) + assert set(colors._HARDCODED_BUILTIN_THEMES) == set( + json_themes + ), "Hardcoded fallback theme set drifted from themes.json" def test_fallback_base_colors_match_json() -> None: @@ -52,9 +52,9 @@ def test_chart_colors_fallback_matches_json() -> None: json_chart = colors._load_chart_colors_from_json() if json_chart is None: pytest.skip("themes.json not available in this environment") - assert colors._HARDCODED_CHART_COLORS == json_chart, ( - "Hardcoded chart-color fallback drifted from themes.json" - ) + assert ( + colors._HARDCODED_CHART_COLORS == json_chart + ), "Hardcoded chart-color fallback drifted from themes.json" def test_builtin_themes_is_json_derived_when_available() -> None: diff --git a/tests/shared/python/ui/test_headless_import.py b/tests/shared/python/ui/test_headless_import.py index 4c13a4dbb6..200db4f8bc 100644 --- a/tests/shared/python/ui/test_headless_import.py +++ b/tests/shared/python/ui/test_headless_import.py @@ -15,8 +15,7 @@ def test_ui_imports_without_pyqt6() -> None: """Importing ``ui`` succeeds with PyQt6 forced absent; widgets are None.""" - script = textwrap.dedent( - """ + script = textwrap.dedent(""" import sys import importlib.abc @@ -42,8 +41,7 @@ def find_spec(self, name, path=None, target=None): assert "AutoCompleteLineEdit" in ui.__all__ assert "HoverCopyTextBrowser" in ui.__all__ print("HEADLESS_UI_IMPORT_OK") - """ - ) + """) result = subprocess.run( [sys.executable, "-c", script], capture_output=True, @@ -51,9 +49,9 @@ def find_spec(self, name, path=None, target=None): check=False, cwd=_repo_src_dir(), ) - assert result.returncode == 0, ( - f"headless ui import failed:\nstdout={result.stdout}\nstderr={result.stderr}" - ) + assert ( + result.returncode == 0 + ), f"headless ui import failed:\nstdout={result.stdout}\nstderr={result.stderr}" assert "HEADLESS_UI_IMPORT_OK" in result.stdout diff --git a/tests/test_gh1655_print_to_logging.py b/tests/test_gh1655_print_to_logging.py index c20440567b..44436efd65 100644 --- a/tests/test_gh1655_print_to_logging.py +++ b/tests/test_gh1655_print_to_logging.py @@ -48,9 +48,9 @@ def test_import_logging_present(self) -> None: elif isinstance(node, ast.ImportFrom): if node.module: import_names.append(node.module) - assert "logging" in import_names, ( - "logging must be imported in modern_robotics.py" - ) + assert ( + "logging" in import_names + ), "logging must be imported in modern_robotics.py" class TestNoUnguardedPrintInSrc: @@ -153,9 +153,9 @@ def test_t201_in_ruff_select(self) -> None: ruff_toml = Path(__file__).parents[1] / "ruff.toml" config = tomllib.loads(ruff_toml.read_text()) lint_select = config["lint"]["select"] - assert "T201" in lint_select, ( - "T201 must be in [lint] select in ruff.toml to enforce no-print policy" - ) + assert ( + "T201" in lint_select + ), "T201 must be in [lint] select in ruff.toml to enforce no-print policy" def test_notebooks_excluded_from_t201(self) -> None: """Notebooks must be excluded from T201 (print is valid).""" diff --git a/tests/test_gh1732_logging_consistency.py b/tests/test_gh1732_logging_consistency.py index e6c75f1010..a513104392 100644 --- a/tests/test_gh1732_logging_consistency.py +++ b/tests/test_gh1732_logging_consistency.py @@ -113,9 +113,9 @@ def test_collection_covers_shared_python(self) -> None: """Sweep includes src/shared/python — the shared library layer.""" files = _collect_library_py_files() shared_files = [f for f in files if "shared" in f.parts and "python" in f.parts] - assert len(shared_files) > 0, ( - "Expected at least one file from src/shared/python/ in the sweep" - ) + assert ( + len(shared_files) > 0 + ), "Expected at least one file from src/shared/python/ in the sweep" def test_collection_excludes_ruff_excluded_dirs(self) -> None: """Files from ruff-excluded directories are not in the sweep.""" @@ -123,18 +123,18 @@ def test_collection_excludes_ruff_excluded_dirs(self) -> None: for f in files: parts = f.relative_to(_SRC_ROOT).parts excluded = [p for p in parts if p in _RUFF_EXCLUDED_SRC_DIRS] - assert not excluded, ( - f"File from excluded directory should not be in sweep: {f}" - ) + assert ( + not excluded + ), f"File from excluded directory should not be in sweep: {f}" def test_collection_excludes_test_subdirs(self) -> None: """Test subdirectories are not in the sweep.""" files = _collect_library_py_files() for f in files: parts = f.relative_to(_SRC_ROOT).parts - assert "tests" not in parts, ( - f"File from tests/ subdirectory should not be in sweep: {f}" - ) + assert ( + "tests" not in parts + ), f"File from tests/ subdirectory should not be in sweep: {f}" class TestLoggingConsistencyRuffConfig: @@ -150,9 +150,9 @@ def test_t201_in_ruff_select(self) -> None: ruff_toml = _REPO_ROOT / "ruff.toml" config = tomllib.loads(ruff_toml.read_text()) lint_select = config["lint"]["select"] - assert "T201" in lint_select, ( - "T201 must be in [lint] select in ruff.toml to enforce the no-print policy" - ) + assert ( + "T201" in lint_select + ), "T201 must be in [lint] select in ruff.toml to enforce the no-print policy" def test_notebooks_excluded_from_t201(self) -> None: """Notebooks must have T201 suppressed (print is valid in notebooks).""" diff --git a/tests/test_no_urdf_builder_root_duplicates.py b/tests/test_no_urdf_builder_root_duplicates.py index 45d4ac2cfa..def755888f 100644 --- a/tests/test_no_urdf_builder_root_duplicates.py +++ b/tests/test_no_urdf_builder_root_duplicates.py @@ -75,9 +75,9 @@ def test_canonical_modules_present(self) -> None: "preview_generator.py", ] missing = [m for m in essential if not (_CANONICAL_PKG / m).exists()] - assert not missing, ( - "Canonical package is missing essential modules: " + ", ".join(missing) - ) + assert ( + not missing + ), "Canonical package is missing essential modules: " + ", ".join(missing) def test_path_bridge_uses_insert_not_append(self) -> None: """__init__.py must use __path__.insert(0, …) not append (#3346). diff --git a/tests/test_review_fixes_2026_03_09.py b/tests/test_review_fixes_2026_03_09.py index b51be34217..f6077cc7cf 100644 --- a/tests/test_review_fixes_2026_03_09.py +++ b/tests/test_review_fixes_2026_03_09.py @@ -57,9 +57,9 @@ def test_points_3_channels_fills_nan_residuals(self, reader): reader._metadata = None df = reader.points_dataframe(include_time=False) assert "residual" in df.columns - assert df["residual"].isna().all(), ( - "Residuals should be NaN when only 3 channels present" - ) + assert ( + df["residual"].isna().all() + ), "Residuals should be NaN when only 3 channels present" def test_points_4_channels_has_residuals(self, reader): """When C3D has 4 channels, residuals are extracted normally.""" diff --git a/tests/test_sidekick_public_api_stability.py b/tests/test_sidekick_public_api_stability.py index 4330060ceb..049da92468 100644 --- a/tests/test_sidekick_public_api_stability.py +++ b/tests/test_sidekick_public_api_stability.py @@ -254,17 +254,17 @@ def test_sidekick_public_api_stability(pytestconfig: pytest.Config) -> None: log.info("Regenerated public API baseline in %s", BASELINE_PATH) return - assert BASELINE_PATH.is_file(), ( - "Baseline file not found. Run with --regenerate-api-baseline to create it." - ) + assert ( + BASELINE_PATH.is_file() + ), "Baseline file not found. Run with --regenerate-api-baseline to create it." with open(BASELINE_PATH, encoding="utf-8") as f: baseline_api = json.load(f) # Compare keys - assert set(current_api.keys()) == set(baseline_api.keys()), ( - "Set of public sidekick module files changed." - ) + assert set(current_api.keys()) == set( + baseline_api.keys() + ), "Set of public sidekick module files changed." # Perform detailed comparison to raise clean assertions mismatches = [] diff --git a/tests/test_src_package_import_contract.py b/tests/test_src_package_import_contract.py index 31596170d4..43cbcdbc0e 100644 --- a/tests/test_src_package_import_contract.py +++ b/tests/test_src_package_import_contract.py @@ -48,6 +48,6 @@ def _import_under_consumer_contract(dotted: str) -> subprocess.CompletedProcess[ def test_top_level_packages_import_under_repo_root_only(package: str) -> None: """``import src.`` must succeed with only the repo root on path.""" result = _import_under_consumer_contract(f"src.{package}") - assert result.returncode == 0, ( - f"import src.{package} failed under repo-root-only sys.path:\n{result.stderr}" - ) + assert ( + result.returncode == 0 + ), f"import src.{package} failed under repo-root-only sys.path:\n{result.stderr}" diff --git a/tests/tools/test_logger_shim.py b/tests/tools/test_logger_shim.py index e13f51251d..bc9aed11a0 100644 --- a/tests/tools/test_logger_shim.py +++ b/tests/tools/test_logger_shim.py @@ -17,9 +17,9 @@ def test_logger_shim_issues_deprecation_warning(): import tools.logger # noqa: F401 dep_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)] - assert any("tools.logger" in str(w.message) for w in dep_warnings), ( - "Expected DeprecationWarning about tools.logger" - ) + assert any( + "tools.logger" in str(w.message) for w in dep_warnings + ), "Expected DeprecationWarning about tools.logger" def test_logger_shim_re_exports_setup_logging(): diff --git a/tests/unit/ai/gui/test_chat_export.py b/tests/unit/ai/gui/test_chat_export.py index 56c8ac1f29..925a4c808c 100644 --- a/tests/unit/ai/gui/test_chat_export.py +++ b/tests/unit/ai/gui/test_chat_export.py @@ -318,7 +318,7 @@ def test_copy_button_visible_on_message_widget(self) -> None: widget = MessageWidget("user", "Hello world") assert hasattr(widget, "_copy_btn"), "MessageWidget missing _copy_btn attribute" - assert isinstance(widget._copy_btn, QToolButton), ( - "_copy_btn must be a QToolButton" - ) + assert isinstance( + widget._copy_btn, QToolButton + ), "_copy_btn must be a QToolButton" _ = app # keep reference alive diff --git a/tests/unit/ai/integrations/github_mcp/test_tool_descriptors.py b/tests/unit/ai/integrations/github_mcp/test_tool_descriptors.py index ebbcfdf8b6..8cfc2c6b23 100644 --- a/tests/unit/ai/integrations/github_mcp/test_tool_descriptors.py +++ b/tests/unit/ai/integrations/github_mcp/test_tool_descriptors.py @@ -45,13 +45,13 @@ def test_write_tools_require_confirmation() -> None: """Mutating tools must opt into ``requires_confirmation=True``.""" for tool in GITHUB_MCP_TOOL_DESCRIPTORS: if tool.name in _EXPECTED_WRITE_TOOLS: - assert tool.requires_confirmation is True, ( - f"write tool {tool.name} must require confirmation" - ) + assert ( + tool.requires_confirmation is True + ), f"write tool {tool.name} must require confirmation" else: - assert tool.requires_confirmation is False, ( - f"read tool {tool.name} must not require confirmation" - ) + assert ( + tool.requires_confirmation is False + ), f"read tool {tool.name} must not require confirmation" def test_write_tool_names_helper() -> None: diff --git a/tests/unit/ai/mcp/test_notebooklm_server_phase2.py b/tests/unit/ai/mcp/test_notebooklm_server_phase2.py index 43977a0259..b63741a26d 100644 --- a/tests/unit/ai/mcp/test_notebooklm_server_phase2.py +++ b/tests/unit/ai/mcp/test_notebooklm_server_phase2.py @@ -90,9 +90,9 @@ def test_phase2_confirmation_tools_have_metadata() -> None: tools_by_name = {tool["name"]: tool for tool in response["result"]["tools"]} for needs_confirm in ("generate_audio_overview", "attach_to_chat"): meta = tools_by_name[needs_confirm].get("metadata") or {} - assert meta.get("requires_confirmation") is True, ( - f"{needs_confirm} must declare requires_confirmation=True" - ) + assert ( + meta.get("requires_confirmation") is True + ), f"{needs_confirm} must declare requires_confirmation=True" # --------------------------------------------------------------------------- diff --git a/tests/unit/ai/test_peer_review.py b/tests/unit/ai/test_peer_review.py index 2eddeb5421..9388c65e1d 100644 --- a/tests/unit/ai/test_peer_review.py +++ b/tests/unit/ai/test_peer_review.py @@ -299,9 +299,9 @@ def test_dialog_has_model_selector(self) -> None: dlg = self._dialog_cls() combos = dlg.findChildren(QComboBox) - assert len(combos) >= 2, ( - "Dialog must have at least two QComboBoxes (provider + model)" - ) + assert ( + len(combos) >= 2 + ), "Dialog must have at least two QComboBoxes (provider + model)" dlg.close() def test_dialog_returns_selected_config(self) -> None: @@ -311,8 +311,8 @@ def test_dialog_returns_selected_config(self) -> None: assert isinstance(config, tuple), "get_config() must return a tuple" assert len(config) == 2, "get_config() must return (provider, model)" provider, model = config - assert isinstance(provider, str) and provider, ( - "provider must be a non-empty str" - ) + assert ( + isinstance(provider, str) and provider + ), "provider must be a non-empty str" assert isinstance(model, str) and model, "model must be a non-empty str" dlg.close() diff --git a/tests/unit/chat/test_adapter_capabilities.py b/tests/unit/chat/test_adapter_capabilities.py index b61b528f30..9ee07414be 100644 --- a/tests/unit/chat/test_adapter_capabilities.py +++ b/tests/unit/chat/test_adapter_capabilities.py @@ -259,14 +259,14 @@ def test_list_models_returns_non_empty_list_of_strings( ) -> None: adapter = factory() models = adapter.list_models() - assert isinstance(models, list), ( - f"{provider_name}: list_models() must return a list" - ) + assert isinstance( + models, list + ), f"{provider_name}: list_models() must return a list" assert models, f"{provider_name}: list_models() must not be empty" for entry in models: - assert isinstance(entry, str) and entry.strip(), ( - f"{provider_name}: every model id must be a non-empty string" - ) + assert ( + isinstance(entry, str) and entry.strip() + ), f"{provider_name}: every model id must be a non-empty string" def test_list_models_is_offline_safe(self, provider_name: str, factory) -> None: """``list_models()`` must fall back to a static catalogue when the @@ -286,9 +286,9 @@ def test_thinking_capabilities_returns_dataclass( assert caps.provider # Must always include at least the "none" level. names = caps.level_names() - assert "none" in names, ( - f"{provider_name}: thinking_capabilities must include 'none'" - ) + assert ( + "none" in names + ), f"{provider_name}: thinking_capabilities must include 'none'" assert caps.default_level_name in names def test_thinking_capabilities_default_resolvable( diff --git a/tests/unit/codemap/test_codemap_db.py b/tests/unit/codemap/test_codemap_db.py index 0710346beb..ef83b67cde 100644 --- a/tests/unit/codemap/test_codemap_db.py +++ b/tests/unit/codemap/test_codemap_db.py @@ -118,8 +118,7 @@ def test_init_schema_migrates_legacy_fts_alias_schema() -> None: conn = sqlite3.connect(":memory:") try: - conn.executescript( - """ + conn.executescript(""" CREATE TABLE meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -158,8 +157,7 @@ def test_init_schema_migrates_legacy_fts_alias_schema() -> None: CREATE TRIGGER symbols_ai AFTER INSERT ON symbols BEGIN SELECT 1; END; - """ - ) + """) codemap_db.init_schema(conn) diff --git a/tests/unit/lower_body_model/test_builder.py b/tests/unit/lower_body_model/test_builder.py index ed1a0df176..a5f689456e 100644 --- a/tests/unit/lower_body_model/test_builder.py +++ b/tests/unit/lower_body_model/test_builder.py @@ -24,9 +24,9 @@ def test_build_lower_body_xml_generates_valid_mjcf() -> None: ] # We expect floating base / pelvis joints, hip, knee, ankle joints - assert "r_hip_x" in joint_names or "r_hip" in joint_names, ( - "Should have right hip joint" - ) + assert ( + "r_hip_x" in joint_names or "r_hip" in joint_names + ), "Should have right hip joint" assert "r_knee" in joint_names, "Should have right knee joint" assert "l_knee" in joint_names, "Should have left knee joint" @@ -137,9 +137,9 @@ def names_by_prefix(obj_type: int, count: int, prefix: str) -> set[str]: ): r_names = names_by_prefix(obj_type, count, "r_") l_names = names_by_prefix(obj_type, count, "l_") - assert r_names == l_names, ( - f"obj_type={obj_type}: mismatch r={r_names} l={l_names}" - ) + assert ( + r_names == l_names + ), f"obj_type={obj_type}: mismatch r={r_names} l={l_names}" def test_builder_total_body_and_joint_counts() -> None: diff --git a/tests/unit/lower_body_model/test_hip_rotation_target.py b/tests/unit/lower_body_model/test_hip_rotation_target.py index d3eb8d965d..4c0cb334a9 100644 --- a/tests/unit/lower_body_model/test_hip_rotation_target.py +++ b/tests/unit/lower_body_model/test_hip_rotation_target.py @@ -120,9 +120,9 @@ def test_simulator_pelvis_driver_tracks_lateral_shift() -> None: final_y = float(sim.data.xpos[sim.pelvis_body_id][1]) # The pelvis should have shifted in +Y during the downswing phase. - assert final_y - initial_y > 0.01, ( - f"expected +Y shift; got {final_y - initial_y:.4f}" - ) + assert ( + final_y - initial_y > 0.01 + ), f"expected +Y shift; got {final_y - initial_y:.4f}" def test_set_pelvis_inclined_rotation_rejects_bad_gains() -> None: diff --git a/tests/unit/lower_body_model/test_simulator.py b/tests/unit/lower_body_model/test_simulator.py index 55eab36fdd..80fbadf9a7 100644 --- a/tests/unit/lower_body_model/test_simulator.py +++ b/tests/unit/lower_body_model/test_simulator.py @@ -80,9 +80,9 @@ def test_induced_acceleration_analysis(simulator: LowerBodySimulator) -> None: # The total induced acceleration shouldn't be identically perfectly zero total_accel = sum(abs(v) for v in iaa_result.values()) - assert total_accel > 1e-4, ( - "Applied torque should induce some acceleration on the root body." - ) + assert ( + total_accel > 1e-4 + ), "Applied torque should induce some acceleration on the root body." def test_history_recording_and_restoring(simulator: LowerBodySimulator) -> None: diff --git a/tests/unit/rust/test_ai_backend_workspace.py b/tests/unit/rust/test_ai_backend_workspace.py index d08a5069ab..a958edd65a 100644 --- a/tests/unit/rust/test_ai_backend_workspace.py +++ b/tests/unit/rust/test_ai_backend_workspace.py @@ -21,9 +21,9 @@ def test_ai_backend_in_cargo_workspace(): """ai_backend must be a declared workspace member in the root Cargo.toml.""" cargo_toml = (REPO_ROOT / "Cargo.toml").read_text(encoding="utf-8") - assert "ai_backend" in cargo_toml, ( - "rust_core/ai_backend is not listed in the root workspace Cargo.toml members" - ) + assert ( + "ai_backend" in cargo_toml + ), "rust_core/ai_backend is not listed in the root workspace Cargo.toml members" @pytest.mark.unit @@ -62,12 +62,12 @@ def test_maturin_ci_covers_all_platforms(): for wf_path in candidates: content = wf_path.read_text(encoding="utf-8").lower() assert "windows" in content, f"{wf_path.name}: missing Windows runner" - assert "ubuntu" in content or "linux" in content, ( - f"{wf_path.name}: missing Ubuntu/Linux runner" - ) - assert "macos" in content or "mac" in content, ( - f"{wf_path.name}: missing macOS runner" - ) + assert ( + "ubuntu" in content or "linux" in content + ), f"{wf_path.name}: missing Ubuntu/Linux runner" + assert ( + "macos" in content or "mac" in content + ), f"{wf_path.name}: missing macOS runner" @pytest.mark.unit @@ -79,9 +79,9 @@ def test_maturin_ci_covers_python_versions(): + list(workflows_dir.glob("*ai_backend*")) + list(workflows_dir.glob("*ai-backend*")) ) - assert candidates, ( - "No maturin CI workflow found — cannot check Python version coverage." - ) + assert ( + candidates + ), "No maturin CI workflow found — cannot check Python version coverage." fleet_toolcache_limited = { "maturin-data-processor-core.yml", @@ -94,9 +94,9 @@ def test_maturin_ci_covers_python_versions(): for version in ["3.10", "3.11", "3.12"]: assert version in content, f"Python {version} not listed in {wf_path.name}" if wf_path.name in fleet_toolcache_limited: - assert "3.13" in content, ( - f"{wf_path.name}: must document why Python 3.13 is not hard-gated" - ) + assert ( + "3.13" in content + ), f"{wf_path.name}: must document why Python 3.13 is not hard-gated" assert "toolcache" in content.lower(), ( f"{wf_path.name}: Python 3.13 deferral must cite runner " "toolcache limits" @@ -133,9 +133,9 @@ def test_ai_backend_cargo_toml_declares_local_embeddings_feature(): crate_toml = (REPO_ROOT / "rust_core" / "ai_backend" / "Cargo.toml").read_text( encoding="utf-8" ) - assert "local-embeddings" in crate_toml, ( - "rust_core/ai_backend/Cargo.toml does not declare 'local-embeddings' feature." - ) + assert ( + "local-embeddings" in crate_toml + ), "rust_core/ai_backend/Cargo.toml does not declare 'local-embeddings' feature." @pytest.mark.unit diff --git a/tests/unit/sidekick/agent/test_action_audit.py b/tests/unit/sidekick/agent/test_action_audit.py index 51274c7b3a..4d5a0f0abb 100644 --- a/tests/unit/sidekick/agent/test_action_audit.py +++ b/tests/unit/sidekick/agent/test_action_audit.py @@ -20,7 +20,9 @@ def _call(**params: Any) -> RecordedCall: return RecordedCall( - timestamp=datetime(2026, 1, 2, tzinfo=timezone.utc), # noqa: UP017 - Python 3.10 CI lacks datetime.UTC. + timestamp=datetime( + 2026, 1, 2, tzinfo=timezone.utc + ), # noqa: UP017 - Python 3.10 CI lacks datetime.UTC. action_id="test.echo", params=params, descriptor=ActionDescriptor( diff --git a/tests/unit/sidekick/agent/test_feature_catalog.py b/tests/unit/sidekick/agent/test_feature_catalog.py index 6987b0b3a1..92a82535f0 100644 --- a/tests/unit/sidekick/agent/test_feature_catalog.py +++ b/tests/unit/sidekick/agent/test_feature_catalog.py @@ -116,7 +116,9 @@ def test_discovery_helpers_extract_metadata_and_walk_fake_package( ), ) - walked = list(discovery._walk_package("sidekick.calculators", "calculator")) # noqa: SLF001 + walked = list( + discovery._walk_package("sidekick.calculators", "calculator") + ) # noqa: SLF001 assert walked[0].feature_id == "calculator.fake_module" assert walked[0].title == "Fake Module" @@ -133,7 +135,9 @@ def test_workflow_and_importability_discovery(monkeypatch: pytest.MonkeyPatch) - workflows = discovery._discover_workflows() # noqa: SLF001 assert workflows[0].feature_id == "workflow.build" - assert discovery._discover_theme()[0].feature_id == "theme.sidekick_tokens" # noqa: SLF001 + assert ( + discovery._discover_theme()[0].feature_id == "theme.sidekick_tokens" + ) # noqa: SLF001 assert tuple(src.__name__ for src in discovery.discover_sources()) == ( "_discover_calculators", "_discover_process_calculators", diff --git a/tests/unit/sidekick/test_chat_redock.py b/tests/unit/sidekick/test_chat_redock.py index 17535835f6..f8f564adb5 100644 --- a/tests/unit/sidekick/test_chat_redock.py +++ b/tests/unit/sidekick/test_chat_redock.py @@ -106,9 +106,9 @@ def test_chat_popout_window_has_redock_button(qtbot) -> None: # type: ignore[no ) qtbot.addWidget(win) redock_btn = win.findChild(QPushButton, _REDOCK_BUTTON_OBJECT_NAME) - assert redock_btn is not None, ( - f"Expected QPushButton with objectName {_REDOCK_BUTTON_OBJECT_NAME!r}" - ) + assert ( + redock_btn is not None + ), f"Expected QPushButton with objectName {_REDOCK_BUTTON_OBJECT_NAME!r}" def test_chat_popout_window_redock_invokes_callback(qtbot) -> None: # type: ignore[no-untyped-def] diff --git a/tests/unit/sidekick/test_sidekick_f4_collaborators.py b/tests/unit/sidekick/test_sidekick_f4_collaborators.py index 6281dc1ddd..158afef153 100644 --- a/tests/unit/sidekick/test_sidekick_f4_collaborators.py +++ b/tests/unit/sidekick/test_sidekick_f4_collaborators.py @@ -85,9 +85,9 @@ def test_set_definitions_mutates_in_place(self, qtbot: Any) -> None: [SidebarTabDefinition(tab_id="chat", title="Chat", factory=lambda *_: None)] ) - assert alias is col._tab_definitions, ( # noqa: SLF001 - "set_definitions() must not rebind the backing dict" - ) + assert ( + alias is col._tab_definitions + ), "set_definitions() must not rebind the backing dict" # noqa: SLF001 assert "chat" in alias, "alias must observe the new definition in place" assert col.definition_for("chat") is not None @@ -105,9 +105,9 @@ def test_sync_order_mutates_ids_in_place(self, qtbot: Any) -> None: col.sync_order_from_widget() - assert alias is col._tab_ids, ( # noqa: SLF001 - "sync_order_from_widget() must not rebind the backing list" - ) + assert ( + alias is col._tab_ids + ), "sync_order_from_widget() must not rebind the backing list" # noqa: SLF001 assert alias == ["a", "b"], "alias must observe current visual order" def test_add_duplicate_raises(self, qtbot: Any) -> None: @@ -153,9 +153,9 @@ def test_replace_swaps_widget(self, qtbot: Any) -> None: result = col.replace(old_w, new_w) assert result is True, "replace() must return True" - assert col.widget_for("chat") is new_w, ( - "widget_for() must return the new widget after replace()" - ) + assert ( + col.widget_for("chat") is new_w + ), "widget_for() must return the new widget after replace()" assert "chat" in col.visible_ids(), "id must still be in visible_ids()" def test_clear_resets_state(self, qtbot: Any) -> None: @@ -170,9 +170,9 @@ def test_clear_resets_state(self, qtbot: Any) -> None: col.clear() assert col.visible_ids() == [], "visible_ids() must be empty after clear()" - assert col.widget_for("t") is None, ( - "widget_for() must return None after clear()" - ) + assert ( + col.widget_for("t") is None + ), "widget_for() must return None after clear()" def test_contains_and_index_of(self, qtbot: Any) -> None: """contains() and index_of() must reflect actual id list.""" @@ -250,7 +250,9 @@ def test_toggle_collapsed_hides_tabs(self, qtbot: Any) -> None: assert not ctrl.is_collapsed, "starts expanded" ctrl.toggle_collapsed() assert ctrl.is_collapsed, "must be collapsed after toggle" - assert ctrl._tabs.isVisible() is False, "tabs must be hidden when collapsed" # noqa: SLF001 + assert ( + ctrl._tabs.isVisible() is False + ), "tabs must be hidden when collapsed" # noqa: SLF001 def test_toggle_collapsed_shows_tabs_on_expand(self, qtbot: Any) -> None: """A second toggle_collapsed() must restore the tabs to visible.""" @@ -259,7 +261,9 @@ def test_toggle_collapsed_shows_tabs_on_expand(self, qtbot: Any) -> None: ctrl.toggle_collapsed() # expand assert not ctrl.is_collapsed, "must be expanded after double toggle" - assert ctrl._tabs.isVisible() is True, "tabs must be visible after expanding" # noqa: SLF001 + assert ( + ctrl._tabs.isVisible() is True + ), "tabs must be visible after expanding" # noqa: SLF001 def test_dock_widget_is_none_before_install(self, qtbot: Any) -> None: """dock_widget must be None before install_as_dock() is called.""" @@ -382,6 +386,8 @@ def test_two_projects_use_different_keys(self, tmp_path: Any) -> None: vp_b = VisibilityPersistence(project_root=root_b) # Access the private key to assert isolation (white-box) - assert vp_a._key != vp_b._key, ( # noqa: SLF001 + assert ( + vp_a._key != vp_b._key + ), ( # noqa: SLF001 "Different roots must produce different QSettings keys (F5 isolation)" ) diff --git a/tests/unit/sidekick/test_sidekick_ux_hardening.py b/tests/unit/sidekick/test_sidekick_ux_hardening.py index be4cc52be8..13bab2a0c0 100644 --- a/tests/unit/sidekick/test_sidekick_ux_hardening.py +++ b/tests/unit/sidekick/test_sidekick_ux_hardening.py @@ -47,7 +47,9 @@ def test_submit_sends_single_newline( # noqa: ANN201 except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 written: list[bytes] = [] @@ -113,7 +115,9 @@ def _make_widget( # noqa: ANN202 except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 widget = SidekickOsTerminalWidget( project_root=tmp_path, shells=[ @@ -169,9 +173,9 @@ def test_persist_helper_exists(self) -> None: except ImportError: pytest.skip("sidekick unavailable") - assert hasattr(UnifiedToolsSidebar, "_persist_visible_tabs"), ( - "_persist_visible_tabs helper missing (F5 regression)" - ) + assert hasattr( + UnifiedToolsSidebar, "_persist_visible_tabs" + ), "_persist_visible_tabs helper missing (F5 regression)" def test_qs_constants_are_defined(self) -> None: """Module-level QSettings constants must be present.""" @@ -182,9 +186,9 @@ def test_qs_constants_are_defined(self) -> None: assert hasattr(sb, "_QS_ORG"), "_QS_ORG constant missing" assert hasattr(sb, "_QS_APP"), "_QS_APP constant missing" - assert hasattr(sb, "_QS_VISIBLE_TABS_KEY"), ( # noqa: E501 - "_QS_VISIBLE_TABS_KEY constant missing" - ) + assert hasattr( + sb, "_QS_VISIBLE_TABS_KEY" + ), "_QS_VISIBLE_TABS_KEY constant missing" # noqa: E501 def test_persist_uses_explicit_org_app( # noqa: ANN201 self, tmp_path: Path, qtbot: Any @@ -198,7 +202,9 @@ def test_persist_uses_explicit_org_app( # noqa: ANN201 except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 written: dict[str, Any] = {} class _FakeQSettings: @@ -210,7 +216,9 @@ def setValue(self, key: str, value: Any) -> None: # noqa: N802 written["key"] = key written["value"] = value - def value(self, key: str, default: Any = None, **kwargs: Any) -> Any: # noqa: N802 + def value( + self, key: str, default: Any = None, **kwargs: Any + ) -> Any: # noqa: N802 return default def sync(self) -> None: # noqa: N802 @@ -265,7 +273,9 @@ def test_second_call_raises_existing_dialog( # noqa: ANN201 except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 sidebar = UnifiedToolsSidebar(project_root=tmp_path) qtbot.addWidget(sidebar) @@ -321,15 +331,15 @@ def test_quick_access_methods_exist(self) -> None: except ImportError: pytest.skip("sidekick unavailable") - assert hasattr(ProjectFileExplorer, "_restore_quick_access"), ( - "_restore_quick_access missing (F10 regression)" - ) - assert hasattr(ProjectFileExplorer, "_save_quick_access"), ( - "_save_quick_access missing (F10 regression)" - ) - assert hasattr(ProjectFileExplorer, "_quick_access_settings_key"), ( - "_quick_access_settings_key missing (F10 regression)" - ) + assert hasattr( + ProjectFileExplorer, "_restore_quick_access" + ), "_restore_quick_access missing (F10 regression)" + assert hasattr( + ProjectFileExplorer, "_save_quick_access" + ), "_save_quick_access missing (F10 regression)" + assert hasattr( + ProjectFileExplorer, "_quick_access_settings_key" + ), "_quick_access_settings_key missing (F10 regression)" def test_add_to_quick_access_rejects_duplicates( # noqa: ANN201 self, tmp_path: Path, qtbot: Any @@ -343,7 +353,9 @@ def test_add_to_quick_access_rejects_duplicates( # noqa: ANN201 except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 explorer = ProjectFileExplorer(project_root=tmp_path, parent=None) qtbot.addWidget(explorer) @@ -464,9 +476,9 @@ def test_replace_tab_widget_exists(self) -> None: except ImportError: pytest.skip("sidekick unavailable") - assert hasattr(UnifiedToolsSidebar, "replace_tab_widget"), ( - "replace_tab_widget public method missing (F8 regression)" - ) + assert hasattr( + UnifiedToolsSidebar, "replace_tab_widget" + ), "replace_tab_widget public method missing (F8 regression)" assert callable(UnifiedToolsSidebar.replace_tab_widget) def test_replace_tab_widget_updates_map(self, tmp_path: Path, qtbot: Any) -> None: @@ -479,7 +491,9 @@ def test_replace_tab_widget_updates_map(self, tmp_path: Path, qtbot: Any) -> Non except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 sidebar = UnifiedToolsSidebar(project_root=tmp_path) qtbot.addWidget(sidebar) @@ -493,7 +507,9 @@ def test_replace_tab_widget_updates_map(self, tmp_path: Path, qtbot: Any) -> Non result = sidebar.replace_tab_widget(old_widget, new_widget) assert result is True, "replace_tab_widget returned False unexpectedly" - assert sidebar._tab_widgets.get("swap_test") is new_widget, ( # noqa: SLF001 + assert ( + sidebar._tab_widgets.get("swap_test") is new_widget + ), ( # noqa: SLF001 "_tab_widgets still points to old_widget after swap (F8 regression)" ) @@ -509,7 +525,9 @@ def test_replace_tab_widget_returns_false_for_unknown( except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 sidebar = UnifiedToolsSidebar(project_root=tmp_path) qtbot.addWidget(sidebar) @@ -544,12 +562,12 @@ def test_update_from_notifies_subscribers(self) -> None: target.update_from(source) notified_names = {name for _, name in events} - assert "x" in notified_names, ( - "Subscriber was not notified for 'x' (F9 regression)" - ) - assert "y" in notified_names, ( - "Subscriber was not notified for 'y' (F9 regression)" - ) + assert ( + "x" in notified_names + ), "Subscriber was not notified for 'x' (F9 regression)" + assert ( + "y" in notified_names + ), "Subscriber was not notified for 'y' (F9 regression)" def test_update_from_validates_names(self) -> None: """update_from must reject invalid variable names from the source.""" @@ -583,9 +601,9 @@ def test_update_from_replace_clears_existing(self) -> None: target.update_from(source, replace=True) - assert target.list_names() == ["new"], ( - "replace=True did not clear existing variables (F9 regression)" - ) + assert target.list_names() == [ + "new" + ], "replace=True did not clear existing variables (F9 regression)" def test_repr_only_entries_are_merged_and_notified(self) -> None: """Repr-only entries from a loaded registry must be merged + notify fired.""" @@ -611,12 +629,12 @@ def test_repr_only_entries_are_merged_and_notified(self) -> None: target.update_from(source) - assert "arr" in target.list_names(), ( - "repr-only variable not merged by update_from (F9 regression)" - ) - assert "arr" in events, ( - "Subscriber not notified for repr-only variable (F9 regression)" - ) + assert ( + "arr" in target.list_names() + ), "repr-only variable not merged by update_from (F9 regression)" + assert ( + "arr" in events + ), "Subscriber not notified for repr-only variable (F9 regression)" # --------------------------------------------------------------------------- @@ -640,7 +658,9 @@ def _make_widget_with_fake_backend( except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 written: list[bytes] = [] @@ -684,9 +704,9 @@ def test_send_interrupt_writes_etx(self, tmp_path: Path, qtbot: Any) -> None: widget, written = self._make_widget_with_fake_backend(tmp_path, qtbot) widget._send_interrupt() # noqa: SLF001 assert written, "_send_interrupt did not write anything to backend" - assert written[0] == b"\x03", ( - f"Expected b'\\x03' but got {written[0]!r} (F2 regression)" - ) + assert ( + written[0] == b"\x03" + ), f"Expected b'\\x03' but got {written[0]!r} (F2 regression)" def test_history_records_submitted_commands( self, tmp_path: Path, qtbot: Any @@ -698,7 +718,9 @@ def test_history_records_submitted_commands( widget._input.setText("pwd") # noqa: SLF001 widget._on_submit() # noqa: SLF001 - assert widget._history[0] == "pwd", ( # noqa: SLF001 + assert ( + widget._history[0] == "pwd" + ), ( # noqa: SLF001 "Most recent command must be first in history (F2 regression)" ) assert widget._history[1] == "ls -la" # noqa: SLF001 @@ -711,9 +733,9 @@ def test_history_rejects_exact_duplicates(self, tmp_path: Path, qtbot: Any) -> N widget._input.setText("echo hi") # noqa: SLF001 widget._on_submit() # noqa: SLF001 - assert widget._history.count("echo hi") == 1, ( # noqa: SLF001 - "Duplicate command was added to history (F2 regression)" - ) + assert ( + widget._history.count("echo hi") == 1 + ), "Duplicate command was added to history (F2 regression)" # noqa: SLF001 def test_navigate_history_older(self, tmp_path: Path, qtbot: Any) -> None: """Up-arrow (direction=1) must populate the input with older commands.""" @@ -725,15 +747,17 @@ def test_navigate_history_older(self, tmp_path: Path, qtbot: Any) -> None: # Navigate one step back (most recent = "second") widget._navigate_history(direction=1) # noqa: SLF001 - assert widget._input.text() == "second", ( # noqa: SLF001 + assert ( + widget._input.text() == "second" + ), ( # noqa: SLF001 "First up-arrow should show most recent command (F2 regression)" ) # Navigate one more step back (older = "first") widget._navigate_history(direction=1) # noqa: SLF001 - assert widget._input.text() == "first", ( # noqa: SLF001 - "Second up-arrow should show older command (F2 regression)" - ) + assert ( + widget._input.text() == "first" + ), "Second up-arrow should show older command (F2 regression)" # noqa: SLF001 def test_navigate_history_forward_restores_scratch( self, tmp_path: Path, qtbot: Any @@ -747,7 +771,9 @@ def test_navigate_history_forward_restores_scratch( widget._navigate_history(direction=1) # noqa: SLF001 # go back widget._navigate_history(direction=-1) # noqa: SLF001 # come forward - assert widget._input.text() == "new draft", ( # noqa: SLF001 + assert ( + widget._input.text() == "new draft" + ), ( # noqa: SLF001 "Navigating forward past newest should restore live draft (F2 regression)" ) @@ -774,7 +800,9 @@ def _make_repl(self, qtbot: Any) -> Any: except ImportError: pytest.skip("Qt/sidekick unavailable") - app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) # noqa: F841 + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication( + [] + ) # noqa: F841 reg = WorkspaceRegistry() widget = runtime_tabs.PythonReplWidget( registry=reg, @@ -787,23 +815,23 @@ def _make_repl(self, qtbot: Any) -> Any: def test_cancel_button_present_and_hidden(self, qtbot: Any) -> None: """Widget must expose _cancel_button, initially hidden and disabled.""" widget = self._make_repl(qtbot) - assert hasattr(widget, "_cancel_button"), ( - "_cancel_button missing (F6 regression)" - ) - assert not widget._cancel_button.isVisible(), ( # noqa: SLF001 - "_cancel_button should be hidden at rest (F6 regression)" - ) - assert not widget._cancel_button.isEnabled(), ( # noqa: SLF001 - "_cancel_button should be disabled at rest (F6 regression)" - ) + assert hasattr( + widget, "_cancel_button" + ), "_cancel_button missing (F6 regression)" + assert ( + not widget._cancel_button.isVisible() + ), "_cancel_button should be hidden at rest (F6 regression)" # noqa: SLF001 + assert ( + not widget._cancel_button.isEnabled() + ), "_cancel_button should be disabled at rest (F6 regression)" # noqa: SLF001 def test_status_label_present_and_hidden(self, qtbot: Any) -> None: """Widget must expose _status_label, initially hidden.""" widget = self._make_repl(qtbot) assert hasattr(widget, "_status_label"), "_status_label missing (F6 regression)" - assert not widget._status_label.isVisible(), ( # noqa: SLF001 - "_status_label should be hidden at rest (F6 regression)" - ) + assert ( + not widget._status_label.isVisible() + ), "_status_label should be hidden at rest (F6 regression)" # noqa: SLF001 def test_execute_completes_and_shows_output(self, qtbot: Any) -> None: """execute() must complete and write output to the output pane.""" @@ -823,23 +851,27 @@ def test_set_running_toggles_controls(self, qtbot: Any) -> None: widget = self._make_repl(qtbot) widget._set_running(True) # noqa: SLF001 - assert not widget._run_button.isEnabled(), ( # noqa: SLF001 - "Run button must be disabled while running (F6 regression)" - ) + assert ( + not widget._run_button.isEnabled() + ), "Run button must be disabled while running (F6 regression)" # noqa: SLF001 # In headless tests the top-level window is never shown, so isVisible() # returns False even after setVisible(True). isHidden() checks the # widget's own explicit visibility bit, which is reliable here. - assert not widget._cancel_button.isHidden(), ( # noqa: SLF001 + assert ( + not widget._cancel_button.isHidden() + ), ( # noqa: SLF001 "Cancel button must not be hidden while running (F6 regression)" ) - assert not widget._status_label.isHidden(), ( # noqa: SLF001 + assert ( + not widget._status_label.isHidden() + ), ( # noqa: SLF001 "Status label must not be hidden while running (F6 regression)" ) widget._set_running(False) # noqa: SLF001 - assert widget._run_button.isEnabled(), ( # noqa: SLF001 - "Run button must re-enable after stop (F6 regression)" - ) - assert widget._cancel_button.isHidden(), ( # noqa: SLF001 - "Cancel button must be hidden after stop (F6 regression)" - ) + assert ( + widget._run_button.isEnabled() + ), "Run button must re-enable after stop (F6 regression)" # noqa: SLF001 + assert ( + widget._cancel_button.isHidden() + ), "Cancel button must be hidden after stop (F6 regression)" # noqa: SLF001 diff --git a/tests/unit/sidekick/test_tab_context_menu.py b/tests/unit/sidekick/test_tab_context_menu.py index b59208657e..2a2c12a1ff 100644 --- a/tests/unit/sidekick/test_tab_context_menu.py +++ b/tests/unit/sidekick/test_tab_context_menu.py @@ -249,9 +249,9 @@ def test_context_menu_has_minimize_action(tmp_path: Path, qtbot: Any) -> None: menu = build_tab_context_menu(sidebar, tab_id) qtbot.addWidget(menu) action_texts = {a.text() for a in menu.actions() if a.text()} - assert "Minimize Sidebar" in action_texts, ( - f"Expected 'Minimize Sidebar' in {action_texts}" - ) + assert ( + "Minimize Sidebar" in action_texts + ), f"Expected 'Minimize Sidebar' in {action_texts}" # --------------------------------------------------------------------------- diff --git a/tests/unit/test_check_coverage_policy.py b/tests/unit/test_check_coverage_policy.py index d997408997..ebffc6c121 100644 --- a/tests/unit/test_check_coverage_policy.py +++ b/tests/unit/test_check_coverage_policy.py @@ -195,7 +195,9 @@ def test_large_consolidation_branch_skips_changed_test_expansion() -> None: run_tests_block = workflow.split( "- name: Run Tests with Coverage", maxsplit=1, - )[1].split("- name: Provider-Contract Suite", maxsplit=1)[0] + )[ + 1 + ].split("- name: Provider-Contract Suite", maxsplit=1)[0] assert "large_consolidation_branch=false" in run_tests_block assert 'BRANCH_NAME" = "consolidate/open-prs-20260620' in run_tests_block diff --git a/tests/unit/test_check_sidekick_coverage.py b/tests/unit/test_check_sidekick_coverage.py index e1b85e8878..f204397ecc 100644 --- a/tests/unit/test_check_sidekick_coverage.py +++ b/tests/unit/test_check_sidekick_coverage.py @@ -13,16 +13,13 @@ def _line_xml(hits_by_line: list[int]) -> str: for idx, hits in enumerate(hits_by_line, 1) ) - class_xml = "\n".join( - f""" + class_xml = "\n".join(f""" {_line_xml(hits_by_line)} - """ - for filename, hits_by_line in classes - ) + """ for filename, hits_by_line in classes) path.write_text( f""" diff --git a/tests/unit/test_epic_2661_children_verification.py b/tests/unit/test_epic_2661_children_verification.py index 3df22bc501..79a0e045d8 100644 --- a/tests/unit/test_epic_2661_children_verification.py +++ b/tests/unit/test_epic_2661_children_verification.py @@ -47,12 +47,12 @@ def _exists(rel_path: str) -> bool: @pytest.mark.unit def test_2662_tab_context_menus() -> None: """#2662: Tab workflow controls moved to right-click menus.""" - assert (SIDEBAR / "tab_context_menu.py").is_file(), ( - "tab_context_menu.py missing — #2662 may be phantom-closed" - ) - assert (SIDEBAR / "tab_context_menu.py").stat().st_size > 500, ( - "tab_context_menu.py appears to be a stub (< 500 bytes)" - ) + assert ( + SIDEBAR / "tab_context_menu.py" + ).is_file(), "tab_context_menu.py missing — #2662 may be phantom-closed" + assert ( + SIDEBAR / "tab_context_menu.py" + ).stat().st_size > 500, "tab_context_menu.py appears to be a stub (< 500 bytes)" @pytest.mark.unit @@ -141,13 +141,13 @@ def test_2673_jupyter_tab_phased_implementation() -> None: """ # The phased implementation should have a jupyter_tab subpackage jupyter_dir = SIDEBAR / "jupyter_tab" - assert jupyter_dir.is_dir(), ( - "jupyter_tab/ directory missing — phased Jupyter implementation not landed" - ) + assert ( + jupyter_dir.is_dir() + ), "jupyter_tab/ directory missing — phased Jupyter implementation not landed" assert (jupyter_dir / "widget.py").is_file(), "jupyter_tab/widget.py missing" - assert (jupyter_dir / "availability.py").is_file(), ( - "jupyter_tab/availability.py missing (soft-dependency guard)" - ) + assert ( + jupyter_dir / "availability.py" + ).is_file(), "jupyter_tab/availability.py missing (soft-dependency guard)" @pytest.mark.unit @@ -182,17 +182,17 @@ def test_2675_shared_calculator_workspace_contract() -> None: workspace_contract = ( REPO_ROOT / "src" / "shared" / "python" / "sidekick" / "workspace_contract.py" ) - assert workspace_contract.is_file(), ( - "workspace_contract.py missing — #2675 shared contract not implemented" - ) + assert ( + workspace_contract.is_file() + ), "workspace_contract.py missing — #2675 shared contract not implemented" @pytest.mark.unit def test_2676_host_integration() -> None: """#2676: Proven shared host integration across downstream consumers.""" - assert (INTEGRATION / "test_sidekick_host_integration.py").is_file(), ( - "Integration test file missing for #2676" - ) + assert ( + INTEGRATION / "test_sidekick_host_integration.py" + ).is_file(), "Integration test file missing for #2676" content = (INTEGRATION / "test_sidekick_host_integration.py").read_text( encoding="utf-8" ) @@ -251,9 +251,9 @@ def test_2682_symbolic_solver() -> None: Being implemented on branch fix/issue-2934-symbolic-solver. """ - assert (SIDEKICK / "symbolic_engine.py").is_file(), ( - "symbolic_engine.py missing — #2682 not yet fully landed" - ) + assert ( + SIDEKICK / "symbolic_engine.py" + ).is_file(), "symbolic_engine.py missing — #2682 not yet fully landed" @pytest.mark.unit @@ -276,9 +276,9 @@ def test_2684_rotation_converter_tab() -> None: """ assert (SIDEBAR / "default_tabs.py").is_file() content = (SIDEBAR / "default_tabs.py").read_text(encoding="utf-8") - assert "rotation" in content.lower() or "ROTATION_CONVERTER" in content, ( - "default_tabs.py does not appear to include Rotation Converter tab" - ) + assert ( + "rotation" in content.lower() or "ROTATION_CONVERTER" in content + ), "default_tabs.py does not appear to include Rotation Converter tab" @pytest.mark.unit @@ -396,6 +396,6 @@ def test_epic_2661_implementation_summary() -> None: UserWarning, stacklevel=2, ) - assert len(present) + len(missing_core) == len(files_to_check), ( - "Epic #2661 summary inventory lost or duplicated file entries" - ) + assert len(present) + len(missing_core) == len( + files_to_check + ), "Epic #2661 summary inventory lost or duplicated file entries" diff --git a/tests/unit/test_sidekick_import_deprecation.py b/tests/unit/test_sidekick_import_deprecation.py index 3711f79f23..d239e114e8 100644 --- a/tests/unit/test_sidekick_import_deprecation.py +++ b/tests/unit/test_sidekick_import_deprecation.py @@ -93,9 +93,9 @@ def test_sidekick_package_exists() -> None: f"sidekick package directory missing: {SIDEKICK_SRC}. " "The Phase 2 rename has not been executed." ) - assert (SIDEKICK_SRC / "__init__.py").is_file(), ( - f"sidekick/__init__.py missing — package is incomplete: {SIDEKICK_SRC}" - ) + assert ( + SIDEKICK_SRC / "__init__.py" + ).is_file(), f"sidekick/__init__.py missing — package is incomplete: {SIDEKICK_SRC}" @pytest.mark.unit @@ -105,7 +105,9 @@ def test_deprecation_shim_exists() -> None: f"Deprecation shim directory missing: {SHIM_DIR}. " "Create it with a DeprecationWarning on import." ) - assert (SHIM_DIR / "__init__.py").is_file(), ( + assert ( + SHIM_DIR / "__init__.py" + ).is_file(), ( f"upstream_drift_tools/__init__.py missing — shim is not a package: {SHIM_DIR}" ) @@ -251,6 +253,6 @@ def test_canonical_package_importable() -> None: import sidekick # noqa: F401 assert sidekick is not None - assert hasattr(sidekick, "__version__"), ( - "sidekick package must expose __version__ for downstream compatibility" - ) + assert hasattr( + sidekick, "__version__" + ), "sidekick package must expose __version__ for downstream compatibility" diff --git a/tests/unit/test_sidekick_package_rename.py b/tests/unit/test_sidekick_package_rename.py index 6ef10e71e4..224fbb68f4 100644 --- a/tests/unit/test_sidekick_package_rename.py +++ b/tests/unit/test_sidekick_package_rename.py @@ -56,21 +56,18 @@ def _assert_import_probe_succeeds(result: subprocess.CompletedProcess[str]) -> N @pytest.mark.unit def test_sidekick_package_importable() -> None: """The new canonical name must be importable.""" - result = _run_import_probe( - """ + result = _run_import_probe(""" import sidekick assert sidekick is not None - """ - ) + """) _assert_import_probe_succeeds(result) @pytest.mark.unit def test_upstream_drift_tools_shim_imports() -> None: """Old name still works (backward compat) and emits a DeprecationWarning.""" - result = _run_import_probe( - """ + result = _run_import_probe(""" import warnings with warnings.catch_warnings(record=True) as caught: @@ -87,16 +84,14 @@ def test_upstream_drift_tools_shim_imports() -> None: "Expected at least one DeprecationWarning about 'deprecated' from " f"the shim, but got: {[str(warning.message) for warning in caught]}" ) - """ - ) + """) _assert_import_probe_succeeds(result) @pytest.mark.unit def test_shim_and_canonical_are_same_object() -> None: """Shim re-exports point to the same canonical sidekick objects (no duplication).""" - result = _run_import_probe( - """ + result = _run_import_probe(""" import warnings import sidekick.data_processing @@ -109,8 +104,7 @@ def test_shim_and_canonical_are_same_object() -> None: "sidekick.data_processing and upstream_drift_tools.data_processing " "must be the same module object (shim must proxy, not copy)" ) - """ - ) + """) _assert_import_probe_succeeds(result)