From 0c6101fe78981a601a7f208a74af9d9f7b9fac2c Mon Sep 17 00:00:00 2001 From: John Travers Date: Fri, 13 Nov 2026 18:27:56 +0000 Subject: [PATCH 1/2] Honour snapshot intervals shorter than the retry back-off Every snapshot attempt set the 60 s retry time, and a successful publication never cleared it, so `_due` held interval-driven snapshots until 60 s had passed: an interval below 60 s acted as 60 s. A successful publication now clears the back-off, which applies only after a failed or deferred attempt. Closes #600 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPwwkUUG9KSAwneHjgH --- changelog.d/snapshot-short-interval.fixed.md | 1 + src/solprop/output/snapshots.py | 1 + tests/output/test_snapshots.py | 17 +++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 changelog.d/snapshot-short-interval.fixed.md diff --git a/changelog.d/snapshot-short-interval.fixed.md b/changelog.d/snapshot-short-interval.fixed.md new file mode 100644 index 000000000..ddca275f3 --- /dev/null +++ b/changelog.d/snapshot-short-interval.fixed.md @@ -0,0 +1 @@ +A snapshot interval shorter than 60 s is honoured ([#600](https://github.com/LupoLab/solprop/issues/600)): the 60 s retry back-off now follows only a failed or deferred snapshot attempt, where every successful publication used to impose it too. diff --git a/src/solprop/output/snapshots.py b/src/solprop/output/snapshots.py index 31bd51e4b..01e43227d 100644 --- a/src/solprop/output/snapshots.py +++ b/src/solprop/output/snapshots.py @@ -354,6 +354,7 @@ def _service( else: assert publication.path is not None self._last_success = time.monotonic() + self._retry_at = 0.0 # Back off only after a failed or deferred try. self._pending = self._requested != requested logger.info( "snapshot %s: %d bytes, %.6f s copying, %.6f s paused", diff --git a/tests/output/test_snapshots.py b/tests/output/test_snapshots.py index 971b6a54b..4da05f5e5 100644 --- a/tests/output/test_snapshots.py +++ b/tests/output/test_snapshots.py @@ -184,6 +184,23 @@ def test_elapsed_requests_and_explicit_retry_are_coalesced(working, monkeypatch) assert latest is not None and latest.generation == generation +def test_interval_below_the_retry_backoff_is_honoured(working, monkeypatch): + output, controller = working + now = controller._last_success + monkeypatch.setattr(module.time, "monotonic", lambda: now) + controller._interval = 10 + generations = [] + for _ in range(2): + now += 11 + controller._service( + output, None, next_save_index=0, require_history=False, persist=lambda: None + ) + latest = controller.last_result + assert latest is not None and latest.disposition == "published" + generations.append(latest.generation) + assert generations[0] != generations[1] + + def test_request_during_copy_remains_pending(working, monkeypatch): _, controller = working original = module._copy_generation From 0ecce2664119546f6fa221f4537d055bd69dbb59 Mon Sep 17 00:00:00 2001 From: John Travers Date: Fri, 13 Nov 2026 18:27:56 +0000 Subject: [PATCH 2/2] Decline the Radau axis node when the transform drops an axis row `axis_node` matched a Radau rule's node against only the first n_pol rows of the on-axis synthesis. On a one-component transform whose modes have a y field on the axis (a rotated mode on components=("x",)), the x-only node scan stood in for the two-row axis row, losing the y field (the hypot on a field grid, the polarisation ellipse on an envelope grid). The node is now declined whenever the axis has a nonzero row the transform does not carry, so both numpy_ref and native (whose plan takes its node from the same function) scan the axis row. Closes #663 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XGENPwwkUUG9KSAwneHjgH --- .../radau-axis-node-uncarried-row.fixed.md | 1 + docs/dev/backends.md | 3 +- src/solprop/backends/populations.py | 5 ++- tests/backends/test_on_axis_statistics.py | 32 ++++++++++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 changelog.d/radau-axis-node-uncarried-row.fixed.md diff --git a/changelog.d/radau-axis-node-uncarried-row.fixed.md b/changelog.d/radau-axis-node-uncarried-row.fixed.md new file mode 100644 index 000000000..3a288da7d --- /dev/null +++ b/changelog.d/radau-axis-node-uncarried-row.fixed.md @@ -0,0 +1 @@ +The on-axis plasma statistics on a Gauss-Radau field-scale rule read the whole axis row on a one-component transform ([#663](https://github.com/LupoLab/solprop/issues/663)): when the modes have an axis field in a component the transform does not carry, as a rotated mode on `components=("x",)` does, the rule's axis node no longer stands in for the axis row, so the statistic keeps that component (its `hypot` with y on a field grid, the polarisation ellipse on an envelope grid) at the cost of one row scan. diff --git a/docs/dev/backends.md b/docs/dev/backends.md index 94411f473..995045132 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -545,7 +545,8 @@ interval. The four on-axis plasma statistics are the plan's too: the axis row th synthesise on the axis (`populations.axis_row_matrix`, the taper and the pad scale in it) is formed from the modal state on the fine grid and scanned natively in the first node's buffer by the node scan's own pass, its tables, preionisation and running-integral rule, or a Radau rule's axis node is -read when the evaluation scans the nodes anyway; the species reduction is finished natively. A +read when the evaluation scans the nodes anyway (`populations.axis_node`; never on a transform that +drops a component the modes have on the axis, whose node scan lacks that row); the species reduction is finished natively. A one-component scan of a field grid reads both rows' magnitude, `hypot(E_x, E_y)`; an envelope's scan reads the polarisation ellipse, so on a one-component transform whose modes have a y field on the axis the two rows go to a row of their own, which the envelope scan reads as a node of two components diff --git a/src/solprop/backends/populations.py b/src/solprop/backends/populations.py index 55ca5d73e..2e40fdbdd 100644 --- a/src/solprop/backends/populations.py +++ b/src/solprop/backends/populations.py @@ -233,7 +233,8 @@ def axis_node( A Gauss-Radau field-scale rule (SEP 19) has one: its synthesis row is the mode profile at ``r = 0``, the on-axis synthesis itself, so its own node scan supplies - the axis statistics without a further one. + the axis statistics without a further one. A transform that does not carry every + component the modes have on the axis has none: its node scan lacks that row. Parameters ---------- @@ -251,6 +252,8 @@ def axis_node( if transform.kind != "modal" or transform.modal_tables is not None: return None n_pol = transform.n_pol + if np.any(axis[n_pol:]): + return None rows = transform.synthesis.reshape(transform.n_nodes, n_pol, -1) scaled = axis[:n_pol] * problem.grids.pad_scale for index, row in enumerate(rows): diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index 2ed0d5f4d..860090c0c 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -69,7 +69,9 @@ def argon(m=1, phi=0.0): return MarcatiliMode(125e-6, "Ar", 1.0, m=m, cladding=1.45, loss=False, phi=phi) -def problem_on(grid, table, modes, rule, start=None, save_planes=(0.02,)): +def problem_on( + grid, table, modes, rule, start=None, save_planes=(0.02,), components=None +): """An argon capillary with Kerr and plasma on a given rule; nothing propagated.""" if start is None: built = GaussianPulse(wavelength=800e-9, fwhm=10e-15, energy=ENERGY) @@ -87,6 +89,7 @@ def problem_on(grid, table, modes, rule, start=None, save_planes=(0.02,)): dz_max=0.002, dz_init=1e-4, quadrature=rule, + components=components, ) @@ -193,6 +196,33 @@ def test_a_radau_axis_node_supplies_the_row_when_the_nodes_were_scanned( assert counted == scans +@pytest.mark.parametrize("name", ["numpy_ref", "native"]) +def test_a_radau_axis_node_is_declined_when_the_transform_drops_the_axis_y_row( + grid, table, name +): + """A rotated mode on an x-only transform: its node scan lacks the axis y field. + + The axis row carries both Cartesian components, so the node scan, which has only + the x row, must not stand in for it: the value is the row scan's, as on a Gauss + rule that has no axis node, and the Radau rule pays one scan for it. + """ + mode = argon(phi=math.pi / 3) + names = ("species_ionisation", *ON_AXIS_PLASMA_STATISTICS) + spec = StatsSpec(names=names) + radau = transverse_quadrature([mode], n_r=5, radial_rule="field_scale_radau") + problem, run_request = problem_on(grid, table, [mode], radau, components=("x",)) + got, counted = backend_stats(name, grid, problem, run_request, [mode], spec) + gauss = transverse_quadrature([mode], n_r=5, radial_rule="field_scale") + problem, run_request = problem_on(grid, table, [mode], gauss, components=("x",)) + want, _ = entrance_stats(grid, problem, run_request, [mode], spec) + assert counted == 1 + assert float(want["on_axis_electron_density"]) > 0.0 + for statistic in ON_AXIS_PLASMA_STATISTICS: + # The same row scan; the native plasma column against the reference's + # multipass scan keeps the node scans' parity tolerance. + np.testing.assert_allclose(got[statistic], want[statistic], rtol=1e-10) + + @pytest.mark.parametrize("radial_rule", ["field_scale", "field_scale_radau"]) def test_an_axis_statistic_leaves_the_node_statistics_the_plan_s( grid, table, radial_rule