2MZO5RDB67LA3ZM752XPJTN3IYGJL3M4UXGPUQVPVQ3CT2ZLFRFAC PXQDGTR53ST5T4EV6XFRCAOC7N5RQX23GWVKMJGS2J35VUQLZL4AC KZKLAINJJWZ64T5MUZT34LJVQIKBTKZ6EJGD7C7TTSSDGCHEDPMAC RUVJ3V4N5V4Z3HSH2YYESKQF5G7RIHBFB5TLV2IPDWXSGJDRD54AC ZTOMARKZZPKZZ3ESL73THF6W74SK7BC7BKWXY5WRDYCISTT5GMQQC NQPVZ3PPQG6EPTTAEHXOXXGK27HZCISHZCOZU6K6RKWTRTOHMY6QC 3DVPQOKB6BX63XSBIYYCPWBL2RBG3LXZS3XPQBANJP2FWVRAOVZQC SJN7IKIVTAZX3ACEWPLFVUT7P2TLB3RQBD4PKC6PEQQ33ECXFJRQC package utilsimport ("testing""time")// Test location: Auckland, New Zealand (approx coordinates)var testLocationAuckland = struct {lat float64lon float64}{lat: -36.8485,lon: 174.7633,}func TestCalculateAstronomicalData(t *testing.T) {tests := []struct {name stringtimestamp stringduration float64lat, lon float64wantNoSNight bool // if true, assert SolarNight=falsewantNoCNight bool // if true, assert CivilNight=false}{{name: "valid moon phase range", timestamp: "2024-06-15T12:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "no solar night during daytime", timestamp: "2024-12-15T00:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon, wantNoSNight: true, wantNoCNight: true},{name: "short duration", timestamp: "2024-06-15T10:00:00Z", duration: 30.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "long duration", timestamp: "2024-06-15T10:00:00Z", duration: 3600.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "midpoint calculation", timestamp: "2024-06-15T10:00:00Z", duration: 7200.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "different location", timestamp: "2024-06-15T12:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "very short duration", timestamp: "2024-06-15T12:00:00Z", duration: 0.1, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "very long duration", timestamp: "2024-06-15T12:00:00Z", duration: 86400.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {ts := parseTime(t, tt.timestamp)result := CalculateAstronomicalData(ts, tt.duration, tt.lat, tt.lon)if result.MoonPhase < 0 || result.MoonPhase > 1 {t.Errorf("MoonPhase out of range: got %f, want 0-1", result.MoonPhase)}if tt.wantNoSNight && result.SolarNight {t.Error("Expected SolarNight to be false")}if tt.wantNoCNight && result.CivilNight {t.Error("Expected CivilNight to be false")}})}}func TestBooleanLogicValidation(t *testing.T) {t.Run("should never return invalid values for valid inputs", func(t *testing.T) {testCases := []string{"2024-06-15T06:00:00Z", // Dawn/dusk time"2024-06-15T12:00:00Z", // Midday/midnight"2024-06-15T18:00:00Z", // Evening/morning"2024-12-15T06:00:00Z", // Summer dawn/dusk"2024-12-15T12:00:00Z", // Summer midday/midnight"2024-12-15T18:00:00Z", // Summer evening/morning}for _, timestamp := range testCases {t.Run(timestamp, func(t *testing.T) {ts := parseTime(t, timestamp)result := CalculateAstronomicalData(ts, 60, testLocationAuckland.lat, testLocationAuckland.lon)// These should be proper boolean types_ = result.SolarNight_ = result.CivilNight// MoonPhase should be in valid rangeif result.MoonPhase < 0 || result.MoonPhase > 1 {t.Errorf("MoonPhase out of range: got %f, want 0-1", result.MoonPhase)}})}})t.Run("should return false for daytime recordings", func(t *testing.T) {// Test a known daytime period in Auckland (summer midday UTC)summerMidday := parseTime(t, "2024-12-15T00:30:00Z") // Should be daytime in Aucklandduration := 60.0result := CalculateAstronomicalData(summerMidday, duration, testLocationAuckland.lat, testLocationAuckland.lon)// The key test: false values should remain falseif result.SolarNight && result.CivilNight {// This would be unexpected during middayt.Logf("Note: Both SolarNight and CivilNight are true (may be valid depending on season)")}})t.Run("should return true for nighttime recordings", func(t *testing.T) {// Test a known nighttime period in Auckland (winter midnight UTC)winterMidnight := parseTime(t, "2024-06-15T12:30:00Z") // Should be nighttime in Aucklandduration := 60.0result := CalculateAstronomicalData(winterMidnight, duration, testLocationAuckland.lat, testLocationAuckland.lon)// The key test: true values should remain true_ = result.SolarNight_ = result.CivilNight})}func TestCalculateMidpointTime(t *testing.T) {t.Run("should calculate midpoint correctly", func(t *testing.T) {startTime := parseTime(t, "2024-06-15T10:00:00Z")duration := 3600.0 // 1 hourmidpoint := CalculateMidpointTime(startTime, duration)expected := parseTime(t, "2024-06-15T10:30:00Z")if !midpoint.Equal(expected) {t.Errorf("Midpoint incorrect: got %v, want %v", midpoint, expected)}})t.Run("should handle short durations", func(t *testing.T) {startTime := parseTime(t, "2024-06-15T10:00:00Z")duration := 10.0 // 10 secondsmidpoint := CalculateMidpointTime(startTime, duration)expected := parseTime(t, "2024-06-15T10:00:05Z")if !midpoint.Equal(expected) {t.Errorf("Midpoint incorrect: got %v, want %v", midpoint, expected)}})}// Helper function to parse time stringsfunc parseTime(t *testing.T, s string) time.Time {t.Helper()parsed, err := time.Parse(time.RFC3339, s)if err != nil {t.Fatalf("Failed to parse time %s: %v", s, err)}return parsed}
package utilsimport ("time""github.com/sixdouglas/suncalc")// AstronomicalData contains calculated astronomical data for a recordingtype AstronomicalData struct {SolarNight bool // True if recording midpoint is between sunset and sunriseCivilNight bool // True if recording midpoint is between dusk and dawn (6° below horizon)MoonPhase float64 // 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last Quarter}// CalculateAstronomicalData calculates astronomical data for a recording.// Uses the recording MIDPOINT time (not start time) for calculations.//// Parameters:// - timestampUTC: Recording start time in UTC// - durationSec: Recording duration in seconds// - lat, lon: Location coordinates in decimal degrees//// Returns:// - solarNight: true if recording midpoint is between sunset and sunrise// - civilNight: true if recording midpoint is between dusk and dawn// - moonPhase: 0.00-1.00 representing moon phase (0=New, 0.5=Full)func CalculateAstronomicalData(timestampUTC time.Time,durationSec float64,lat, lon float64,) AstronomicalData {// Calculate recording MIDPOINT (not start time)midpoint := timestampUTC.Add(time.Duration(durationSec/2) * time.Second)// Get solar times for midpoint datetimes := suncalc.GetTimes(midpoint, lat, lon)// Solar night: between sunset and sunrise// Note: Handle day/night transitions properlysunrise := times[suncalc.Sunrise].Valuesunset := times[suncalc.Sunset].ValuesolarNight := isBetweenSunTimes(midpoint, sunset, sunrise)// Civil night: between dusk and dawn (6° below horizon)dawn := times[suncalc.Dawn].Valuedusk := times[suncalc.Dusk].ValuecivilNight := isBetweenSunTimes(midpoint, dusk, dawn)// Moon phase: 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last QuartermoonIllum := suncalc.GetMoonIllumination(midpoint)moonPhase := moonIllum.Phasereturn AstronomicalData{SolarNight: solarNight,CivilNight: civilNight,MoonPhase: moonPhase,}}// isBetweenSunTimes determines if a time is between sunset/dusk and sunrise/dawn// Handles the case where the night period crosses midnightfunc isBetweenSunTimes(t, evening, morning time.Time) bool {// If evening time is before morning time (normal case: both on same day)// Then we're NOT in night period (daytime)if evening.Before(morning) {return false}// Otherwise, night period crosses midnight// Night is: after evening OR before morningreturn t.After(evening) || t.Before(morning)}// CalculateMidpointTime calculates the midpoint time of a recordingfunc CalculateMidpointTime(startTime time.Time, durationSec float64) time.Time {return startTime.Add(time.Duration(durationSec/2) * time.Second)}
package astroimport ("testing""time")// Test location: Auckland, New Zealand (approx coordinates)var testLocationAuckland = struct {lat float64lon float64}{lat: -36.8485,lon: 174.7633,}func TestCalculateAstronomicalData(t *testing.T) {tests := []struct {name stringtimestamp stringduration float64lat, lon float64wantNoSNight bool // if true, assert SolarNight=falsewantNoCNight bool // if true, assert CivilNight=false}{{name: "valid moon phase range", timestamp: "2024-06-15T12:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "no solar night during daytime", timestamp: "2024-12-15T00:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon, wantNoSNight: true, wantNoCNight: true},{name: "short duration", timestamp: "2024-06-15T10:00:00Z", duration: 30.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "long duration", timestamp: "2024-06-15T10:00:00Z", duration: 3600.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "midpoint calculation", timestamp: "2024-06-15T10:00:00Z", duration: 7200.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "different location", timestamp: "2024-06-15T12:00:00Z", duration: 60.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "very short duration", timestamp: "2024-06-15T12:00:00Z", duration: 0.1, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},{name: "very long duration", timestamp: "2024-06-15T12:00:00Z", duration: 86400.0, lat: testLocationAuckland.lat, lon: testLocationAuckland.lon},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {ts := parseTime(t, tt.timestamp)result := CalculateAstronomicalData(ts, tt.duration, tt.lat, tt.lon)if result.MoonPhase < 0 || result.MoonPhase > 1 {t.Errorf("MoonPhase out of range: got %f, want 0-1", result.MoonPhase)}if tt.wantNoSNight && result.SolarNight {t.Error("Expected SolarNight to be false")}if tt.wantNoCNight && result.CivilNight {t.Error("Expected CivilNight to be false")}})}}func TestBooleanLogicValidation(t *testing.T) {t.Run("should never return invalid values for valid inputs", func(t *testing.T) {testCases := []string{"2024-06-15T06:00:00Z", // Dawn/dusk time"2024-06-15T12:00:00Z", // Midday/midnight"2024-06-15T18:00:00Z", // Evening/morning"2024-12-15T06:00:00Z", // Summer dawn/dusk"2024-12-15T12:00:00Z", // Summer midday/midnight"2024-12-15T18:00:00Z", // Summer evening/morning}for _, timestamp := range testCases {t.Run(timestamp, func(t *testing.T) {ts := parseTime(t, timestamp)result := CalculateAstronomicalData(ts, 60, testLocationAuckland.lat, testLocationAuckland.lon)// These should be proper boolean types_ = result.SolarNight_ = result.CivilNight// MoonPhase should be in valid rangeif result.MoonPhase < 0 || result.MoonPhase > 1 {t.Errorf("MoonPhase out of range: got %f, want 0-1", result.MoonPhase)}})}})t.Run("should return false for daytime recordings", func(t *testing.T) {// Test a known daytime period in Auckland (summer midday UTC)summerMidday := parseTime(t, "2024-12-15T00:30:00Z") // Should be daytime in Aucklandduration := 60.0result := CalculateAstronomicalData(summerMidday, duration, testLocationAuckland.lat, testLocationAuckland.lon)// The key test: false values should remain falseif result.SolarNight && result.CivilNight {// This would be unexpected during middayt.Logf("Note: Both SolarNight and CivilNight are true (may be valid depending on season)")}})t.Run("should return true for nighttime recordings", func(t *testing.T) {// Test a known nighttime period in Auckland (winter midnight UTC)winterMidnight := parseTime(t, "2024-06-15T12:30:00Z") // Should be nighttime in Aucklandduration := 60.0result := CalculateAstronomicalData(winterMidnight, duration, testLocationAuckland.lat, testLocationAuckland.lon)// The key test: true values should remain true_ = result.SolarNight_ = result.CivilNight})}func TestCalculateMidpointTime(t *testing.T) {t.Run("should calculate midpoint correctly", func(t *testing.T) {startTime := parseTime(t, "2024-06-15T10:00:00Z")duration := 3600.0 // 1 hourmidpoint := CalculateMidpointTime(startTime, duration)expected := parseTime(t, "2024-06-15T10:30:00Z")if !midpoint.Equal(expected) {t.Errorf("Midpoint incorrect: got %v, want %v", midpoint, expected)}})t.Run("should handle short durations", func(t *testing.T) {startTime := parseTime(t, "2024-06-15T10:00:00Z")duration := 10.0 // 10 secondsmidpoint := CalculateMidpointTime(startTime, duration)expected := parseTime(t, "2024-06-15T10:00:05Z")if !midpoint.Equal(expected) {t.Errorf("Midpoint incorrect: got %v, want %v", midpoint, expected)}})}// Helper function to parse time stringsfunc parseTime(t *testing.T, s string) time.Time {t.Helper()parsed, err := time.Parse(time.RFC3339, s)if err != nil {t.Fatalf("Failed to parse time %s: %v", s, err)}return parsed}
package astroimport ("time""github.com/sixdouglas/suncalc")// AstronomicalData contains calculated astronomical data for a recordingtype AstronomicalData struct {SolarNight bool // True if recording midpoint is between sunset and sunriseCivilNight bool // True if recording midpoint is between dusk and dawn (6° below horizon)MoonPhase float64 // 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last Quarter}// CalculateAstronomicalData calculates astronomical data for a recording.// Uses the recording MIDPOINT time (not start time) for calculations.//// Parameters:// - timestampUTC: Recording start time in UTC// - durationSec: Recording duration in seconds// - lat, lon: Location coordinates in decimal degrees//// Returns:// - solarNight: true if recording midpoint is between sunset and sunrise// - civilNight: true if recording midpoint is between dusk and dawn// - moonPhase: 0.00-1.00 representing moon phase (0=New, 0.5=Full)func CalculateAstronomicalData(timestampUTC time.Time,durationSec float64,lat, lon float64,) AstronomicalData {// Calculate recording MIDPOINT (not start time)midpoint := timestampUTC.Add(time.Duration(durationSec/2) * time.Second)// Get solar times for midpoint datetimes := suncalc.GetTimes(midpoint, lat, lon)// Solar night: between sunset and sunrise// Note: Handle day/night transitions properlysunrise := times[suncalc.Sunrise].Valuesunset := times[suncalc.Sunset].ValuesolarNight := isBetweenSunTimes(midpoint, sunset, sunrise)// Civil night: between dusk and dawn (6° below horizon)dawn := times[suncalc.Dawn].Valuedusk := times[suncalc.Dusk].ValuecivilNight := isBetweenSunTimes(midpoint, dusk, dawn)// Moon phase: 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last QuartermoonIllum := suncalc.GetMoonIllumination(midpoint)moonPhase := moonIllum.Phasereturn AstronomicalData{SolarNight: solarNight,CivilNight: civilNight,MoonPhase: moonPhase,}}// isBetweenSunTimes determines if a time is between sunset/dusk and sunrise/dawn// Handles the case where the night period crosses midnightfunc isBetweenSunTimes(t, evening, morning time.Time) bool {// If evening time is before morning time (normal case: both on same day)// Then we're NOT in night period (daytime)if evening.Before(morning) {return false}// Otherwise, night period crosses midnight// Night is: after evening OR before morningreturn t.After(evening) || t.Before(morning)}// CalculateMidpointTime calculates the midpoint time of a recordingfunc CalculateMidpointTime(startTime time.Time, durationSec float64) time.Time {return startTime.Add(time.Duration(durationSec/2) * time.Second)}
- **Created `astro/` package** with `astronomical.go` from `utils/`:- `AstronomicalData`, `CalculateAstronomicalData`, `CalculateMidpointTime`- Standalone — only depends on stdlib `time` and `suncalc`- Updated callers: `tools/calls/isnight.go`, `tools/import/cluster_import.go`, `wav/file_import.go`- `wav/file_import.go` now imports both `astro/` and `utils/`- `utils/` shrinks from 1,365 → 1,287 LOC (12 → 11 non-test files)