diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 48de1e69..882ffad7 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -1060,7 +1060,7 @@ Put one node down at a position, which is how a repeater a feed never carried ge | `height_m` | number | optional | antenna height above ground in metres; absent is 10 | | `tx_dbm` | number | optional | transmit power in decibel-milliwatts; absent is 22 | -**Answers** `placed`, `kind`, `regions`, `board`, `nodes`. `regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it. +**Answers** `placed`, `kind`, `regions`, `board`, `nodes`. `regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. So is the radio: the modem most of its ten nearest neighbours are on, or EU/UK (Narrow) where none has a majority, since a node on a channel nobody else uses hears nobody. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it. **Example** - put a repeater on the hill and see what it wins diff --git a/docs/verbs.json b/docs/verbs.json index 38e15670..02a0b386 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -3649,7 +3649,7 @@ "board", "nodes" ], - "answers": "`regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it.", + "answers": "`regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. So is the radio: the modem most of its ten nearest neighbours are on, or EU/UK (Narrow) where none has a majority, since a node on a channel nobody else uses hears nobody. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it.", "example": { "params": { "board": "Heltec_v3", diff --git a/internal/app/session/presetplace.go b/internal/app/session/presetplace.go index 6f255cc4..a55a420c 100644 --- a/internal/app/session/presetplace.go +++ b/internal/app/session/presetplace.go @@ -31,12 +31,11 @@ func registerPresetsAndPlace(st *state.Store, s *Sim) { if only != "" && s.nodes[i].Name != only { continue } - s.nodes[i].Radio = scenario.RadioConfig{ - CentreHz: preset.FreqMHz * 1e6, - BandwidthHz: preset.BwKHz * 1e3, - SpreadFactor: preset.SF, - CodingRate: preset.CR, - } + // The preset's own conversion. The table holds MeshCore's + // denominator (8 for 4/8) and a node's radio holds it minus four; + // copying the table across made every preset 4/12, half again the + // airtime, and a companion told CR 12 refuses it. + s.nodes[i].Radio = preset.Config() n++ } if n > 0 && len(s.nodes) > 0 { @@ -73,6 +72,7 @@ func registerPresetsAndPlace(st *state.Store, s *Sim) { } node.HeightAGLm = 10 node.TxPowerDBm = 22 + node.Radio = placedRadio(s.nodes, lat, lon) if v, ok := namedNum(p, "height_m"); ok { node.HeightAGLm = v } @@ -191,3 +191,42 @@ func regionsOfNeighbours(nodes []scenario.Node, lat, lon float64) []string { } return out } + +// placedRadio is the modem a placed node joins on: the one most of its ten +// nearest neighbours are on, as regionsOfNeighbours decides regions, or the +// documented default where there are none or no modem has a majority. +// +// Left empty, a placed node fell back to the engine's own figures - SF10 at +// 250 kHz - while its firmware booted on its build's SF8 at 62.5 kHz, so on any +// narrow mesh it was on a channel nothing else used and never heard anybody, +// with nothing to say why. Nearest rather than the whole mesh, so a node +// dropped into one side of a mixed mesh joins the side it was dropped into. +func placedRadio(nodes []scenario.Node, lat, lon float64) scenario.RadioConfig { + type near struct { + d float64 + r scenario.RadioConfig + } + var all []near + for _, n := range nodes { + if n.Radio.CentreHz == 0 { + continue + } + all = append(all, near{geo.DistanceKm(lat, lon, n.Position.Lat, n.Position.Lon), n.Radio}) + } + for i := 1; i < len(all); i++ { + for j := i; j > 0 && all[j].d < all[j-1].d; j-- { + all[j], all[j-1] = all[j-1], all[j] + } + } + if len(all) > 10 { + all = all[:10] + } + count := map[scenario.RadioConfig]int{} + for _, a := range all { + count[a.r]++ + if count[a.r]*2 > len(all) { + return a.r + } + } + return scenario.DefaultRadio() +} diff --git a/internal/app/session/presetplace.verbs.json b/internal/app/session/presetplace.verbs.json index 15686a21..8e47d410 100644 --- a/internal/app/session/presetplace.verbs.json +++ b/internal/app/session/presetplace.verbs.json @@ -49,7 +49,7 @@ "board", "nodes" ], - "answers": "`regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it.", + "answers": "`regions` and the firmware build are inherited from the neighbours rather than left empty: a node holding a region its neighbours do not is as silent as one holding none. So is the radio: the modem most of its ten nearest neighbours are on, or EU/UK (Narrow) where none has a majority, since a node on a channel nobody else uses hears nobody. `nodes` is the whole network's count afterwards. The links are not in the answer, because measuring them is a worker's job and this returns before it.", "example": { "params": { "board": "Heltec_v3", diff --git a/internal/app/session/presetradio_test.go b/internal/app/session/presetradio_test.go new file mode 100644 index 00000000..6ab244d0 --- /dev/null +++ b/internal/app/session/presetradio_test.go @@ -0,0 +1,99 @@ +package session + +import ( + "context" + "testing" + + "github.com/MeshBench/meshbench/internal/app/state" + "github.com/MeshBench/meshbench/internal/world/scenario" +) + +func presetSim(t *testing.T, nodes []scenario.Node) (*state.Store, *Sim, context.Context, func()) { + t.Helper() + st := state.New(10) + s := &Sim{gpuAsked: true} + Register(st, s) + ctx, cancel := context.WithCancel(context.Background()) + go st.Run(ctx) + s.build(nodes, 869.618) + return st, s, ctx, cancel +} + +// A preset puts a node on the preset's coding rate. The table holds MeshCore's +// denominator, and copying it straight into the node's radio made EU/UK +// (Narrow) 4/12. +func TestAPresetKeepsItsCodingRate(t *testing.T) { + st, s, ctx, cancel := presetSim(t, []scenario.Node{repeaterNode("Alpha")}) + defer cancel() + if _, err := st.Do(ctx, "radio.preset", map[string]any{"preset": "EU/UK (Narrow)"}); err != nil { + t.Fatal(err) + } + got := s.nodes[0].Radio + want, _ := scenario.PresetByLabel("EU/UK (Narrow)") + if !want.Matches(got) { + t.Fatalf("after the preset the node's radio is %+v, which is not EU/UK (Narrow)", got) + } + if got.CodingRate != 4 { + t.Fatalf("coding rate field %d, want 4 (4/8)", got.CodingRate) + } +} + +// A placed node joins on the modem the mesh already shares, rather than on +// the engine's fallback that nothing else is using. +func TestAPlacedNodeJoinsTheMeshItIsPlacedIn(t *testing.T) { + nl, _ := scenario.PresetByLabel("Netherlands") + a, b := repeaterNode("Alpha"), repeaterNode("Bravo") + a.Radio, b.Radio = nl.Config(), nl.Config() + st, s, ctx, cancel := presetSim(t, []scenario.Node{a, b}) + defer cancel() + if _, err := st.Do(ctx, "nodes.place", map[string]any{ + "name": "Charlie", "kind": "companion", "lat": 56.31, "lon": -3.21, + }); err != nil { + t.Fatal(err) + } + for _, n := range s.nodes { + if n.Name == "Charlie" && n.Radio != nl.Config() { + t.Fatalf("Charlie was placed on %+v, the mesh is on %+v", n.Radio, nl.Config()) + } + } +} + +// With nothing to join, or neighbours with no majority, a placed node gets the +// default the presets table documents. +func TestAPlacedNodeOnNoAgreedModemGetsTheDefault(t *testing.T) { + if got := placedRadio(nil, 56, -3); got != scenario.DefaultRadio() { + t.Fatalf("an empty mesh placed on %+v", got) + } + nl, _ := scenario.PresetByLabel("Netherlands") + uk, _ := scenario.PresetByLabel("EU/UK (Narrow)") + tied := []scenario.Node{ + {Radio: nl.Config(), Position: scenario.LatLon{Lat: 56, Lon: -3}}, + {Radio: uk.Config(), Position: scenario.LatLon{Lat: 56, Lon: -3}}, + } + if got := placedRadio(tied, 56, -3); got != scenario.DefaultRadio() { + t.Fatalf("a tied mesh placed on %+v", got) + } +} + +// In a mixed mesh a placed node joins the side it was placed on. +func TestAPlacedNodeJoinsItsNeighboursInAMixedMesh(t *testing.T) { + nl, _ := scenario.PresetByLabel("Netherlands") + uk, _ := scenario.PresetByLabel("EU/UK (Narrow)") + var mesh []scenario.Node + // Six Dutch nodes and ten Scottish ones: the mesh as a whole is mostly + // Scottish, and the ten nearest the Dutch spot are mostly Dutch. + for i := 0; i < 6; i++ { + mesh = append(mesh, scenario.Node{Radio: nl.Config(), + Position: scenario.LatLon{Lat: 52.0 + float64(i)*0.01, Lon: 5.0}}) + } + for i := 0; i < 10; i++ { + mesh = append(mesh, scenario.Node{Radio: uk.Config(), + Position: scenario.LatLon{Lat: 56.0 + float64(i)*0.01, Lon: -3.0}}) + } + if got := placedRadio(mesh, 52.01, 5.0); got != nl.Config() { + t.Fatalf("a node placed among the Dutch nodes joined %+v", got) + } + if got := placedRadio(mesh, 56.01, -3.0); got != uk.Config() { + t.Fatalf("a node placed among the Scottish nodes joined %+v", got) + } +} diff --git a/internal/world/scenario/presets.go b/internal/world/scenario/presets.go index 541157ca..33d3cae4 100644 --- a/internal/world/scenario/presets.go +++ b/internal/world/scenario/presets.go @@ -7,8 +7,8 @@ type RadioPreset struct { FreqMHz float64 BwKHz float64 SF int - // CR is the denominator minus four, as MeshCore's CLI takes it: 5..8 on the - // wire means 4/5..4/8. + // CR is the coding rate's denominator, as MeshCore's CLI takes it: 5..8 + // means 4/5..4/8. A node's RadioConfig holds it minus four; Config converts. CR int }