fifth phase of utils refactor, astro/
Dependencies
- [2]
PXQDGTR5fourth phase of utils refactor, spectrogram/ - [3]
ZTOMARKZfix lint test fails - [4]
Q4JPMGETfixed tests - [5]
3DVPQOKBbig tidy up of tools/ - [6]
TUC452XHnew util shared by 3 cmd's needing location - [7]
JZRF7OBJrefactor to get db omports out of utils, but still have failing tests, may need updating - [8]
N57PNZPFsecond phase of utils refactor, audio/ - [9]
NQPVZ3PPfirst phase of utils refactor, all realted to db interfaces - [10]
43TMU2JOmore tests, glm much better than claude - [11]
A6MCX2V6emptied audio/ and moved files into testdata folders - [12]
KZKLAINJrun out of space on nest, cleaned out - [13]
P4CJMBYKadded first version of --bandpass flag to calls classify, work to do - [14]
FCCJNYCVmore tests for utils/ - [15]
RUVJ3V4Ncyclo to 14 now - [16]
XU7FTYK3third phase of utils refactor, wav/ - [17]
VU3KBTQ6more tests - [18]
LBWQJEDHminor refactor and more tests for utils/ - [19]
ZKLAOPURfix event logging - [*]
SJN7IKIV
Change contents
- edit in wav/file_import.go at line 8
"skraak/astro" - replacement in wav/file_import.go at line 73
AstroData utils.AstronomicalDataAstroData astro.AstronomicalData - replacement in wav/file_import.go at line 100
astroData := utils.CalculateAstronomicalData(astroData := astro.CalculateAstronomicalData( - file deletion: astronomical_test.go
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} - file deletion: astronomical.go
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)} - edit in tools/import/cluster_import.go at line 11
"skraak/astro" - replacement in tools/import/cluster_import.go at line 271
astroData := utils.CalculateAstronomicalData(astroData := astro.CalculateAstronomicalData( - edit in tools/calls/isnight.go at line 10
"skraak/astro" - replacement in tools/calls/isnight.go at line 73
astroData := utils.CalculateAstronomicalData(astroData := astro.CalculateAstronomicalData( - replacement in tools/calls/isnight.go at line 81
midpoint := utils.CalculateMidpointTime(tsResult.Timestamp.UTC(), metadata.Duration)midpoint := astro.CalculateMidpointTime(tsResult.Timestamp.UTC(), metadata.Duration) - file addition: astro[21.1]
- file addition: astronomical_test.go[0.1]
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} - file addition: astronomical.go[0.1]
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)} - edit in CHANGELOG.md at line 4
## [2026-05-19] Extract astro/ package (Phase 5) - edit in CHANGELOG.md at line 7
- **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)