From 5f2b4215b2ca771e8485f345f2adb687a6fb3eda Mon Sep 17 00:00:00 2001 From: Luke Pighetti Date: Wed, 3 Sep 2025 18:21:53 -0400 Subject: [PATCH] add trajectory data interpolation --- lib/src/trajectory_data.dart | 79 ++++++++++++++++++++++++++++++ test/interpolation_test.dart | 94 ++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 test/interpolation_test.dart diff --git a/lib/src/trajectory_data.dart b/lib/src/trajectory_data.dart index 4d16e38..ce03948 100644 --- a/lib/src/trajectory_data.dart +++ b/lib/src/trajectory_data.dart @@ -82,3 +82,82 @@ class TrajectoryData { return energy; } } + +extension TrajectoryDataInterpolation on List { + TrajectoryData interpolate(Distance travelDistance) { + for (final (a, b) in pairwise(this)) { + final da = a.travelDistance.value; + final db = b.travelDistance.value; + final dt = travelDistance.value; + final inRange = da <= dt && db >= dt; + if (!inRange) continue; + + t(double a, double b) => lerp(a, b, da, db, dt); + + return TrajectoryData( + time: Timespan(t(a.time.time, b.time.time)), + travelDistance: Distance( + t(a.travelDistance.value, b.travelDistance.value), + a.travelDistance.unit, + convert: false, + ), + velocity: Velocity( + t(a.velocity.value, b.velocity.value), + a.velocity.unit, + convert: false, + ), + mach: t(a.mach, b.mach), + drop: Distance( + t(a.drop.value, b.drop.value), + a.drop.unit, + convert: false, + ), + dropAdjustment: Angular( + t(a.dropAdjustment.value, b.dropAdjustment.value), + a.dropAdjustment.unit, + convert: false, + ), + windage: Distance( + t(a.windage.value, b.windage.value), + a.windage.unit, + convert: false, + ), + windageAdjustment: Angular( + t(a.windageAdjustment.value, b.windageAdjustment.value), + a.windageAdjustment.unit, + convert: false, + ), + energy: Energy( + t(a.energy.value, b.energy.value), + a.energy.unit, + convert: false, + ), + optimalGameWeight: Weight( + t(a.optimalGameWeight.value, b.optimalGameWeight.value), + a.optimalGameWeight.unit, + convert: false, + ), + ); + } + + throw RangeError('reference point is out of bounds'); + } + + // visible for testing + static List<(T, T)> pairwise(List source) { + return [ + for (var i = 0; i < source.length - 1; i++) + ( + source[i], + source[i + 1], + ) + ]; + } + + // visible for testing + static double lerp(double y1, double y2, double x1, double x2, double x) { + if (x < x1) throw RangeError("x must be greater than x1"); + if (x > x2) throw RangeError("x must be less than x2"); + return y1 + (y2 - y1) / (x2 - x1) * (x - x1); + } +} diff --git a/test/interpolation_test.dart b/test/interpolation_test.dart new file mode 100644 index 0000000..4b897c3 --- /dev/null +++ b/test/interpolation_test.dart @@ -0,0 +1,94 @@ +import 'package:ballistic/src/bmath/unit/unit.dart'; +import 'package:ballistic/src/trajectory_data.dart'; +import 'package:test/test.dart'; + +main() { + test('pairwise', () { + final pairwise = TrajectoryDataInterpolation.pairwise; + expect(pairwise([1, 2, 3]), [(1, 2), (2, 3)]); + }); + + test('lerp', () { + final lerp = TrajectoryDataInterpolation.lerp; + + // simple sweep + expect(() => lerp(0, 1, 0, 1, -1), throwsRangeError); + expect(lerp(0, 1, 0, 1, 0), 0); + expect(lerp(0, 1, 0, 1, 0.5), 0.5); + expect(lerp(0, 1, 0, 1, 1), 1); + expect(() => lerp(0, 1, 0, 1, 2), throwsRangeError); + + // complexish + expect(lerp(0, 50, 0, 200, 100), 25); + }); + + group('TrajectoryData interpolation', () { + final data = [ + TrajectoryData( + drop: Distance(0, DistanceUnit.inch), + dropAdjustment: Angular(0, AngularUnit.moa), + energy: Energy(100, EnergyUnit.footPound), + mach: 0, + optimalGameWeight: Weight(100, WeightUnit.pound), + time: Timespan(0), + travelDistance: Distance(0, DistanceUnit.yard), + velocity: Velocity(500, VelocityUnit.fps), + windage: Distance(0, DistanceUnit.inch), + windageAdjustment: Angular(0, AngularUnit.moa), + ), + TrajectoryData( + drop: Distance(1, DistanceUnit.inch), + dropAdjustment: Angular(1, AngularUnit.moa), + energy: Energy(80, EnergyUnit.footPound), + mach: 1, + optimalGameWeight: Weight(80, WeightUnit.pound), + time: Timespan(1), + travelDistance: Distance(100, DistanceUnit.yard), + velocity: Velocity(400, VelocityUnit.fps), + windage: Distance(1, DistanceUnit.inch), + windageAdjustment: Angular(1, AngularUnit.moa), + ), + TrajectoryData( + drop: Distance(2, DistanceUnit.inch), + dropAdjustment: Angular(2, AngularUnit.moa), + energy: Energy(60, EnergyUnit.footPound), + mach: 2, + optimalGameWeight: Weight(60, WeightUnit.pound), + time: Timespan(2), + travelDistance: Distance(200, DistanceUnit.yard), + velocity: Velocity(300, VelocityUnit.fps), + windage: Distance(2, DistanceUnit.inch), + windageAdjustment: Angular(2, AngularUnit.moa), + ), + ]; + + test('in bounds, all fields', () { + final x = data.interpolate(Distance(50, DistanceUnit.yard)); + expect(x.drop.unitValue, 0.5); + expect(x.dropAdjustment.unitValue, 0.5); + expect(x.energy.unitValue, 90); + expect(x.mach, 0.5); + expect(x.optimalGameWeight.unitValue, closeTo(90, .001)); + expect(x.time.time, 0.5); + expect(x.travelDistance.unitValue, 50); + expect(x.velocity.unitValue, closeTo(450, .001)); + expect(x.windage.unitValue, 0.5); + expect(x.windageAdjustment.unitValue, 0.5); + }); + + test('out of bounds', () { + expect(() => data.interpolate(Distance(5000, DistanceUnit.yard)), + throwsRangeError); + expect(() => data.interpolate(Distance(50, DistanceUnit.kilometer)), + throwsRangeError); + }); + + test('unit sweep', () { + fn(DistanceUnit d) => data.interpolate(Distance(50, d)).drop.unitValue; + expect(fn(DistanceUnit.centimeter), closeTo(0.0055, 0.001)); + expect(fn(DistanceUnit.inch), closeTo(0.0138, 0.001)); + expect(fn(DistanceUnit.yard), closeTo(0.5, 0.001)); + expect(fn(DistanceUnit.meter), closeTo(0.547, 0.001)); + }); + }); +}