From 34305670c554cfbc39f51d332678d888121d3667 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:20:24 -0800 Subject: [PATCH] Add n body simulation --- .../sims/engine.go | 1 + .../grafana-testdata-datasource/sims/nbody.go | 327 ++++++++++++++++++ .../sims/nbody_test.go | 238 +++++++++++++ .../components/SimulationQueryEditor.tsx | 21 +- .../components/SimulationSchemaForm.tsx | 4 +- 5 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 pkg/tsdb/grafana-testdata-datasource/sims/nbody.go create mode 100644 pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go diff --git a/pkg/tsdb/grafana-testdata-datasource/sims/engine.go b/pkg/tsdb/grafana-testdata-datasource/sims/engine.go index 557484de97f..e7b02028a55 100644 --- a/pkg/tsdb/grafana-testdata-datasource/sims/engine.go +++ b/pkg/tsdb/grafana-testdata-datasource/sims/engine.go @@ -66,6 +66,7 @@ func NewSimulationEngine() (*SimulationEngine, error) { newFlightSimInfo, newSinewaveInfo, newTankSimInfo, + newNBodySimInfo, } for _, init := range initializers { diff --git a/pkg/tsdb/grafana-testdata-datasource/sims/nbody.go b/pkg/tsdb/grafana-testdata-datasource/sims/nbody.go new file mode 100644 index 00000000000..4e1b588b3fb --- /dev/null +++ b/pkg/tsdb/grafana-testdata-datasource/sims/nbody.go @@ -0,0 +1,327 @@ +package sims + +import ( + "fmt" + "math" + "math/rand" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type nbodySim struct { + key simulationKey + cfg nbodyConfig + state nbodyState + random *rand.Rand +} + +var ( + _ Simulation = (*nbodySim)(nil) +) + +type nbodyConfig struct { + N int `json:"n"` // number of bodies + Width float64 `json:"width"` // boundary width in pixels + Height float64 `json:"height"` // boundary height in pixels + Seed int64 `json:"seed"` // random seed for reproducibility +} + +type circle struct { + x float64 // x position + y float64 // y position + vx float64 // x velocity + vy float64 // y velocity + radius float64 // radius + mass float64 // mass (proportional to radius^2 for simplicity) +} + +type nbodyState struct { + lastTime time.Time + circles []circle +} + +func (s *nbodySim) GetState() simulationState { + return simulationState{ + Key: s.key, + Config: s.cfg, + } +} + +func (s *nbodySim) SetConfig(vals map[string]any) error { + oldCfg := s.cfg + err := updateConfigObjectFromJSON(&s.cfg, vals) + if err != nil { + return err + } + + // If configuration changed, reinitialize the simulation + if oldCfg.N != s.cfg.N || oldCfg.Width != s.cfg.Width || oldCfg.Height != s.cfg.Height || oldCfg.Seed != s.cfg.Seed { + s.initialize() + } + + return nil +} + +func (s *nbodySim) initialize() { + s.random = rand.New(rand.NewSource(s.cfg.Seed)) + s.state.circles = make([]circle, s.cfg.N) + s.state.lastTime = time.Time{} + + // Generate random circles + for i := 0; i < s.cfg.N; i++ { + // Random radius between 5 and 30 pixels + radius := 5.0 + s.random.Float64()*25.0 + + // Random position ensuring the circle is within bounds + x := radius + s.random.Float64()*(s.cfg.Width-2*radius) + y := radius + s.random.Float64()*(s.cfg.Height-2*radius) + + // Random velocity between -50 and 50 pixels/second + vx := (s.random.Float64()*2.0 - 1.0) * 250.0 + vy := (s.random.Float64()*2.0 - 1.0) * 250.0 + + // Mass proportional to area (radius squared) + mass := radius * radius + + s.state.circles[i] = circle{ + x: x, + y: y, + vx: vx, + vy: vy, + radius: radius, + mass: mass, + } + } +} + +func (s *nbodySim) NewFrame(size int) *data.Frame { + frame := data.NewFrame("") + + // Time field - create with length=size for pre-allocated storage + frame.Fields = append(frame.Fields, data.NewField("time", nil, make([]time.Time, size))) + + // For each circle, add position, bounding box, size, and velocity fields with pre-allocated storage + for i := 0; i < s.cfg.N; i++ { + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_x", i), nil, make([]float64, size)), + ) + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_y", i), nil, make([]float64, size)), + ) + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_left", i), nil, make([]float64, size)), + ) + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_top", i), nil, make([]float64, size)), + ) + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_diameter", i), nil, make([]float64, size)), + ) + frame.Fields = append(frame.Fields, + data.NewField(fmt.Sprintf("circle_%d_velocity", i), nil, make([]float64, size)), + ) + } + + return frame +} + +func (s *nbodySim) GetValues(t time.Time) map[string]any { + // Initialize if this is the first call + if s.state.lastTime.IsZero() { + s.state.lastTime = t + if len(s.state.circles) == 0 { + s.initialize() + } + } + + // Calculate elapsed time in seconds + if t.After(s.state.lastTime) { + dt := t.Sub(s.state.lastTime).Seconds() + s.simulate(dt) + s.state.lastTime = t + } else if t.Before(s.state.lastTime) { + // Can't go backwards - reinitialize + s.initialize() + s.state.lastTime = t + } + + // Build result map + result := map[string]any{ + "time": t, + } + + for i := 0; i < len(s.state.circles); i++ { + c := s.state.circles[i] + // Calculate velocity magnitude: sqrt(vx^2 + vy^2) + velocity := math.Sqrt(c.vx*c.vx + c.vy*c.vy) + + // Center position + result[fmt.Sprintf("circle_%d_x", i)] = c.x + result[fmt.Sprintf("circle_%d_y", i)] = c.y + + // Top-left corner of bounding box (for easier canvas positioning) + result[fmt.Sprintf("circle_%d_left", i)] = c.x - c.radius + result[fmt.Sprintf("circle_%d_top", i)] = c.y - c.radius + + // Size and velocity + result[fmt.Sprintf("circle_%d_diameter", i)] = c.radius * 2.0 + result[fmt.Sprintf("circle_%d_velocity", i)] = velocity + } + + return result +} + +func (s *nbodySim) simulate(dt float64) { + // Don't simulate too large time steps + if dt > 1.0 { + dt = 1.0 + } + + // Use smaller sub-steps for more accurate collision detection + steps := int(math.Ceil(dt * 60)) // 60 sub-steps per second + if steps < 1 { + steps = 1 + } + subDt := dt / float64(steps) + + for step := 0; step < steps; step++ { + // Update positions + for i := range s.state.circles { + s.state.circles[i].x += s.state.circles[i].vx * subDt + s.state.circles[i].y += s.state.circles[i].vy * subDt + } + + // Handle wall collisions + for i := range s.state.circles { + c := &s.state.circles[i] + + // Left/right walls (perfectly elastic - no energy loss) + if c.x-c.radius < 0 { + c.x = c.radius + c.vx = math.Abs(c.vx) + } else if c.x+c.radius > s.cfg.Width { + c.x = s.cfg.Width - c.radius + c.vx = -math.Abs(c.vx) + } + + // Top/bottom walls (perfectly elastic - no energy loss) + if c.y-c.radius < 0 { + c.y = c.radius + c.vy = math.Abs(c.vy) + } else if c.y+c.radius > s.cfg.Height { + c.y = s.cfg.Height - c.radius + c.vy = -math.Abs(c.vy) + } + } + + // Handle circle-to-circle collisions + for i := 0; i < len(s.state.circles); i++ { + for j := i + 1; j < len(s.state.circles); j++ { + c1 := &s.state.circles[i] + c2 := &s.state.circles[j] + + // Calculate distance between centers + dx := c2.x - c1.x + dy := c2.y - c1.y + distSq := dx*dx + dy*dy + minDist := c1.radius + c2.radius + + // Check for collision + if distSq < minDist*minDist && distSq > 0 { + dist := math.Sqrt(distSq) + + // Normalize collision vector + nx := dx / dist + ny := dy / dist + + // Separate the circles so they don't overlap + overlap := minDist - dist + c1.x -= nx * overlap * 0.5 + c1.y -= ny * overlap * 0.5 + c2.x += nx * overlap * 0.5 + c2.y += ny * overlap * 0.5 + + // Calculate relative velocity + dvx := c2.vx - c1.vx + dvy := c2.vy - c1.vy + + // Calculate relative velocity in collision normal direction + dvn := dvx*nx + dvy*ny + + // Do not resolve if velocities are separating + if dvn > 0 { + continue + } + + // Calculate impulse scalar (perfectly elastic collision) + restitution := 1.0 // coefficient of restitution (1.0 = perfectly elastic, no energy loss) + impulse := (1 + restitution) * dvn / (1/c1.mass + 1/c2.mass) + + // Apply impulse + c1.vx += impulse * nx / c1.mass + c1.vy += impulse * ny / c1.mass + c2.vx -= impulse * nx / c2.mass + c2.vy -= impulse * ny / c2.mass + } + } + } + } +} + +func (s *nbodySim) Close() error { + return nil +} + +func newNBodySimInfo() simulationInfo { + defaultCfg := nbodyConfig{ + N: 10, + Width: 800, + Height: 600, + Seed: 12345, + } + + // Create config frame that describes the available configuration fields + df := data.NewFrame("") + df.Fields = append(df.Fields, data.NewField("n", nil, []int64{int64(defaultCfg.N)})) + df.Fields = append(df.Fields, data.NewField("width", nil, []float64{defaultCfg.Width}).SetConfig(&data.FieldConfig{ + Unit: "px", + })) + df.Fields = append(df.Fields, data.NewField("height", nil, []float64{defaultCfg.Height}).SetConfig(&data.FieldConfig{ + Unit: "px", + })) + df.Fields = append(df.Fields, data.NewField("seed", nil, []int64{defaultCfg.Seed})) + + return simulationInfo{ + Type: "nbody", + Name: "N-Body", + Description: "N-body collision simulation with circles bouncing in a bounded space", + ConfigFields: df, + OnlyForward: false, + create: func(cfg simulationState) (Simulation, error) { + s := &nbodySim{ + key: cfg.Key, + cfg: defaultCfg, + } + err := updateConfigObjectFromJSON(&s.cfg, cfg.Config) + if err != nil { + return nil, err + } + + // Validate configuration + if s.cfg.N <= 0 { + return nil, fmt.Errorf("n must be positive") + } + if s.cfg.Width <= 0 || s.cfg.Height <= 0 { + return nil, fmt.Errorf("width and height must be positive") + } + if s.cfg.N > 100 { + return nil, fmt.Errorf("n is too large (max 100)") + } + + s.initialize() + return s, nil + }, + } +} + diff --git a/pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go b/pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go new file mode 100644 index 00000000000..1ab6937ab77 --- /dev/null +++ b/pkg/tsdb/grafana-testdata-datasource/sims/nbody_test.go @@ -0,0 +1,238 @@ +package sims + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" +) + +func TestNBodyQuery(t *testing.T) { + s, err := NewSimulationEngine() + require.NoError(t, err) + + t.Run("simple nbody simulation", func(t *testing.T) { + sq := &simulationQuery{} + sq.Key = simulationKey{ + Type: "nbody", + TickHZ: 10, + } + sq.Config = map[string]any{ + "n": 5, + "width": 400.0, + "height": 300.0, + "seed": 42, + } + + sb, err := json.Marshal(map[string]any{ + "sim": sq, + }) + require.NoError(t, err) + + start := time.Date(2020, time.January, 10, 23, 0, 0, 0, time.UTC) + qr := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{ + From: start, + To: start.Add(time.Second * 2), + }, + Interval: 100 * time.Millisecond, + MaxDataPoints: 20, + JSON: sb, + }, + }, + } + + rsp, err := s.QueryData(context.Background(), qr) + require.NoError(t, err) + require.NotNil(t, rsp) + + // Verify we got a response + dr, ok := rsp.Responses["A"] + require.True(t, ok) + require.NoError(t, dr.Error) + require.Len(t, dr.Frames, 1) + + frame := dr.Frames[0] + // Should have time + (x, y, left, top, diameter, velocity) for each of 5 circles = 31 fields + require.Equal(t, 31, len(frame.Fields)) + + // Check field names + require.Equal(t, "time", frame.Fields[0].Name) + require.Equal(t, "circle_0_x", frame.Fields[1].Name) + require.Equal(t, "circle_0_y", frame.Fields[2].Name) + require.Equal(t, "circle_0_left", frame.Fields[3].Name) + require.Equal(t, "circle_0_top", frame.Fields[4].Name) + require.Equal(t, "circle_0_diameter", frame.Fields[5].Name) + require.Equal(t, "circle_0_velocity", frame.Fields[6].Name) + + // Verify we have data points + require.Greater(t, frame.Fields[0].Len(), 0) + }) + + t.Run("nbody with different configurations", func(t *testing.T) { + testCases := []struct { + name string + n int + width float64 + height float64 + seed int64 + }{ + {"small", 3, 200, 200, 1}, + {"medium", 10, 800, 600, 2}, + {"large", 20, 1000, 800, 3}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sq := &simulationQuery{} + sq.Key = simulationKey{ + Type: "nbody", + TickHZ: 10, + } + sq.Config = map[string]any{ + "n": tc.n, + "width": tc.width, + "height": tc.height, + "seed": tc.seed, + } + + sb, err := json.Marshal(map[string]any{ + "sim": sq, + }) + require.NoError(t, err) + + start := time.Now() + qr := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{ + From: start, + To: start.Add(time.Second), + }, + Interval: 100 * time.Millisecond, + MaxDataPoints: 10, + JSON: sb, + }, + }, + } + + rsp, err := s.QueryData(context.Background(), qr) + require.NoError(t, err) + require.NotNil(t, rsp) + + dr, ok := rsp.Responses["A"] + require.True(t, ok) + require.NoError(t, dr.Error) + require.Len(t, dr.Frames, 1) + + frame := dr.Frames[0] + // Should have time + (x, y, left, top, diameter, velocity) for each of N circles = 1 + 6*N fields + expectedFields := 1 + 6*tc.n + require.Equal(t, expectedFields, len(frame.Fields)) + }) + } + }) + + t.Run("nbody validates configuration", func(t *testing.T) { + testCases := []struct { + name string + config map[string]any + shouldError bool + }{ + {"valid", map[string]any{"n": 5, "width": 400.0, "height": 300.0, "seed": 42}, false}, + {"zero n", map[string]any{"n": 0, "width": 400.0, "height": 300.0, "seed": 42}, true}, + {"negative n", map[string]any{"n": -5, "width": 400.0, "height": 300.0, "seed": 42}, true}, + {"zero width", map[string]any{"n": 5, "width": 0.0, "height": 300.0, "seed": 42}, true}, + {"negative height", map[string]any{"n": 5, "width": 400.0, "height": -300.0, "seed": 42}, true}, + {"too many bodies", map[string]any{"n": 150, "width": 400.0, "height": 300.0, "seed": 42}, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sq := &simulationQuery{} + sq.Key = simulationKey{ + Type: "nbody", + TickHZ: 10, + } + sq.Config = tc.config + + sb, err := json.Marshal(map[string]any{ + "sim": sq, + }) + require.NoError(t, err) + + start := time.Now() + qr := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + TimeRange: backend.TimeRange{ + From: start, + To: start.Add(time.Second), + }, + Interval: 100 * time.Millisecond, + MaxDataPoints: 10, + JSON: sb, + }, + }, + } + + rsp, err := s.QueryData(context.Background(), qr) + + if tc.shouldError { + require.Error(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, rsp) + } + }) + } + }) +} + +func TestNBodyCollisions(t *testing.T) { + // Test that circles actually collide and bounce + info := newNBodySimInfo() + cfg := simulationState{ + Key: simulationKey{ + Type: "nbody", + TickHZ: 10, + }, + Config: map[string]any{ + "n": 2, + "width": 200.0, + "height": 200.0, + "seed": 12345, + }, + } + + sim, err := info.create(cfg) + require.NoError(t, err) + require.NotNil(t, sim) + + // Get initial values + t0 := time.Now() + v0 := sim.GetValues(t0) + + // Simulate for 2 seconds + t1 := t0.Add(2 * time.Second) + v1 := sim.GetValues(t1) + + // Verify that positions have changed (circles are moving) + require.NotEqual(t, v0["circle_0_x"], v1["circle_0_x"]) + require.NotEqual(t, v0["circle_0_y"], v1["circle_0_y"]) + + // Verify diameters remain constant + require.Equal(t, v0["circle_0_diameter"], v1["circle_0_diameter"]) + require.Equal(t, v0["circle_1_diameter"], v1["circle_1_diameter"]) + + sim.Close() +} + diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx index 468bfd042de..8fb28005ca5 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationQueryEditor.tsx @@ -27,7 +27,8 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { const simQuery = query.sim ?? ({} as SimulationQuery); const simKey = simQuery.key ?? {}; // keep track of updated config state to pass down to form - const [cfgValue, setCfgValue] = useState({}); + // Initialize from saved query config if it exists + const [cfgValue, setCfgValue] = useState(simQuery.config ?? {}); // This only changes once const info = useAsync(async () => { @@ -50,6 +51,19 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { }, [info.value, simKey?.type]); let config = useAsync(async () => { + // If we have a saved config in the query, use that and update server + if (simQuery.config && Object.keys(simQuery.config).length > 0) { + let path = simKey.type + '/' + simKey.tick + 'hz'; + if (simKey.uid) { + path += '/' + simKey.uid; + } + // Update server with saved config + ds.postResource('sim/' + path, simQuery.config).then((res) => { + setCfgValue(res.config); + }); + return simQuery.config; + } + // Otherwise fetch default config from server let path = simKey.type + '/' + simKey.tick + 'hz'; if (simKey.uid) { path += '/' + simKey.uid; @@ -57,7 +71,7 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { let config = (await ds.getResource('sim/' + path))?.config; setCfgValue(config.value); return config; - }, [simKey.type, simKey.tick, simKey.uid]); + }, [simKey.type, simKey.tick, simKey.uid, simQuery.config]); const onUpdateKey = (key: typeof simQuery.key) => { onChange({ ...query, sim: { ...simQuery, key } }); @@ -90,6 +104,9 @@ export const SimulationQueryEditor = ({ onChange, query, ds }: EditorProps) => { if (simKey.uid) { path += '/' + simKey.uid; } + // Save config to query JSON so it persists in dashboard + onChange({ ...query, sim: { ...simQuery, config } }); + // Also update server state ds.postResource('sim/' + path, config).then((res) => { setCfgValue(res.config); }); diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationSchemaForm.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationSchemaForm.tsx index f5d35734788..5f8ba08d172 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationSchemaForm.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/SimulationSchemaForm.tsx @@ -18,7 +18,7 @@ const renderInput = (field: FieldSchema, onChange: SchemaFormProps['onChange'], return ( ) => { const newValue = e.currentTarget.valueAsNumber; onChange({ ...config, [field.name]: newValue }); @@ -76,7 +76,7 @@ export const SimulationSchemaForm = ({ config, schema, onChange }: SchemaFormPro onChange={() => setJsonView(!jsonView)} /> {jsonView ? ( -