openscvx 0.3.2.dev170__py3-none-any.whl

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.

Potentially problematic release.


This version of openscvx might be problematic. Click here for more details.

Files changed (79) hide show
  1. openscvx/__init__.py +123 -0
  2. openscvx/_version.py +34 -0
  3. openscvx/algorithms/__init__.py +92 -0
  4. openscvx/algorithms/autotuning.py +24 -0
  5. openscvx/algorithms/base.py +351 -0
  6. openscvx/algorithms/optimization_results.py +215 -0
  7. openscvx/algorithms/penalized_trust_region.py +384 -0
  8. openscvx/config.py +437 -0
  9. openscvx/discretization/__init__.py +47 -0
  10. openscvx/discretization/discretization.py +236 -0
  11. openscvx/expert/__init__.py +23 -0
  12. openscvx/expert/byof.py +326 -0
  13. openscvx/expert/lowering.py +419 -0
  14. openscvx/expert/validation.py +357 -0
  15. openscvx/integrators/__init__.py +48 -0
  16. openscvx/integrators/runge_kutta.py +281 -0
  17. openscvx/lowered/__init__.py +30 -0
  18. openscvx/lowered/cvxpy_constraints.py +23 -0
  19. openscvx/lowered/cvxpy_variables.py +124 -0
  20. openscvx/lowered/dynamics.py +34 -0
  21. openscvx/lowered/jax_constraints.py +133 -0
  22. openscvx/lowered/parameters.py +54 -0
  23. openscvx/lowered/problem.py +70 -0
  24. openscvx/lowered/unified.py +718 -0
  25. openscvx/plotting/__init__.py +63 -0
  26. openscvx/plotting/plotting.py +756 -0
  27. openscvx/plotting/scp_iteration.py +299 -0
  28. openscvx/plotting/viser/__init__.py +126 -0
  29. openscvx/plotting/viser/animated.py +605 -0
  30. openscvx/plotting/viser/plotly_integration.py +333 -0
  31. openscvx/plotting/viser/primitives.py +355 -0
  32. openscvx/plotting/viser/scp.py +459 -0
  33. openscvx/plotting/viser/server.py +112 -0
  34. openscvx/problem.py +734 -0
  35. openscvx/propagation/__init__.py +60 -0
  36. openscvx/propagation/post_processing.py +104 -0
  37. openscvx/propagation/propagation.py +248 -0
  38. openscvx/solvers/__init__.py +51 -0
  39. openscvx/solvers/cvxpy.py +226 -0
  40. openscvx/symbolic/__init__.py +9 -0
  41. openscvx/symbolic/augmentation.py +630 -0
  42. openscvx/symbolic/builder.py +492 -0
  43. openscvx/symbolic/constraint_set.py +92 -0
  44. openscvx/symbolic/expr/__init__.py +222 -0
  45. openscvx/symbolic/expr/arithmetic.py +517 -0
  46. openscvx/symbolic/expr/array.py +632 -0
  47. openscvx/symbolic/expr/constraint.py +796 -0
  48. openscvx/symbolic/expr/control.py +135 -0
  49. openscvx/symbolic/expr/expr.py +720 -0
  50. openscvx/symbolic/expr/lie/__init__.py +87 -0
  51. openscvx/symbolic/expr/lie/adjoint.py +357 -0
  52. openscvx/symbolic/expr/lie/se3.py +172 -0
  53. openscvx/symbolic/expr/lie/so3.py +138 -0
  54. openscvx/symbolic/expr/linalg.py +279 -0
  55. openscvx/symbolic/expr/math.py +699 -0
  56. openscvx/symbolic/expr/spatial.py +209 -0
  57. openscvx/symbolic/expr/state.py +607 -0
  58. openscvx/symbolic/expr/stl.py +136 -0
  59. openscvx/symbolic/expr/variable.py +321 -0
  60. openscvx/symbolic/hashing.py +112 -0
  61. openscvx/symbolic/lower.py +760 -0
  62. openscvx/symbolic/lowerers/__init__.py +106 -0
  63. openscvx/symbolic/lowerers/cvxpy.py +1302 -0
  64. openscvx/symbolic/lowerers/jax.py +1382 -0
  65. openscvx/symbolic/preprocessing.py +757 -0
  66. openscvx/symbolic/problem.py +110 -0
  67. openscvx/symbolic/time.py +116 -0
  68. openscvx/symbolic/unified.py +420 -0
  69. openscvx/utils/__init__.py +20 -0
  70. openscvx/utils/cache.py +131 -0
  71. openscvx/utils/caching.py +210 -0
  72. openscvx/utils/printing.py +301 -0
  73. openscvx/utils/profiling.py +37 -0
  74. openscvx/utils/utils.py +100 -0
  75. openscvx-0.3.2.dev170.dist-info/METADATA +350 -0
  76. openscvx-0.3.2.dev170.dist-info/RECORD +79 -0
  77. openscvx-0.3.2.dev170.dist-info/WHEEL +5 -0
  78. openscvx-0.3.2.dev170.dist-info/licenses/LICENSE +201 -0
  79. openscvx-0.3.2.dev170.dist-info/top_level.txt +1 -0
@@ -0,0 +1,718 @@
1
+ """Unified state and control dataclasses for the lowered representation.
2
+
3
+ This module contains the UnifiedState and UnifiedControl dataclasses that describe
4
+ the structure of the monolithic state and control vectors used in numerical optimization.
5
+
6
+ In the symbolic world, users define many named State and Control objects (position,
7
+ velocity, thrust, etc.). In the lowered world, these are aggregated into single
8
+ monolithic x and u vectors. UnifiedState and UnifiedControl hold the metadata
9
+ describing this aggregation: bounds, guesses, boundary conditions, and slices
10
+ for extracting individual components.
11
+
12
+ See Also:
13
+ - openscvx.symbolic.unified: Contains unify_states() and unify_controls()
14
+ functions that create these dataclasses from symbolic State/Control objects.
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING, Optional
19
+
20
+ import numpy as np
21
+
22
+ if TYPE_CHECKING:
23
+ from openscvx.symbolic.expr.control import Control
24
+ from openscvx.symbolic.expr.state import State
25
+
26
+
27
+ @dataclass
28
+ class UnifiedState:
29
+ """Unified state vector aggregating multiple State objects.
30
+
31
+ UnifiedState is a drop-in replacement for individual State objects that holds
32
+ aggregated data from multiple State instances. It maintains compatibility with
33
+ optimization infrastructure while providing access to individual state components
34
+ through slicing.
35
+
36
+ The unified state separates user-defined "true" states from augmented states
37
+ added internally (e.g., for CTCS constraints or time variables). This separation
38
+ allows clean access to physical states while supporting advanced features.
39
+
40
+ Attributes:
41
+ name (str): Name identifier for the unified state vector
42
+ shape (tuple): Combined shape (total_dim,) of all aggregated states
43
+ min (np.ndarray): Lower bounds for all state variables, shape (total_dim,)
44
+ max (np.ndarray): Upper bounds for all state variables, shape (total_dim,)
45
+ guess (np.ndarray): Initial guess trajectory, shape (num_nodes, total_dim)
46
+ initial (np.ndarray): Initial boundary conditions, shape (total_dim,)
47
+ final (np.ndarray): Final boundary conditions, shape (total_dim,)
48
+ _initial (np.ndarray): Internal initial values, shape (total_dim,)
49
+ _final (np.ndarray): Internal final values, shape (total_dim,)
50
+ initial_type (np.ndarray): Boundary condition types at t0 ("Fix" or "Free"),
51
+ shape (total_dim,), dtype=object
52
+ final_type (np.ndarray): Boundary condition types at tf ("Fix" or "Free"),
53
+ shape (total_dim,), dtype=object
54
+ _true_dim (int): Number of user-defined state dimensions (excludes augmented)
55
+ _true_slice (slice): Slice for extracting true states from unified vector
56
+ _augmented_slice (slice): Slice for extracting augmented states
57
+ time_slice (Optional[slice]): Slice for time state variable, if present
58
+ ctcs_slice (Optional[slice]): Slice for CTCS augmented states, if present
59
+
60
+ Properties:
61
+ true: Returns UnifiedState view containing only true (user-defined) states
62
+ augmented: Returns UnifiedState view containing only augmented states
63
+
64
+ Example:
65
+ Creating a unified state from multiple State objects::
66
+
67
+ from openscvx.symbolic.unified import unify_states
68
+
69
+ position = ox.State("pos", shape=(3,), min=-10, max=10)
70
+ velocity = ox.State("vel", shape=(3,), min=-5, max=5)
71
+
72
+ unified = unify_states([position, velocity], name="x")
73
+ print(unified.shape) # (6,)
74
+ print(unified.min) # [-10, -10, -10, -5, -5, -5]
75
+ print(unified.true.shape) # (6,) - all are true states
76
+ print(unified.augmented.shape) # (0,) - no augmented states
77
+
78
+ Appending states dynamically::
79
+
80
+ unified = UnifiedState(name="x", shape=(0,), _true_dim=0)
81
+ unified.append(min=-1, max=1, guess=0.5) # Add scalar state
82
+ print(unified.shape) # (1,)
83
+
84
+ See Also:
85
+ - unify_states(): Factory function for creating UnifiedState from State list
86
+ - State: Individual symbolic state variable
87
+ - UnifiedControl: Analogous unified control vector
88
+ """
89
+
90
+ name: str
91
+ shape: tuple
92
+ min: Optional[np.ndarray] = None
93
+ max: Optional[np.ndarray] = None
94
+ guess: Optional[np.ndarray] = None
95
+ initial: Optional[np.ndarray] = None
96
+ final: Optional[np.ndarray] = None
97
+ _initial: Optional[np.ndarray] = None
98
+ _final: Optional[np.ndarray] = None
99
+ initial_type: Optional[np.ndarray] = None
100
+ final_type: Optional[np.ndarray] = None
101
+ _true_dim: int = 0
102
+ _true_slice: Optional[slice] = None
103
+ _augmented_slice: Optional[slice] = None
104
+ time_slice: Optional[slice] = None # Slice for time state
105
+ ctcs_slice: Optional[slice] = None # Slice for CTCS augmented states
106
+ scaling_min: Optional[np.ndarray] = None # Scaling minimum bounds for unified state
107
+ scaling_max: Optional[np.ndarray] = None # Scaling maximum bounds for unified state
108
+
109
+ def __post_init__(self):
110
+ """Initialize slices after dataclass creation."""
111
+ if self._true_slice is None:
112
+ self._true_slice = slice(0, self._true_dim)
113
+ if self._augmented_slice is None:
114
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
115
+
116
+ @property
117
+ def true(self) -> "UnifiedState":
118
+ """Get the true (user-defined) state variables.
119
+
120
+ Returns a view of the unified state containing only user-defined states,
121
+ excluding internal augmented states added for CTCS, time, etc.
122
+
123
+ Returns:
124
+ UnifiedState: Sliced view containing only true state variables
125
+
126
+ Example:
127
+ Get true user-defined state::
128
+
129
+ unified = unify_states([position, velocity, ctcs_aug], name="x")
130
+ true_states = unified.true # Only position and velocity
131
+ true_states.shape # (6,) if position and velocity are 3D each
132
+ """
133
+ return self[self._true_slice]
134
+
135
+ @property
136
+ def augmented(self) -> "UnifiedState":
137
+ """Get the augmented (internal) state variables.
138
+
139
+ Returns a view of the unified state containing only augmented states
140
+ added internally by the optimization framework (e.g., CTCS penalty states,
141
+ time variables).
142
+
143
+ Returns:
144
+ UnifiedState: Sliced view containing only augmented state variables
145
+
146
+ Example:
147
+ Get augmented state::
148
+
149
+ unified = unify_states([position, ctcs_aug], name="x")
150
+ aug_states = unified.augmented # Only CTCS states
151
+ """
152
+ return self[self._augmented_slice]
153
+
154
+ def append(
155
+ self,
156
+ other: "Optional[State | UnifiedState]" = None,
157
+ *,
158
+ min=-np.inf,
159
+ max=np.inf,
160
+ guess=0.0,
161
+ initial=0.0,
162
+ final=0.0,
163
+ augmented=False,
164
+ ):
165
+ """Append another state or create a new state variable.
166
+
167
+ This method allows dynamic extension of the unified state, either by appending
168
+ another State/UnifiedState object or by creating a new scalar state variable
169
+ with specified properties. Modifies the unified state in-place.
170
+
171
+ Args:
172
+ other (Optional[State | UnifiedState]): State object to append. If None,
173
+ creates a new scalar state variable with properties from keyword args.
174
+ min (float): Lower bound for new scalar state (default: -inf)
175
+ max (float): Upper bound for new scalar state (default: inf)
176
+ guess (float): Initial guess value for new scalar state (default: 0.0)
177
+ initial (float): Initial boundary condition for new scalar state (default: 0.0)
178
+ final (float): Final boundary condition for new scalar state (default: 0.0)
179
+ augmented (bool): Whether the appended state is augmented (internal) rather
180
+ than true (user-defined). Affects _true_dim tracking. Default: False
181
+
182
+ Returns:
183
+ None: Modifies the unified state in-place
184
+
185
+ Example:
186
+ Appending a State object::
187
+
188
+ unified = unify_states([position], name="x")
189
+ velocity = ox.State("vel", shape=(3,), min=-5, max=5)
190
+ unified.append(velocity)
191
+ print(unified.shape) # (6,) - position (3) + velocity (3)
192
+
193
+ Creating new scalar state variables::
194
+
195
+ unified = UnifiedState(name="x", shape=(0,), _true_dim=0)
196
+ unified.append(min=-1, max=1, guess=0.5) # Add scalar state
197
+ unified.append(min=-2, max=2, augmented=True) # Add augmented state
198
+ print(unified.shape) # (2,)
199
+ print(unified._true_dim) # 1 (only first is true)
200
+
201
+ Note:
202
+ Maintains the invariant that true states appear before augmented states
203
+ in the unified vector. When appending augmented states, they are added
204
+ to the end but don't increment _true_dim.
205
+ """
206
+ # Import here to avoid circular imports at module level
207
+ from openscvx.symbolic.expr.state import State
208
+
209
+ if isinstance(other, (State, UnifiedState)):
210
+ # Append another state object
211
+ new_shape = (self.shape[0] + other.shape[0],)
212
+
213
+ # Update bounds
214
+ if self.min is not None and other.min is not None:
215
+ new_min = np.concatenate([self.min, other.min])
216
+ else:
217
+ new_min = self.min
218
+
219
+ if self.max is not None and other.max is not None:
220
+ new_max = np.concatenate([self.max, other.max])
221
+ else:
222
+ new_max = self.max
223
+
224
+ # Update guess
225
+ if self.guess is not None and other.guess is not None:
226
+ new_guess = np.concatenate([self.guess, other.guess], axis=1)
227
+ else:
228
+ new_guess = self.guess
229
+
230
+ # Update initial/final conditions
231
+ if self.initial is not None and other.initial is not None:
232
+ new_initial = np.concatenate([self.initial, other.initial])
233
+ else:
234
+ new_initial = self.initial
235
+
236
+ if self.final is not None and other.final is not None:
237
+ new_final = np.concatenate([self.final, other.final])
238
+ else:
239
+ new_final = self.final
240
+
241
+ # Update internal arrays
242
+ if self._initial is not None and other._initial is not None:
243
+ new__initial = np.concatenate([self._initial, other._initial])
244
+ else:
245
+ new__initial = self._initial
246
+
247
+ if self._final is not None and other._final is not None:
248
+ new__final = np.concatenate([self._final, other._final])
249
+ else:
250
+ new__final = self._final
251
+
252
+ # Update types
253
+ if self.initial_type is not None and other.initial_type is not None:
254
+ new_initial_type = np.concatenate([self.initial_type, other.initial_type])
255
+ else:
256
+ new_initial_type = self.initial_type
257
+
258
+ if self.final_type is not None and other.final_type is not None:
259
+ new_final_type = np.concatenate([self.final_type, other.final_type])
260
+ else:
261
+ new_final_type = self.final_type
262
+
263
+ # Update scaling bounds (if present)
264
+ if (
265
+ self.scaling_min is not None
266
+ and hasattr(other, "scaling_min")
267
+ and other.scaling_min is not None
268
+ ):
269
+ new_scaling_min = np.concatenate([self.scaling_min, other.scaling_min])
270
+ else:
271
+ new_scaling_min = self.scaling_min
272
+
273
+ if (
274
+ self.scaling_max is not None
275
+ and hasattr(other, "scaling_max")
276
+ and other.scaling_max is not None
277
+ ):
278
+ new_scaling_max = np.concatenate([self.scaling_max, other.scaling_max])
279
+ else:
280
+ new_scaling_max = self.scaling_max
281
+
282
+ # Update true dimension
283
+ if not augmented:
284
+ new_true_dim = self._true_dim + getattr(other, "_true_dim", other.shape[0])
285
+ else:
286
+ new_true_dim = self._true_dim
287
+
288
+ # Update all attributes in place
289
+ self.shape = new_shape
290
+ self.min = new_min
291
+ self.max = new_max
292
+ self.guess = new_guess
293
+ self.initial = new_initial
294
+ self.final = new_final
295
+ self._initial = new__initial
296
+ self._final = new__final
297
+ self.initial_type = new_initial_type
298
+ self.final_type = new_final_type
299
+ self.scaling_min = new_scaling_min
300
+ self.scaling_max = new_scaling_max
301
+ self._true_dim = new_true_dim
302
+ self._true_slice = slice(0, self._true_dim)
303
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
304
+
305
+ else:
306
+ # Create a single new variable
307
+ new_shape = (self.shape[0] + 1,)
308
+
309
+ # Extend arrays
310
+ if self.min is not None:
311
+ self.min = np.concatenate([self.min, np.array([min])])
312
+ if self.max is not None:
313
+ self.max = np.concatenate([self.max, np.array([max])])
314
+ if self.guess is not None:
315
+ guess_arr = np.full((self.guess.shape[0], 1), guess)
316
+ self.guess = np.concatenate([self.guess, guess_arr], axis=1)
317
+ if self.initial is not None:
318
+ self.initial = np.concatenate([self.initial, np.array([initial])])
319
+ if self.final is not None:
320
+ self.final = np.concatenate([self.final, np.array([final])])
321
+ if self._initial is not None:
322
+ self._initial = np.concatenate([self._initial, np.array([initial])])
323
+ if self._final is not None:
324
+ self._final = np.concatenate([self._final, np.array([final])])
325
+ if self.initial_type is not None:
326
+ self.initial_type = np.concatenate(
327
+ [self.initial_type, np.array(["Fix"], dtype=object)]
328
+ )
329
+ if self.final_type is not None:
330
+ self.final_type = np.concatenate([self.final_type, np.array(["Fix"], dtype=object)])
331
+ if self.scaling_min is not None:
332
+ self.scaling_min = np.concatenate([self.scaling_min, np.array([min])])
333
+ if self.scaling_max is not None:
334
+ self.scaling_max = np.concatenate([self.scaling_max, np.array([max])])
335
+
336
+ # Update dimensions
337
+ self.shape = new_shape
338
+ if not augmented:
339
+ self._true_dim += 1
340
+ self._true_slice = slice(0, self._true_dim)
341
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
342
+
343
+ def __getitem__(self, idx):
344
+ """Get a subset of the unified state variables.
345
+
346
+ Enables slicing of the unified state to extract subsets of state variables.
347
+ Returns a new UnifiedState containing only the sliced dimensions.
348
+
349
+ Args:
350
+ idx (slice): Slice object specifying which state dimensions to extract.
351
+ Only simple slices with step=1 are supported.
352
+
353
+ Returns:
354
+ UnifiedState: New unified state containing only the sliced dimensions
355
+
356
+ Raises:
357
+ NotImplementedError: If idx is not a slice, or if step != 1
358
+
359
+ Example:
360
+ Generate unified state object::
361
+
362
+ unified = unify_states([position, velocity], name="x")
363
+
364
+ position has shape (3,), velocity has shape (3,)::
365
+
366
+ first_three = unified[0:3] # Extract position only
367
+ print(first_three.shape) # (3,)
368
+ last_three = unified[3:6] # Extract velocity only
369
+ print(last_three.shape) # (3,)
370
+
371
+ Note:
372
+ The sliced state maintains all properties (bounds, guesses, etc.) for
373
+ the selected dimensions. The _true_dim is recalculated based on which
374
+ dimensions fall within the original true state range.
375
+ """
376
+ if isinstance(idx, slice):
377
+ start, stop, step = idx.indices(self.shape[0])
378
+ if step != 1:
379
+ raise NotImplementedError("Step slicing not supported")
380
+
381
+ new_shape = (stop - start,)
382
+ new_name = f"{self.name}[{start}:{stop}]"
383
+
384
+ # Slice all arrays
385
+ new_min = self.min[idx] if self.min is not None else None
386
+ new_max = self.max[idx] if self.max is not None else None
387
+ new_guess = self.guess[:, idx] if self.guess is not None else None
388
+ new_initial = self.initial[idx] if self.initial is not None else None
389
+ new_final = self.final[idx] if self.final is not None else None
390
+ new__initial = self._initial[idx] if self._initial is not None else None
391
+ new__final = self._final[idx] if self._final is not None else None
392
+ new_initial_type = self.initial_type[idx] if self.initial_type is not None else None
393
+ new_final_type = self.final_type[idx] if self.final_type is not None else None
394
+
395
+ # Calculate new true dimension
396
+ new_true_dim = max(0, min(stop, self._true_dim) - max(start, 0))
397
+
398
+ return UnifiedState(
399
+ name=new_name,
400
+ shape=new_shape,
401
+ min=new_min,
402
+ max=new_max,
403
+ guess=new_guess,
404
+ initial=new_initial,
405
+ final=new_final,
406
+ _initial=new__initial,
407
+ _final=new__final,
408
+ initial_type=new_initial_type,
409
+ final_type=new_final_type,
410
+ _true_dim=new_true_dim,
411
+ _true_slice=slice(0, new_true_dim),
412
+ _augmented_slice=slice(new_true_dim, new_shape[0]),
413
+ )
414
+ else:
415
+ raise NotImplementedError("Only slice indexing is supported")
416
+
417
+ def __repr__(self):
418
+ """String representation of the UnifiedState object."""
419
+ return f"UnifiedState('{self.name}', shape={self.shape})"
420
+
421
+
422
+ @dataclass
423
+ class UnifiedControl:
424
+ """Unified control vector aggregating multiple Control objects.
425
+
426
+ UnifiedControl is a drop-in replacement for individual Control objects that holds
427
+ aggregated data from multiple Control instances. It maintains compatibility with
428
+ optimization infrastructure while providing access to individual control components
429
+ through slicing.
430
+
431
+ The unified control separates user-defined "true" controls from augmented controls
432
+ added internally (e.g., for time dilation). This separation allows clean access to
433
+ physical control inputs while supporting advanced features.
434
+
435
+ Attributes:
436
+ name (str): Name identifier for the unified control vector
437
+ shape (tuple): Combined shape (total_dim,) of all aggregated controls
438
+ min (np.ndarray): Lower bounds for all control variables, shape (total_dim,)
439
+ max (np.ndarray): Upper bounds for all control variables, shape (total_dim,)
440
+ guess (np.ndarray): Initial guess trajectory, shape (num_nodes, total_dim)
441
+ _true_dim (int): Number of user-defined control dimensions (excludes augmented)
442
+ _true_slice (slice): Slice for extracting true controls from unified vector
443
+ _augmented_slice (slice): Slice for extracting augmented controls
444
+ time_dilation_slice (Optional[slice]): Slice for time dilation control, if present
445
+
446
+ Properties:
447
+ true: Returns UnifiedControl view containing only true (user-defined) controls
448
+ augmented: Returns UnifiedControl view containing only augmented controls
449
+
450
+ Example:
451
+ Creating a unified control from multiple Control objects::
452
+
453
+ from openscvx.symbolic.unified import unify_controls
454
+
455
+ thrust = ox.Control("thrust", shape=(3,), min=0, max=10)
456
+ torque = ox.Control("torque", shape=(3,), min=-1, max=1)
457
+
458
+ unified = unify_controls([thrust, torque], name="u")
459
+ print(unified.shape) # (6,)
460
+ print(unified.min) # [0, 0, 0, -1, -1, -1]
461
+ print(unified.true.shape) # (6,) - all are true controls
462
+ print(unified.augmented.shape) # (0,) - no augmented controls
463
+
464
+ Appending controls dynamically::
465
+
466
+ unified = UnifiedControl(name="u", shape=(0,), _true_dim=0)
467
+ unified.append(min=-1, max=1, guess=0.0) # Add scalar control
468
+ print(unified.shape) # (1,)
469
+
470
+ See Also:
471
+ - unify_controls(): Factory function for creating UnifiedControl from Control list
472
+ - Control: Individual symbolic control variable
473
+ - UnifiedState: Analogous unified state vector
474
+ """
475
+
476
+ name: str
477
+ shape: tuple
478
+ min: Optional[np.ndarray] = None
479
+ max: Optional[np.ndarray] = None
480
+ guess: Optional[np.ndarray] = None
481
+ _true_dim: int = 0
482
+ _true_slice: Optional[slice] = None
483
+ _augmented_slice: Optional[slice] = None
484
+ time_dilation_slice: Optional[slice] = None # Slice for time dilation control
485
+ scaling_min: Optional[np.ndarray] = None # Scaling minimum bounds for unified control
486
+ scaling_max: Optional[np.ndarray] = None # Scaling maximum bounds for unified control
487
+
488
+ def __post_init__(self):
489
+ """Initialize slices after dataclass creation."""
490
+ if self._true_slice is None:
491
+ self._true_slice = slice(0, self._true_dim)
492
+ if self._augmented_slice is None:
493
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
494
+
495
+ @property
496
+ def true(self) -> "UnifiedControl":
497
+ """Get the true (user-defined) control variables.
498
+
499
+ Returns a view of the unified control containing only user-defined controls,
500
+ excluding internal augmented controls added for time dilation, etc.
501
+
502
+ Returns:
503
+ UnifiedControl: Sliced view containing only true control variables
504
+
505
+ Example:
506
+ Get true user defined controls::
507
+
508
+ unified = unify_controls([thrust, torque, time_dilation], name="u")
509
+ true_controls = unified.true # Only thrust and torque
510
+ """
511
+ return self[self._true_slice]
512
+
513
+ @property
514
+ def augmented(self) -> "UnifiedControl":
515
+ """Get the augmented (internal) control variables.
516
+
517
+ Returns a view of the unified control containing only augmented controls
518
+ added internally by the optimization framework (e.g., time dilation control).
519
+
520
+ Returns:
521
+ UnifiedControl: Sliced view containing only augmented control variables
522
+
523
+ Example:
524
+ Get augmented controls::
525
+
526
+ unified = unify_controls([thrust, time_dilation], name="u")
527
+ aug_controls = unified.augmented # Only time dilation
528
+ """
529
+ return self[self._augmented_slice]
530
+
531
+ def append(
532
+ self,
533
+ other: "Optional[Control | UnifiedControl]" = None,
534
+ *,
535
+ min=-np.inf,
536
+ max=np.inf,
537
+ guess=0.0,
538
+ augmented=False,
539
+ ):
540
+ """Append another control or create a new control variable.
541
+
542
+ This method allows dynamic extension of the unified control, either by appending
543
+ another Control/UnifiedControl object or by creating a new scalar control variable
544
+ with specified properties. Modifies the unified control in-place.
545
+
546
+ Args:
547
+ other (Optional[Control | UnifiedControl]): Control object to append. If None,
548
+ creates a new scalar control variable with properties from keyword args.
549
+ min (float): Lower bound for new scalar control (default: -inf)
550
+ max (float): Upper bound for new scalar control (default: inf)
551
+ guess (float): Initial guess value for new scalar control (default: 0.0)
552
+ augmented (bool): Whether the appended control is augmented (internal) rather
553
+ than true (user-defined). Affects _true_dim tracking. Default: False
554
+
555
+ Returns:
556
+ None: Modifies the unified control in-place
557
+
558
+ Example:
559
+ Appending a Control object::
560
+
561
+ unified = unify_controls([thrust], name="u")
562
+ torque = ox.Control("torque", shape=(3,), min=-1, max=1)
563
+ unified.append(torque)
564
+ print(unified.shape) # (6,) - thrust (3) + torque (3)
565
+
566
+ Creating new scalar control variables::
567
+
568
+ unified = UnifiedControl(name="u", shape=(0,), _true_dim=0)
569
+ unified.append(min=-1, max=1, guess=0.0) # Add scalar control
570
+ print(unified.shape) # (1,)
571
+ """
572
+ # Import here to avoid circular imports at module level
573
+ from openscvx.symbolic.expr.control import Control
574
+
575
+ if isinstance(other, (Control, UnifiedControl)):
576
+ # Append another control object
577
+ new_shape = (self.shape[0] + other.shape[0],)
578
+
579
+ # Update bounds
580
+ if self.min is not None and other.min is not None:
581
+ new_min = np.concatenate([self.min, other.min])
582
+ else:
583
+ new_min = self.min
584
+
585
+ if self.max is not None and other.max is not None:
586
+ new_max = np.concatenate([self.max, other.max])
587
+ else:
588
+ new_max = self.max
589
+
590
+ # Update guess
591
+ if self.guess is not None and other.guess is not None:
592
+ new_guess = np.concatenate([self.guess, other.guess], axis=1)
593
+ else:
594
+ new_guess = self.guess
595
+
596
+ # Update scaling bounds (if present)
597
+ if (
598
+ self.scaling_min is not None
599
+ and hasattr(other, "scaling_min")
600
+ and other.scaling_min is not None
601
+ ):
602
+ new_scaling_min = np.concatenate([self.scaling_min, other.scaling_min])
603
+ else:
604
+ new_scaling_min = self.scaling_min
605
+
606
+ if (
607
+ self.scaling_max is not None
608
+ and hasattr(other, "scaling_max")
609
+ and other.scaling_max is not None
610
+ ):
611
+ new_scaling_max = np.concatenate([self.scaling_max, other.scaling_max])
612
+ else:
613
+ new_scaling_max = self.scaling_max
614
+
615
+ # Update true dimension
616
+ if not augmented:
617
+ new_true_dim = self._true_dim + getattr(other, "_true_dim", other.shape[0])
618
+ else:
619
+ new_true_dim = self._true_dim
620
+
621
+ # Update all attributes in place
622
+ self.shape = new_shape
623
+ self.min = new_min
624
+ self.max = new_max
625
+ self.guess = new_guess
626
+ self.scaling_min = new_scaling_min
627
+ self.scaling_max = new_scaling_max
628
+ self._true_dim = new_true_dim
629
+ self._true_slice = slice(0, self._true_dim)
630
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
631
+
632
+ else:
633
+ # Create a single new variable
634
+ new_shape = (self.shape[0] + 1,)
635
+
636
+ # Extend arrays
637
+ if self.min is not None:
638
+ self.min = np.concatenate([self.min, np.array([min])])
639
+ if self.max is not None:
640
+ self.max = np.concatenate([self.max, np.array([max])])
641
+ if self.guess is not None:
642
+ guess_arr = np.full((self.guess.shape[0], 1), guess)
643
+ self.guess = np.concatenate([self.guess, guess_arr], axis=1)
644
+ if self.scaling_min is not None:
645
+ self.scaling_min = np.concatenate([self.scaling_min, np.array([min])])
646
+ if self.scaling_max is not None:
647
+ self.scaling_max = np.concatenate([self.scaling_max, np.array([max])])
648
+
649
+ # Update dimensions
650
+ self.shape = new_shape
651
+ if not augmented:
652
+ self._true_dim += 1
653
+ self._true_slice = slice(0, self._true_dim)
654
+ self._augmented_slice = slice(self._true_dim, self.shape[0])
655
+
656
+ def __getitem__(self, idx):
657
+ """Get a subset of the unified control variables.
658
+
659
+ Enables slicing of the unified control to extract subsets of control variables.
660
+ Returns a new UnifiedControl containing only the sliced dimensions.
661
+
662
+ Args:
663
+ idx (slice): Slice object specifying which control dimensions to extract.
664
+ Only simple slices with step=1 are supported.
665
+
666
+ Returns:
667
+ UnifiedControl: New unified control containing only the sliced dimensions
668
+
669
+ Raises:
670
+ NotImplementedError: If idx is not a slice, or if step != 1
671
+
672
+ Example:
673
+ Generate unified control object::
674
+
675
+ unified = unify_controls([thrust, torque], name="u")
676
+
677
+ thrust has shape (3,), torque has shape (3,)::
678
+
679
+ first_three = unified[0:3] # Extract thrust only
680
+ print(first_three.shape) # (3,)
681
+
682
+ Note:
683
+ The sliced control maintains all properties (bounds, guesses, etc.) for
684
+ the selected dimensions. The _true_dim is recalculated based on which
685
+ dimensions fall within the original true control range.
686
+ """
687
+ if isinstance(idx, slice):
688
+ start, stop, step = idx.indices(self.shape[0])
689
+ if step != 1:
690
+ raise NotImplementedError("Step slicing not supported")
691
+
692
+ new_shape = (stop - start,)
693
+ new_name = f"{self.name}[{start}:{stop}]"
694
+
695
+ # Slice all arrays
696
+ new_min = self.min[idx] if self.min is not None else None
697
+ new_max = self.max[idx] if self.max is not None else None
698
+ new_guess = self.guess[:, idx] if self.guess is not None else None
699
+
700
+ # Calculate new true dimension
701
+ new_true_dim = max(0, min(stop, self._true_dim) - max(start, 0))
702
+
703
+ return UnifiedControl(
704
+ name=new_name,
705
+ shape=new_shape,
706
+ min=new_min,
707
+ max=new_max,
708
+ guess=new_guess,
709
+ _true_dim=new_true_dim,
710
+ _true_slice=slice(0, new_true_dim),
711
+ _augmented_slice=slice(new_true_dim, new_shape[0]),
712
+ )
713
+ else:
714
+ raise NotImplementedError("Only slice indexing is supported")
715
+
716
+ def __repr__(self):
717
+ """String representation of the UnifiedControl object."""
718
+ return f"UnifiedControl('{self.name}', shape={self.shape})"