Repository: csu-hmc/opty
Pull request #318: time_vector method added. Should Fix #316
Author: login: Peter230655
Created: 2025-02-05T06:54:37Z | Merged: 2025-03-02T07:58:28Z

Added identical time_vector method in Problem and in ConstraintCollocator. Should fix issue #316

Linked issue #316: Would be helpful to have a convenience function that returns the time vector
```python
@property
def time_vector(self):
    return np.arange(0.0, num_nodes*h_val, h_val)
```

```
p = Problem(...)
p.time_vector
```

This can be an attribute as it if very fast to compute. I could be computed on initialization. I guess it should be both on ConstraintCollocator and Problem.

Linked issue comments: user: login: Peter230655
created_at: 2025-02-04T07:33:04Z
body: > @property
> def time_vector(self):
>     return np.arange(0.0, num_nodes*h_val, h_val)
> ```
> p = Problem(...)
> p.time_vector
> ```
> 
> This can be an attribute as it if very fast to compute. I could be computed on initialization. I guess it should be both on ConstraintCollocator and Problem.

Would this be useful for both fixed time interval and variable time interval?

Could I include this in my ``read-only-attributes``?
Reason: If you merged this before you merge my read-only-attributes, this will have a conflict again.
To fix these conflicts, at least for me!, takes several hours of very tedious work. :-) 

Your suggestion seems to assume that the starting time t0 = 0.0
I have never seen any simulation where this was not the case, except my "three stage" simulation, PR #262.
would it make sense to include an optional keyword ``t0 = 0.0`` to allow for $t_0 \neq 0.0$
author_association: CONTRIBUTOR
user: login: moorepants
created_at: 2025-02-04T08:15:43Z
body: > Would this be useful for both fixed time interval and variable time interval?

This would have to output the correct value for fixed and variable. So I guess that means it would need to take the solution as the argument.

> Could I include this in my read-only-attributes?

If we need it to work with a variable time interval, I don't' think it can be a property attribute.

> Your suggestion seems to assume that the starting time t0 = 0.0

I didn't suggest the precise solution, only an example. We would have to generalize.
author_association: MEMBER
user: login: Peter230655
created_at: 2025-02-04T08:19:35Z
body: > > Would this be useful for both fixed time interval and variable time interval?
> 
> This would have to output the correct value for fixed and variable. So I guess that means it would need to take the solution as the argument.
> 
> > Could I include this in my read-only-attributes?
> 
> If we need it to work with a variable time interval, I don't' think it can be a property attribute.

Sorry, what I meant was can I include it in the same PR to avoid conflicts?
(Fixing conflicts with my ``read-only-attributes is a mess!)
> 
> > Your suggestion seems to assume that the starting time t0 = 0.0
> 
> I didn't suggest the precise solution, only an example. We would have to generalize.


author_association: CONTRIBUTOR
user: login: moorepants
created_at: 2025-02-04T09:31:00Z
body: > Sorry, what I meant was can I include it in the same PR to avoid conflicts?

No, recommend not mixing topics into PRs. This is "atomically" a different thing.
author_association: MEMBER
user: login: Peter230655
created_at: 2025-02-04T09:39:31Z
body: > > Sorry, what I meant was can I include it in the same PR to avoid conflicts?
> 
> No, recommend not mixing topics into PRs. This is "atomically" a different thing.

Fair enough, I understand!
I am on it already. 
If possible, and if you want to merge it at all!, merge my ``read-only-attributes`` before you merge this.
( I am less worried about my ``detailed-eoms`` as you do not seem to like it that much :-)    )
author_association: CONTRIBUTOR
user: login: Peter230655
created_at: 2025-02-04T10:25:52Z
body: Question:

Would this require a test function, or is it too simple to be tested?

thanks!
author_association: CONTRIBUTOR
user: login: moorepants
created_at: 2025-02-04T12:32:57Z
body: > Would this require a test function, or is it too simple to be tested?

Everything requires a test function.
author_association: MEMBER

Diff:
diff --git a/opty/direct_collocation.py b/opty/direct_collocation.py
index c025f2ba..974a2a06 100644
--- a/opty/direct_collocation.py
+++ b/opty/direct_collocation.py
@@ -686,6 +686,43 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, solution=None, start_time=0.0):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ==========
+        solution : ndarray, shape(n*N + q*N + r + s,), optional
+            The solution to to problem. Needed if the time interval is variable.
+        start_time : float, optional
+            The initial time of the problem. Default is 0.0.
+
+        Returns
+        =======
+        time_vector : ndarray, shape(num_collocation_nodes,)
+            The array of time instances.
+
+        """
+        t0 = start_time
+        if self.collocator._variable_duration:
+            if solution is None:
+                msg = 'Solution vector must be provided for variable duration.'
+                raise ValueError(msg)
+            elif solution[-1] <= 0:
+                msg = 'Time interval must be strictly greater than zero.'
+                raise ValueError(msg)
+            elif t0 >= solution[-1] * self.collocator.num_collocation_nodes:
+                msg = 'Start time must be less than the final time.'
+                raise ValueError(msg)
+            else:
+                return np.arange(t0, t0 + self.collocator.num_collocation_nodes
+                        *solution[-1], solution[-1])
+
+        else:
+            return np.arange(t0, t0 + self.collocator.num_collocation_nodes*
+                self.collocator.node_time_interval,
+                self.collocator.node_time_interval)
+
+
 class ConstraintCollocator(object):
     """This class is responsible for generating the constraint function and the
     sparse Jacobian of the constraint function using direct collocation methods
diff --git a/opty/tests/test_direct_collocation.py b/opty/tests/test_direct_collocation.py
index 5af3f885..23c553cc 100644
--- a/opty/tests/test_direct_collocation.py
+++ b/opty/tests/test_direct_collocation.py
@@ -1923,3 +1923,99 @@ def test_attributes_read_only():
 
         with raises(AttributeError):
             setattr(test, XX, 5)
+
+def test_time_vector():
+    """Test to ensure that time_vector retunrs the correct values."""
+
+    x, ux = mech.dynamicsymbols('x ux')
+    t = mech.dynamicsymbols._t
+
+    # just random eoms, no physical meaning.
+    eom = sym.Matrix([
+        -x.diff(t) + ux,
+        -ux.diff(t) + 2.0,
+        ])
+
+    state_symbols = (x, ux)
+    num_nodes = 25
+
+    # A: constant time interval
+    t0, tf = np.random.uniform(0.0, 2.0), 10.0
+    interval_value = (tf - t0) / (num_nodes - 1)
+
+    def obj(free):
+        Fx = free[0*num_nodes:2*num_nodes]
+        return interval_value*np.sum(Fx**2)
+
+    def obj_grad(free):
+        grad = np.zeros_like(free)
+        grad[0:2*num_nodes] = 2.0*free[0:2*num_nodes]*interval_value
+        return grad
+
+    prob = Problem(
+        obj,
+        obj_grad,
+        eom,
+        state_symbols,
+        num_nodes,
+        interval_value,
+        time_symbol=t,
+        backend='numpy'
+    )
+    expected_time_vector = np.arange(t0, t0 + num_nodes*interval_value,
+                        interval_value)
+    time_vector = prob.time_vector(start_time=t0)
+    assert np.allclose(time_vector, expected_time_vector)
+
+    solution = np.random.randn(prob.num_free)
+    time_vector = prob.time_vector(solution, start_time=t0)
+    assert np.allclose(time_vector, expected_time_vector)
+
+    # B: variable time interval
+    h =sym.symbols('h')
+    interval_value = h
+
+    def obj(free):
+        Fx = free[0*num_nodes:2*num_nodes]
+        return solution[-1]*np.sum(Fx**2)
+
+    def obj_grad(free):
+        grad = np.zeros_like(free)
+        grad[0:2*num_nodes] = 2.0*free[0:2*num_nodes]*solution[-1]
+        return grad
+
+    prob = Problem(
+        obj,
+        obj_grad,
+        eom,
+        state_symbols,
+        num_nodes,
+        interval_value,
+        time_symbol=t,
+        backend='numpy'
+    )
+
+    # solution must be given
+    with raises(ValueError):
+        time_vector = prob.time_vector(start_time=t0)
+    with raises(ValueError):
+        time_vector = prob.time_vector()
+
+    solution = np.random.randn(prob.num_free)
+    solution[-1] = np.random.uniform(2.5/(num_nodes-1), 10.0/(num_nodes-1))
+    time_vector = prob.time_vector(solution, start_time=t0)
+    expected_time_vector = np.arange(t0, t0 + num_nodes*solution[-1],
+                solution[-1])
+    assert np.allclose(time_vector, expected_time_vector)
+
+    # final time > initial time
+    solution[-1] = 1.e-75
+    expected_time_vector = np.arange(t0, t0 + num_nodes*solution[-1],
+                solution[-1])
+    with raises(ValueError):
+        time_vector = prob.time_vector(solution, start_time=t0)
+
+    # interval_value must be greater than zero
+    solution[-1] = 0.0
+    with raises(ValueError):
+        time_vector = prob.time_vector(solution, start_time=t0)


Reviews:
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T08:00:42Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T08:02:04Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T08:02:43Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T08:02:59Z
user: login: Peter230655
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T08:24:02Z
user: login: Peter230655
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T08:25:44Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T12:50:17Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-27T12:50:35Z
user: login: Peter230655
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T12:57:18Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T06:31:11Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T07:12:36Z
user: login: moorepants
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T07:13:03Z

Review comments:
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
path: opty/direct_collocation.py
user: login: moorepants
body: ```suggestion
    def time_vector(self, solution=None, start_time=0.0):
```

I think people will rarely use the start time kwarg, so make it second. And rename to have more informative variable name.
created_at: 2025-02-27T08:00:42Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ----------
+        t0 : float, optional
+            The initial time of the problem. Default is 0.0.
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the interval is variable.
+
+        Returns
+        -------
+        A numpy num_collocation_nodes-array of time instances.
+
+        """
+        if isinstance(self.collocator.node_time_interval, sm.Symbol):
path: opty/direct_collocation.py
user: login: moorepants
body: There is a flag `self.collocator._variable_duration` that should be used for this check.
created_at: 2025-02-27T08:02:04Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ----------
+        t0 : float, optional
+            The initial time of the problem. Default is 0.0.
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the interval is variable.
+
+        Returns
+        -------
+        A numpy num_collocation_nodes-array of time instances.
+
+        """
+        if isinstance(self.collocator.node_time_interval, sm.Symbol):
+            if solution is None:
+                msg = 'Solution vector must be provided for variable duration.'
+                raise ValueError(msg)
+            else:
+                return np.arange(t0, t0+self.collocator.num_collocation_nodes*
path: opty/direct_collocation.py
user: login: moorepants
body: ```suggestion
                return np.arange(t0, t0 + self.collocator.num_collocation_nodes*
```
created_at: 2025-02-27T08:02:43Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ----------
+        t0 : float, optional
+            The initial time of the problem. Default is 0.0.
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the interval is variable.
+
+        Returns
+        -------
+        A numpy num_collocation_nodes-array of time instances.
+
+        """
+        if isinstance(self.collocator.node_time_interval, sm.Symbol):
+            if solution is None:
+                msg = 'Solution vector must be provided for variable duration.'
+                raise ValueError(msg)
+            else:
+                return np.arange(t0, t0+self.collocator.num_collocation_nodes*
+                    solution[-1], solution[-1])
+
+        else:
+            return np.arange(t0, t0+self.collocator.num_collocation_nodes*
path: opty/direct_collocation.py
user: login: moorepants
body: ```suggestion
            return np.arange(t0, t0 + self.collocator.num_collocation_nodes*
```
created_at: 2025-02-27T08:02:59Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
path: opty/direct_collocation.py
user: login: Peter230655
body: I now wonder, whether we should give t0 at all: I would not know how to tell opty not to start at t= = 0.0
created_at: 2025-02-27T08:24:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ----------
+        t0 : float, optional
+            The initial time of the problem. Default is 0.0.
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the interval is variable.
+
+        Returns
+        -------
+        A numpy num_collocation_nodes-array of time instances.
+
+        """
+        if isinstance(self.collocator.node_time_interval, sm.Symbol):
+            if solution is None:
+                msg = 'Solution vector must be provided for variable duration.'
+                raise ValueError(msg)
+            else:
+                return np.arange(t0, t0+self.collocator.num_collocation_nodes*
path: opty/direct_collocation.py
user: login: Peter230655
body: When do you put a space and when do you not put a space?
I thought in signatures one does not put a space?
Of course I will correct it.
created_at: 2025-02-27T08:25:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
path: opty/direct_collocation.py
user: login: moorepants
body: We can leave it off and add it later if we ever think we need it.
created_at: 2025-02-27T12:50:17Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ----------
+        t0 : float, optional
+            The initial time of the problem. Default is 0.0.
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the interval is variable.
+
+        Returns
+        -------
+        A numpy num_collocation_nodes-array of time instances.
+
+        """
+        if isinstance(self.collocator.node_time_interval, sm.Symbol):
+            if solution is None:
+                msg = 'Solution vector must be provided for variable duration.'
+                raise ValueError(msg)
+            else:
+                return np.arange(t0, t0+self.collocator.num_collocation_nodes*
path: opty/direct_collocation.py
user: login: moorepants
body: PEP8 has the guidelines.
created_at: 2025-02-27T12:50:35Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,35 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, t0=0.0, solution=None):
path: opty/direct_collocation.py
user: login: Peter230655
body: > We can leave it off and add it later if we ever think we need it.

I left it in. Do you want me to remove it?
created_at: 2025-02-27T12:57:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1923,3 +1923,98 @@ def test_attributes_read_only():
 
         with raises(AttributeError):
             setattr(test, XX, 5)
+
+def test_time_vector():
+    """Test to ensure that time_vector retunrs the correct values."""
+
+    x, ux = mech.dynamicsymbols('x ux')
+    t = mech.dynamicsymbols._t
+
+    # just random eoms, no physical meaning.
+    eom = sym.Matrix([
+        -x.diff(t) + ux,
+        -ux.diff(t) + 2.0,
+        ])
+
+    state_symbols = (x, ux)
+    num_nodes = 25
+
+    # A: constant time interval
+    t0, tf = np.random.uniform(0.0, 2.0), 10.0
+    interval_value = (tf - t0) / (num_nodes - 1)
+
+    def obj(free):
+        Fx = free[0*num_nodes:2*num_nodes]
+        return interval_value*np.sum(Fx**2)
+
+    def obj_grad(free):
+        grad = np.zeros_like(free)
+        grad[0:2*num_nodes] = 2.0*free[0:2*num_nodes]*interval_value
+        return grad
+
+    prob = Problem(
+        obj,
+        obj_grad,
+        eom,
+        state_symbols,
+        num_nodes,
+        interval_value,
+        time_symbol=t,
+        backend='numpy'
+    )
+    expected_time_vector = np.arange(t0, t0 + num_nodes*interval_value,
+                        interval_value)
+    time_vector = prob.time_vector(start_time=t0)
+    assert np.allclose(time_vector, expected_time_vector)
+
+    solution = np.random.randn(prob.num_free)
+    time_vector = prob.time_vector(solution, start_time=t0)
+    assert np.allclose(time_vector, expected_time_vector)
+
+    # B: variable time interval
+    h =sym.symbols('h')
+    interval_value = h
+
+    def obj(free):
+        Fx = free[0*num_nodes:2*num_nodes]
+        return solution[-1]*np.sum(Fx**2)
+
+    def obj_grad(free):
+        grad = np.zeros_like(free)
+        grad[0:2*num_nodes] = 2.0*free[0:2*num_nodes]*solution[-1]
+        return grad
+
+    prob = Problem(
+        obj,
+        obj_grad,
+        eom,
+        state_symbols,
+        num_nodes,
+        interval_value,
+        time_symbol=t,
+        backend='numpy'
+    )
+
+    # solution must be given
+    with raises(ValueError):
+        time_vector = prob.time_vector(start_time=t0)
+        time_vector = prob.time_vector()
path: opty/tests/test_direct_collocation.py
user: login: moorepants
body: These need to be in two with clauses, one for each.
created_at: 2025-03-02T06:31:11Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,43 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, solution=None, start_time=0.0):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ==========
+        solution : (n*N + q*N + r + s)-ndarray, optional
path: opty/direct_collocation.py
user: login: moorepants
body: This is not the correct numpy doc format.

The basic format is:

```
variable : array_like, shape(5, 3, 2), optional
```
created_at: 2025-03-02T07:12:36Z
author_association: MEMBER
diff_hunk: @@ -686,6 +686,43 @@ def parse_free(self, free):
 
         return parse_free(free, n, q, N, variable_duration)
 
+    def time_vector(self, solution=None, start_time=0.0):
+        """Returns the time instances of the problem as an numpy ndarray.
+
+        Parameters
+        ==========
+        solution : (n*N + q*N + r + s)-ndarray, optional
+            The solution to to problem. Needed if the time interval is variable.
+        start_time : float, optional
+            The initial time of the problem. Default is 0.0.
+
+        Returns
+        =======
+        time_vector : ndarray, shape(num_collocation_nodes,)
path: opty/direct_collocation.py
user: login: moorepants
body: this is the correct format, for example
created_at: 2025-03-02T07:13:03Z
author_association: MEMBER

PR comments:
user: login: moorepants
created_at: 2025-02-27T07:59:09Z
body: This needs to resolve conflicts.
author_association: MEMBER
user: login: moorepants
created_at: 2025-02-27T20:13:36Z
body: This needs a unit test.
author_association: MEMBER
user: login: Peter230655
created_at: 2025-03-01T07:26:48Z
body: > This needs a unit test.

Started on it. I think, I will get it done tomorrow.
author_association: CONTRIBUTOR
user: login: Peter230655
created_at: 2025-03-02T06:36:54Z
body: I added ValueErrors for:
- start time >= final time (only with variable time_intervall)
- time intervall <= 0 (only with variable time intervall)

I felt for fixed time interval user will know that t0 < tf
author_association: CONTRIBUTOR
user: login: moorepants
created_at: 2025-03-02T07:58:24Z
body: This is good now! Thanks.
author_association: MEMBER
user: login: Peter230655
created_at: 2025-03-02T08:10:48Z
body:    

> This is good now! Thanks.

Thanks!
author_association: CONTRIBUTOR