package uplift import ( "math" "testing" "salty/terrain/internal/plates" "salty/terrain/internal/world" ) // A straight north-south margin down the middle of a planet, closing head-on. Everything a belt fault is // supposed to do is measurable against a line whose direction is known: the traces should run along it, sit // near it, and face away from it. func straightMargin(t *testing.T, p world.Planet) []plates.Boundary { t.Helper() const n = 200 xM := p.CircumferenceM() / 2 v := make([]plates.Vertex, n) for i := range v { v[i] = plates.Vertex{ XM: xM, YM: p.HeightM() * float64(i) / float64(n-1), NX: 1, // the margin runs north-south, so its normal points east NY: 0, ClosingMYr: 0.04, Kind: plates.Collision, Over: -1, } } return []plates.Boundary{{A: 0, B: 1, V: v}} } func beltPlanet(t *testing.T) world.Planet { t.Helper() p, err := world.New(40000, 8, 100, 50, 0, 40000) if err != nil { t.Fatalf("planet: %v", err) } return p } func testBelt() plates.Belt { b := plates.DefaultBelt() b.ZoneKm = 3 b.Per1000Km2 = 400 none := 0.0 b.ConjugateFraction = &none // measured separately; the main set has to be parallel on its own return b } func allLand(xM, yM float64) bool { return true } func TestBeltFaultsRunAlongTheMargin(t *testing.T) { p := beltPlanet(t) fs := BuildBeltFaults(p, 7, testBelt(), straightMargin(t, p), allLand) if len(fs) < 20 { t.Fatalf("%d traces; not enough to measure anything", len(fs)) } // The margin runs north-south, so every trace should too. Measured as the angle between the trace's own // end-to-end direction and the line, folded into 0..90 because a fault has no head or tail. worst, total := 0.0, 0.0 for _, f := range fs { a, b := f.PointsM[0], f.PointsM[len(f.PointsM)-1] deg := foldedAngleDeg(math.Atan2(b[1]-a[1], b[0]-a[0]), math.Pi/2) total += deg if deg > worst { worst = deg } } mean := total / float64(len(fs)) // The configured spread is 11 degrees, and the walk wanders on top of it. A mean much above that would // mean the strike is not coming from the boundary at all, which is the defect this whole file exists for. if mean > 20 { t.Errorf("traces average %.1f degrees off the margin; they are not following it", mean) } if worst > 55 { t.Errorf("a trace is %.1f degrees off the margin; nothing should be near perpendicular to it", worst) } } // foldedAngleDeg is the angle between two directions, in degrees, folded into 0..90: a line at 170 degrees // and one at 10 are twenty degrees apart, not a hundred and sixty. func foldedAngleDeg(a, b float64) float64 { d := math.Abs(a-b) * 180 / math.Pi d = math.Mod(d, 180) if d > 90 { d = 180 - d } return d } func TestBeltFaultsStayInTheDeformationZone(t *testing.T) { p := beltPlanet(t) cfg := testBelt() fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand) if len(fs) == 0 { t.Fatal("no traces") } xM := p.CircumferenceM() / 2 // The zone half-width here is the configured width times the collision multiplier times the rate scale. // A *fault's* centre is placed inside it, but a trace's need not be: a fault over twelve kilometres is // broken into en-echelon segments staggered up to 0.06 of its length across strike, which is the whole // point of the stepping. So the bound on a segment centre is the zone plus that stagger, and the bound on // any point of it is a further half-length beyond that. half := cfg.ZoneKm * 1000 * beltWidth[plates.Collision] * math.Sqrt(0.04/(cfg.ReferenceCmYr/100)) longest := cfg.LengthKm[1] * 1000 * 2.2 centreBound := half + 0.06*longest anyBound := centreBound + longest far := 0 inZone := 0 for _, f := range fs { mid := f.PointsM[len(f.PointsM)/2] d := math.Abs(mid[0] - xM) if d > centreBound { far++ } if d <= half { inZone++ } for _, pt := range f.PointsM { if math.Abs(pt[0]-xM) > anyBound { t.Fatalf("a trace reaches %.0f m from the margin; the zone, the stagger and a trace is %.0f m", math.Abs(pt[0]-xM), anyBound) } } } if far > 0 { t.Errorf("%d of %d trace centres sit outside the deformation zone and its en-echelon stagger", far, len(fs)) } // And they should be *concentrated* near the line rather than spread evenly across the zone: that is what // beltFalloff is for, and what the reference map shows. near := 0 for _, f := range fs { if math.Abs(f.PointsM[len(f.PointsM)/2][0]-xM) < half/2 { near++ } } if float64(near)/float64(inZone) < 0.55 { t.Errorf("only %d of %d traces are in the inner half of the zone; the falloff is not biting", near, inZone) } } func TestBeltFaultsVergeAwayFromTheMargin(t *testing.T) { p := beltPlanet(t) cfg := testBelt() // Short faults only. Vergence is decided per *fault*, from which side of the line it was placed on, and // then every en-echelon segment of it inherits that - correctly, since the segments are one fault. Keeping // every fault under enEchelonM means one trace per placement, so the side a trace sits on and the side it // was placed on are the same thing and the property can be measured at all. cfg.LengthKm = [2]float64{2, 4} fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand) if len(fs) < 20 { t.Fatalf("%d traces; not enough to measure anything", len(fs)) } xM := p.CircumferenceM() / 2 wrong := 0 for _, f := range fs { mid := f.PointsM[len(f.PointsM)/2] // A doubly-vergent belt faces outwards on both flanks, so the two sides must disagree about which // block goes up. Which flank got which sign does not matter; that they are consistent within a flank // does, because the alternative is the coin flip a class fault set has to use. if (mid[0] > xM) != f.Reverse { wrong++ } } if wrong != 0 && wrong != len(fs) { t.Errorf("%d of %d traces disagree with their own flank about vergence; a belt is doubly vergent, "+ "not randomly vergent", min(wrong, len(fs)-wrong), len(fs)) } } func TestBeltFaultsNeedLand(t *testing.T) { p := beltPlanet(t) m := straightMargin(t, p) if got := BuildBeltFaults(p, 7, testBelt(), m, func(xM, yM float64) bool { return false }); len(got) != 0 { t.Errorf("%d traces on a planet with no land", len(got)) } // A coast down one side of the margin: every trace must be mostly on the land side. xM := p.CircumferenceM() / 2 half := func(x, y float64) bool { return x < xM } fs := BuildBeltFaults(p, 7, testBelt(), m, half) if len(fs) == 0 { t.Fatal("no traces on a half-land planet") } for _, f := range fs { on := 0 for _, pt := range f.PointsM { if half(pt[0], pt[1]) { on++ } } if share := float64(on) / float64(len(f.PointsM)); share < 0.3 { t.Errorf("a trace is only %.0f%% on land; the span test should have refused it", share*100) } } } func TestABeltWithNoNumbersAsksForNothing(t *testing.T) { p := beltPlanet(t) if got := BuildBeltFaults(p, 7, plates.Belt{}, straightMargin(t, p), allLand); got != nil { t.Errorf("%d traces from an empty config; leaving the block out must leave the feature off", len(got)) } } func TestAFasterMarginDeformsAWiderBelt(t *testing.T) { p := beltPlanet(t) cfg := testBelt() spread := func(closing float64) float64 { bs := straightMargin(t, p) for i := range bs[0].V { bs[0].V[i].ClosingMYr = closing } fs := BuildBeltFaults(p, 7, cfg, bs, allLand) if len(fs) == 0 { t.Fatalf("no traces at %.3g m/yr", closing) } xM := p.CircumferenceM() / 2 worst := 0.0 for _, f := range fs { mid := f.PointsM[len(f.PointsM)/2] if d := math.Abs(mid[0] - xM); d > worst { worst = d } } return worst } slow, fast := spread(0.01), spread(0.08) if fast <= slow*1.4 { t.Errorf("a margin closing eight times faster deforms a belt %.0f m wide against %.0f m; the zone is "+ "not scaling with the rate", fast, slow) } }