fifth phase of utils refactor, astro/

quietlight
May 18, 2026, 11:39 PM
2MZO5RDB67LA3ZM752XPJTN3IYGJL3M4UXGPUQVPVQ3CT2ZLFRFAC

Dependencies

  • [2] PXQDGTR5 fourth phase of utils refactor, spectrogram/
  • [3] ZTOMARKZ fix lint test fails
  • [4] Q4JPMGET fixed tests
  • [5] 3DVPQOKB big tidy up of tools/
  • [6] TUC452XH new util shared by 3 cmd's needing location
  • [7] JZRF7OBJ refactor to get db omports out of utils, but still have failing tests, may need updating
  • [8] N57PNZPF second phase of utils refactor, audio/
  • [9] NQPVZ3PP first phase of utils refactor, all realted to db interfaces
  • [10] 43TMU2JO more tests, glm much better than claude
  • [11] A6MCX2V6 emptied audio/ and moved files into testdata folders
  • [12] KZKLAINJ run out of space on nest, cleaned out
  • [13] P4CJMBYK added first version of --bandpass flag to calls classify, work to do
  • [14] FCCJNYCV more tests for utils/
  • [15] RUVJ3V4N cyclo to 14 now
  • [16] XU7FTYK3 third phase of utils refactor, wav/
  • [17] VU3KBTQ6 more tests
  • [18] LBWQJEDH minor refactor and more tests for utils/
  • [19] ZKLAOPUR fix event logging
  • [*] SJN7IKIV

Change contents

  • edit in wav/file_import.go at line 8
    [2.3599]
    [2.3599]
    "skraak/astro"
  • replacement in wav/file_import.go at line 73
    [2.5752][2.5752:5791]()
    AstroData utils.AstronomicalData
    [2.5752]
    [2.5791]
    AstroData astro.AstronomicalData
  • replacement in wav/file_import.go at line 100
    [2.6683][2.6683:6730]()
    astroData := utils.CalculateAstronomicalData(
    [2.6683]
    [2.6730]
    astroData := astro.CalculateAstronomicalData(
  • file deletion: astronomical_test.go (----------)
    [3.1][3.224299:224343](),[3.224343][3.216340:216340]()
    package utils
    import (
    "testing"
    "time"
    )
    // Test location: Auckland, New Zealand (approx coordinates)
    var testLocationAuckland = struct {
    lat float64
    lon float64
    }{
    lat: -36.8485,
    lon: 174.7633,
    }
    func TestCalculateAstronomicalData(t *testing.T) {
    tests := []struct {
    name string
    timestamp string
    duration float64
    lat, lon float64
    wantNoSNight bool // if true, assert SolarNight=false
    wantNoCNight 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 range
    if 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 Auckland
    duration := 60.0
    result := CalculateAstronomicalData(summerMidday, duration, testLocationAuckland.lat, testLocationAuckland.lon)
    // The key test: false values should remain false
    if result.SolarNight && result.CivilNight {
    // This would be unexpected during midday
    t.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 Auckland
    duration := 60.0
    result := 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 hour
    midpoint := 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 seconds
    midpoint := 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 strings
    func 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 (----------)
    [3.1][3.227098:227137](),[3.227137][3.224345:224345]()
    package utils
    import (
    "time"
    "github.com/sixdouglas/suncalc"
    )
    // AstronomicalData contains calculated astronomical data for a recording
    type AstronomicalData struct {
    SolarNight bool // True if recording midpoint is between sunset and sunrise
    CivilNight 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 date
    times := suncalc.GetTimes(midpoint, lat, lon)
    // Solar night: between sunset and sunrise
    // Note: Handle day/night transitions properly
    sunrise := times[suncalc.Sunrise].Value
    sunset := times[suncalc.Sunset].Value
    solarNight := isBetweenSunTimes(midpoint, sunset, sunrise)
    // Civil night: between dusk and dawn (6° below horizon)
    dawn := times[suncalc.Dawn].Value
    dusk := times[suncalc.Dusk].Value
    civilNight := isBetweenSunTimes(midpoint, dusk, dawn)
    // Moon phase: 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last Quarter
    moonIllum := suncalc.GetMoonIllumination(midpoint)
    moonPhase := moonIllum.Phase
    return 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 midnight
    func 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 morning
    return t.After(evening) || t.Before(morning)
    }
    // CalculateMidpointTime calculates the midpoint time of a recording
    func 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
    [3.17934]
    [3.17934]
    "skraak/astro"
  • replacement in tools/import/cluster_import.go at line 271
    [3.25806][3.25806:25853]()
    astroData := utils.CalculateAstronomicalData(
    [3.25806]
    [3.25853]
    astroData := astro.CalculateAstronomicalData(
  • edit in tools/calls/isnight.go at line 10
    [3.69799]
    [3.69799]
    "skraak/astro"
  • replacement in tools/calls/isnight.go at line 73
    [3.71920][3.71920:71967]()
    astroData := utils.CalculateAstronomicalData(
    [3.71920]
    [3.71967]
    astroData := astro.CalculateAstronomicalData(
  • replacement in tools/calls/isnight.go at line 81
    [3.72103][3.72103:72189]()
    midpoint := utils.CalculateMidpointTime(tsResult.Timestamp.UTC(), metadata.Duration)
    [3.72103]
    [3.72189]
    midpoint := astro.CalculateMidpointTime(tsResult.Timestamp.UTC(), metadata.Duration)
  • file addition: astro (d--r------)
    [21.1]
  • file addition: astronomical_test.go (----------)
    [0.1]
    package astro
    import (
    "testing"
    "time"
    )
    // Test location: Auckland, New Zealand (approx coordinates)
    var testLocationAuckland = struct {
    lat float64
    lon float64
    }{
    lat: -36.8485,
    lon: 174.7633,
    }
    func TestCalculateAstronomicalData(t *testing.T) {
    tests := []struct {
    name string
    timestamp string
    duration float64
    lat, lon float64
    wantNoSNight bool // if true, assert SolarNight=false
    wantNoCNight 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 range
    if 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 Auckland
    duration := 60.0
    result := CalculateAstronomicalData(summerMidday, duration, testLocationAuckland.lat, testLocationAuckland.lon)
    // The key test: false values should remain false
    if result.SolarNight && result.CivilNight {
    // This would be unexpected during midday
    t.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 Auckland
    duration := 60.0
    result := 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 hour
    midpoint := 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 seconds
    midpoint := 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 strings
    func 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 astro
    import (
    "time"
    "github.com/sixdouglas/suncalc"
    )
    // AstronomicalData contains calculated astronomical data for a recording
    type AstronomicalData struct {
    SolarNight bool // True if recording midpoint is between sunset and sunrise
    CivilNight 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 date
    times := suncalc.GetTimes(midpoint, lat, lon)
    // Solar night: between sunset and sunrise
    // Note: Handle day/night transitions properly
    sunrise := times[suncalc.Sunrise].Value
    sunset := times[suncalc.Sunset].Value
    solarNight := isBetweenSunTimes(midpoint, sunset, sunrise)
    // Civil night: between dusk and dawn (6° below horizon)
    dawn := times[suncalc.Dawn].Value
    dusk := times[suncalc.Dusk].Value
    civilNight := isBetweenSunTimes(midpoint, dusk, dawn)
    // Moon phase: 0.00=New Moon, 0.25=First Quarter, 0.50=Full Moon, 0.75=Last Quarter
    moonIllum := suncalc.GetMoonIllumination(midpoint)
    moonPhase := moonIllum.Phase
    return 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 midnight
    func 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 morning
    return t.After(evening) || t.Before(morning)
    }
    // CalculateMidpointTime calculates the midpoint time of a recording
    func CalculateMidpointTime(startTime time.Time, durationSec float64) time.Time {
    return startTime.Add(time.Duration(durationSec/2) * time.Second)
    }
  • edit in CHANGELOG.md at line 4
    [3.1198010]
    [2.50113]
    ## [2026-05-19] Extract astro/ package (Phase 5)
  • edit in CHANGELOG.md at line 7
    [2.50114]
    [2.50114]
    - **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)