isnight.go
package calls
import (
"fmt"
"strings"
"time"
"github.com/sixdouglas/suncalc"
"skraak/astro"
"skraak/wav"
)
// IsNightInput defines the input parameters for the isnight tool
type IsNightInput struct {
FilePath string `json:"file_path"`
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Timezone string `json:"timezone,omitempty"`
}
// IsNightOutput defines the output structure for the isnight tool
type IsNightOutput struct {
FilePath string `json:"file_path"`
TimestampUTC string `json:"timestamp_utc"`
SolarNight bool `json:"solar_night"`
CivilNight bool `json:"civil_night"`
DiurnalActive bool `json:"diurnal_active"`
MoonPhase float64 `json:"moon_phase"`
DurationSec float64 `json:"duration_seconds"`
TimestampSrc string `json:"timestamp_source"`
MidpointUTC string `json:"midpoint_utc"`
SunriseUTC string `json:"sunrise_utc,omitempty"`
SunsetUTC string `json:"sunset_utc,omitempty"`
DawnUTC string `json:"dawn_utc,omitempty"`
DuskUTC string `json:"dusk_utc,omitempty"`
}
// IsNight determines if a WAV file was recorded at night based on its
// metadata timestamp and the given GPS coordinates.
//
// Timestamp resolution order:
// 1. AudioMoth comment (timezone embedded)
// 2. Filename timestamp + timezone offset (requires --timezone)
// 3. File modification time (system local time)
func IsNight(input IsNightInput) (IsNightOutput, error) {
var output IsNightOutput
// Step 1: Parse WAV header
metadata, err := wav.ParseWAVHeader(input.FilePath)
if err != nil {
return output, fmt.Errorf("WAV header parsing failed: %w", err)
}
output.DurationSec = metadata.Duration
// Step 2: Resolve timestamp (use file mod time as fallback)
tsResult, err := wav.ResolveTimestamp(metadata, input.FilePath, input.Timezone, true, nil)
if err != nil {
return output, fmt.Errorf("cannot determine recording timestamp: %w", err)
}
// Determine timestamp source label
tsSource := "file_mod_time"
if tsResult.IsAudioMoth {
tsSource = "audiomoth_comment"
} else if wav.HasTimestampFilename(input.FilePath) {
tsSource = "filename"
}
// Step 3: Calculate astronomical data using recording midpoint
astroData := astro.CalculateAstronomicalData(
tsResult.Timestamp.UTC(),
metadata.Duration,
input.Lat,
input.Lng,
)
// Step 4: Get sun event times for informational output
midpoint := astro.CalculateMidpointTime(tsResult.Timestamp.UTC(), metadata.Duration)
sunTimes := suncalc.GetTimes(midpoint, input.Lat, input.Lng)
output.FilePath = input.FilePath
output.TimestampUTC = tsResult.Timestamp.UTC().Format(time.RFC3339)
output.SolarNight = astroData.SolarNight
output.CivilNight = astroData.CivilNight
output.MoonPhase = astroData.MoonPhase
output.TimestampSrc = tsSource
output.MidpointUTC = midpoint.Format(time.RFC3339)
populateSunTimes(&output, sunTimes, midpoint)
return output, nil
}
// sunTimeUTC returns the UTC RFC3339 string for a suncalc event, or "" if absent/zero.
func sunTimeUTC(sunTimes map[suncalc.DayTimeName]suncalc.DayTime, name suncalc.DayTimeName) string {
if entry, ok := sunTimes[name]; ok && !entry.Value.IsZero() {
return entry.Value.UTC().Format(time.RFC3339)
}
return ""
}
// populateSunTimes fills in sun event times and diurnal status from suncalc results.
func populateSunTimes(output *IsNightOutput, sunTimes map[suncalc.DayTimeName]suncalc.DayTime, midpoint time.Time) {
// Diurnal: midpoint is between dawn and sunset
if dawn, ok := sunTimes[suncalc.Dawn]; ok && !dawn.Value.IsZero() {
if sunset, ok := sunTimes[suncalc.Sunset]; ok && !sunset.Value.IsZero() {
output.DiurnalActive = !midpoint.Before(dawn.Value) && !midpoint.After(sunset.Value)
}
}
output.SunriseUTC = sunTimeUTC(sunTimes, suncalc.Sunrise)
output.SunsetUTC = sunTimeUTC(sunTimes, suncalc.Sunset)
output.DawnUTC = sunTimeUTC(sunTimes, suncalc.Dawn)
output.DuskUTC = sunTimeUTC(sunTimes, suncalc.Dusk)
}
// String returns a human-readable summary of the isnight result
func (o IsNightOutput) String() string {
var sb strings.Builder
fmt.Fprintf(&sb, "File: %s\n", o.FilePath)
fmt.Fprintf(&sb, "Timestamp (UTC): %s\n", o.TimestampUTC)
fmt.Fprintf(&sb, "Midpoint (UTC): %s\n", o.MidpointUTC)
fmt.Fprintf(&sb, "Duration: %.1f seconds\n", o.DurationSec)
fmt.Fprintf(&sb, "Source: %s\n", o.TimestampSrc)
fmt.Fprintf(&sb, "Solar night: %v\n", o.SolarNight)
fmt.Fprintf(&sb, "Civil night: %v\n", o.CivilNight)
fmt.Fprintf(&sb, "Moon phase: %.2f\n", o.MoonPhase)
if o.SunriseUTC != "" {
fmt.Fprintf(&sb, "Sunrise (UTC): %s\n", o.SunriseUTC)
}
if o.SunsetUTC != "" {
fmt.Fprintf(&sb, "Sunset (UTC): %s\n", o.SunsetUTC)
}
if o.DawnUTC != "" {
fmt.Fprintf(&sb, "Dawn (UTC): %s\n", o.DawnUTC)
}
if o.DuskUTC != "" {
fmt.Fprintf(&sb, "Dusk (UTC): %s\n", o.DuskUTC)
}
return sb.String()
}