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.
Files changed (162) hide show
  1. {bec_lib-4.2.0 → bec_lib-4.3.0}/PKG-INFO +1 -1
  2. bec_lib-4.3.0/bec_lib/path_optimization.py +511 -0
  3. bec_lib-4.3.0/bec_lib/position_generators.py +567 -0
  4. bec_lib-4.3.0/bec_lib/scan_preview.py +67 -0
  5. {bec_lib-4.2.0 → bec_lib-4.3.0}/pyproject.toml +1 -1
  6. bec_lib-4.3.0/tests/test_path_optimization.py +273 -0
  7. bec_lib-4.3.0/tests/test_position_generators.py +212 -0
  8. bec_lib-4.3.0/tests/test_scan_preview.py +62 -0
  9. {bec_lib-4.2.0 → bec_lib-4.3.0}/.gitignore +0 -0
  10. {bec_lib-4.2.0 → bec_lib-4.3.0}/README.md +0 -0
  11. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/__init__.py +0 -0
  12. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/_print_versions.py +0 -0
  13. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/acl_login.py +0 -0
  14. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/actors.py +0 -0
  15. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/alarm_handler.py +0 -0
  16. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/atlas_models.py +0 -0
  17. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_errors.py +0 -0
  18. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_service.py +0 -0
  19. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bec_yaml_loader.py +0 -0
  20. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bl_state_manager.py +0 -0
  21. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/bl_states.py +0 -0
  22. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/builtin_actor_hli.py +0 -0
  23. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/callback_handler.py +0 -0
  24. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/channel_monitor.py +0 -0
  25. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/client.py +0 -0
  26. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/codecs.py +0 -0
  27. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/config_helper.py +0 -0
  28. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/config_values.py +0 -0
  29. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/__init__.py +0 -0
  30. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/demo_config.yaml +0 -0
  31. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/configs/openapi_schema.json +0 -0
  32. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/connector.py +0 -0
  33. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/dap_plugin_objects.py +0 -0
  34. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/dap_plugins.py +0 -0
  35. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/device.py +0 -0
  36. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/devicemanager.py +0 -0
  37. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/endpoints.py +0 -0
  38. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/file_utils.py +0 -0
  39. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/live_scan_data.py +0 -0
  40. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/lmfit_serializer.py +0 -0
  41. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/logger.py +0 -0
  42. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/macro_update_handler.py +0 -0
  43. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messages.py +0 -0
  44. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messaging_hooks.py +0 -0
  45. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/messaging_services.py +0 -0
  46. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/metadata_schema.py +0 -0
  47. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/numpy_encoder.py +0 -0
  48. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/observer.py +0 -0
  49. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/pdf_writer.py +0 -0
  50. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/plugin_helper.py +0 -0
  51. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/__init__.py +0 -0
  52. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/helper.py +0 -0
  53. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/hli.py +0 -0
  54. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/procedures/status.py +0 -0
  55. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/queue_items.py +0 -0
  56. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/__init__.py +0 -0
  57. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/constants.py +0 -0
  58. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/hli.py +0 -0
  59. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/managed_redis_connection.py +0 -0
  60. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/streams.py +0 -0
  61. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/redis_connector/validation.py +0 -0
  62. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/request_context.py +0 -0
  63. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/request_items.py +0 -0
  64. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_args.py +0 -0
  65. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_data_container.py +0 -0
  66. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_history.py +0 -0
  67. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_input_validator.py +0 -0
  68. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_items.py +0 -0
  69. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_manager.py +0 -0
  70. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_number_container.py +0 -0
  71. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_repeat.py +0 -0
  72. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scan_report.py +0 -0
  73. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scans.py +0 -0
  74. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/scibec_validator.py +0 -0
  75. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/script_executor.py +0 -0
  76. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/serialization.py +0 -0
  77. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/serialization_registry.py +0 -0
  78. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/service_config.py +0 -0
  79. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/signature_serializer.py +0 -0
  80. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/__init__.py +0 -0
  81. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/fixtures.py +0 -0
  82. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/test_config.yaml +0 -0
  83. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/test_service_config.yaml +0 -0
  84. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/tests/utils.py +0 -0
  85. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/user_macros.py +0 -0
  86. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/__init__.py +0 -0
  87. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/copier_jinja_filters.py +0 -0
  88. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/deprecation.py +0 -0
  89. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/import_utils.py +0 -0
  90. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/json_extended.py +0 -0
  91. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/__init__.py +0 -0
  92. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/_constants.py +0 -0
  93. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/_util.py +0 -0
  94. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/__init__.py +0 -0
  95. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/device.py +0 -0
  96. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/scan.py +0 -0
  97. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/create/templates/scan.py.jinja +0 -0
  98. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/plugin_manager/main.py +0 -0
  99. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/proxy.py +0 -0
  100. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/pydantic_pretty_print.py +0 -0
  101. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/rpc_utils.py +0 -0
  102. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/scan_utils.py +0 -0
  103. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/threading_utils.py +0 -0
  104. {bec_lib-4.2.0 → bec_lib-4.3.0}/bec_lib/utils/user_acls_test.py +0 -0
  105. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/__init__.py +0 -0
  106. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/_additional_for_signature_serializer_test.py +0 -0
  107. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/conftest.py +0 -0
  108. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_acl_login.py +0 -0
  109. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_alarm_handler.py +0 -0
  110. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_atlas_models.py +0 -0
  111. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_beamline_states.py +0 -0
  112. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_logger.py +0 -0
  113. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_messages.py +0 -0
  114. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_bec_service.py +0 -0
  115. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_callback_handler.py +0 -0
  116. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_channel_monitor.py +0 -0
  117. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_client.py +0 -0
  118. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_config_helper.py +0 -0
  119. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_core_utils.py +0 -0
  120. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_dap_plugins.py +0 -0
  121. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_data_container.py +0 -0
  122. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_deprecation.py +0 -0
  123. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_device_hashing.py +0 -0
  124. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_device_manager.py +0 -0
  125. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_devices.py +0 -0
  126. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_file_utils.py +0 -0
  127. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_hli_connector_fakeredis.py +0 -0
  128. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_hli_redis_connector.py +0 -0
  129. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_import_utils.py +0 -0
  130. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_jinja_plugins.py +0 -0
  131. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_lmfit_serializer.py +0 -0
  132. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_managed_config_values.py +0 -0
  133. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_managed_redis_connection.py +0 -0
  134. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_messaging_hooks.py +0 -0
  135. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_messaging_service.py +0 -0
  136. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_metadata_schema.py +0 -0
  137. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_observer.py +0 -0
  138. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_pdf_writer.py +0 -0
  139. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_helper.py +0 -0
  140. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_manager.py +0 -0
  141. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_plugin_system.py +0 -0
  142. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_pydantic_pretty_print.py +0 -0
  143. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_queue_items.py +0 -0
  144. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_redis_connector_fakeredis.py +0 -0
  145. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_context.py +0 -0
  146. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_data.py +0 -0
  147. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_history.py +0 -0
  148. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_items.py +0 -0
  149. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_manager.py +0 -0
  150. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_object.py +0 -0
  151. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_repeat.py +0 -0
  152. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scan_report.py +0 -0
  153. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_scibec_validator.py +0 -0
  154. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_script_executor.py +0 -0
  155. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_serializer.py +0 -0
  156. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_signature_serializer.py +0 -0
  157. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_signature_serializer_with_future_import.py +0 -0
  158. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_stream_subs.py +0 -0
  159. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_user_macros.py +0 -0
  160. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_utils.py +0 -0
  161. {bec_lib-4.2.0 → bec_lib-4.3.0}/tests/test_yaml_loader.py +0 -0
  162. {bec_lib-4.2.0 → bec_lib-4.3.0}/util_scripts/init_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bec_lib
3
- Version: 4.2.0
3
+ Version: 4.3.0
4
4
  Summary: BEC library
5
5
  Project-URL: Bug Tracker, https://github.com/bec-project/bec/issues
6
6
  Project-URL: Homepage, https://github.com/bec-project/bec
@@ -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