2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
535 lines
17 KiB
Go
535 lines
17 KiB
Go
package geojson_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"b612.me/astro/eclipse"
|
|
"b612.me/astro/geojson"
|
|
"b612.me/astro/internal/geodata"
|
|
)
|
|
|
|
// The static central band must describe the region where the eclipse is
|
|
// actually annular or total. For grazing events the shadow axis crosses Earth
|
|
// over only a fraction of the umbral contact interval, so a band derived from
|
|
// the paired limits alone silently drops the flared ends of the real path.
|
|
// These fixtures pin the coverage that NASA's path tables list from U1 to U4
|
|
// (for example 2003 May 31: limits from 004 35.9W to 060 19.3W).
|
|
const grazingBandCoverageToleranceKM = 100.0
|
|
|
|
type grazingBandCase struct {
|
|
date string
|
|
options string
|
|
limit float64 // maximum tolerated footprint distance outside the band
|
|
}
|
|
|
|
var grazingBandCases = []grazingBandCase{
|
|
// One-limit (|gamma| ~ 0.98-0.997) annulars: the reported defect.
|
|
{"2003-05-31", "overview", 25},
|
|
{"2003-05-31", "detail", 25},
|
|
{"1874-10-10", "overview", 60},
|
|
{"1874-10-10", "detail", 60},
|
|
// One-limit total across the antimeridian.
|
|
{"2185-07-26", "overview", 50},
|
|
{"2185-07-26", "detail", 25},
|
|
// Two-limit annulars whose analytic envelope is unavailable and whose
|
|
// paired-limit ribbon used to be accepted without validation.
|
|
{"1552-07-21", "overview", 25},
|
|
{"-1480-12-27", "overview", 60},
|
|
{"4862-09-28", "overview", 60},
|
|
{"1042-06-20", "overview", 80},
|
|
{"5705-06-17", "overview", 80},
|
|
// Already-correct polar one-limit totality: must not regress.
|
|
{"1522-03-27", "overview", 10},
|
|
{"1522-03-27", "detail", 10},
|
|
// Ordinary two-limit totality.
|
|
{"2024-04-08", "overview", 25},
|
|
}
|
|
|
|
func TestSolarEclipseGrazingCentralBandCoversUmbralSweep(t *testing.T) {
|
|
for _, testCase := range grazingBandCases {
|
|
t.Run(testCase.date+"-"+testCase.options, func(t *testing.T) {
|
|
date := grazingBandDate(t, testCase.date)
|
|
info, ok := eclipse.SolarEclipseOnDate(date)
|
|
if !ok || !info.HasCentral {
|
|
t.Fatalf("expected a central solar eclipse on %s", testCase.date)
|
|
}
|
|
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
|
if !ok {
|
|
t.Fatal("missing partial footprints")
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
|
if !ok {
|
|
t.Fatal("missing central path")
|
|
}
|
|
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rings := grazingBandRings(t, raw)
|
|
if len(rings) == 0 {
|
|
t.Fatal("missing central-band feature")
|
|
}
|
|
paths := grazingFootprintPaths(partial.CentralBandFootprints)
|
|
if len(paths) == 0 {
|
|
t.Fatal("missing central band footprints")
|
|
}
|
|
miss := geodata.SphericalPolygonsPathMissDistanceKM(rings, paths, true)
|
|
if miss > testCase.limit {
|
|
t.Fatalf("central band leaves the umbral sweep %.1f km outside (limit %.1f km)",
|
|
miss, testCase.limit)
|
|
}
|
|
centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine))
|
|
for _, point := range central.CenterLine {
|
|
centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
|
}
|
|
if centerMiss := geodata.SphericalPolygonsPathMissDistanceKM(
|
|
rings, [][]geodata.GeoPoint{centerPath}, false,
|
|
); centerMiss > 25 {
|
|
t.Fatalf("central band leaves the center line %.1f km outside", centerMiss)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSolarEclipseGrazingCentralBandIsNotRejectedAsEnvelope guards the other
|
|
// direction: the flared-end band must still be a single closed continuous
|
|
// activation per mode, not a fan of open slices.
|
|
func TestSolarEclipseGrazingCentralBandIsSingleContinuousBand(t *testing.T) {
|
|
for _, testCase := range grazingBandCases {
|
|
if testCase.options != "overview" {
|
|
continue
|
|
}
|
|
t.Run(testCase.date, func(t *testing.T) {
|
|
date := grazingBandDate(t, testCase.date)
|
|
info, ok := eclipse.SolarEclipseOnDate(date)
|
|
if !ok {
|
|
t.Fatalf("missing eclipse on %s", testCase.date)
|
|
}
|
|
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
|
if !ok {
|
|
t.Fatal("missing partial footprints")
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
|
if !ok {
|
|
t.Fatal("missing central path")
|
|
}
|
|
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var collection solarGeoJSONScanCollection
|
|
if err := json.Unmarshal(raw, &collection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bands := 0
|
|
for _, feature := range collection.Features {
|
|
if role, _ := feature.Properties["role"].(string); role == "central-band" {
|
|
bands++
|
|
}
|
|
}
|
|
if bands != 1 {
|
|
t.Fatalf("central-band feature count = %d, want 1", bands)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func grazingBandDate(t *testing.T, text string) time.Time {
|
|
t.Helper()
|
|
if strings.HasPrefix(text, "-") {
|
|
parts := strings.Split(strings.TrimPrefix(text, "-"), "-")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("invalid astronomical date %q", text)
|
|
}
|
|
year, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
month, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
day, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return time.Date(-year, time.Month(month), day, 12, 0, 0, 0, time.UTC)
|
|
}
|
|
date, err := time.Parse("2006-01-02", text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return date
|
|
}
|
|
|
|
func grazingBandOptions(
|
|
info eclipse.SolarEclipseInfo,
|
|
mode string,
|
|
) (eclipse.SolarEclipsePartialFootprintOptions, eclipse.SolarEclipsePathOptions) {
|
|
if mode == "detail" {
|
|
return eclipse.SolarEclipsePartialFootprintOptions{
|
|
Step: 2 * time.Minute,
|
|
BoundaryPoints: 96,
|
|
CentralShadowStep: 2 * time.Minute,
|
|
RiseSetStep: time.Minute,
|
|
MagnitudeValues: []float64{0.2, 0.4, 0.6, 0.8, 1.0},
|
|
}, eclipse.SolarEclipsePathOptions{
|
|
Step: 2 * time.Minute,
|
|
TargetSpacingKM: 700,
|
|
}
|
|
}
|
|
partial := eclipse.SolarEclipsePartialFootprintOptions{
|
|
Step: 2 * time.Minute,
|
|
BoundaryPoints: 96,
|
|
RiseSetStep: 2 * time.Minute,
|
|
}
|
|
if info.Type == eclipse.SolarEclipseTotal {
|
|
partial.MagnitudeValues = []float64{1}
|
|
}
|
|
return partial, eclipse.SolarEclipsePathOptions{
|
|
Step: 2 * time.Minute,
|
|
TargetSpacingKM: 150,
|
|
}
|
|
}
|
|
|
|
func grazingBandRings(t *testing.T, raw []byte) [][]geodata.GeoPoint {
|
|
t.Helper()
|
|
var collection solarGeoJSONScanCollection
|
|
if err := json.Unmarshal(raw, &collection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var rings [][]geodata.GeoPoint
|
|
for _, feature := range collection.Features {
|
|
if role, _ := feature.Properties["role"].(string); role != "central-band" {
|
|
continue
|
|
}
|
|
var polygons [][][][]float64
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &polygons); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, polygon := range polygons {
|
|
if len(polygon) == 0 {
|
|
continue
|
|
}
|
|
ring := make([]geodata.GeoPoint, 0, len(polygon[0]))
|
|
for _, position := range polygon[0] {
|
|
if len(position) < 2 {
|
|
continue
|
|
}
|
|
ring = append(ring, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
|
}
|
|
if len(ring) >= 4 {
|
|
rings = append(rings, ring)
|
|
}
|
|
}
|
|
}
|
|
return rings
|
|
}
|
|
|
|
func grazingFootprintPaths(footprints []eclipse.SolarEclipsePartialFootprint) [][]geodata.GeoPoint {
|
|
var paths [][]geodata.GeoPoint
|
|
for _, footprint := range footprints {
|
|
for _, boundary := range footprint.Boundaries {
|
|
path := make([]geodata.GeoPoint, 0, len(boundary))
|
|
for _, point := range boundary {
|
|
path = append(path, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
|
}
|
|
if len(path) >= 3 {
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// TestSolarEclipseGrazingLimitsFollowBandBoundary pins the contract the map
|
|
// relies on: the dashed north/south limits and the filled central band must
|
|
// describe the same region. Ordinary events agree to a few kilometres because
|
|
// the band is built from those very limits; a grazing band is rebuilt from the
|
|
// umbral sweep, where the instantaneous cross-section limits stop describing
|
|
// the boundary at all (1136-06-01 sat 456 km inside its own band).
|
|
func TestSolarEclipseGrazingLimitsFollowBandBoundary(t *testing.T) {
|
|
for _, testCase := range grazingBandCases {
|
|
if testCase.options != "overview" {
|
|
continue
|
|
}
|
|
t.Run(testCase.date, func(t *testing.T) {
|
|
date := grazingBandDate(t, testCase.date)
|
|
info, ok := eclipse.SolarEclipseOnDate(date)
|
|
if !ok {
|
|
t.Fatalf("missing eclipse on %s", testCase.date)
|
|
}
|
|
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
|
if !ok {
|
|
t.Fatal("missing partial footprints")
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
|
if !ok {
|
|
t.Fatal("missing central path")
|
|
}
|
|
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rings := grazingBandRings(t, raw)
|
|
if len(rings) == 0 {
|
|
t.Fatal("missing central-band feature")
|
|
}
|
|
if len(rings) == 0 {
|
|
t.Fatal("missing central-band feature")
|
|
}
|
|
for _, role := range []string{"north-limit", "south-limit"} {
|
|
path, ok := grazingLimitPath(t, raw, role)
|
|
if !ok {
|
|
t.Fatalf("missing %s feature", role)
|
|
}
|
|
miss := geodata.SphericalPolygonsPathMissDistanceKM(
|
|
rings, [][]geodata.GeoPoint{path}, false,
|
|
)
|
|
if miss > 1.0 {
|
|
t.Fatalf("%s sits %.1f km from the band boundary", role, miss)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve pins the export
|
|
// contract the map shows: for a band rebuilt from sampled footprints, the edge
|
|
// that is bounded by the greatest-at-horizon condition must lie on that curve,
|
|
// otherwise the filled band and the drawn visibility line weave across each
|
|
// other at high zoom.
|
|
func TestSolarEclipseSampledBandEdgeFollowsGreatestHorizonCurve(t *testing.T) {
|
|
exercised, skipped := 0, 0
|
|
for _, testCase := range grazingBandCases {
|
|
if testCase.options != "overview" || testCase.limit > 60 {
|
|
continue
|
|
}
|
|
t.Run(testCase.date, func(t *testing.T) {
|
|
date := grazingBandDate(t, testCase.date)
|
|
info, ok := eclipse.SolarEclipseOnDate(date)
|
|
if !ok {
|
|
t.Fatalf("missing eclipse on %s", testCase.date)
|
|
}
|
|
partialOptions, pathOptions := grazingBandOptions(info, testCase.options)
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
|
if !ok {
|
|
t.Fatal("missing partial footprints")
|
|
}
|
|
if !partial.CentralBandSampled {
|
|
skipped++
|
|
t.Skip("analytic envelope: the band is already the exact boundary")
|
|
}
|
|
exercised++
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
|
if !ok {
|
|
t.Fatal("missing central path")
|
|
}
|
|
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rings := grazingBandRings(t, raw)
|
|
if len(rings) == 0 {
|
|
t.Fatal("missing central-band feature")
|
|
}
|
|
curves := grazingGreatestCurves(t, raw)
|
|
if len(curves) == 0 {
|
|
t.Fatal("missing greatest visibility curves")
|
|
}
|
|
closest := math.Inf(1)
|
|
for _, point := range rings[0] {
|
|
for _, curve := range curves {
|
|
for index := 0; index+1 < len(curve); index++ {
|
|
closest = math.Min(closest, grazingPointSegmentKM(point, curve[index], curve[index+1]))
|
|
}
|
|
}
|
|
}
|
|
if closest > 1.0 {
|
|
t.Fatalf("band edge stays %.1f km away from the greatest-at-horizon curve", closest)
|
|
}
|
|
})
|
|
}
|
|
// 采样带是少数情形:15 个夹具里只有 4 个走这条断言。若夹具筛选或"权威带"来源变化,
|
|
// 这些用例会退化成一堆 skip 而不是失败,所以钉住覆盖数下限。
|
|
if exercised < 4 {
|
|
t.Fatalf("sampled-band assertion exercised by %d fixtures (%d analytic skips); the fixture filter or the band source narrowed silently", exercised, skipped)
|
|
}
|
|
t.Logf("sampled-band edge assertion exercised by %d fixtures, %d analytic skips", exercised, skipped)
|
|
}
|
|
|
|
// grazingGreatestCurves returns the exported greatest-at-horizon boundaries.
|
|
func grazingGreatestCurves(t *testing.T, raw []byte) [][]geodata.GeoPoint {
|
|
t.Helper()
|
|
var collection solarGeoJSONScanCollection
|
|
if err := json.Unmarshal(raw, &collection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var curves [][]geodata.GeoPoint
|
|
for _, feature := range collection.Features {
|
|
if role, _ := feature.Properties["role"].(string); role != "visibility-boundary" {
|
|
continue
|
|
}
|
|
if phase, _ := feature.Properties["phase"].(string); phase != "greatest" {
|
|
continue
|
|
}
|
|
var lines [][][]float64
|
|
encoded, err := json.Marshal(feature.Geometry.Coordinates)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := json.Unmarshal(encoded, &lines); err != nil {
|
|
// A single LineString is exported as one coordinate array.
|
|
var line [][]float64
|
|
if lineErr := json.Unmarshal(encoded, &line); lineErr != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lines = [][][]float64{line}
|
|
}
|
|
for _, line := range lines {
|
|
curve := make([]geodata.GeoPoint, 0, len(line))
|
|
for _, position := range line {
|
|
if len(position) < 2 {
|
|
continue
|
|
}
|
|
curve = append(curve, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
|
}
|
|
if len(curve) >= 2 {
|
|
curves = append(curves, curve)
|
|
}
|
|
}
|
|
}
|
|
return curves
|
|
}
|
|
|
|
// grazingPointSegmentKM is the planar distance from a point to one segment.
|
|
func grazingPointSegmentKM(point, first, second geodata.GeoPoint) float64 {
|
|
scale := math.Cos(point.Latitude * math.Pi / 180)
|
|
ax := (first.Longitude - point.Longitude) * scale
|
|
ay := first.Latitude - point.Latitude
|
|
bx := (second.Longitude - point.Longitude) * 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))
|
|
}
|
|
return 111.32 * math.Hypot(ax+fraction*dx, ay+fraction*dy)
|
|
}
|
|
|
|
// grazingLimitPath returns one exported limit line as a geographic path.
|
|
func grazingLimitPath(t *testing.T, raw []byte, role string) ([]geodata.GeoPoint, bool) {
|
|
t.Helper()
|
|
var collection solarGeoJSONScanCollection
|
|
if err := json.Unmarshal(raw, &collection); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, feature := range collection.Features {
|
|
if value, _ := feature.Properties["role"].(string); value != role {
|
|
continue
|
|
}
|
|
var line [][]float64
|
|
if err := json.Unmarshal(feature.Geometry.Coordinates, &line); err != nil {
|
|
var lines [][][]float64
|
|
if multiErr := json.Unmarshal(feature.Geometry.Coordinates, &lines); multiErr != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(lines) == 0 {
|
|
return nil, false
|
|
}
|
|
line = lines[0]
|
|
}
|
|
path := make([]geodata.GeoPoint, 0, len(line))
|
|
for _, position := range line {
|
|
if len(position) < 2 {
|
|
continue
|
|
}
|
|
path = append(path, geodata.GeoPoint{Longitude: position[0], Latitude: position[1]})
|
|
}
|
|
if len(path) >= 2 {
|
|
return path, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple covers a shallow
|
|
// two-limit event whose northern limit runs through a cusp near the apex of a
|
|
// high-latitude path. Concatenating the two limits into one ribbon ring used to
|
|
// fold the ring onto itself, so the export contained a spike triangle plus
|
|
// disconnected end pieces instead of the swept band.
|
|
func TestMarshalSolarEclipse11360601KeepsItsCentralBandSimple(t *testing.T) {
|
|
date := time.Date(1136, time.June, 1, 12, 0, 0, 0, time.UTC)
|
|
info, ok := eclipse.SolarEclipseOnDate(date)
|
|
if !ok || !info.HasCentral {
|
|
t.Fatal("expected a central solar eclipse on 1136-06-01")
|
|
}
|
|
partialOptions, pathOptions := grazingBandOptions(info, "overview")
|
|
partial, ok := eclipse.SolarEclipsePartialFootprints(date, partialOptions)
|
|
if !ok {
|
|
t.Fatal("missing partial footprints")
|
|
}
|
|
central, ok := eclipse.SolarEclipseCentralPath(date, pathOptions)
|
|
if !ok {
|
|
t.Fatal("missing central path")
|
|
}
|
|
raw, err := geojson.MarshalSolarEclipse(partial, ¢ral)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rings := grazingBandRings(t, raw)
|
|
if len(rings) != 1 {
|
|
t.Fatalf("central band exported as %d polygons, want 1 simple ring", len(rings))
|
|
}
|
|
if len(rings[0]) < 8 {
|
|
t.Fatalf("central band ring has %d vertices", len(rings[0]))
|
|
}
|
|
if i, j, crossed := grazingRingCrossing(rings[0]); crossed {
|
|
t.Fatalf("central band ring crosses itself between vertices %d and %d", i, j)
|
|
}
|
|
centerPath := make([]geodata.GeoPoint, 0, len(central.CenterLine))
|
|
for _, point := range central.CenterLine {
|
|
centerPath = append(centerPath, geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude})
|
|
}
|
|
if miss := geodata.SphericalPolygonsPathMissDistanceKM(
|
|
rings, [][]geodata.GeoPoint{centerPath}, false,
|
|
); miss > 25 {
|
|
t.Fatalf("central band leaves the center line %.1f km outside", miss)
|
|
}
|
|
}
|
|
|
|
// grazingRingCrossing reports the first planar self-intersection of a ring.
|
|
func grazingRingCrossing(ring []geodata.GeoPoint) (int, int, bool) {
|
|
for first := 0; first+1 < len(ring); first++ {
|
|
for second := first + 2; second+1 < len(ring); second++ {
|
|
if first == 0 && second+1 == len(ring)-1 {
|
|
continue
|
|
}
|
|
if grazingSegmentsCross(ring[first], ring[first+1], ring[second], ring[second+1]) {
|
|
return first, second, true
|
|
}
|
|
}
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
func grazingSegmentsCross(a, b, c, d geodata.GeoPoint) bool {
|
|
side := func(p, q, r geodata.GeoPoint) float64 {
|
|
return (q.Longitude-p.Longitude)*(r.Latitude-p.Latitude) - (q.Latitude-p.Latitude)*(r.Longitude-p.Longitude)
|
|
}
|
|
first := side(c, d, a)
|
|
second := side(c, d, b)
|
|
third := side(a, b, c)
|
|
fourth := side(a, b, d)
|
|
return (first > 0) != (second > 0) && (third > 0) != (fourth > 0)
|
|
}
|