Files
astro/geojson/review15_coverage_internal_test.go
T

334 lines
13 KiB
Go
Raw Normal View History

package geojson
import (
"math"
"testing"
"time"
eclipsecore "b612.me/astro/eclipse"
"b612.me/astro/internal/geodata"
)
// 本文件钉住 §1.5 的两条导出契约:覆盖判据必须逐点成立(不能靠抽样),
// 中心线走廊必须让每个探针都落在容差内,且只修补越界的那一段。
const review15ToleranceKM = solarCentralBandCoverageToleranceKM
func review15SquareRing(minLongitude, minLatitude, maxLongitude, maxLatitude float64) []geodata.GeoPoint {
return []geodata.GeoPoint{
{Longitude: minLongitude, Latitude: minLatitude},
{Longitude: maxLongitude, Latitude: minLatitude},
{Longitude: maxLongitude, Latitude: maxLatitude},
{Longitude: minLongitude, Latitude: maxLatitude},
}
}
func review15CenterLine(points ...[2]float64) []eclipsecore.SolarEclipsePathPoint {
base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
line := make([]eclipsecore.SolarEclipsePathPoint, len(points))
for index, point := range points {
line[index] = eclipsecore.SolarEclipsePathPoint{
Time: base.Add(time.Duration(index) * time.Minute),
Longitude: point[0],
Latitude: point[1],
}
}
return line
}
func TestSolarCentralBandPointsCoverProbesEveryVertex(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
points := make([]geodata.GeoPoint, 0, 257)
for index := 0; index < 257; index++ {
points = append(points, geodata.GeoPoint{
Longitude: 0.1 + 0.8*float64(index)/256,
Latitude: 0.1 + 0.8*float64(index%97)/96,
})
}
if !solarCentralBandPointsCover(polygons, points, review15ToleranceKM) {
t.Fatal("points inside the ring must be covered")
}
for index := range points {
moved := append([]geodata.GeoPoint(nil), points...)
moved[index] = geodata.GeoPoint{Longitude: 40, Latitude: 40}
if solarCentralBandPointsCover(polygons, moved, review15ToleranceKM) {
t.Fatalf("vertex %d outside the tolerance was accepted; every vertex must be probed", index)
}
}
}
func TestSolarCentralBandPointsCoverKeepsMacroTolerance(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
near := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 1.4}}
far := []geodata.GeoPoint{{Longitude: 0.5, Latitude: 2.0}}
if !solarCentralBandPointsCover(polygons, near, review15ToleranceKM) {
t.Fatal("a point 45 km outside must stay inside the 100 km macro tolerance")
}
if solarCentralBandPointsCover(polygons, far, review15ToleranceKM) {
t.Fatal("a point 111 km outside must exceed the 100 km macro tolerance")
}
}
func TestSolarCentralBandRingsCoverProbesEveryFootprintVertex(t *testing.T) {
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
boundary := make([]eclipsecore.SolarEclipsePathPoint, 0, 64)
for index := 0; index < 64; index++ {
boundary = append(boundary, eclipsecore.SolarEclipsePathPoint{
Time: stamp,
Longitude: 0.2 + 0.6*float64(index)/63,
Latitude: 0.3,
})
}
footprints := []eclipsecore.SolarEclipsePartialFootprint{{
Time: stamp,
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{boundary},
}}
if !solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), footprints) {
t.Fatal("a covered footprint must pass the coverage check")
}
for index := range boundary {
moved := append([]eclipsecore.SolarEclipsePathPoint(nil), boundary...)
moved[index] = eclipsecore.SolarEclipsePathPoint{Time: stamp, Longitude: 40, Latitude: 40}
probe := []eclipsecore.SolarEclipsePartialFootprint{{
Time: stamp,
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{moved},
}}
if solarCentralBandRingsCover(rings, review15CenterLine([2]float64{0.5, 0.5}), probe) {
t.Fatalf("footprint vertex %d outside the tolerance was accepted", index)
}
}
}
func TestSolarCentralBandRingsCoverProbesEveryCenterLineVertex(t *testing.T) {
rings := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 0.6}, [2]float64{0.5, 0.7})
if !solarCentralBandRingsCover(rings, line, nil) {
t.Fatal("a covered center line must pass the coverage check")
}
for index := range line {
moved := append([]eclipsecore.SolarEclipsePathPoint(nil), line...)
moved[index].Longitude = 40
moved[index].Latitude = 40
if solarCentralBandRingsCover(rings, moved, nil) {
t.Fatalf("center-line vertex %d outside the tolerance was accepted", index)
}
}
}
func TestSolarCentralBandCorridorCoversEveryCenterlineProbe(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 1.2}, [2]float64{0.5, 1.8})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
probes := solarCentralBandCenterlineProbes(line)
miss := geodata.SphericalPolygonsPathMissDistanceKM(
repaired, [][]geodata.GeoPoint{probes}, false,
)
if miss > solarCentralBandCenterlineToleranceKM {
t.Fatalf("center-line probe sits %.1f km from the repaired band, want <= %.1f km",
miss, solarCentralBandCenterlineToleranceKM)
}
}
func TestSolarCentralBandCorridorPatchesOnlyTheClippedSegment(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(-1, -0.05, 1, 0.05)}
line := review15CenterLine([2]float64{-0.5, 0.08}, [2]float64{0.5, 0.6})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
witness := []geodata.GeoPoint{{Longitude: -0.5, Latitude: 0.35}}
if covered := geodata.SphericalPolygonsContainPoints(repaired, witness); covered[0] {
t.Fatal("the repaired band inflated the shallow end to the deepest probe's radius")
}
}
func TestSolarCentralBandCorridorLeavesUnboundedMissAlone(t *testing.T) {
polygons := [][]geodata.GeoPoint{review15SquareRing(0, 0, 1, 1)}
line := review15CenterLine([2]float64{0.5, 0.5}, [2]float64{0.5, 20})
repaired := solarCentralBandWithCenterlineCorridor(polygons, line)
if len(repaired) != len(polygons) {
t.Fatalf("polygons=%d, want the input unchanged when the miss exceeds the corridor cap", len(repaired))
}
for index := range polygons {
if len(repaired[index]) != len(polygons[index]) {
t.Fatalf("ring %d has %d vertices, want %d", index, len(repaired[index]), len(polygons[index]))
}
}
}
func TestSolarShadowRegionDegenerateSkipsOnlyDegenerateRings(t *testing.T) {
normal := review15SquareRing(0, 0, 0.1, 0.1)
if solarShadowRegionDegenerate(normal, normal) {
t.Fatal("an 11 km square must not be degenerate")
}
sliver := []geodata.GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 0.01, Latitude: 0},
{Longitude: 0.01, Latitude: 1e-11},
{Longitude: 0, Latitude: 1e-11},
}
if !solarShadowRegionDegenerate(sliver, sliver) {
t.Fatal("a 2.2 km long sliver with 1e-13 square degrees of area must be degenerate")
}
short := []geodata.GeoPoint{{Longitude: 0, Latitude: 0}, {Longitude: 0.001, Latitude: 0}}
if !solarShadowRegionDegenerate(short, short) {
t.Fatal("a 0.1 km boundary must be degenerate")
}
crossing := []geodata.GeoPoint{
{Longitude: 179.9, Latitude: -0.05},
{Longitude: -179.9, Latitude: -0.05},
{Longitude: -179.9, Latitude: 0.05},
{Longitude: 179.9, Latitude: 0.05},
}
if solarShadowRegionDegenerate(crossing, crossing) {
t.Fatal("a region crossing the antimeridian must not be degenerate")
}
}
func TestSolarShadowSegmentClosedUsesBasicLayerCaliber(t *testing.T) {
start := eclipsecore.SolarEclipsePathPoint{Longitude: 12, Latitude: 30}
nearClosed := []eclipsecore.SolarEclipsePathPoint{
start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12 + 5e-9, Latitude: 30 + 5e-9},
}
if !solarShadowSegmentClosed(nearClosed) {
t.Fatal("a 0.8 mm gap counts as closed for the basic layer signature")
}
open := []eclipsecore.SolarEclipsePathPoint{
start, {Longitude: 12.5, Latitude: 30.5}, {Longitude: 12.001, Latitude: 30.001},
}
if solarShadowSegmentClosed(open) {
t.Fatal("a 150 m gap is not a closed ring")
}
if solarShadowSegmentClosed(nearClosed[:2]) {
t.Fatal("a two-point segment is not a closed ring")
}
}
func TestOccultationBandSourcePropertiesCoverBothPaths(t *testing.T) {
for _, testCase := range []struct {
authoritative bool
contours int
source string
boundary string
}{
{authoritative: true, contours: 2, source: "visible-footprint-sweep", boundary: "footprint-sweep+horizon-visible"},
{authoritative: false, contours: 2, source: "footprint-sweep-fallback", boundary: "contact-contours+horizon-boundary"},
{authoritative: false, contours: 0, source: "footprint-sweep-fallback", boundary: ""},
} {
properties := map[string]interface{}{}
applyOccultationBandSourceProperties(properties, testCase.authoritative, testCase.contours)
if properties["source"] != testCase.source {
t.Fatalf("source=%v, want %s", properties["source"], testCase.source)
}
boundary, present := properties["boundary_source"]
if testCase.boundary == "" {
if present {
t.Fatalf("boundary_source=%v, want absent", boundary)
}
continue
}
if boundary != testCase.boundary {
t.Fatalf("boundary_source=%v, want %s", boundary, testCase.boundary)
}
}
}
func TestAppendSolarFootprintFeaturesSkipsDegenerateFootprint(t *testing.T) {
stamp := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
degenerate := eclipsecore.SolarEclipsePartialFootprint{
Time: stamp,
Closed: true,
Boundaries: [][]eclipsecore.SolarEclipsePathPoint{{
{Time: stamp, Longitude: 0, Latitude: 0},
{Time: stamp, Longitude: 0.01, Latitude: 0},
{Time: stamp, Longitude: 0.01, Latitude: 1e-11},
{Time: stamp, Longitude: 0, Latitude: 1e-11},
}},
}
features, err := appendSolarFootprintFeatures(
nil, solarCentralShadowFootprintRole,
[]eclipsecore.SolarEclipsePartialFootprint{degenerate},
map[string]interface{}{},
)
if err != nil {
t.Fatalf("degenerate footprint must be omitted, not rejected: %v", err)
}
if len(features) != 0 {
t.Fatalf("features=%d, want the degenerate footprint omitted", len(features))
}
}
// review15ProjectTime 独立复算顶点在中心线上的投影时刻。
func review15ProjectTime(
point eclipsecore.SolarEclipsePathPoint,
centerLine []eclipsecore.SolarEclipsePathPoint,
) time.Time {
bestDistance := math.Inf(1)
bestTime := centerLine[0].Time
scale := math.Cos(point.Latitude * math.Pi / 180)
for index := 0; index+1 < len(centerLine); index++ {
first, second := centerLine[index], centerLine[index+1]
ax := math.Remainder(first.Longitude-point.Longitude, 360) * scale
ay := first.Latitude - point.Latitude
bx := math.Remainder(second.Longitude-point.Longitude, 360) * scale
by := second.Latitude - point.Latitude
dx, dy := bx-ax, by-ay
length := dx*dx + dy*dy
fraction := 0.0
if length > 0 {
fraction = math.Max(0, math.Min(1, -(ax*dx+ay*dy)/length))
}
distance := math.Hypot(ax+fraction*dx, ay+fraction*dy)
if distance >= bestDistance {
continue
}
bestDistance = distance
bestTime = first.Time.Add(time.Duration(float64(second.Time.Sub(first.Time)) * fraction))
}
return bestTime
}
func TestSolarCentralBandLimitSidesKeepProjectionTimes(t *testing.T) {
base := time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)
centerLine := []eclipsecore.SolarEclipsePathPoint{
{Time: base, Longitude: 0, Latitude: 0},
{Time: base.Add(time.Minute), Longitude: 0.3, Latitude: 0},
{Time: base.Add(20 * time.Minute), Longitude: 1, Latitude: 0},
}
ring := []eclipsecore.SolarEclipsePathPoint{
{Longitude: 0, Latitude: 0.1},
{Longitude: 0.3, Latitude: 0.1},
{Longitude: 1, Latitude: 0.1},
{Longitude: 1.05, Latitude: -0.05},
{Longitude: 1, Latitude: -0.1},
{Longitude: 0.3, Latitude: -0.1},
{Longitude: 0, Latitude: -0.1},
{Longitude: -0.05, Latitude: -0.05},
}
north, south, ok := solarCentralBandLimitSidesFromRings(
[][]eclipsecore.SolarEclipsePathPoint{ring}, centerLine,
)
if !ok {
t.Fatal("synthetic band must produce two limit sides")
}
if len(north) != 3 || len(south) != 5 {
t.Fatalf("north=%d south=%d, want the 3 north and 5 south vertices without the end caps",
len(north), len(south))
}
for _, side := range []struct {
name string
north bool
points []eclipsecore.SolarEclipsePathPoint
}{{"north-limit", true, north}, {"south-limit", false, south}} {
for index, point := range side.points {
if side.north && point.Latitude <= 0 {
t.Fatalf("%s vertex %d latitude=%v, want north of the center line", side.name, index, point.Latitude)
}
if !side.north && point.Latitude >= 0 {
t.Fatalf("%s vertex %d latitude=%v, want south of the center line", side.name, index, point.Latitude)
}
projected := review15ProjectTime(point, centerLine)
if delta := point.Time.Sub(projected); delta > time.Millisecond || delta < -time.Millisecond {
t.Fatalf("%s vertex %d time=%v, projected=%v", side.name, index, point.Time, projected)
}
}
}
}