From d9eb7dd9ad221071f4b623fd3358485aab77ecf6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 4 Sep 2026 13:50:20 +0300 Subject: [PATCH] feat(plot): lock axes and calculate marker levels in dB --- c/set-protocol/include/set_plot.h | 3 ++- .../ru/setcorp/setprotocol/trends/NativePlot.kt | 2 ++ .../ru/setcorp/setprotocol/trends/PlotViewport.kt | 6 ++++-- .../ru/setcorp/setprotocol/trends/PlotContractTest.kt | 8 ++++++++ c/set-protocol/src/set_plot.c | 8 ++++++-- c/set-protocol/tests/fixtures/plot-v1.json | 3 +++ python/protocan/plot.py | 11 ++++++++++- python/tests/test_plot.py | 7 +++++++ 8 files changed, 42 insertions(+), 6 deletions(-) diff --git a/c/set-protocol/include/set_plot.h b/c/set-protocol/include/set_plot.h index c249bb3..2ae6847 100644 --- a/c/set-protocol/include/set_plot.h +++ b/c/set-protocol/include/set_plot.h @@ -16,7 +16,8 @@ enum set_plot_operation { SET_PLOT_VALUE = 3, /* fraction,low,high,inverted -> value (not clamped) */ SET_PLOT_DRAG = 4, /* initial,deltaPixels,length,low,high,inverted -> clamped value */ SET_PLOT_TICK_STEP = 5, /* range,lengthPixels -> nice step */ - SET_PLOT_DELTA = 6 /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DELTA = 6, /* A,B,multiplier -> (B-A)*multiplier */ + SET_PLOT_DB_DELTA = 7 /* A,B -> 20*log10(abs(B/A)); zero is invalid */ }; /** Version of this plot ABI, independently of the transport ABI. */ diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt index 984a732..92f264b 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/NativePlot.kt @@ -30,3 +30,5 @@ data class PlotBounds(val left: Double, val right: Double, val bottom: Double, v fun plotTickStep(range: Double, pixels: Double): Double = NativePlot.call(5, range, pixels)[0] fun plotDelta(a: Double, b: Double, multiplier: Double = 1.0): Double = NativePlot.call(6, a, b, multiplier)[0] +fun plotDbDelta(a: Double, b: Double): Double? = + runCatching { NativePlot.call(7, a, b)[0] }.getOrNull() diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt index 3f53a25..7ec4442 100644 --- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt +++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/PlotViewport.kt @@ -1,10 +1,12 @@ package ru.setcorp.setprotocol.trends /** Normalized top-left viewport; independent of pixels, units, toolkit and samples. */ -data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0) { +data class PlotViewport(val x: Double = 0.0, val y: Double = 0.0, val width: Double = 1.0, val height: Double = 1.0, + val locked: Boolean = false) { fun transform(zoomX: Double = 1.0, zoomY: Double = 1.0, panX: Double = 0.0, panY: Double = 0.0, focusX: Double = 0.5, focusY: Double = 0.5): PlotViewport { + if (locked) return this val result = NativePlot.call(0, x, y, width, height, zoomX, zoomY, panX, panY, focusX, focusY) - return PlotViewport(result[0], result[1], result[2], result[3]) + return PlotViewport(result[0], result[1], result[2], result[3], locked) } } diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt index 803820a..024b85b 100644 --- a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt +++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/PlotContractTest.kt @@ -37,4 +37,12 @@ class PlotContractTest { val crossed = markers.move(TrendMarker.A, 1900.0).move(TrendMarker.B, 1100.0) assertEquals(-800.0, plotDelta(crossed.a!!, crossed.b!!), 0.0) } + + @Test fun lockedViewportIgnoresZoomAndPanAndDbUsesAmplitudeRatio() { + val locked = PlotViewport(locked = true) + assertEquals(locked, locked.transform(zoomX = 2.0, panY = 0.2)) + assertEquals(20.0, plotDbDelta(1.0, 10.0)!!, 1e-10) + assertEquals(-20.0, plotDbDelta(10.0, 1.0)!!, 1e-10) + assertNull(plotDbDelta(0.0, 1.0)) + } } diff --git a/c/set-protocol/src/set_plot.c b/c/set-protocol/src/set_plot.c index 3f000e3..66ed13c 100644 --- a/c/set-protocol/src/set_plot.c +++ b/c/set-protocol/src/set_plot.c @@ -11,9 +11,9 @@ static int finite_values(const double *v, size_t n) { uint32_t set_plot_abi_version(void) { return 1U; } size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t cap) { - static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3}; + static const size_t sizes[] = {10, 3, 4, 4, 6, 2, 3, 2}; double span, fraction; - if (op > SET_PLOT_DELTA || !v || !out || n != sizes[op] || + if (op > SET_PLOT_DB_DELTA || !v || !out || n != sizes[op] || cap < (op == SET_PLOT_TRANSFORM ? 4U : 1U)) return 0; if (op == SET_PLOT_TRANSFORM) { double w, h, fx, fy; @@ -65,6 +65,10 @@ size_t set_plot_eval(uint32_t op, const double *v, size_t n, double *out, size_t break; } case SET_PLOT_DELTA: out[0] = (v[1] - v[0]) * v[2]; break; + case SET_PLOT_DB_DELTA: + if (v[0] == 0 || v[1] == 0) return 0; + out[0] = 20 * log10(fabs(v[1] / v[0])); + break; default: return 0; } return isfinite(out[0]) ? 1 : 0; diff --git a/c/set-protocol/tests/fixtures/plot-v1.json b/c/set-protocol/tests/fixtures/plot-v1.json index 3428ba4..1e8715d 100644 --- a/c/set-protocol/tests/fixtures/plot-v1.json +++ b/c/set-protocol/tests/fixtures/plot-v1.json @@ -20,6 +20,9 @@ {"name":"ticks","op":5,"input":[100,800],"output":[20]}, {"name":"negative_delta","op":6,"input":[10,0,1],"output":[-10]}, {"name":"milliseconds","op":6,"input":[0,0.01,1000],"output":[10]}, + {"name":"db_gain","op":7,"input":[1,10],"output":[20]}, + {"name":"db_attenuation","op":7,"input":[10,1],"output":[-20]}, + {"name":"db_zero_reference","op":7,"input":[0,1],"output":null}, {"name":"zero_range","op":2,"input":[1,1,1,0],"output":null}, {"name":"zero_pixels","op":4,"input":[0,1,0,0,1,0],"output":null}, {"name":"bad_viewport","op":0,"input":[0,0,0,1,2,1,0,0,0.5,0.5],"output":null} diff --git a/python/protocan/plot.py b/python/protocan/plot.py index 9661827..67248f5 100644 --- a/python/protocan/plot.py +++ b/python/protocan/plot.py @@ -61,6 +61,12 @@ class PlotMath: def delta(self, a: float, b: float, multiplier: float = 1) -> float: return self.call(6, a, b, multiplier)[0] + def db_delta(self, a: float, b: float) -> Optional[float]: + try: + return self.call(7, a, b)[0] + except ValueError: + return None + @dataclass(frozen=True) class Viewport: @@ -68,12 +74,15 @@ class Viewport: y: float = 0.0 width: float = 1.0 height: float = 1.0 + locked: bool = False def transform(self, core: PlotMath, zoom_x: float = 1, zoom_y: float = 1, pan_x: float = 0, pan_y: float = 0, focus_x: float = 0.5, focus_y: float = 0.5) -> "Viewport": + if self.locked: + return self return Viewport(*core.call(0, self.x, self.y, self.width, self.height, - zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y)) + zoom_x, zoom_y, pan_x, pan_y, focus_x, focus_y), locked=self.locked) @dataclass(frozen=True) diff --git a/python/tests/test_plot.py b/python/tests/test_plot.py index 9fe8640..a2ba188 100644 --- a/python/tests/test_plot.py +++ b/python/tests/test_plot.py @@ -50,6 +50,13 @@ class PlotTests(unittest.TestCase): with self.assertRaises(ValueError): self.core.pinch_axis(value, 10, 8) + def test_locked_viewport_and_decibel_delta(self): + locked = Viewport(locked=True) + self.assertEqual(locked, locked.transform(self.core, zoom_x=2, pan_y=.2)) + self.assertAlmostEqual(20, self.core.db_delta(1, 10)) + self.assertAlmostEqual(-20, self.core.db_delta(10, 1)) + self.assertIsNone(self.core.db_delta(0, 1)) + if __name__ == "__main__": unittest.main()