solve-nivp 0.2.0.dev0__tar.gz → 0.2.0.dev1__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 (79) hide show
  1. {solve_nivp-0.2.0.dev0/solve_nivp.egg-info → solve_nivp-0.2.0.dev1}/PKG-INFO +1 -1
  2. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/pyproject.toml +1 -1
  3. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/setup.py +1 -1
  4. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/ODESolver.py +102 -36
  5. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/__init__.py +2 -0
  6. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1/solve_nivp.egg-info}/PKG-INFO +1 -1
  7. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp.egg-info/SOURCES.txt +1 -0
  8. solve_nivp-0.2.0.dev1/tests/test_t_eval.py +95 -0
  9. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/LICENSE +0 -0
  10. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/README.md +0 -0
  11. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/setup.cfg +0 -0
  12. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/ODESystem.py +0 -0
  13. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/_numba_accel.py +0 -0
  14. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/_selftest.py +0 -0
  15. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/adaptive_integrator.py +0 -0
  16. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/alart_curnier_contact.py +0 -0
  17. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/block_system.py +0 -0
  18. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/contact.py +0 -0
  19. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/desaxce_contact.py +0 -0
  20. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/integrations.py +0 -0
  21. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/macklin_contact.py +0 -0
  22. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/ncp_contact.py +0 -0
  23. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/nonlinear_solvers.py +0 -0
  24. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/pcr.py +0 -0
  25. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/projected_radau_contact.py +0 -0
  26. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/projections.py +0 -0
  27. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/rattle_contact.py +0 -0
  28. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/rl/__init__.py +0 -0
  29. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/rl/callbacks.py +0 -0
  30. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/rl/dependency.py +0 -0
  31. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp/rl/env.py +0 -0
  32. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp.egg-info/dependency_links.txt +0 -0
  33. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp.egg-info/entry_points.txt +0 -0
  34. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp.egg-info/requires.txt +0 -0
  35. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/solve_nivp.egg-info/top_level.txt +0 -0
  36. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/__init__.py +0 -0
  37. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_3d_and_anisotropic_contact.py +0 -0
  38. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_active_set_filter.py +0 -0
  39. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_alart_curnier_contact.py +0 -0
  40. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_algebraic_constraint_projection.py +0 -0
  41. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_anisotropic_soc_projection.py +0 -0
  42. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_auto_h0_and_dae_weighting.py +0 -0
  43. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_block_system.py +0 -0
  44. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_bouncing_ball_schur_comparison.py +0 -0
  45. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_build_impulse_contact.py +0 -0
  46. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_c_extract_contact.py +0 -0
  47. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_composite_contact_projection.py +0 -0
  48. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_coulomb_projection.py +0 -0
  49. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_coulomb_projection_jacobian.py +0 -0
  50. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_desaxce_contact.py +0 -0
  51. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_dilatancy.py +0 -0
  52. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_error_predictive_rejection.py +0 -0
  53. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_general_moreau_projection.py +0 -0
  54. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_globalization.py +0 -0
  55. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_identity_newton_linear_solver.py +0 -0
  56. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_import_and_api.py +0 -0
  57. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_integrators_added.py +0 -0
  58. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_jacobian_scaling.py +0 -0
  59. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_large_scale_solver_fixes.py +0 -0
  60. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_macklin_contact.py +0 -0
  61. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_mu_scaled_soc_projection.py +0 -0
  62. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_ncp_contact.py +0 -0
  63. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_ncp_schur_integration.py +0 -0
  64. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_nl_recovery_cap.py +0 -0
  65. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_nonlinear_solvers_added.py +0 -0
  66. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_pcr.py +0 -0
  67. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_per_dof_tolerances.py +0 -0
  68. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_prestress_soc.py +0 -0
  69. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_prestressed_fault_dynamic_helper.py +0 -0
  70. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_projected_radau_contact.py +0 -0
  71. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_projection_batch_equivalence.py +0 -0
  72. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_projections_added.py +0 -0
  73. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_radau_iia.py +0 -0
  74. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_rattle_integrator.py +0 -0
  75. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_rattle_local_slider.py +0 -0
  76. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_sdirk2.py +0 -0
  77. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_sdirk2_soc_contact.py +0 -0
  78. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_sparse_semismooth_newton.py +0 -0
  79. {solve_nivp-0.2.0.dev0 → solve_nivp-0.2.0.dev1}/tests/test_threading_time_and_fk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solve_nivp
3
- Version: 0.2.0.dev0
3
+ Version: 0.2.0.dev1
4
4
  Summary: A Python toolkit for integrating nonsmooth dynamical systems
5
5
  Author: David Riley, Ioannis Stefanou
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "solve_nivp"
7
- version = "0.2.0.dev0"
7
+ version = "0.2.0.dev1"
8
8
  description = "A Python toolkit for integrating nonsmooth dynamical systems"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -50,7 +50,7 @@ class BuildSphinx(Command):
50
50
 
51
51
  setup(
52
52
  name="solve_nivp",
53
- version="0.2.0.dev0",
53
+ version="0.2.0.dev1",
54
54
  packages=find_packages(), # automatically discovers packages
55
55
  description="A solver package for implicit ODEs and projection-based solvers",
56
56
  cmdclass={'build_sphinx': BuildSphinx},
@@ -40,10 +40,11 @@ class ODESolver:
40
40
  store_fk: bool = True,
41
41
  gc_interval: int = 0,
42
42
  abort_on_fixed_failure: bool = True,
43
+ t_eval: Optional[np.ndarray] = None,
43
44
  ):
44
45
  """
45
46
  Initialize the ODESolver.
46
-
47
+
47
48
  Parameters:
48
49
  system: The ODE system to be integrated.
49
50
  t_span: A tuple (t0, tf) specifying the start and end times.
@@ -59,15 +60,57 @@ class ODESolver:
59
60
  nonlinear failure instead of marching forward with the failed
60
61
  state. The failed attempt is still recorded in
61
62
  ``error_estimates``.
63
+ t_eval: Strictly increasing array of times in ``[t0, tf]`` that
64
+ must be evaluated. When provided, the time loop clips the
65
+ step so it lands exactly on each ``t_eval`` entry, and the
66
+ returned histories contain only those entries (matches the
67
+ scipy ``solve_ivp`` convention). Pass ``None`` to keep the
68
+ adaptive/fixed grid as the output.
62
69
  """
63
70
  self.system = system
64
71
  self.t0, self.tf = t_span
65
72
  self.h_initial = h
66
- self.t_values: List[float] = [self.t0]
67
- self.y_values: List[np.ndarray] = [self.system.current_y.copy()]
68
- self.h_values: List[float] = [h]
73
+
74
+ # ---- t_eval validation and bookkeeping ----
75
+ self._use_t_eval: bool = False
76
+ self._t_eval: Optional[np.ndarray] = None
77
+ self._t_eval_idx: int = 0
78
+ if t_eval is not None:
79
+ t_eval_arr = np.asarray(t_eval, dtype=float).reshape(-1)
80
+ if t_eval_arr.size > 0:
81
+ if np.any(np.diff(t_eval_arr) <= 0.0):
82
+ raise ValueError("t_eval must be strictly increasing")
83
+ tf_eps = 1.0e-12 * max(abs(self.t0), abs(self.tf), 1.0)
84
+ if (t_eval_arr[0] < self.t0 - tf_eps
85
+ or t_eval_arr[-1] > self.tf + tf_eps):
86
+ raise ValueError(
87
+ f"t_eval out of range [{self.t0}, {self.tf}]: "
88
+ f"got [{t_eval_arr[0]}, {t_eval_arr[-1]}]"
89
+ )
90
+ self._t_eval = np.clip(t_eval_arr, self.t0, self.tf)
91
+ self._use_t_eval = True
92
+
93
+ if self._use_t_eval:
94
+ self.t_values: List[float] = []
95
+ self.y_values: List[np.ndarray] = []
96
+ self.h_values: List[float] = []
97
+ self.fk: List[Any] = []
98
+ # If t_eval[0] coincides with t0, record the initial state.
99
+ te0 = float(self._t_eval[0])
100
+ t0_eps = 1.0e-12 * max(abs(self.t0), 1.0)
101
+ if abs(te0 - self.t0) <= t0_eps:
102
+ self.t_values.append(te0)
103
+ self.y_values.append(self.system.current_y.copy())
104
+ self.h_values.append(h)
105
+ self.fk.append(None)
106
+ self._t_eval_idx = 1
107
+ else:
108
+ self.t_values = [self.t0]
109
+ self.y_values = [self.system.current_y.copy()]
110
+ self.h_values = [h]
111
+ self.fk = []
112
+
69
113
  self.error_estimates: List[Tuple[Any, bool, int]] = []
70
- self.fk: List[Any] = []
71
114
  # Memory-saving options
72
115
  self.thin_output = max(1, int(thin_output))
73
116
  self.store_fk = bool(store_fk)
@@ -123,9 +166,38 @@ class ODESolver:
123
166
  # fail, falsely reporting "reached minimum step size".
124
167
  _tf_eps = 4.0 * np.finfo(float).eps * max(abs(self.t0), abs(self.tf), 1.0)
125
168
 
169
+ # Helper to record a stored sample (handles store_fk + fk fallback).
170
+ def _record(t_store, y, fk_val, h_taken, errinfo):
171
+ self.t_values.append(t_store)
172
+ self.y_values.append(y.copy())
173
+ if self.store_fk:
174
+ self.fk.append(fk_val.copy() if fk_val is not None else None)
175
+ else:
176
+ self.fk.append(None)
177
+ self.h_values.append(h_taken)
178
+ self.error_estimates.append(errinfo)
179
+
180
+ # Helper to drain any t_eval points that the latest accepted step
181
+ # advanced over (catches both exact landings and tiny float drift).
182
+ def _drain_t_eval(t_now, y_now, fk_val, h_taken, errinfo):
183
+ if not self._use_t_eval:
184
+ return
185
+ te_eps = 1.0e-9 * max(abs(t_now), 1.0)
186
+ while (self._t_eval_idx < len(self._t_eval)
187
+ and self._t_eval[self._t_eval_idx] <= t_now + te_eps):
188
+ te = float(self._t_eval[self._t_eval_idx])
189
+ _record(te, y_now, fk_val, h_taken, errinfo)
190
+ self._t_eval_idx += 1
191
+
126
192
  while self.tf - t > _tf_eps:
127
193
  # Ensure we do not overshoot the final time.
128
194
  h_step = min(h, self.tf - t)
195
+ # When t_eval is given, also clip the step so it lands exactly
196
+ # on the next required output time.
197
+ if self._use_t_eval and self._t_eval_idx < len(self._t_eval):
198
+ next_te = float(self._t_eval[self._t_eval_idx])
199
+ if next_te > t:
200
+ h_step = min(h_step, next_te - t)
129
201
  if self.system.adaptive:
130
202
  # Adaptive stepping returns:
131
203
  # (y_new, fk_new, h_new, E, success, solver_error, iterations)
@@ -133,17 +205,15 @@ class ODESolver:
133
205
  if success:
134
206
  t += h_step
135
207
  _step_count += 1
136
- # Thin output: only store every Nth step (always store last)
137
- _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
138
- if _step_count % _thin == 0 or _is_last:
139
- self.t_values.append(t)
140
- self.y_values.append(y_new.copy())
141
- if self.store_fk:
142
- self.fk.append(fk_new.copy() if fk_new is not None else None)
143
- else:
144
- self.fk.append(None)
145
- self.h_values.append(h_step)
146
- self.error_estimates.append((solver_error, success, iterations))
208
+ if self._use_t_eval:
209
+ _drain_t_eval(t, y_new, fk_new, h_step,
210
+ (solver_error, success, iterations))
211
+ else:
212
+ # Thin output: only store every Nth step (always store last)
213
+ _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
214
+ if _step_count % _thin == 0 or _is_last:
215
+ _record(t, y_new, fk_new, h_step,
216
+ (solver_error, success, iterations))
147
217
  self.system.current_y = y_new
148
218
  h = h_new # Update step size for next iteration.
149
219
  # Periodic garbage collection for large problems
@@ -162,16 +232,14 @@ class ODESolver:
162
232
  if success:
163
233
  t += h_step
164
234
  _step_count += 1
165
- _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
166
- if _step_count % _thin == 0 or _is_last:
167
- self.t_values.append(t)
168
- self.y_values.append(y_new.copy())
169
- if self.store_fk:
170
- self.fk.append(fk_new.copy() if fk_new is not None else None)
171
- else:
172
- self.fk.append(None)
173
- self.h_values.append(h_step)
174
- self.error_estimates.append((solver_error, success, iterations))
235
+ if self._use_t_eval:
236
+ _drain_t_eval(t, y_new, fk_new, h_step,
237
+ (solver_error, success, iterations))
238
+ else:
239
+ _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
240
+ if _step_count % _thin == 0 or _is_last:
241
+ _record(t, y_new, fk_new, h_step,
242
+ (solver_error, success, iterations))
175
243
  self.system.current_y = y_new
176
244
  if _gc_iv > 0 and _step_count % _gc_iv == 0:
177
245
  gc.collect()
@@ -187,16 +255,14 @@ class ODESolver:
187
255
  break
188
256
  t += h_step
189
257
  _step_count += 1
190
- _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
191
- if _step_count % _thin == 0 or _is_last:
192
- self.t_values.append(t)
193
- self.y_values.append(y_new.copy())
194
- if self.store_fk:
195
- self.fk.append(fk_new.copy() if fk_new is not None else None)
196
- else:
197
- self.fk.append(None)
198
- self.h_values.append(h_step)
199
- self.error_estimates.append((solver_error, success, iterations))
258
+ if self._use_t_eval:
259
+ _drain_t_eval(t, y_new, fk_new, h_step,
260
+ (solver_error, success, iterations))
261
+ else:
262
+ _is_last = (t >= self.tf - 1e-14 * abs(self.tf))
263
+ if _step_count % _thin == 0 or _is_last:
264
+ _record(t, y_new, fk_new, h_step,
265
+ (solver_error, success, iterations))
200
266
  self.system.current_y = y_new
201
267
  if _gc_iv > 0 and _step_count % _gc_iv == 0:
202
268
  gc.collect()
@@ -160,6 +160,7 @@ def solve_nivp(
160
160
  abort_on_fixed_failure=True,
161
161
  jacobian_scaling=None,
162
162
  active_set_filter=False,
163
+ t_eval=None,
163
164
  ):
164
165
  """Integrate an ODE / simple index–1 DAE with optional nonsmooth projection.
165
166
 
@@ -504,6 +505,7 @@ def solve_nivp(
504
505
  store_fk=store_fk,
505
506
  gc_interval=gc_interval,
506
507
  abort_on_fixed_failure=abort_on_fixed_failure,
508
+ t_eval=t_eval,
507
509
  )
508
510
  return solver_obj.solve(return_attempts=return_attempts)
509
511
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solve_nivp
3
- Version: 0.2.0.dev0
3
+ Version: 0.2.0.dev1
4
4
  Summary: A Python toolkit for integrating nonsmooth dynamical systems
5
5
  Author: David Riley, Ioannis Stefanou
6
6
  License: MIT
@@ -73,4 +73,5 @@ tests/test_rattle_local_slider.py
73
73
  tests/test_sdirk2.py
74
74
  tests/test_sdirk2_soc_contact.py
75
75
  tests/test_sparse_semismooth_newton.py
76
+ tests/test_t_eval.py
76
77
  tests/test_threading_time_and_fk.py
@@ -0,0 +1,95 @@
1
+ """Regressions for the ``t_eval`` output-time feature on solve_nivp."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ import solve_nivp
7
+
8
+
9
+ def _decay_rhs(t, y):
10
+ return -y
11
+
12
+
13
+ def _harmonic_rhs(t, y):
14
+ return np.array([y[1], -y[0]])
15
+
16
+
17
+ def test_t_eval_lands_exactly_on_requested_points_adaptive():
18
+ """Output times must be element-wise equal to the requested t_eval."""
19
+ t_eval = np.array([0.0, 0.1, 0.4, 0.9, 1.5, 2.0])
20
+ t, y, h, _, _ = solve_nivp.solve_nivp(
21
+ fun=_decay_rhs, t_span=(0.0, 2.0), y0=np.array([1.0]),
22
+ method='backward_euler', solver='semismooth_newton',
23
+ adaptive=True, h0=0.05, atol=1e-10, rtol=1e-8,
24
+ t_eval=t_eval,
25
+ )
26
+ np.testing.assert_array_equal(t, t_eval)
27
+ assert y.shape == (t_eval.size, 1)
28
+
29
+
30
+ def test_t_eval_accuracy_matches_exact_solution():
31
+ """Adaptive solve at t_eval points must converge to the exact solution."""
32
+ t_eval = np.array([0.0, 0.5, 1.0, 1.5, 2.0])
33
+ _, y, *_ = solve_nivp.solve_nivp(
34
+ fun=_decay_rhs, t_span=(0.0, 2.0), y0=np.array([1.0]),
35
+ method='backward_euler', solver='semismooth_newton',
36
+ adaptive=True, h0=0.01, atol=1.0e-8, rtol=1.0e-6,
37
+ t_eval=t_eval,
38
+ )
39
+ np.testing.assert_allclose(y.flatten(), np.exp(-t_eval), rtol=5.0e-3)
40
+
41
+
42
+ def test_t_eval_works_with_radau_iia():
43
+ """t_eval must work with the higher-order RadauIIA integrator."""
44
+ t_eval = np.linspace(0.0, np.pi, 7)
45
+ y0 = np.array([1.0, 0.0])
46
+ _, y, *_ = solve_nivp.solve_nivp(
47
+ fun=_harmonic_rhs, t_span=(0.0, np.pi), y0=y0,
48
+ method='radau_iia', solver='semismooth_newton',
49
+ integrator_opts=dict(stages=2),
50
+ adaptive=True, h0=0.05, atol=1e-10, rtol=1e-8,
51
+ t_eval=t_eval,
52
+ )
53
+ np.testing.assert_allclose(y[:, 0], np.cos(t_eval), atol=1.0e-4)
54
+ np.testing.assert_allclose(y[:, 1], -np.sin(t_eval), atol=1.0e-4)
55
+
56
+
57
+ def test_t_eval_works_in_fixed_step_mode():
58
+ """Fixed-step integration also clips to t_eval points."""
59
+ t_eval = np.array([0.0, 0.3, 0.7, 1.0])
60
+ t, y, *_ = solve_nivp.solve_nivp(
61
+ fun=_decay_rhs, t_span=(0.0, 1.0), y0=np.array([1.0]),
62
+ method='backward_euler', solver='semismooth_newton',
63
+ adaptive=False, h0=0.01,
64
+ t_eval=t_eval,
65
+ )
66
+ np.testing.assert_array_equal(t, t_eval)
67
+
68
+
69
+ def test_t_eval_skipping_t0_returns_only_listed_points():
70
+ """When t_eval omits t0, the initial state is not included in output."""
71
+ t_eval = np.array([0.5, 1.0, 1.5])
72
+ t, y, *_ = solve_nivp.solve_nivp(
73
+ fun=_decay_rhs, t_span=(0.0, 1.5), y0=np.array([1.0]),
74
+ method='backward_euler', solver='semismooth_newton',
75
+ adaptive=True, h0=0.05,
76
+ t_eval=t_eval,
77
+ )
78
+ np.testing.assert_array_equal(t, t_eval)
79
+ assert y.shape == (3, 1)
80
+
81
+
82
+ def test_t_eval_validates_monotone():
83
+ with pytest.raises(ValueError, match='strictly increasing'):
84
+ solve_nivp.solve_nivp(
85
+ fun=_decay_rhs, t_span=(0.0, 1.0), y0=np.array([1.0]),
86
+ t_eval=np.array([0.0, 0.5, 0.4, 1.0]),
87
+ )
88
+
89
+
90
+ def test_t_eval_validates_range():
91
+ with pytest.raises(ValueError, match='out of range'):
92
+ solve_nivp.solve_nivp(
93
+ fun=_decay_rhs, t_span=(0.0, 1.0), y0=np.array([1.0]),
94
+ t_eval=np.array([0.0, 0.5, 1.5]),
95
+ )
File without changes