From 479f14af44b63b8008ce9f1f0fba9f974fb57edb Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Thu, 5 Mar 2026 17:09:50 -0800 Subject: [PATCH 01/18] Fix to/from-file without events --- src/condor/contrib.py | 6 ++---- .../solvers/sweeping_gradient_method.py | 5 +---- tests/test_trajectory_analysis.py | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index bfabc9e7..202c092b 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -679,12 +679,9 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): t_size = t_grid.size if include_events: t_size += 2 * len(self._res.e) - 1 - es = [] elif t_grid[-1] + dt == self._res.t[-1]: t_size += 1 - if not include_events: - es = None # self.t = np.empty((t_size,)) new_self.t = np.ones((t_size,)) * -1 # all self.t should go to new_self.t new_self.t[: t_grid.size] = t_grid @@ -699,10 +696,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): p = self._res.p ys = np.empty((t_size, model.dynamic_output._count)) else: - ys = None + ys = [] idx0 = 0 + es = [] for event, x_interp_segment in zip(self._res.e, state_interp): t_select = np.where( (new_self.t >= x_interp_segment.t0) diff --git a/src/condor/solvers/sweeping_gradient_method.py b/src/condor/solvers/sweeping_gradient_method.py index 177cdb07..bbe3ebd7 100644 --- a/src/condor/solvers/sweeping_gradient_method.py +++ b/src/condor/solvers/sweeping_gradient_method.py @@ -693,10 +693,7 @@ def __getitem__(self, key): e=self.e[key], ) - def save( - self, - filename, - ): + def save(self, filename): e_idxs = [e.index for e in self.e] e_roots = [e.rootsfound for e in self.e] np.savez( diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index 762ff056..45e4624f 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -380,3 +380,24 @@ class Sim(odesys.TrajectoryAnalysis): tf = 10 Sim(wn=10, u_hold=0.8) + + +def test_file_io(odesys, tmp_path): + class Ev(odesys.Event): + function = x + + class Sim(odesys.TrajectoryAnalysis): + tf = 10 + + sim = Sim(wn=1) + + fp1 = tmp_path / "sim.npz" + sim.to_file(fp1) + sim_from_file = Sim.from_file(fp1) + assert len(sim_from_file._res.e) > 1 + + fp2 = tmp_path / "sim_no_events.npz" + sim_resamp = sim.resample(0.1, include_events=False) + sim_resamp.to_file(fp2) + sim_resamp_from_file = Sim.from_file(fp2) + assert len(sim_resamp_from_file._res.e) == 0 From ce74e70917d12ac69a7df854fc649c09019e21c3 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Tue, 17 Mar 2026 12:11:16 -0700 Subject: [PATCH 02/18] Fix resample for single state sims, add test --- src/condor/contrib.py | 7 +++--- tests/test_trajectory_analysis.py | 41 ++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 202c092b..6158ab43 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -682,8 +682,7 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): elif t_grid[-1] + dt == self._res.t[-1]: t_size += 1 - # self.t = np.empty((t_size,)) - new_self.t = np.ones((t_size,)) * -1 # all self.t should go to new_self.t + new_self.t = np.full((t_size,), -1) new_self.t[: t_grid.size] = t_grid if t_grid[-1] + dt == self._res.t[-1]: new_self.t[t_grid.size] = t_grid[-1] + dt @@ -720,7 +719,9 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): # TODO figure out how to get root info ts_to_call = new_self.t[idx0:idx1] - xs[idx0:idx1] = x_interp_segment(ts_to_call) + xs[idx0:idx1] = np.atleast_2d(x_interp_segment(ts_to_call)).reshape( + len(ts_to_call), -1 + ) if include_output: for idx, t, x in zip(range(idx0, idx1), ts_to_call, xs[idx0:idx1]): ys[idx, None] = dynamic_output(p, t, x).T diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index 45e4624f..612c6b88 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -332,7 +332,7 @@ class Options: @pytest.fixture -def odesys(): +def mass_spring_ode(): class MassSpring(co.ODESystem): x = state() v = state() @@ -345,48 +345,48 @@ class MassSpring(co.ODESystem): return MassSpring -def test_event_state_to_mode(odesys): +def test_event_state_to_mode(mass_spring_ode): # verify you can reference a state created in an event from a mode - class Event(odesys.Event): + class Event(mass_spring_ode.Event): function = v count = state(name="count_") update[count] = count + 1 - class Mode(odesys.Mode): + class Mode(mass_spring_ode.Mode): condition = Event.count > 0 action[u] = 1 - class Sim(odesys.TrajectoryAnalysis): + class Sim(mass_spring_ode.TrajectoryAnalysis): total_count = trajectory_output(Event.count) tf = 10 print(Sim(wn=10).total_count) -def test_mode_param_to_mode(odesys): +def test_mode_param_to_mode(mass_spring_ode): # verify you can reference a parameter created in a mode in another mode - class ModeA(odesys.Mode): + class ModeA(mass_spring_ode.Mode): condition = v > 0 u_hold = parameter() action[u] = u_hold - class ModeB(odesys.Mode): + class ModeB(mass_spring_ode.Mode): condition = 1 action[u] = ModeA.u_hold - class Sim(odesys.TrajectoryAnalysis): + class Sim(mass_spring_ode.TrajectoryAnalysis): tf = 10 Sim(wn=10, u_hold=0.8) -def test_file_io(odesys, tmp_path): - class Ev(odesys.Event): +def test_file_io(mass_spring_ode, tmp_path): + class Ev(mass_spring_ode.Event): function = x - class Sim(odesys.TrajectoryAnalysis): + class Sim(mass_spring_ode.TrajectoryAnalysis): tf = 10 sim = Sim(wn=1) @@ -401,3 +401,20 @@ class Sim(odesys.TrajectoryAnalysis): sim_resamp.to_file(fp2) sim_resamp_from_file = Sim.from_file(fp2) assert len(sim_resamp_from_file._res.e) == 0 + + +def test_resample_single_state(): + class ODE(co.ODESystem): + a = parameter() + x = state() + dot[x] = -a * x + + class Sim(ODE.TrajectoryAnalysis): + tf = 10 + initial[x] = 1 + + sim = Sim(a=0.5) + + sim_resamp = sim.resample(1.0, include_events=False) + assert sim_resamp._res.x.shape == (11, 1) + assert sim_resamp._res.e == [] From 82a267c41d8abc307a2a7e2388ed5c25fb56f029 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Tue, 17 Mar 2026 13:23:12 -0700 Subject: [PATCH 03/18] Intermediate fix for resample with events --- src/condor/contrib.py | 4 ++-- tests/test_trajectory_analysis.py | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 6158ab43..d1a781be 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -678,11 +678,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): t_grid = np.arange(self._res.t[0], self._res.t[-1], dt) t_size = t_grid.size if include_events: - t_size += 2 * len(self._res.e) - 1 + t_size += 2 * len(self._res.e) elif t_grid[-1] + dt == self._res.t[-1]: t_size += 1 - new_self.t = np.full((t_size,), -1) + new_self.t = np.full((t_size,), -1, dtype=float) new_self.t[: t_grid.size] = t_grid if t_grid[-1] + dt == self._res.t[-1]: new_self.t[t_grid.size] = t_grid[-1] + dt diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index 612c6b88..b096ac1e 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -342,6 +342,8 @@ class MassSpring(co.ODESystem): dot[v] = u - wn**2 * x initial[x] = 1 + dynamic_output.specific_energy = 0.5 * wn**2 * x**2 + 0.5 * v**2 + return MassSpring @@ -361,8 +363,6 @@ class Sim(mass_spring_ode.TrajectoryAnalysis): total_count = trajectory_output(Event.count) tf = 10 - print(Sim(wn=10).total_count) - def test_mode_param_to_mode(mass_spring_ode): # verify you can reference a parameter created in a mode in another mode @@ -410,7 +410,7 @@ class ODE(co.ODESystem): dot[x] = -a * x class Sim(ODE.TrajectoryAnalysis): - tf = 10 + tf = 10.0 # TODO test with tf not on resample grid initial[x] = 1 sim = Sim(a=0.5) @@ -418,3 +418,21 @@ class Sim(ODE.TrajectoryAnalysis): sim_resamp = sim.resample(1.0, include_events=False) assert sim_resamp._res.x.shape == (11, 1) assert sim_resamp._res.e == [] + + sim_resamp_events = sim.resample(1.0, include_events=True) + assert sim_resamp_events._res.x.shape == (14, 1) + assert len(sim_resamp_events._res.e) == 2 + + +@pytest.mark.parametrize("e_time", [0.5, 0.55]) # on/off grid +def test_resample_with_events(mass_spring_ode, e_time): + class Ev(mass_spring_ode.Event): + at_time = e_time + + class Sim(mass_spring_ode.TrajectoryAnalysis): + tf = 1 + + sim = Sim(wn=10) + + sim_resamp = sim.resample(0.1, include_events=True) + assert len(sim_resamp._res.e) == 3 From 966f063fdf8179503f9105f623716ccaf8f1c4e9 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Tue, 24 Mar 2026 14:41:13 -0700 Subject: [PATCH 04/18] Use savez_compressed --- src/condor/solvers/sweeping_gradient_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/condor/solvers/sweeping_gradient_method.py b/src/condor/solvers/sweeping_gradient_method.py index bbe3ebd7..0be089aa 100644 --- a/src/condor/solvers/sweeping_gradient_method.py +++ b/src/condor/solvers/sweeping_gradient_method.py @@ -696,7 +696,7 @@ def __getitem__(self, key): def save(self, filename): e_idxs = [e.index for e in self.e] e_roots = [e.rootsfound for e in self.e] - np.savez( + np.savez_compressed( filename, e_idxs=e_idxs, e_roots=e_roots, From d6e584732206c81ec04bf2b934d49a4f3c3b7075 Mon Sep 17 00:00:00 2001 From: "Natividad, Carlos Anthony D. (ARC-AA)" Date: Mon, 6 Apr 2026 14:50:02 -0700 Subject: [PATCH 05/18] if no implementation is found during resample, don't try to assign one. override include_output if implemention is missing in resample --- src/condor/contrib.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index bfabc9e7..46063607 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -670,7 +670,10 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): return self new_self = model.__new__(model) - new_self.implementation = self.implementation + if getattr(self, "implementation", False): + new_self.implementation = self.implementation + else: + include_output = False # override include_output if implementation is not found new_self._original_instance = original_instance new_self.bind_field(self.parameter) new_self.input_kwargs = self.input_kwargs From 326df6cfd8ec3c7e39b0e854e0b306c811b181bf Mon Sep 17 00:00:00 2001 From: "Natividad, Carlos Anthony D. (ARC-AA)" Date: Mon, 6 Apr 2026 14:51:35 -0700 Subject: [PATCH 06/18] add TODO to resample to add option to rebuild implementation --- src/condor/contrib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 46063607..5e2e8954 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -670,6 +670,7 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): return self new_self = model.__new__(model) + # TODO: add option to rebuild the implemention if getattr(self, "implementation", False): new_self.implementation = self.implementation else: From 6eed2ee58cd9c66083d60ebe7a259b3f1f7965b6 Mon Sep 17 00:00:00 2001 From: "Natividad, Carlos Anthony D. (ARC-AA)" Date: Mon, 6 Apr 2026 14:51:35 -0700 Subject: [PATCH 07/18] add TODO to resample to add option to rebuild implementation --- src/condor/contrib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 46063607..41c3a468 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -670,10 +670,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): return self new_self = model.__new__(model) + # TODO: add option to rebuild the implemention if getattr(self, "implementation", False): new_self.implementation = self.implementation - else: - include_output = False # override include_output if implementation is not found + else: # override include_output if implementation is not found + include_output = False new_self._original_instance = original_instance new_self.bind_field(self.parameter) new_self.input_kwargs = self.input_kwargs From ec4740983306d30cbcd4f063f839489ce52cae11 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Wed, 8 Apr 2026 08:52:49 -0700 Subject: [PATCH 08/18] Skip segments containing no sample times --- src/condor/contrib.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index d1a781be..63846f0b 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -705,6 +705,8 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): (new_self.t >= x_interp_segment.t0) & (new_self.t <= x_interp_segment.t1) ) + if t_select[0].size == 0: + continue idx0 = t_select[0][0] idx1 = t_select[0][-1] + 1 From c87129a4b89958f673716e9a3c873ac2b37039ac Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Wed, 8 Apr 2026 09:09:40 -0700 Subject: [PATCH 09/18] Update resample docstring --- src/condor/contrib.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 63846f0b..7d074213 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -651,9 +651,27 @@ def __getitem__(self, idx): return new_self def resample(self, dt, include_output=True, include_events=True, max_deg=3): - """Re-sample the trajectory, to a grid based on evenly-spaced points. With - include_events=True, two points will be inserted for each internal event to get - the state immediately before and after the event.""" + """Re-sample the trajectory on a grid of evenly-spaced points + + Parameters + ---------- + dt : float + Sample spacing in the independent variable (usually time). + include_output : bool, optional + Include :attr:`~ODESystem.dynamic_output` in the returned result. + include_events : bool, optional + Include events regardless of whether or not they fall on a multiple of `dt`. + Two points will be inserted for each internal event to get the state + immediately before and after the event. + max_deg : int, optional + Maximum degree of the interpolating spline. Actual degree used in any given + segment between events may be fewer if there are not sufficient samples. + + Returns + ------- + new_sim : TrajectoryAnalysis + A new trajectory instance with the requested sample spacing. + """ original_instance = getattr(self, "_original_instance", self) if original_instance is not self: From 83a15b103a289de1f6a0ab7a1e0f18106c206ec8 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Thu, 9 Apr 2026 18:23:35 -0700 Subject: [PATCH 10/18] Refactor and fix up TrajectoryAnalysis.resample with tests --- src/condor/contrib.py | 131 +++++++++++++++--------------- tests/test_trajectory_analysis.py | 123 ++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 81 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 7d074213..d677bfac 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -693,79 +693,80 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): new_self.bind_field(self.parameter) new_self.input_kwargs = self.input_kwargs - t_grid = np.arange(self._res.t[0], self._res.t[-1], dt) - t_size = t_grid.size - if include_events: - t_size += 2 * len(self._res.e) - elif t_grid[-1] + dt == self._res.t[-1]: - t_size += 1 - - new_self.t = np.full((t_size,), -1, dtype=float) - new_self.t[: t_grid.size] = t_grid - if t_grid[-1] + dt == self._res.t[-1]: - new_self.t[t_grid.size] = t_grid[-1] + dt - - state_interp = ResultInterpolant(self._res, max_deg=max_deg) - xs = np.empty((t_size, model.state._count)) - include_output = include_output and model.dynamic_output._count - if include_output: - dynamic_output = self.implementation.state_system.dynamic_output - p = self._res.p - ys = np.empty((t_size, model.dynamic_output._count)) + t0, tf = self.t[[0, -1]] + + # grid with endpoint if it coincides with tf + length = np.ceil((tf - t0) / dt) + if length * dt == tf: + t_grid = np.arange(t0, tf + dt, dt) else: - ys = [] + t_grid = np.arange(t0, tf, dt) - idx0 = 0 + interp = ResultInterpolant(self._res, max_deg=3) - es = [] - for event, x_interp_segment in zip(self._res.e, state_interp): - t_select = np.where( - (new_self.t >= x_interp_segment.t0) - & (new_self.t <= x_interp_segment.t1) - ) - if t_select[0].size == 0: - continue - idx0 = t_select[0][0] - idx1 = t_select[0][-1] + 1 - - if include_events: - new_self.t[idx0 + 1 :] = new_self.t[idx0:-1] - xs[idx0, :] = self._res.x[x_interp_segment.idx0] - if include_output: - ys[idx0, :] = self._res.y[x_interp_segment.idx0] - es.append(Root(idx0, event.rootsfound)) - idx0 += 1 - idx1 += 1 - # TODO figure out how to get root info - - ts_to_call = new_self.t[idx0:idx1] - xs[idx0:idx1] = np.atleast_2d(x_interp_segment(ts_to_call)).reshape( - len(ts_to_call), -1 - ) - if include_output: - for idx, t, x in zip(range(idx0, idx1), ts_to_call, xs[idx0:idx1]): - ys[idx, None] = dynamic_output(p, t, x).T - - if include_events: - new_self.t[idx1 + 1 :] = new_self.t[idx1:-1] - new_self.t[idx1 : idx1 + 2] = self._res.t[x_interp_segment.idx1] - xs[idx1, :] = self._res.x[x_interp_segment.idx1] - if include_output: - ys[idx1, :] = self._res.y[x_interp_segment.idx1] - - if include_events: - xs[idx1 + 1, :] = self._res.x[x_interp_segment.idx1] - if include_output: - ys[idx1 + 1, :] = self._res.y[x_interp_segment.idx1] - es.append(Root(idx1, self._res.e[-1].rootsfound)) + new_e = [] + new_y = [] - new_self.bind_field(model.state.wrap(xs.T)) + if not include_events: + new_t = t_grid + new_x = np.empty((t_grid.size, self._res.x.shape[1]), float) + for seg in interp: + i_to_samp = np.nonzero((t_grid >= seg.t0) & (t_grid <= seg.t1)) + if len(i_to_samp) == 0: + continue + + x_seg = seg(t_grid[i_to_samp]) + if x_seg.ndim == 1: + x_seg = x_seg[:, None] + new_x[i_to_samp] = x_seg + else: + all_et = self._res.t[[e.index for e in self._res.e]] + samps_and_es = np.intersect1d(t_grid, all_et, assume_unique=True) + n_samps = t_grid.size + 2 * all_et.size - samps_and_es.size + + new_t = np.empty(n_samps, float) + new_x = np.empty((n_samps, self._res.x.shape[1]), float) + new_t[[0, -1]] = t0, tf + new_x[[0, -1]] = self._res.x[[0, -1]] + idx0 = 1 + for seg, ev in zip(interp, self._res.e, strict=False): + i_to_samp = np.nonzero((t_grid > seg.t0) & (t_grid < seg.t1))[0] + n_samp_seg = len(i_to_samp) + + # insert event times + new_t[idx0] = seg.t0 + new_t[idx0 + 1 + n_samp_seg] = seg.t1 + # insert sample times + new_t[idx0 + 1 : idx0 + 1 + n_samp_seg] = t_grid[i_to_samp] + + # interpolate + x_seg = seg(new_t[idx0 : idx0 + n_samp_seg + 2]) + if x_seg.ndim == 1: + x_seg = x_seg[:, None] + new_x[idx0 : idx0 + n_samp_seg + 2] = x_seg + + new_e.append(Root(idx0, ev.rootsfound)) + + idx0 += n_samp_seg + 2 + + new_e.append(Root(idx0, self._res.e[-1].rootsfound)) + + include_output = include_output and model.dynamic_output._count + if include_output: + dynamic_output = self.implementation.state_system.dynamic_output + p = self._res.p + new_y = np.empty((new_t.size, model.dynamic_output._count)) + for i, (t, x) in enumerate(zip(new_t, new_x, strict=True)): + new_y[i] = dynamic_output(p, t, x).T + + new_self.t = new_t + new_self.bind_field(model.state.wrap(new_x.T)) if include_output: - new_self.bind_field(model.dynamic_output.wrap(ys.T)) + new_self.bind_field(model.dynamic_output.wrap(new_y.T)) new_self._res = Result( - t=new_self.t, x=xs, y=ys, e=es, p=self._res.p, system=self._res.system + t=new_t, x=new_x, y=new_y, e=new_e, p=self._res.p, system=self._res.system ) return new_self diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index b096ac1e..885ee833 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -403,6 +403,111 @@ class Sim(mass_spring_ode.TrajectoryAnalysis): assert len(sim_resamp_from_file._res.e) == 0 +def make_resample_sim(tf_, e_times=None, add_output=False): + class MassSpring(co.ODESystem): + x = state() + v = state() + wn = parameter() + + dot[x] = v + dot[v] = wn**2 * x + + initial[x] = 1 + + if add_output: + dynamic_output.ke = 0.5 * v**2 + + if e_times: + for e in e_times: + + class Ev(MassSpring.Event): + at_time = e + + class Sim(MassSpring.TrajectoryAnalysis): + tf = tf_ + + sim = Sim(wn=8.0) + return sim + + +def test_resample_no_events(): + sim = make_resample_sim(1.0, add_output=False) + + simd = sim.resample(0.2, include_events=False) + np.testing.assert_allclose( + simd.t, + [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], + rtol=1e-12, + atol=1e-12, + ) + + # include_events doubles t0 and tf + simd2 = sim.resample(0.2, include_events=True) + np.testing.assert_allclose( + simd2.t, + [0.0, 0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.0], + rtol=1e-12, + atol=1e-12, + ) + + assert simd._res.y == [] + + +@pytest.mark.parametrize("add_output", [False, True]) +def test_resample_with_events(add_output): + # cases: + # - no samples between two events + # - sample coincides exactly with event + + e_times = [0.05, 0.06, 0.3, 0.4] + sim = make_resample_sim(1.0, e_times=e_times, add_output=add_output) + + simd = sim.resample(0.2, include_events=False) + np.testing.assert_allclose( + simd.t, + [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], + rtol=1e-12, + atol=1e-12, + ) + if add_output: + assert simd.ke.size == simd.t.size + else: + assert simd._res.y == [] + + simd = sim.resample(0.2, include_events=True, include_output=add_output) + np.testing.assert_allclose( + simd.t, + [0.0, 0.0, 0.05, 0.05, 0.06, 0.06, 0.2, 0.3, 0.3, 0.4, 0.4, 0.6, 0.8, 1.0, 1.0], + rtol=1e-12, + atol=1e-12, + ) + if add_output: + assert simd.ke.size == simd.t.size + else: + assert simd._res.y == [] + + +def test_resample_nonsampled_tf(): + sim = make_resample_sim(1.1, add_output=False) + simd = sim.resample(0.2, include_events=False) + np.testing.assert_allclose( + simd.t, + [0.0, 0.2, 0.4, 0.6, 0.8, 1.0], + rtol=1e-12, + atol=1e-12, + ) + + tf = 1.0 + 1e-8 + sim = make_resample_sim(tf, e_times=[0.5]) + simd = sim.resample(0.2, include_events=True) + np.testing.assert_allclose( + simd.t, + [0.0, 0.0, 0.2, 0.4, 0.5, 0.5, 0.6, 0.8, 1.0, tf, tf], + rtol=1e-12, + atol=1e-12, + ) + + def test_resample_single_state(): class ODE(co.ODESystem): a = parameter() @@ -410,7 +515,7 @@ class ODE(co.ODESystem): dot[x] = -a * x class Sim(ODE.TrajectoryAnalysis): - tf = 10.0 # TODO test with tf not on resample grid + tf = 10.0 initial[x] = 1 sim = Sim(a=0.5) @@ -420,19 +525,5 @@ class Sim(ODE.TrajectoryAnalysis): assert sim_resamp._res.e == [] sim_resamp_events = sim.resample(1.0, include_events=True) - assert sim_resamp_events._res.x.shape == (14, 1) + assert sim_resamp_events._res.x.shape == (13, 1) assert len(sim_resamp_events._res.e) == 2 - - -@pytest.mark.parametrize("e_time", [0.5, 0.55]) # on/off grid -def test_resample_with_events(mass_spring_ode, e_time): - class Ev(mass_spring_ode.Event): - at_time = e_time - - class Sim(mass_spring_ode.TrajectoryAnalysis): - tf = 1 - - sim = Sim(wn=10) - - sim_resamp = sim.resample(0.1, include_events=True) - assert len(sim_resamp._res.e) == 3 From 505a116cec07988295f95a4cf0283216da1b6032 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 11:23:13 -0700 Subject: [PATCH 11/18] Raise NotImplementedError if separate_events=True for now --- src/condor/contrib.py | 8 +++++++- tests/test_trajectory_analysis.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index d677bfac..5fd37584 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -682,6 +682,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): max_deg=max_deg, ) + if self.options_dict.get("separate_events", False): + raise NotImplementedError( + "Resampling a trajectory with separate_events=True` not yet supported" + ) + model = self.__class__ if dt <= 0.0: @@ -711,10 +716,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): new_t = t_grid new_x = np.empty((t_grid.size, self._res.x.shape[1]), float) for seg in interp: - i_to_samp = np.nonzero((t_grid >= seg.t0) & (t_grid <= seg.t1)) + i_to_samp = np.nonzero((t_grid >= seg.t0) & (t_grid < seg.t1)) if len(i_to_samp) == 0: continue + print(i_to_samp) x_seg = seg(t_grid[i_to_samp]) if x_seg.ndim == 1: x_seg = x_seg[:, None] diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index 885ee833..e5a44d88 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -527,3 +527,16 @@ class Sim(ODE.TrajectoryAnalysis): sim_resamp_events = sim.resample(1.0, include_events=True) assert sim_resamp_events._res.x.shape == (13, 1) assert len(sim_resamp_events._res.e) == 2 + + +def test_resample_separate_events(mass_spring_ode): + class Sim(mass_spring_ode.TrajectoryAnalysis): + tf = 1 + + class Options: + separate_events = True + + sim = Sim(wn=10) + + with pytest.raises(NotImplementedError): + sim.resample(0.1) From d3d1077ea879d403681a9b74422ab70070483eaf Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 11:36:05 -0700 Subject: [PATCH 12/18] Warn when overriding include_output, add test --- src/condor/contrib.py | 18 +++++++++++++----- tests/test_trajectory_analysis.py | 12 ++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 3d1f1c6d..60510630 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -1,6 +1,7 @@ """Built-in model templates""" import logging +import warnings from dataclasses import dataclass, field import ndsplines @@ -683,9 +684,8 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): ) if self.options_dict.get("separate_events", False): - raise NotImplementedError( - "Resampling a trajectory with separate_events=True` not yet supported" - ) + msg = "Resampling a trajectory with separate_events=True` not yet supported" + raise NotImplementedError(msg) model = self.__class__ @@ -693,11 +693,19 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): return self new_self = model.__new__(model) + # TODO: add option to rebuild the implemention - if getattr(self, "implementation", False): + if hasattr(self, "implementation"): new_self.implementation = self.implementation - else: # override include_output if implementation is not found + elif include_output: # override include_output if implementation is not found include_output = False + warnings.warn( + "Trajectory instances without an implementation currently do not " + "support dynamic output sampling. Set include_output=False to " + "suppress.", + stacklevel=2, + ) + new_self._original_instance = original_instance new_self.bind_field(self.parameter) new_self.input_kwargs = self.input_kwargs diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index e5a44d88..5b1372ed 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -540,3 +540,15 @@ class Options: with pytest.raises(NotImplementedError): sim.resample(0.1) + + +def test_resample_no_impl(mass_spring_ode): + # mock pickle dump/load (as in multiprocessing) by deleting implementation + class Sim(mass_spring_ode.TrajectoryAnalysis): + tf = 10 + + sim = Sim(wn=10) + del sim.implementation + + with pytest.warns(UserWarning, match="include_output"): + sim.resample(0.5, include_output=True) From c5daf6335e9ec8de4e2bcc0d25af4e1586a56496 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 11:37:20 -0700 Subject: [PATCH 13/18] Typo fix --- src/condor/contrib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 60510630..8e220fb1 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -684,7 +684,7 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): ) if self.options_dict.get("separate_events", False): - msg = "Resampling a trajectory with separate_events=True` not yet supported" + msg = "Resampling a trajectory with separate_events not yet supported" raise NotImplementedError(msg) model = self.__class__ From e8d0263b662c317fece0615b43b23abe18f7b396 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 11:41:03 -0700 Subject: [PATCH 14/18] goo goo g'joob --- src/condor/contrib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 8e220fb1..1caddc2c 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -695,8 +695,8 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): new_self = model.__new__(model) # TODO: add option to rebuild the implemention - if hasattr(self, "implementation"): - new_self.implementation = self.implementation + if (impl := getattr(self, "implementation", None)) is not None: + new_self.implementation = impl elif include_output: # override include_output if implementation is not found include_output = False warnings.warn( From 22137484bc6af34a373bd58292556e4910efbbf2 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 12:12:24 -0700 Subject: [PATCH 15/18] More explicit handling of segment end point with include_events=False --- src/condor/contrib.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index 1caddc2c..c89d1567 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -729,14 +729,11 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): new_x = np.empty((t_grid.size, self._res.x.shape[1]), float) for seg in interp: i_to_samp = np.nonzero((t_grid >= seg.t0) & (t_grid < seg.t1)) - if len(i_to_samp) == 0: - continue - - print(i_to_samp) x_seg = seg(t_grid[i_to_samp]) if x_seg.ndim == 1: x_seg = x_seg[:, None] new_x[i_to_samp] = x_seg + new_x[-1] = self._res.x[-1] else: all_et = self._res.t[[e.index for e in self._res.e]] samps_and_es = np.intersect1d(t_grid, all_et, assume_unique=True) From aa9d57dd578e6b44c87124ebf7ce4d4dded465e7 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 12:21:23 -0700 Subject: [PATCH 16/18] Add test for coincident sample/event to verify value comes from t+ --- tests/test_trajectory_analysis.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_trajectory_analysis.py b/tests/test_trajectory_analysis.py index 5b1372ed..d1b652ed 100644 --- a/tests/test_trajectory_analysis.py +++ b/tests/test_trajectory_analysis.py @@ -552,3 +552,21 @@ class Sim(mass_spring_ode.TrajectoryAnalysis): with pytest.warns(UserWarning, match="include_output"): sim.resample(0.5, include_output=True) + + +def test_resample_check_tplus(mass_spring_ode): + # check that resample with include_events=False and a coincident event take from t+ + # strategy is to create an event exactly coincident with a sample time and update + # the state to switch signs, check that the sample at the event has the changed sign + + class Ev(mass_spring_ode.Event): + at_time = 0.5 + update[x] = -1 + + class Sim(mass_spring_ode.TrajectoryAnalysis): + tf = 1 + + sim = Sim(wn=1) + simd = sim.resample(0.1, include_events=False) + assert all(simd.x[simd.t < 0.45] > 0) + assert all(simd.x[simd.t > 0.45] < 0) From 0a63543582dc3b7066e421062acf8fe8fc0d7440 Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Fri, 10 Apr 2026 12:28:59 -0700 Subject: [PATCH 17/18] Document behavior with coincident event/sample and include_events=False --- src/condor/contrib.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index c89d1567..bdb8a37e 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -663,7 +663,8 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): include_events : bool, optional Include events regardless of whether or not they fall on a multiple of `dt`. Two points will be inserted for each internal event to get the state - immediately before and after the event. + immediately before and after the event. If disabled and a sample coincides + exactly with an event, the state *after* the update is returned. max_deg : int, optional Maximum degree of the interpolating spline. Actual degree used in any given segment between events may be fewer if there are not sufficient samples. From 09783bb658e48f8643f14aec3284edccef6d1f7e Mon Sep 17 00:00:00 2001 From: Kenneth Lyons Date: Sat, 11 Apr 2026 09:44:30 -0700 Subject: [PATCH 18/18] Use Options instead in case model is embedded --- src/condor/contrib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/condor/contrib.py b/src/condor/contrib.py index bdb8a37e..eca90e17 100644 --- a/src/condor/contrib.py +++ b/src/condor/contrib.py @@ -684,7 +684,7 @@ def resample(self, dt, include_output=True, include_events=True, max_deg=3): max_deg=max_deg, ) - if self.options_dict.get("separate_events", False): + if getattr(self.Options, "separate_events", False): msg = "Resampling a trajectory with separate_events not yet supported" raise NotImplementedError(msg)