Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ web/charts-user.*
web/charts-index.json
web/chartplotter.wasm

# Working design notes / specs (kept local, never committed)
/specs/

# Editor / OS
.DS_Store
.idea/
Expand Down
59 changes: 47 additions & 12 deletions cmd/chartplotter/simulate.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,25 @@ import (
type simulateCmd struct {
Host string `default:"127.0.0.1" help:"Bind host."`
Port int `default:"10110" help:"Bind port (IANA NMEA-0183-over-IP)."`
Center string `default:"38.978,-76.478" help:"Own-ship start as lat,lon."`
Scenario string `help:"Named Annapolis preset (sets start/route/traffic). Use 'list' to print them."`
Center string `default:"38.978,-76.478" help:"Own-ship start as lat,lon (ignored when --scenario is set)."`
Course float64 `default:"45" help:"Own-ship course (degrees true)."`
Speed float64 `default:"6" help:"Own-ship speed (knots)."`
Targets int `default:"6" help:"Number of AIS targets."`
Collision bool `default:"true" negatable:"" help:"Put one target on a collision course."`
Sailing bool `help:"Own-ship tacks (COG weaves) with a varying leeway so heading ≠ COG."`
DropGPS int `name:"drop-gps" help:"Stop own-ship position fixes after N seconds (test stale/lost GPS); 0 = never."`
Seed int64 `default:"1" help:"RNG seed (reproducible scenarios)."`
Cell string `type:"existingfile" help:"S-57 cell (.000 or exchange .zip) to keep traffic in navigable water."`
MinDepth float64 `name:"min-depth" default:"2" help:"Minimum charted depth (DRVAL1, m) for navigable water when --cell is set."`
}

func (c simulateCmd) Run() error {
lat, lon, err := parseLatLon(c.Center)
if err != nil {
return err
if strings.EqualFold(c.Scenario, "list") {
fmt.Print("Annapolis scenarios (--scenario <name>):\n", sim.ScenarioList())
return nil
}

var water *sim.WaterMask
if c.Cell != "" {
chart, err := loadCell(c.Cell)
Expand All @@ -50,20 +54,44 @@ func (c simulateCmd) Run() error {
fmt.Println("warning: no navigable depth areas (DEPARE ≥ min-depth) in cell; placing traffic unconstrained")
}
}
s := sim.New(sim.Options{
Lat: lat, Lon: lon, Course: c.Course, Speed: c.Speed,
Targets: c.Targets, Collision: c.Collision, Seed: c.Seed, Water: water,
})

// A scenario fully defines the world's start/route/traffic; otherwise build it
// from the position/motion flags.
var opts sim.Options
desc := ""
if c.Scenario != "" {
sc, ok := sim.ScenarioByName(c.Scenario)
if !ok {
return fmt.Errorf("unknown scenario %q; --scenario list to see options", c.Scenario)
}
opts = sc.Options(c.Seed, water)
desc = sc.Desc
} else {
lat, lon, err := parseLatLon(c.Center)
if err != nil {
return err
}
opts = sim.Options{
Lat: lat, Lon: lon, Course: c.Course, Speed: c.Speed,
Targets: c.Targets, Collision: c.Collision, Sailing: c.Sailing,
Seed: c.Seed, Water: water,
}
}
s := sim.New(opts)

addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
defer ln.Close()
fmt.Printf("nmea0183 simulator → tcp://%s (own-ship %.4f,%.4f @ %.0f° %.0fkn, %d AIS targets%s%s)\n",
addr, s.Own.Lat, s.Own.Lon, c.Course, c.Speed, c.Targets,
ifStr(c.Collision, ", 1 on collision course", ""),
if desc != "" {
fmt.Printf("scenario %q — %s\n", c.Scenario, desc)
}
fmt.Printf("nmea0183 simulator → tcp://%s (own-ship %.4f,%.4f @ %.0f° %.0fkn, %d AIS targets%s%s%s)\n",
addr, s.Own.Lat, s.Own.Lon, s.Own.Course, opts.Speed, opts.Targets,
ifStr(opts.Collision, ", 1 on collision course", ""),
ifStr(c.DropGPS > 0, fmt.Sprintf(", GPS drops at %ds", c.DropGPS), ""),
ifStr(water != nil, ", in navigable water from "+filepath.Base(c.Cell), ""))
fmt.Println("point a Connection at this host:port; Ctrl-C to stop")

Expand All @@ -86,7 +114,14 @@ func (c simulateCmd) Run() error {
for tick := 0; ; tick++ {
<-ticker.C
s.Step(1)
lines := s.OwnSentences()
// After --drop-gps seconds, withhold the position/motion fixes (depth/wind
// keep flowing) so the client sees a frozen-then-lost GPS.
var lines []string
if c.DropGPS > 0 && tick >= c.DropGPS {
lines = s.EnvSentences()
} else {
lines = s.OwnSentences()
}
if tick%3 == 0 {
lines = append(lines, s.AISPositions()...)
}
Expand Down
92 changes: 92 additions & 0 deletions internal/engine/nmea/sim/scenarios.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package sim

import (
"fmt"
"strings"
)

// scenarios.go holds named, ready-to-run simulation presets set around Annapolis,
// MD on the Chesapeake Bay — the same waters the default cell covers. Each is a
// realistic situation for exercising own-ship UX (follow / look-ahead / heading
// line / stale-GPS) and AIS/CPA: a harbour departure, a bay crossing, a Bay
// Bridge transit, sitting at anchor, a close-quarters CPA, and a tight river run.
// `cp simulate --scenario <name>` loads one; coordinates are WGS-84 lat,lon.

// Scenario is a preset world: an own-ship start (+ optional route to steer) and a
// traffic mix. Seed and the optional water mask come from the command flags.
type Scenario struct {
Name string
Desc string
Lat, Lon float64 // own-ship start
Course float64 // initial course (deg true); ignored when Route is set
Speed float64 // knots
Route [][2]float64 // optional own-ship waypoints (lat,lon)
Targets int // AIS targets
Collision bool // put one target on a collision course
Sailing bool // own-ship tacks with varying leeway (heading ≠ COG)
}

// Options turns the scenario into sim Options, folding in the run's seed and the
// optional navigable-water mask.
func (sc Scenario) Options(seed int64, water *WaterMask) Options {
return Options{
Lat: sc.Lat, Lon: sc.Lon, Course: sc.Course, Speed: sc.Speed,
OwnRoute: sc.Route, Targets: sc.Targets, Collision: sc.Collision,
Sailing: sc.Sailing, Seed: seed, Water: water,
}
}

// scenarios is the ordered registry (order = list/help order).
var scenarios = []Scenario{
{
Name: "harbor", Desc: "Departing Annapolis harbour (Severn mouth out past Tolly Point)",
Lat: 38.9785, Lon: -76.4770, Speed: 5, Targets: 8,
Route: [][2]float64{{38.972, -76.466}, {38.962, -76.452}, {38.958, -76.440}},
},
{
Name: "bay-crossing", Desc: "Crossing the Chesapeake from Annapolis to the Eastern Shore",
Lat: 38.966, Lon: -76.430, Speed: 7, Targets: 6,
Route: [][2]float64{{38.964, -76.400}, {38.962, -76.370}, {38.960, -76.346}},
},
{
Name: "bay-bridge", Desc: "Transiting north under the Chesapeake Bay Bridge spans",
Lat: 38.975, Lon: -76.392, Speed: 6, Targets: 5,
Route: [][2]float64{{38.986, -76.390}, {38.995, -76.389}, {39.006, -76.388}},
},
{
Name: "anchorage", Desc: "At anchor in Whitehall Bay (idle GPS + heading-at-rest)",
Lat: 38.992, Lon: -76.448, Course: 200, Speed: 0, Targets: 3,
},
{
Name: "collision", Desc: "Close-quarters crossing in the open bay (CPA alert)",
Lat: 38.950, Lon: -76.420, Course: 80, Speed: 7, Targets: 6, Collision: true,
},
{
Name: "river", Desc: "Gunkholing up the South River (tight quarters, course-up)",
Lat: 38.913, Lon: -76.458, Speed: 4, Targets: 4,
Route: [][2]float64{{38.917, -76.474}, {38.920, -76.492}, {38.922, -76.508}},
},
{
Name: "sailing", Desc: "Sailboat working to windward in the bay (COG tacks, heading ≠ COG)",
Lat: 38.945, Lon: -76.415, Course: 30, Speed: 5, Targets: 4, Sailing: true,
},
}

// ScenarioByName returns the named scenario (case-insensitive), or ok=false.
func ScenarioByName(name string) (Scenario, bool) {
for _, sc := range scenarios {
if strings.EqualFold(sc.Name, name) {
return sc, true
}
}
return Scenario{}, false
}

// ScenarioList is a human-readable "name — description" listing, one per line.
func ScenarioList() string {
var b strings.Builder
for _, sc := range scenarios {
fmt.Fprintf(&b, " %-13s %s\n", sc.Name, sc.Desc)
}
return b.String()
}
96 changes: 96 additions & 0 deletions internal/engine/nmea/sim/scenarios_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package sim

import (
"math"
"testing"

"github.com/beetlebugorg/chartplotter/internal/engine/nmea"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// parseSim parses the given sentences into a fresh VesselState through the real
// NMEA parser (the path the server uses).
func parseSim(t *testing.T, lines []string) *nmea.VesselState {
t.Helper()
vs := &nmea.VesselState{}
p := &nmea.Parser{}
for _, ln := range lines {
sent, err := nmea.ParseSentence(ln)
require.NoErrorf(t, err, "sentence must frame: %s", ln)
p.Apply(sent, vs)
}
return vs
}

// Every registered scenario must resolve and yield runnable Options.
func TestScenarios_Registry(t *testing.T) {
require.NotEmpty(t, scenarios)
for _, sc := range scenarios {
got, ok := ScenarioByName(sc.Name)
require.Truef(t, ok, "scenario %q should resolve", sc.Name)
assert.Equal(t, sc.Name, got.Name)
s := New(sc.Options(1, nil))
assert.NotZero(t, s.Own.Lat)
assert.NotZero(t, s.Own.Lon)
}
_, ok := ScenarioByName("nope")
assert.False(t, ok)
}

// A scenario with an own-ship route steers own-ship toward its first waypoint
// (course points roughly down-route, and the boat makes progress that way).
func TestScenarios_OwnRouteSteers(t *testing.T) {
sc, ok := ScenarioByName("harbor")
require.True(t, ok)
require.NotEmpty(t, sc.Route)
s := New(sc.Options(1, nil))

wp := sc.Route[0]
startBrg := bearing(s.Own.Lat, s.Own.Lon, wp[0], wp[1])
assert.InDelta(t, startBrg, s.Own.Course, 1.0, "opening course should point at the first waypoint")

startDist := dist(s.Own.Lat, s.Own.Lon, wp[0], wp[1])
for range 60 { // one simulated minute
s.Step(1)
}
assert.Less(t, dist(s.Own.Lat, s.Own.Lon, wp[0], wp[1]), startDist, "own-ship should close on the waypoint")
}

// The sailing scenario must make both COG and the heading-vs-COG gap vary widely
// over a few minutes (the case the heading line exists to show).
func TestScenarios_SailingVaries(t *testing.T) {
sc, ok := ScenarioByName("sailing")
require.True(t, ok)
require.True(t, sc.Sailing)
s := New(sc.Options(1, nil))

var cogMin, cogMax, gapMin, gapMax = 360.0, -360.0, 360.0, -360.0
for range 240 { // four simulated minutes
s.Step(1)
nav := parseSim(t, s.NavSentences()).Navigation
require.NotNil(t, nav.COGTrue)
require.NotNil(t, nav.HeadingTrue)
cog := *nav.COGTrue
gap := math.Mod(*nav.HeadingTrue-cog+540, 360) - 180 // signed heading−COG
cogMin, cogMax = math.Min(cogMin, cog), math.Max(cogMax, cog)
gapMin, gapMax = math.Min(gapMin, gap), math.Max(gapMax, gap)
}
assert.Greater(t, cogMax-cogMin, 60.0, "COG should tack across a wide arc")
assert.Greater(t, gapMax-gapMin, 20.0, "heading−COG (leeway) should swing, and flip sign")
assert.Less(t, gapMin, 0.0)
assert.Greater(t, gapMax, 0.0)
}

// --drop-gps's mechanism: env sentences must be position-free, nav sentences carry
// the position. (The command withholds NavSentences to simulate signal loss.)
func TestScenarios_NavEnvSplit(t *testing.T) {
s := New(Options{Lat: 38.978, Lon: -76.478, Course: 45, Speed: 6, Seed: 1})

env := parseSim(t, s.EnvSentences())
assert.Nil(t, env.Navigation.Position, "env sentences must not carry a position")
require.NotNil(t, env.Environment.Depth.BelowTransducer, "env sentences carry depth")

nav := parseSim(t, s.NavSentences())
require.NotNil(t, nav.Navigation.Position, "nav sentences carry the position fix")
}
Loading