bec-lib 4.2.0__tar.gz → 4.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bec_lib-4.2.0 → bec_lib-4.3.0}/PKG-INFO +1 -1
- bec_lib-4.3.0/bec_lib/path_optimization.py +511 -0
- bec_lib-4.3.0/bec_lib/position_generators.py +567 -0
- bec_lib-4.3.0/bec_lib/scan_preview.py +67 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/pyproject.toml +1 -1
- bec_lib-4.3.0/tests/test_path_optimization.py +273 -0
- bec_lib-4.3.0/tests/test_position_generators.py +212 -0
- bec_lib-4.3.0/tests/test_scan_preview.py +62 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/.gitignore +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/README.md +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/_print_versions.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/acl_login.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/actors.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/alarm_handler.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/atlas_models.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_errors.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_service.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_yaml_loader.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bl_state_manager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bl_states.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/builtin_actor_hli.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/callback_handler.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/channel_monitor.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/client.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/codecs.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/config_helper.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/config_values.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/demo_config.yaml +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/openapi_schema.json +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/connector.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/dap_plugin_objects.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/dap_plugins.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/device.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/devicemanager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/endpoints.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/file_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/live_scan_data.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/lmfit_serializer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/logger.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/macro_update_handler.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messages.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messaging_hooks.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messaging_services.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/metadata_schema.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/numpy_encoder.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/observer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/pdf_writer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/plugin_helper.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/helper.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/hli.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/status.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/queue_items.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/constants.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/hli.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/managed_redis_connection.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/streams.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/validation.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/request_context.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/request_items.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_args.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_data_container.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_history.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_input_validator.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_items.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_manager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_number_container.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_repeat.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_report.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scans.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scibec_validator.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/script_executor.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/serialization.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/serialization_registry.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/service_config.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/signature_serializer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/fixtures.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/test_config.yaml +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/test_service_config.yaml +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/user_macros.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/copier_jinja_filters.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/deprecation.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/import_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/json_extended.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/_constants.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/_util.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/device.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/scan.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/templates/scan.py.jinja +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/main.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/proxy.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/pydantic_pretty_print.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/rpc_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/scan_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/threading_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/user_acls_test.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/__init__.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/_additional_for_signature_serializer_test.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/conftest.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_acl_login.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_alarm_handler.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_atlas_models.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_beamline_states.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_logger.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_messages.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_service.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_callback_handler.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_channel_monitor.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_client.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_config_helper.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_core_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_dap_plugins.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_data_container.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_deprecation.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_device_hashing.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_device_manager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_devices.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_file_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_hli_connector_fakeredis.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_hli_redis_connector.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_import_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_jinja_plugins.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_lmfit_serializer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_managed_config_values.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_managed_redis_connection.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_messaging_hooks.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_messaging_service.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_metadata_schema.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_observer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_pdf_writer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_helper.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_manager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_system.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_pydantic_pretty_print.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_queue_items.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_redis_connector_fakeredis.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_context.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_data.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_history.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_items.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_manager.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_object.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_repeat.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_report.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scibec_validator.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_script_executor.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_serializer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_signature_serializer.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_signature_serializer_with_future_import.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_stream_subs.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_user_macros.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_utils.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_yaml_loader.py +0 -0
- {bec_lib-4.2.0 → bec_lib-4.3.0}/util_scripts/init_config.py +0 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Trajectory optimization and path quality analysis for clients and services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from time import perf_counter
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from scipy.spatial import cKDTree # type: ignore
|
|
11
|
+
|
|
12
|
+
from bec_lib.logger import bec_logger
|
|
13
|
+
from bec_lib.position_generators import Direction
|
|
14
|
+
|
|
15
|
+
logger = bec_logger.logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PathQualityStats(BaseModel):
|
|
19
|
+
"""Distance statistics for a trajectory."""
|
|
20
|
+
|
|
21
|
+
max_jump: float
|
|
22
|
+
avg_step: np.floating | float
|
|
23
|
+
std_step: np.floating | float
|
|
24
|
+
total_length: float
|
|
25
|
+
|
|
26
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PathOptimizerMixin:
|
|
30
|
+
"""Device-independent trajectory optimizers, usable directly or as a mixin."""
|
|
31
|
+
|
|
32
|
+
def _log_optimization_result(
|
|
33
|
+
self, method: str, original_path: np.ndarray, optimized_path: np.ndarray, duration_s: float
|
|
34
|
+
) -> None:
|
|
35
|
+
original_length = self.get_path_length(original_path)
|
|
36
|
+
optimized_length = self.get_path_length(optimized_path)
|
|
37
|
+
reduced_by = original_length - optimized_length
|
|
38
|
+
reduction_pct = (reduced_by / original_length * 100) if original_length > 0 else 0.0
|
|
39
|
+
|
|
40
|
+
logger.info(
|
|
41
|
+
f"Path optimizer ({method}) reduced path length from {original_length:.4f} to {optimized_length:.4f} "
|
|
42
|
+
f"({reduced_by:.4f} reduction, {reduction_pct:.2f}% decrease) in {duration_s:.4f} seconds."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def get_radius(self, pos: np.ndarray) -> np.ndarray:
|
|
46
|
+
"""
|
|
47
|
+
Calculate the radial distance from the origin for each position
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
pos (np.ndarray): Array of positions
|
|
51
|
+
Returns:
|
|
52
|
+
np.ndarray: Radial distances
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
return np.sqrt(np.abs(pos[:, 0]) ** 2 + np.abs(pos[:, 1]) ** 2)
|
|
56
|
+
|
|
57
|
+
def optimize_corridor(
|
|
58
|
+
self,
|
|
59
|
+
positions: np.ndarray,
|
|
60
|
+
corridor_size: float | None = None,
|
|
61
|
+
fast_axis: Literal[0, 1] = 0,
|
|
62
|
+
num_iterations: int = 1,
|
|
63
|
+
first_corridor_direction: Direction | Literal[-1, 1] = Direction.ASCENDING,
|
|
64
|
+
snaked: bool = True,
|
|
65
|
+
corridor_estimation: Literal["density", "median_distance"] = "median_distance",
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Optimize positions using a corridor-based approach.
|
|
69
|
+
|
|
70
|
+
Note: This method is designed for 2D positions. If higher dimensions are provided,
|
|
71
|
+
the positions are returned unmodified.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
positions (np.ndarray): Array of positions
|
|
75
|
+
corridor_size (float, optional): Width of each corridor. Defaults to None (auto-estimated).
|
|
76
|
+
fast_axis (Literal[0, 1], optional): Axis traversed within each corridor (0 or 1). Defaults to 0.
|
|
77
|
+
num_iterations (int, optional): Number of corridor sizes to try. Defaults to 1.
|
|
78
|
+
first_corridor_direction (Direction | Literal[-1, 1], optional): Traversal direction of the first
|
|
79
|
+
corridor along the fast axis. Positive means ascending, negative means
|
|
80
|
+
descending. Defaults to Direction.ASCENDING.
|
|
81
|
+
snaked (bool, optional): If True, alternate the traversal direction between
|
|
82
|
+
successive corridors. If False, keep the same direction in every corridor.
|
|
83
|
+
corridor_estimation (Literal["density", "median_distance"], optional): Method for estimating corridor size if not provided.
|
|
84
|
+
Options are "density" or "median_distance". Defaults to "median_distance".
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
np.ndarray: Optimized positions
|
|
88
|
+
"""
|
|
89
|
+
if positions.ndim != 2 or positions.shape[1] < 2:
|
|
90
|
+
return positions
|
|
91
|
+
|
|
92
|
+
start_time = perf_counter()
|
|
93
|
+
original_path = positions
|
|
94
|
+
|
|
95
|
+
# Estimate corridor size if needed
|
|
96
|
+
if corridor_size is None:
|
|
97
|
+
if corridor_estimation == "density":
|
|
98
|
+
corridor_size = self._corridor_estimate_density(positions)
|
|
99
|
+
elif corridor_estimation == "median_distance":
|
|
100
|
+
corridor_size = self._corridor_estimate_median_distance(positions)
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Invalid corridor_estimation method: {corridor_estimation}. "
|
|
104
|
+
'Choose "density" or "median_distance".'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if not isinstance(first_corridor_direction, Direction):
|
|
108
|
+
first_corridor_direction = Direction(first_corridor_direction)
|
|
109
|
+
|
|
110
|
+
if fast_axis not in (0, 1):
|
|
111
|
+
raise ValueError("fast_axis must be 0 or 1")
|
|
112
|
+
|
|
113
|
+
slow_axis = int(not fast_axis)
|
|
114
|
+
axis_vals = positions[:, slow_axis]
|
|
115
|
+
|
|
116
|
+
best_length = np.inf
|
|
117
|
+
best_path = positions
|
|
118
|
+
|
|
119
|
+
if num_iterations > 1:
|
|
120
|
+
corridor_sizes = corridor_size * np.logspace(-0.7, 0.7, num_iterations)
|
|
121
|
+
else:
|
|
122
|
+
corridor_sizes = [corridor_size]
|
|
123
|
+
|
|
124
|
+
for cs in corridor_sizes:
|
|
125
|
+
if cs <= 0:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Integer corridor binning
|
|
129
|
+
min_val = axis_vals.min()
|
|
130
|
+
bin_idx = ((axis_vals - min_val) / cs).astype(int)
|
|
131
|
+
n_steps = bin_idx.max() + 1
|
|
132
|
+
|
|
133
|
+
index_sorted: list[np.ndarray] = []
|
|
134
|
+
|
|
135
|
+
for step in range(n_steps):
|
|
136
|
+
block = np.where(bin_idx == step)[0]
|
|
137
|
+
if block.size == 0:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Sort within corridor along fast axis
|
|
141
|
+
block = block[np.argsort(positions[block, fast_axis])]
|
|
142
|
+
|
|
143
|
+
# Direction handling
|
|
144
|
+
direction = first_corridor_direction
|
|
145
|
+
if snaked and step % 2 == 1:
|
|
146
|
+
direction = Direction(-direction)
|
|
147
|
+
if direction == Direction.DESCENDING:
|
|
148
|
+
block = block[::-1]
|
|
149
|
+
|
|
150
|
+
index_sorted.append(block)
|
|
151
|
+
|
|
152
|
+
if not index_sorted:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
path_idx = np.concatenate(index_sorted)
|
|
156
|
+
path = positions[path_idx]
|
|
157
|
+
path_length = self.get_path_length(path)
|
|
158
|
+
|
|
159
|
+
if path_length < best_length:
|
|
160
|
+
best_length = path_length
|
|
161
|
+
best_path = path
|
|
162
|
+
|
|
163
|
+
self._log_optimization_result(
|
|
164
|
+
"corridor", original_path, best_path, perf_counter() - start_time
|
|
165
|
+
)
|
|
166
|
+
return best_path
|
|
167
|
+
|
|
168
|
+
def _corridor_estimate_density(self, positions: np.ndarray) -> float:
|
|
169
|
+
"""
|
|
170
|
+
Estimate corridor size based on point density.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
positions (np.ndarray): Array of positions
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
float: Estimated corridor size
|
|
177
|
+
"""
|
|
178
|
+
dims = [
|
|
179
|
+
abs(min(positions[:, 0]) - max(positions[:, 0])),
|
|
180
|
+
abs(min(positions[:, 1]) - max(positions[:, 1])),
|
|
181
|
+
]
|
|
182
|
+
density = np.sqrt(len(positions) / (dims[1] * dims[0]))
|
|
183
|
+
corridor_size = 2 / density
|
|
184
|
+
return corridor_size
|
|
185
|
+
|
|
186
|
+
def _corridor_estimate_median_distance(
|
|
187
|
+
self, positions: np.ndarray, factor: float = 1.5
|
|
188
|
+
) -> float:
|
|
189
|
+
"""
|
|
190
|
+
Estimate corridor size based on median nearest neighbor distance.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
positions (np.ndarray): Array of positions
|
|
194
|
+
factor (float): Scaling factor for the median distance
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
float: Estimated corridor size
|
|
198
|
+
"""
|
|
199
|
+
tree = cKDTree(positions[:, :2])
|
|
200
|
+
dists, _ = tree.query(positions[:, :2], k=2) # k=1 is itself
|
|
201
|
+
nn = dists[:, 1]
|
|
202
|
+
return factor * np.median(nn)
|
|
203
|
+
|
|
204
|
+
def optimize_shell(
|
|
205
|
+
self,
|
|
206
|
+
pos: np.ndarray,
|
|
207
|
+
offset: float | None = None,
|
|
208
|
+
dr: float | None = None,
|
|
209
|
+
num_iterations: int = 3,
|
|
210
|
+
):
|
|
211
|
+
"""Optimize a path through a set of positions by sorting them in concentric shells.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
pos (np.ndarray): Array of positions
|
|
215
|
+
offset (float, optional): Offset for the first shell. Defaults to None (auto-estimated).
|
|
216
|
+
dr (float, optional): Width of each shell. Defaults to None (auto-estimated).
|
|
217
|
+
num_iterations (int, optional): Number of parameter variations to try. Defaults to 3.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
np.ndarray: Optimized positions
|
|
221
|
+
"""
|
|
222
|
+
# Handle edge cases
|
|
223
|
+
if pos.ndim != 2 or pos.shape[1] < 2 or len(pos) < 2:
|
|
224
|
+
return pos
|
|
225
|
+
|
|
226
|
+
start_time = perf_counter()
|
|
227
|
+
original_path = pos
|
|
228
|
+
|
|
229
|
+
# Calculate radii once
|
|
230
|
+
radii = self.get_radius(pos)
|
|
231
|
+
max_rad = np.max(radii)
|
|
232
|
+
|
|
233
|
+
# Set default dr if not provided
|
|
234
|
+
if dr is None:
|
|
235
|
+
dr_default = self._estimate_shell_width(pos, radii, max_rad)
|
|
236
|
+
else:
|
|
237
|
+
dr_default = dr
|
|
238
|
+
|
|
239
|
+
# Set default offset if not provided
|
|
240
|
+
if offset is None:
|
|
241
|
+
offset_default = 1
|
|
242
|
+
else:
|
|
243
|
+
offset_default = offset
|
|
244
|
+
|
|
245
|
+
# Try different parameter combinations if num_iterations > 1
|
|
246
|
+
best_path = None
|
|
247
|
+
best_score = float("inf") # Lower is better
|
|
248
|
+
|
|
249
|
+
if num_iterations > 1:
|
|
250
|
+
# Try variations of dr
|
|
251
|
+
dr_values = np.linspace(dr_default * 0.7, dr_default * 1.3, num_iterations)
|
|
252
|
+
offset_values = [offset_default]
|
|
253
|
+
else:
|
|
254
|
+
dr_values = [dr_default]
|
|
255
|
+
offset_values = [offset_default]
|
|
256
|
+
|
|
257
|
+
for current_dr in dr_values:
|
|
258
|
+
for current_offset in offset_values:
|
|
259
|
+
optimized_path = self._optimize_shell_with_params(
|
|
260
|
+
pos, radii, current_offset, current_dr, max_rad
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Analyze the quality of this path
|
|
264
|
+
quality = self.analyze_path_quality(optimized_path)
|
|
265
|
+
|
|
266
|
+
# Score based on maximum jump and total path length
|
|
267
|
+
# We want to minimize both the largest jump and the total path length
|
|
268
|
+
score = quality.max_jump * 0.7 + quality.total_length * 0.3
|
|
269
|
+
|
|
270
|
+
if score < best_score:
|
|
271
|
+
best_score = score
|
|
272
|
+
best_path = optimized_path
|
|
273
|
+
|
|
274
|
+
result = best_path if best_path is not None else pos
|
|
275
|
+
self._log_optimization_result("shell", original_path, result, perf_counter() - start_time)
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
def _estimate_shell_width(self, pos: np.ndarray, radii: np.ndarray, max_rad: float) -> float:
|
|
279
|
+
"""
|
|
280
|
+
Estimate optimal shell width based on point distribution characteristics.
|
|
281
|
+
|
|
282
|
+
Uses scale-invariant approach based on:
|
|
283
|
+
1. Typical spacing between points (nearest neighbor distances)
|
|
284
|
+
2. Radial distribution of points
|
|
285
|
+
3. Overall point density
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
pos (np.ndarray): Array of positions
|
|
289
|
+
radii (np.ndarray): Pre-computed radii for all positions
|
|
290
|
+
max_rad (float): Maximum radius
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
float: Estimated shell width
|
|
294
|
+
"""
|
|
295
|
+
n_points = len(pos)
|
|
296
|
+
|
|
297
|
+
# Get nearest neighbor distances to understand typical point spacing
|
|
298
|
+
tree = cKDTree(pos[:, :2])
|
|
299
|
+
k_neighbors = min(5, n_points) # Use up to 5 nearest neighbors
|
|
300
|
+
dists, _ = tree.query(pos[:, :2], k=k_neighbors)
|
|
301
|
+
|
|
302
|
+
# Analyze the distribution of nearest neighbor distances
|
|
303
|
+
# Using 75th percentile is more robust than mean/median for varying densities
|
|
304
|
+
typical_spacing = np.percentile(dists[:, 1:], 75)
|
|
305
|
+
|
|
306
|
+
# Estimate reasonable number of shells based on point density
|
|
307
|
+
# More points → can afford more shells for finer optimization
|
|
308
|
+
# Use logarithmic scaling to avoid too many/few shells
|
|
309
|
+
target_shells = np.sqrt(n_points)
|
|
310
|
+
|
|
311
|
+
# Calculate shell width to achieve target number of shells
|
|
312
|
+
estimated_dr = max_rad / target_shells
|
|
313
|
+
|
|
314
|
+
# Use the larger of: 2x typical spacing or the calculated shell width
|
|
315
|
+
# This ensures shells contain enough points while respecting point spacing
|
|
316
|
+
return max(typical_spacing * 2.0, estimated_dr)
|
|
317
|
+
|
|
318
|
+
def _optimize_shell_with_params(
|
|
319
|
+
self, pos: np.ndarray, radii: np.ndarray, offset: float, dr: float, max_rad: float
|
|
320
|
+
) -> np.ndarray:
|
|
321
|
+
"""
|
|
322
|
+
Helper function to optimize a path with specific parameters.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
pos (np.ndarray): Array of positions
|
|
326
|
+
radii (np.ndarray): Pre-computed radii for all positions
|
|
327
|
+
offset (float): Offset for the first shell
|
|
328
|
+
dr (float): Width of each shell
|
|
329
|
+
max_rad (float): Maximum radius
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
np.ndarray: Optimized positions
|
|
333
|
+
"""
|
|
334
|
+
# Calculate number of shells needed
|
|
335
|
+
nsteps = int(np.floor(max_rad / dr) + int(bool(np.mod(max_rad, dr))))
|
|
336
|
+
|
|
337
|
+
shells = []
|
|
338
|
+
shell_radius_min = -offset * dr
|
|
339
|
+
last_end_angle = 0.0 # Track the ending angle of the previous shell
|
|
340
|
+
|
|
341
|
+
# Process each shell
|
|
342
|
+
for shell_idx in range(nsteps + 2):
|
|
343
|
+
shell_radius_max = shell_radius_min + dr
|
|
344
|
+
|
|
345
|
+
# Find points in the current shell
|
|
346
|
+
shell_mask = (radii >= shell_radius_min) & (radii < shell_radius_max)
|
|
347
|
+
shell_points = pos[shell_mask]
|
|
348
|
+
|
|
349
|
+
if len(shell_points) > 0:
|
|
350
|
+
# Calculate angles for the current shell
|
|
351
|
+
angles = np.arctan2(shell_points[:, 0], shell_points[:, 1])
|
|
352
|
+
|
|
353
|
+
# For smooth transitions, start sorting from where the previous shell ended
|
|
354
|
+
if shell_idx > 0 and len(shells) > 0 and len(shells[-1]) > 0:
|
|
355
|
+
# Rotate angles to start from the last ending angle
|
|
356
|
+
shifted_angles = (angles - last_end_angle) % (2 * np.pi)
|
|
357
|
+
sorted_indices = np.argsort(shifted_angles)
|
|
358
|
+
shell_sorted = shell_points[sorted_indices]
|
|
359
|
+
|
|
360
|
+
# Update last_end_angle based on the actual last point in this shell
|
|
361
|
+
last_point = shell_sorted[-1]
|
|
362
|
+
last_end_angle = np.arctan2(last_point[0], last_point[1])
|
|
363
|
+
else:
|
|
364
|
+
# For the first shell, just sort by angle
|
|
365
|
+
sorted_indices = np.argsort(angles)
|
|
366
|
+
shell_sorted = shell_points[sorted_indices]
|
|
367
|
+
|
|
368
|
+
if len(shell_sorted) > 0:
|
|
369
|
+
last_point = shell_sorted[-1]
|
|
370
|
+
last_end_angle = np.arctan2(last_point[0], last_point[1])
|
|
371
|
+
|
|
372
|
+
shells.append(shell_sorted)
|
|
373
|
+
|
|
374
|
+
shell_radius_min = shell_radius_max
|
|
375
|
+
|
|
376
|
+
# Handle any remaining points beyond the last shell
|
|
377
|
+
remaining_mask = radii >= shell_radius_min
|
|
378
|
+
remaining_points = pos[remaining_mask]
|
|
379
|
+
|
|
380
|
+
if len(remaining_points) > 0:
|
|
381
|
+
angles = np.arctan2(remaining_points[:, 0], remaining_points[:, 1])
|
|
382
|
+
|
|
383
|
+
if len(shells) > 0 and len(shells[-1]) > 0:
|
|
384
|
+
# Continue from the last shell's ending angle
|
|
385
|
+
shifted_angles = (angles - last_end_angle) % (2 * np.pi)
|
|
386
|
+
remaining_sorted = remaining_points[np.argsort(shifted_angles)]
|
|
387
|
+
else:
|
|
388
|
+
# Just sort by angle if there's no previous shell
|
|
389
|
+
remaining_sorted = remaining_points[np.argsort(angles)]
|
|
390
|
+
|
|
391
|
+
shells.append(remaining_sorted)
|
|
392
|
+
|
|
393
|
+
# Flatten the shells into a single array
|
|
394
|
+
if not shells:
|
|
395
|
+
return pos # Return original if something went wrong
|
|
396
|
+
|
|
397
|
+
result = np.vstack([shell for shell in shells if len(shell) > 0])
|
|
398
|
+
|
|
399
|
+
# Validate that we haven't lost any points
|
|
400
|
+
if len(result) != len(pos):
|
|
401
|
+
return pos # Return original positions if something went wrong
|
|
402
|
+
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
def get_path_length(self, pos: np.ndarray) -> float:
|
|
406
|
+
"""
|
|
407
|
+
Calculate the total length of a path defined by a sequence of positions.
|
|
408
|
+
Args:
|
|
409
|
+
pos (np.ndarray): Array of positions
|
|
410
|
+
Returns:
|
|
411
|
+
float: Total path length
|
|
412
|
+
"""
|
|
413
|
+
path_length = 0
|
|
414
|
+
for ii in range(len(pos) - 1):
|
|
415
|
+
path_length += np.sqrt(np.sum(abs(pos[ii + 1] - pos[ii]) ** 2))
|
|
416
|
+
return path_length
|
|
417
|
+
|
|
418
|
+
def analyze_path_quality(self, pos: np.ndarray) -> PathQualityStats:
|
|
419
|
+
"""Analyze the quality of a path by looking at the distribution of step sizes.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
pos (np.ndarray): Array of positions
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
dict: Dictionary with statistics about the path quality
|
|
426
|
+
"""
|
|
427
|
+
if len(pos) < 2:
|
|
428
|
+
return PathQualityStats(max_jump=0.0, avg_step=0.0, std_step=0.0, total_length=0.0)
|
|
429
|
+
|
|
430
|
+
steps = []
|
|
431
|
+
for ii in range(len(pos) - 1):
|
|
432
|
+
step = np.sqrt(np.sum(abs(pos[ii + 1] - pos[ii]) ** 2))
|
|
433
|
+
steps.append(step)
|
|
434
|
+
|
|
435
|
+
return PathQualityStats(
|
|
436
|
+
max_jump=np.max(steps),
|
|
437
|
+
avg_step=np.mean(steps),
|
|
438
|
+
std_step=np.std(steps),
|
|
439
|
+
total_length=np.sum(steps),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def optimize_nearest_neighbor(
|
|
443
|
+
self, positions: np.ndarray, start_index: int | None = None
|
|
444
|
+
) -> np.ndarray:
|
|
445
|
+
"""
|
|
446
|
+
Optimize path by always moving to the nearest unvisited point.
|
|
447
|
+
|
|
448
|
+
This is a greedy algorithm that provides good results for many scenarios
|
|
449
|
+
with relatively low computational complexity.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
positions (np.ndarray): Array of positions
|
|
453
|
+
start_index (int | None, optional): Index of the starting point. If None,
|
|
454
|
+
the algorithm will start from the point closest to the origin.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
np.ndarray: Optimized positions
|
|
458
|
+
"""
|
|
459
|
+
if len(positions) <= 1:
|
|
460
|
+
return positions
|
|
461
|
+
|
|
462
|
+
start_time = perf_counter()
|
|
463
|
+
original_path = positions
|
|
464
|
+
|
|
465
|
+
n_points = len(positions)
|
|
466
|
+
|
|
467
|
+
# Use a copy to avoid modifying the original
|
|
468
|
+
positions_copy = positions.copy()
|
|
469
|
+
|
|
470
|
+
# Start from the point closest to the origin if not specified
|
|
471
|
+
if start_index is None:
|
|
472
|
+
# Find the point closest to the origin
|
|
473
|
+
distances_to_origin = np.sum(positions_copy**2, axis=1)
|
|
474
|
+
start_index = int(np.argmin(distances_to_origin))
|
|
475
|
+
|
|
476
|
+
# Initialize the path with the starting point
|
|
477
|
+
path = [positions_copy[start_index]]
|
|
478
|
+
|
|
479
|
+
# Keep track of visited indices
|
|
480
|
+
remaining_indices = set(range(n_points))
|
|
481
|
+
remaining_indices.remove(start_index)
|
|
482
|
+
|
|
483
|
+
# Current position is the starting point
|
|
484
|
+
current_pos = positions_copy[start_index]
|
|
485
|
+
|
|
486
|
+
# Add points to the path one by one
|
|
487
|
+
while remaining_indices:
|
|
488
|
+
# Calculate distances to all remaining points
|
|
489
|
+
remaining_positions = positions_copy[list(remaining_indices)]
|
|
490
|
+
|
|
491
|
+
# Calculate distances from current position to all remaining points
|
|
492
|
+
distances = np.sqrt(np.sum((remaining_positions - current_pos) ** 2, axis=1))
|
|
493
|
+
|
|
494
|
+
# Find the closest point
|
|
495
|
+
closest_idx = np.argmin(distances)
|
|
496
|
+
|
|
497
|
+
# Get the actual index in the original array
|
|
498
|
+
original_idx = list(remaining_indices)[closest_idx]
|
|
499
|
+
|
|
500
|
+
# Update current position
|
|
501
|
+
current_pos = positions_copy[original_idx]
|
|
502
|
+
|
|
503
|
+
# Add to path and remove from remaining
|
|
504
|
+
path.append(current_pos)
|
|
505
|
+
remaining_indices.remove(original_idx)
|
|
506
|
+
|
|
507
|
+
result = np.array(path)
|
|
508
|
+
self._log_optimization_result(
|
|
509
|
+
"nearest_neighbor", original_path, result, perf_counter() - start_time
|
|
510
|
+
)
|
|
511
|
+
return result
|