Files
astro/moon/svg/occultation_simplify_test.go
T

301 lines
11 KiB
Go
Raw Normal View History

package svg
import (
"math"
"strconv"
"strings"
"testing"
"time"
"b612.me/astro/moon"
)
// 本节钉住写出前折线简化的契约:简化几何与原几何逐点偏差不超过容差,环数不变,
// 文档字节与顶点数显著下降;渲染速度只由同文件的基准度量,单元测试不做墙钟断言。
func TestSimplifyOccultationPolylineCollapsesCollinearRun(t *testing.T) {
points := make([]occultationScreenPoint, 0, 101)
for index := 0; index <= 100; index++ {
points = append(points, occultationScreenPoint{x: float64(index), y: 0})
}
simplified := simplifyOccultationPolyline(points, 0.4)
if len(simplified) != 2 {
t.Fatalf("collinear run simplified to %d points, want 2", len(simplified))
}
if simplified[0] != points[0] || simplified[1] != points[len(points)-1] {
t.Fatal("polyline simplification dropped an endpoint")
}
}
func TestSimplifyOccultationRingKeepsClosureAndAnchors(t *testing.T) {
ring := make([]occultationScreenPoint, 0, 73)
for index := 0; index < 72; index++ {
angle := 2 * math.Pi * float64(index) / 72
ring = append(ring, occultationScreenPoint{x: 200 + 80*math.Cos(angle), y: 200 + 80*math.Sin(angle)})
}
for _, input := range [][]occultationScreenPoint{ring, append(append([]occultationScreenPoint(nil), ring...), ring[0])} {
simplified := simplifyOccultationRing(input, 0.4)
if len(simplified) >= len(input) {
t.Fatalf("circle simplification kept %d of %d vertices", len(simplified), len(input))
}
if simplified[0] != input[0] {
t.Fatal("ring simplification dropped the first vertex")
}
if closed := input[0] == input[len(input)-1]; closed != (simplified[0] == simplified[len(simplified)-1]) {
t.Fatalf("ring closure changed: input closed=%v output closed=%v", closed, simplified[0] == simplified[len(simplified)-1])
}
if deviation := occultationRingMaxDeviation([][]occultationScreenPoint{input}, [][]occultationScreenPoint{simplified}); deviation > 0.4+1e-9 {
t.Fatalf("circle simplification deviation = %.6f px, want <= 0.4", deviation)
}
}
}
func TestSimplifyOccultationRingKeepsDegenerateRing(t *testing.T) {
ring := []occultationScreenPoint{{x: 10, y: 10}, {x: 10.05, y: 10}, {x: 10, y: 10.05}}
simplified := simplifyOccultationRing(ring, 0.4)
if len(simplified) != len(ring) {
t.Fatalf("sub-tolerance ring simplified to %d points, want %d", len(simplified), len(ring))
}
}
func TestStarOccultationBandSimplificationWithinTolerance(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawRings := occultationSVGPathRings(t, raw, "occultation-band")
rings := occultationSVGPathRings(t, simplified, "occultation-band")
if len(rawRings) == 0 {
t.Fatal("stellar reference document has no occultation-band rings")
}
if len(rawRings) != len(rings) {
t.Fatalf("band ring count changed from %d to %d", len(rawRings), len(rings))
}
deviation := occultationRingMaxDeviation(rawRings, rings)
if deviation > occultationGeometryTolerancePixels+0.01 {
t.Fatalf("band deviation = %.4f px, tolerance = %.4f px", deviation, occultationGeometryTolerancePixels)
}
}
func TestPlanetOccultationBandSimplificationWithinTolerance(t *testing.T) {
zone := time.FixedZone("UTC+8", 8*60*60)
start := time.Date(2024, time.July, 25, 0, 0, 0, 0, zone)
paths, err := moon.FindPlanetOccultationPaths(
start, start.Add(24*time.Hour), moon.OccultationSaturn,
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindPlanetOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
render := func(tolerance float64) string {
t.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
return occultationRender(t, func() (string, error) {
return PlanetOccultationPathSVG(paths[0], PlanetOccultationSVGOptions{Width: 1200, Height: 800})
})
}
raw := render(0)
simplified := render(occultationGeometryTolerancePixels)
rawVertices, simplifiedVertices := 0, 0
for _, className := range []string{"occultation-band", "total-occultation-band"} {
rawRings := occultationSVGPathRings(t, raw, className)
rings := occultationSVGPathRings(t, simplified, className)
if len(rawRings) == 0 || len(rawRings) != len(rings) {
t.Fatalf("%s ring count changed from %d to %d", className, len(rawRings), len(rings))
}
if deviation := occultationRingMaxDeviation(rawRings, rings); deviation > occultationGeometryTolerancePixels+0.01 {
t.Fatalf("%s deviation = %.4f px, tolerance = %.4f px", className, deviation, occultationGeometryTolerancePixels)
}
rawVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, raw, className))
simplifiedVertices += occultationPathVertexCount(planetOccultationSVGPathData(t, simplified, className))
}
if len(simplified)*4 > len(raw)*3 {
t.Fatalf("planetary document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified))
}
if simplifiedVertices*100 > rawVertices*40 {
t.Fatalf("planetary band vertices %d -> %d, want at least a 60%% reduction", rawVertices, simplifiedVertices)
}
t.Logf("planetary document %d -> %d bytes, band vertices %d -> %d",
len(raw), len(simplified), rawVertices, simplifiedVertices)
}
func TestStarOccultationSVGSimplificationShrinksOutput(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawVertices := occultationBandVertexCount(t, raw)
simplifiedVertices := occultationBandVertexCount(t, simplified)
if rawVertices == 0 || simplifiedVertices == 0 {
t.Fatalf("band vertex count raw=%d simplified=%d", rawVertices, simplifiedVertices)
}
if len(simplified)*4 > len(raw)*3 {
t.Fatalf("document bytes %d -> %d, want at least a 25%% reduction", len(raw), len(simplified))
}
if simplifiedVertices*100 > rawVertices*35 {
t.Fatalf("band vertices %d -> %d, want at least a 65%% reduction", rawVertices, simplifiedVertices)
}
t.Logf("document %d -> %d bytes, band vertices %d -> %d",
len(raw), len(simplified), rawVertices, simplifiedVertices)
}
func TestStarOccultationBandSimplificationPreservesRingTopology(t *testing.T) {
path := starOccultationSimplificationPath(t)
raw := occultationRenderWithTolerance(t, path, 0)
simplified := occultationRenderWithTolerance(t, path, occultationGeometryTolerancePixels)
rawRings := occultationSVGPathRings(t, raw, "occultation-band")
rings := occultationSVGPathRings(t, simplified, "occultation-band")
if len(rawRings) != len(rings) {
t.Fatalf("band ring count %d -> %d, want unchanged", len(rawRings), len(rings))
}
for index := range rawRings {
if len(rings[index]) < 3 {
t.Fatalf("ring %d has %d vertices after simplification, want at least 3", index, len(rings[index]))
}
if len(rings[index]) > len(rawRings[index]) {
t.Fatalf("ring %d grew from %d to %d vertices", index, len(rawRings[index]), len(rings[index]))
}
if math.Signbit(occultationRingSignedArea(rawRings[index])) != math.Signbit(occultationRingSignedArea(rings[index])) {
t.Fatalf("ring %d winding changed by simplification", index)
}
for _, point := range rings[index] {
if !occultationRingContainsVertex(rawRings[index], point) {
t.Fatalf("ring %d vertex %.3f,%.3f is not an original vertex", index, point.x, point.y)
}
}
}
}
func occultationRingSignedArea(ring []occultationScreenPoint) float64 {
area := 0.0
for index, point := range ring {
next := ring[(index+1)%len(ring)]
area += point.x*next.y - next.x*point.y
}
return area / 2
}
func occultationRingContainsVertex(ring []occultationScreenPoint, target occultationScreenPoint) bool {
for _, point := range ring {
if point == target {
return true
}
}
return false
}
func starOccultationSimplificationPath(t testing.TB) moon.StarOccultationPath {
t.Helper()
location := time.FixedZone("CST", 8*3600)
paths, err := moon.FindStarOccultationPaths(
time.Date(2025, 6, 5, 0, 0, 0, 0, location),
time.Date(2025, 6, 6, 0, 0, 0, 0, location),
hr4799StarCoordinate(),
moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200},
)
if err != nil || len(paths) != 1 {
t.Fatalf("FindStarOccultationPaths() paths=%d err=%v, want one", len(paths), err)
}
return paths[0]
}
func occultationRenderWithTolerance(
t testing.TB,
path moon.StarOccultationPath,
tolerance float64,
) string {
t.Helper()
previous := occultationGeometryTolerancePixels
occultationGeometryTolerancePixels = tolerance
defer func() { occultationGeometryTolerancePixels = previous }()
return occultationRender(t, func() (string, error) {
return StarOccultationPathSVG(path, StarOccultationSVGOptions{Width: 720, Height: 520})
})
}
func occultationRender(t testing.TB, render func() (string, error)) string {
t.Helper()
diagram, err := render()
if err != nil {
t.Fatalf("occultation render: %v", err)
}
return diagram
}
func occultationBandVertexCount(t testing.TB, diagram string) int {
t.Helper()
return occultationPathVertexCount(planetOccultationSVGPathData(t, diagram, "occultation-band"))
}
func occultationPathVertexCount(pathData string) int {
count := 0
for _, field := range strings.Fields(pathData) {
if field == "M" || field == "L" {
count++
}
}
return count
}
func occultationSVGPathRings(t testing.TB, diagram, className string) [][]occultationScreenPoint {
t.Helper()
fields := strings.Fields(planetOccultationSVGPathData(t, diagram, className))
rings := make([][]occultationScreenPoint, 0, 8)
for index := 0; index < len(fields); {
switch fields[index] {
case "M":
rings = append(rings, nil)
fallthrough
case "L":
if index+2 >= len(fields) {
t.Fatalf("truncated path data in %q", className)
}
x, xErr := strconv.ParseFloat(fields[index+1], 64)
y, yErr := strconv.ParseFloat(fields[index+2], 64)
if xErr != nil || yErr != nil {
t.Fatalf("parse %q coordinate %q/%q: %v %v", className, fields[index+1], fields[index+2], xErr, yErr)
}
rings[len(rings)-1] = append(rings[len(rings)-1], occultationScreenPoint{x: x, y: y})
index += 3
case "Z":
index++
default:
t.Fatalf("unexpected path command %q in %q", fields[index], className)
}
}
return rings
}
func occultationRingMaxDeviation(raw, simplified [][]occultationScreenPoint) float64 {
maximum := 0.0
for index := range raw {
for _, point := range raw[index] {
maximum = math.Max(maximum, occultationRingDeviation(point, simplified[index]))
}
}
return maximum
}
func occultationRingDeviation(point occultationScreenPoint, ring []occultationScreenPoint) float64 {
minimum := math.Inf(1)
for index := range ring {
minimum = math.Min(minimum, occultationSegmentDistance(point, ring[index], ring[(index+1)%len(ring)]))
}
return minimum
}
func occultationSegmentDistance(point, start, end occultationScreenPoint) float64 {
dx, dy := end.x-start.x, end.y-start.y
lengthSquared := dx*dx + dy*dy
position := 0.0
if lengthSquared > 0 {
position = ((point.x-start.x)*dx + (point.y-start.y)*dy) / lengthSquared
}
if position < 0 {
position = 0
} else if position > 1 {
position = 1
}
return math.Hypot(point.x-(start.x+position*dx), point.y-(start.y+position*dy))
}