Add n body simulation

This commit is contained in:
Drew Slobodnjak
2025-12-04 10:20:24 -08:00
parent 308bc56d0f
commit 34305670c5
5 changed files with 587 additions and 4 deletions
@@ -66,6 +66,7 @@ func NewSimulationEngine() (*SimulationEngine, error) {
newFlightSimInfo,
newSinewaveInfo,
newTankSimInfo,
newNBodySimInfo,
}
for _, init := range initializers {
@@ -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
},
}
}
@@ -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()
}
@@ -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<Config>({});
// Initialize from saved query config if it exists
const [cfgValue, setCfgValue] = useState<Config>(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<SimInfo>('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<SimInfo>('sim/' + path, config).then((res) => {
setCfgValue(res.config);
});
@@ -18,7 +18,7 @@ const renderInput = (field: FieldSchema, onChange: SchemaFormProps['onChange'],
return (
<Input
type="number"
defaultValue={config?.[field.name]}
value={config?.[field.name]}
onChange={(e: FormEvent<HTMLInputElement>) => {
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 ? (
<TextArea defaultValue={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
<TextArea value={JSON.stringify(config, null, 2)} rows={7} onChange={onUpdateTextArea} />
) : (
<>
{schema.fields.map((field) => (