Tooling
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
package uplift
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// oneClassCandidates is a planet where every painted cell belongs to class 0, sampled at a stride.
|
||||
func oneClassCandidates(p world.Planet, stride int) ([][]int32, []int) {
|
||||
var cells []int32
|
||||
for y := 0; y < p.H; y += stride {
|
||||
for x := 0; x < p.W; x += stride {
|
||||
cells = append(cells, int32(y*p.W+x))
|
||||
}
|
||||
}
|
||||
return [][]int32{cells}, []int{p.W * p.H}
|
||||
}
|
||||
|
||||
func testSpec() []FaultSpec {
|
||||
return []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 8}}}
|
||||
}
|
||||
|
||||
// A seed names a fault set, and the same seed names the same one. Everything else here rests on that.
|
||||
func TestTheSameSeedDrawsTheSameFaults(t *testing.T) {
|
||||
const w, h, cellM = 1024, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
|
||||
a := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
b := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
if len(a) == 0 {
|
||||
t.Fatal("no faults were drawn; this test measured nothing")
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
t.Fatalf("two runs of one seed drew %d and %d traces", len(a), len(b))
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].ThrowM != b[i].ThrowM || a[i].Reverse != b[i].Reverse ||
|
||||
len(a[i].PointsM) != len(b[i].PointsM) || a[i].PointsM[0] != b[i].PointsM[0] {
|
||||
t.Fatalf("trace %d differs between two runs of one seed", i)
|
||||
}
|
||||
}
|
||||
// And a different seed is a different world.
|
||||
if c := BuildFaults(p, 9342, 32, testSpec(), cand, area); len(c) > 0 && c[0].PointsM[0] == a[0].PointsM[0] {
|
||||
t.Error("a different seed put the first trace in the same place")
|
||||
}
|
||||
}
|
||||
|
||||
// The density means what it says: traces per thousand square kilometres of the class, not per map.
|
||||
func TestFaultDensityIsPerAreaOfTheClass(t *testing.T) {
|
||||
const cellM = 64.0
|
||||
small := testPlanet(t, 512, 256, cellM)
|
||||
big := testPlanet(t, 1024, 512, cellM)
|
||||
|
||||
count := func(p world.Planet) int {
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
return len(BuildFaults(p, 7, 32, testSpec(), cand, area))
|
||||
}
|
||||
ns, nb := count(small), count(big)
|
||||
if ns == 0 {
|
||||
t.Fatal("the small planet drew nothing; this test measured nothing")
|
||||
}
|
||||
// Four times the area, so about four times the traces. Loose, because a long fault becomes two or three
|
||||
// en-echelon segments and the draw is stochastic - the assertion is that it scales, not that it is exact.
|
||||
if ratio := float64(nb) / float64(ns); ratio < 2.5 || ratio > 6 {
|
||||
t.Errorf("four times the area gave %d traces against %d, a ratio of %.1f", nb, ns, ratio)
|
||||
}
|
||||
}
|
||||
|
||||
// The property the whole port exists for: a fault is the planet's, not a region's. Two frames overlapping the
|
||||
// same ground have to agree about the rate it contributes, and both have to agree with the whole planet.
|
||||
//
|
||||
// `uplift.Build` places a trace at two calls to Float() read as fractions of the grid it is filling, so the
|
||||
// obvious port of it fails this - the same fault would land somewhere different in every region.
|
||||
func TestTwoFramesAgreeAboutTheSameFaults(t *testing.T) {
|
||||
const w, h, cellM = 1024, 512, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
faults := BuildFaults(p, 7, 32, testSpec(), cand, area)
|
||||
if len(faults) == 0 {
|
||||
t.Fatal("no faults; this test measured nothing")
|
||||
}
|
||||
const runYears = 1.5e6
|
||||
|
||||
whole := FaultDelta(world.Whole(p), faults, runYears)
|
||||
a := world.Frame{P: p, X0: 200, Y0: 100, W: 300, H: 200}
|
||||
b := world.Frame{P: p, X0: 380, Y0: 160, W: 300, H: 200}
|
||||
da, db := FaultDelta(a, faults, runYears), FaultDelta(b, faults, runYears)
|
||||
if da == nil || db == nil {
|
||||
t.Fatal("neither frame was reached by any fault; move the windows")
|
||||
}
|
||||
|
||||
checked, nonZero := 0, 0
|
||||
for y := 0; y < a.H; y++ {
|
||||
for x := 0; x < a.W; x++ {
|
||||
px, py := a.PlanetXY(x, y)
|
||||
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
|
||||
continue
|
||||
}
|
||||
got := da[y*a.W+x]
|
||||
if want := db[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
|
||||
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
|
||||
}
|
||||
if wh := whole[py*p.W+px]; wh != got {
|
||||
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
|
||||
}
|
||||
checked++
|
||||
if got != 0 {
|
||||
nonZero++
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("the two frames do not overlap")
|
||||
}
|
||||
if nonZero == 0 {
|
||||
t.Fatal("every cell of the overlap is zero; the agreement is vacuous")
|
||||
}
|
||||
}
|
||||
|
||||
// X wraps, so a fault whose trace runs past the meridian has to reach the ground on the other side of it.
|
||||
// The trace is stored unwrapped and shifted once per fault; this is what says that shift works.
|
||||
func TestAFaultReachesAcrossTheSeam(t *testing.T) {
|
||||
const w, h, cellM = 512, 256, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
circ := p.CircumferenceM()
|
||||
|
||||
// A trace lying just east of the seam, running north-south, well inside the reach of the map's west edge.
|
||||
x := 300.0
|
||||
trace := FaultTrace{
|
||||
PointsM: [][2]float64{{x, 2000}, {x, 5000}, {x, 8000}},
|
||||
ThrowM: 400, LengthM: 6000, Reverse: false,
|
||||
}
|
||||
west := world.Frame{P: p, X0: 0, Y0: 0, W: 40, H: h}
|
||||
if d := FaultDelta(west, []FaultTrace{trace}, 1.5e6); d == nil {
|
||||
t.Fatal("a trace 300 m east of the seam did not reach a frame at the seam")
|
||||
}
|
||||
|
||||
// The same trace written with its X a whole world further east is the same fault, so a frame at the far
|
||||
// end of the map must see the identical field.
|
||||
shifted := FaultTrace{
|
||||
PointsM: [][2]float64{{x + circ, 2000}, {x + circ, 5000}, {x + circ, 8000}},
|
||||
ThrowM: 400, LengthM: 6000,
|
||||
}
|
||||
near := FaultDelta(west, []FaultTrace{trace}, 1.5e6)
|
||||
far := FaultDelta(west, []FaultTrace{shifted}, 1.5e6)
|
||||
if far == nil {
|
||||
t.Fatal("the shifted trace reached nothing; the wrap is not being applied")
|
||||
}
|
||||
for i := range near {
|
||||
if near[i] != far[i] {
|
||||
t.Fatalf("a trace and the same trace one circumference east differ at cell %d: %v vs %v",
|
||||
i, near[i], far[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The scarp is asymmetric, which is what makes a fault a tilted block rather than a ridge: it rises fast on
|
||||
// one side over a couple of hundred metres and falls away slowly on the other over a couple of kilometres.
|
||||
func TestAFaultIsATiltedBlockAndNotARidge(t *testing.T) {
|
||||
const w, h, cellM = 1024, 1024, 32.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
mid := float64(h) * cellM / 2
|
||||
// A straight east-west trace across the middle.
|
||||
pts := make([][2]float64, 9)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 8, mid}
|
||||
}
|
||||
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}, 1.5e6)
|
||||
if d == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
col := w / 2
|
||||
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) }
|
||||
// One side is positive and the other negative: the block tilts.
|
||||
up, down := at(mid-1400), at(mid+1200)
|
||||
if up*down >= 0 {
|
||||
t.Fatalf("both sides of the trace have the same sign (%v, %v); that is a ridge, not a fault", up, down)
|
||||
}
|
||||
// And the footwall reaches further than the hanging wall.
|
||||
if math.Abs(at(mid-4800)) <= math.Abs(at(mid+4800)) {
|
||||
t.Errorf("at 4800 m the footwall is %v and the hanging wall %v; the asymmetry is the wrong way "+
|
||||
"round or absent", math.Abs(at(mid-4800)), math.Abs(at(mid+4800)))
|
||||
}
|
||||
}
|
||||
|
||||
// The defect this file's shape block is about (D-62), as two numbers rather than a hillshade.
|
||||
//
|
||||
// The profile used to put the whole throw on one side of the trace and the whole throw negated on the
|
||||
// other, one cell apart, inside a welt six hundred metres wide. That is unsolvable twice over: a step in
|
||||
// the rate field is a cliff the erosion can only clamp at the angle of repose, and a block narrower than
|
||||
// one hillslope has no drainage area on it for stream power to cut with, so the profile is printed onto
|
||||
// the surface instead of being eroded into a landform.
|
||||
//
|
||||
// So: continuous through the trace, and wide enough on the upthrown side for a drainage network to live
|
||||
// on. 1100 m is the hillslope length measured on Bake_013 (a drainage density of 0.45 channels per km),
|
||||
// and three of them is the least that can carry a valley and its two divides.
|
||||
func TestAFaultIsSolvableRatherThanPrinted(t *testing.T) {
|
||||
const w, h, cellM = 2048, 2048, 8.0
|
||||
const throw, runYears = 400.0, 1.5e6
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
mid := float64(h) * cellM / 2
|
||||
pts := make([][2]float64, 17)
|
||||
for i := range pts {
|
||||
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, mid}
|
||||
}
|
||||
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
|
||||
if d == nil {
|
||||
t.Fatal("the trace reached nothing")
|
||||
}
|
||||
|
||||
col := w / 2
|
||||
// Metres of displacement the rate builds over the whole run, which is what the surface has to carry.
|
||||
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) * runYears }
|
||||
|
||||
var crest, trough, crestAt, troughAt, steepest, steepestAt float64
|
||||
prev := at(mid - faultReachM)
|
||||
for dy := -faultReachM + cellM; dy <= faultReachM; dy += cellM {
|
||||
v := at(mid + dy)
|
||||
if v > crest {
|
||||
crest, crestAt = v, dy
|
||||
}
|
||||
if v < trough {
|
||||
trough, troughAt = v, dy
|
||||
}
|
||||
if g := math.Abs(v-prev) / cellM; g > steepest {
|
||||
steepest, steepestAt = g, dy
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
|
||||
// Continuous: no cell-to-cell step steeper than ground the solve can actually shape. The repose clamp
|
||||
// is at 35 degrees and the old profile measured 89.
|
||||
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
|
||||
t.Errorf("the steepest cell-to-cell step in the rate field is %.1f degrees at %+.0f m; that is a "+
|
||||
"cliff in the uplift, and the solve can only clamp it at the angle of repose", deg, steepestAt)
|
||||
}
|
||||
// Zero on the trace itself: a rate difference across a line averages to the regional rate at the line.
|
||||
if v := math.Abs(at(mid)); v > throw/50 {
|
||||
t.Errorf("the anomaly on the trace is %.1f m; it should be nothing", v)
|
||||
}
|
||||
// Wide enough to be dissected: the upthrown flank has to carry a drainage network.
|
||||
const hillslopeM = 1100
|
||||
var above float64
|
||||
for dy := 0.0; dy <= faultReachM; dy += cellM {
|
||||
if at(mid-dy) >= crest/2 {
|
||||
above = dy
|
||||
}
|
||||
}
|
||||
if above < 3*hillslopeM {
|
||||
t.Errorf("the footwall stands above half its crest for only %.0f m, under three hillslope lengths "+
|
||||
"(%d m); nothing can cut a valley into it and the profile will print", above, 3*hillslopeM)
|
||||
}
|
||||
// And the step across the fault is the throw the author asked for, not two of them.
|
||||
if step := crest - trough; math.Abs(step-throw) > throw/20 {
|
||||
t.Errorf("the step across the fault is %.0f m against a throw of %.0f m", step, throw)
|
||||
}
|
||||
t.Logf("crest %+.0f m at %+.0f m, trough %+.0f m at %+.0f m, step %.0f m over %.0f m (%.1f deg mean), "+
|
||||
"steepest cell %.1f deg, footwall above half-crest for %.0f m",
|
||||
crest, crestAt, trough, troughAt, crest-trough, crestAt-troughAt,
|
||||
math.Atan((crest-trough)/math.Abs(crestAt-troughAt))*180/math.Pi,
|
||||
math.Atan(steepest)*180/math.Pi, above)
|
||||
}
|
||||
|
||||
// A fault dies out along strike instead of stopping dead, which is what left an abrupt cut across a summit on
|
||||
// the procedural path - and it is never flat along strike either, which is what left it extruded.
|
||||
func TestTheThrowTapersToNothingAtTheTips(t *testing.T) {
|
||||
if tipTaper(0) != 0 || tipTaper(1) != 0 {
|
||||
t.Errorf("the tips carry no throw: got %v and %v", tipTaper(0), tipTaper(1))
|
||||
}
|
||||
if tipTaper(0.5) != 1 {
|
||||
t.Errorf("the middle carries all of it: got %v", tipTaper(0.5))
|
||||
}
|
||||
// Monotone to the middle, so the ramp has no step in it.
|
||||
prev := 0.0
|
||||
for a := 0.0; a <= 0.5; a += 0.01 {
|
||||
v := tipTaper(a)
|
||||
if v < prev-1e-12 {
|
||||
t.Fatalf("the taper goes backwards at %v: %v after %v", a, v, prev)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
// Symmetric about the middle.
|
||||
for _, a := range []float64{0.05, 0.2, 0.37} {
|
||||
if math.Abs(tipTaper(a)-tipTaper(1-a)) > 1e-12 {
|
||||
t.Errorf("the two ends differ at %v: %v against %v", a, tipTaper(a), tipTaper(1-a))
|
||||
}
|
||||
}
|
||||
// Nowhere flat: the old taper held exactly 1 across the middle two thirds, which extrudes the
|
||||
// cross-section along most of every trace. Nothing between the tips and the centre may repeat.
|
||||
if tipTaper(0.2) >= tipTaper(0.35) || tipTaper(0.35) >= tipTaper(0.5) {
|
||||
t.Errorf("the throw is flat along strike: %v, %v, %v at a fifth, a third and the middle",
|
||||
tipTaper(0.2), tipTaper(0.35), tipTaper(0.5))
|
||||
}
|
||||
// But it still has a body: most of a trace carries at least half its throw.
|
||||
above := 0
|
||||
for i := 0; i <= 1000; i++ {
|
||||
if tipTaper(float64(i)/1000) >= 0.5 {
|
||||
above++
|
||||
}
|
||||
}
|
||||
if above < 750 {
|
||||
t.Errorf("only %d parts in a thousand of the trace carry half the throw; the fault is a spike", above)
|
||||
}
|
||||
}
|
||||
|
||||
// A long fault steps rather than running as one ruled line.
|
||||
func TestALongFaultBreaksIntoEnEchelonSegments(t *testing.T) {
|
||||
const w, h, cellM = 2048, 1024, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
|
||||
short := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 6}}}
|
||||
long := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{20, 26}}}
|
||||
ns := len(BuildFaults(p, 7, 32, short, cand, area))
|
||||
nl := len(BuildFaults(p, 7, 32, long, cand, area))
|
||||
if ns == 0 {
|
||||
t.Fatal("nothing was drawn; this test measured nothing")
|
||||
}
|
||||
if nl <= ns {
|
||||
t.Errorf("faults over the en-echelon length gave %d traces against %d for short ones; they are not "+
|
||||
"stepping", nl, ns)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing is drawn when nothing asks, and nothing is rasterised when no trace reaches a frame - which is what
|
||||
// keeps a planet with no faults paying nothing for the pass.
|
||||
func TestNoFaultsCostsNothing(t *testing.T) {
|
||||
const w, h, cellM = 256, 128, 64.0
|
||||
p := testPlanet(t, w, h, cellM)
|
||||
cand, area := oneClassCandidates(p, 8)
|
||||
if got := BuildFaults(p, 7, 32, []FaultSpec{{}}, cand, area); got != nil {
|
||||
t.Errorf("an empty spec drew %d traces", len(got))
|
||||
}
|
||||
if got := BuildFaults(p, 7, 0, testSpec(), cand, area); got != nil {
|
||||
t.Error("a zero grain wavelength should draw nothing")
|
||||
}
|
||||
far := FaultTrace{PointsM: [][2]float64{{0, 100000}, {1000, 100000}}, ThrowM: 400}
|
||||
if d := FaultDelta(world.Whole(p), []FaultTrace{far}, 1.5e6); d != nil {
|
||||
t.Error("a trace far off the frame should allocate no field at all")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user