pat/internal/propagation/voacap/encode.go
Martin Hebnes Pedersen d73c5ec057 Fix VOACAP midnight edgecase
VOACAP uses 1-24 instead of 0-23 to represent hours.
2025-09-26 15:01:34 +02:00

165 lines
5.5 KiB
Go

package voacap
import (
"cmp"
"fmt"
"io"
"math"
"time"
"github.com/pd0mz/go-maidenhead"
)
// EncodeParams holds the input parameters for a VOACAP prediction.
type EncodeParams struct {
DateTime time.Time
From string
To string
SSN int
TransmitPower int
MinSNR int
Frequency float64 // Frequency in MHz
MinTakeoffAngle float64 // Minimum takeoff angle in degrees
MultipathDelay float64 // Multipath delay in ms
MultipathTolerance float64 // Multipath tolerance in dB
LocalNoise float64 // Local noise at 3 MHz in -dBw/Hz
IncludeMUF bool // Whether to include MUF in prediction output
}
// Encode writes the input data for a VOACAP prediction to the given writer.
func Encode(w io.Writer, params EncodeParams) error {
from, err := maidenhead.ParseLocator(params.From)
if err != nil {
return fmt.Errorf("error converting 'from' Maidenhead locator: %w", err)
}
to, err := maidenhead.ParseLocator(params.To)
if err != nil {
return fmt.Errorf("error converting 'to' Maidenhead locator: %w", err)
}
// VOACAP expects time in UTC
params.DateTime = params.DateTime.UTC()
writeCommentCard(w, fmt.Sprintf("%.1f MHz, %s -> %s @ %s", params.Frequency, params.From, params.To, params.DateTime.Truncate(time.Second).Format(time.RFC3339)))
writeLineMaxCard(w, 55)
writeCoeffsCard(w, "CCIR")
writeTimeCard(w, params.DateTime.Hour(), params.DateTime.Hour(), 1, 1)
writeMonthCard(w, params.DateTime.Year(), params.DateTime.Month())
writeSunspotCard(w, params.SSN)
writeLabelCard(w, params.From, params.To)
writeCircuitCard(w, from.Latitude, from.Longitude, to.Latitude, to.Longitude, 0)
// Default values from express-params.md if not specified
minTakeoffAngle := params.MinTakeoffAngle
if minTakeoffAngle == 0 {
minTakeoffAngle = 15.0
}
multipathDelay := params.MultipathDelay
if multipathDelay == 0 {
multipathDelay = 0.1
}
multipathTolerance := params.MultipathTolerance
if multipathTolerance == 0 {
multipathTolerance = 0.5
}
localNoise := params.LocalNoise
if localNoise == 0 {
localNoise = 145.0
}
writeSystemCard(w, float64(params.MinSNR), minTakeoffAngle, multipathDelay, multipathTolerance, localNoise)
writeFprobCard(w)
writeAntennaCard(w, 1, 1, 2, 30, params.Frequency, "", 90.0, float64(params.TransmitPower))
writeAntennaCard(w, 2, 2, 2, 30, params.Frequency, "", 270.0, float64(params.TransmitPower))
writeFrequencyCard(w, params.Frequency)
writeMethodCard(w, 30, 0)
writeExecuteCard(w)
writeQuitCard(w)
return nil
}
func getLatHemi(lat float64) string {
if lat < 0 {
return "S"
}
return "N"
}
func getLonHemi(lon float64) string {
if lon < 0 {
return "W"
}
return "E"
}
func writeCommentCard(w io.Writer, comment string) {
fmt.Fprintf(w, "COMMENT %-74s\n", comment)
}
func writeLineMaxCard(w io.Writer, maxLines int) {
fmt.Fprintf(w, "LINEMAX %5d\n", maxLines)
}
func writeCoeffsCard(w io.Writer, coeffs string) {
fmt.Fprintf(w, "COEFFS %-4s\n", coeffs)
}
func writeTimeCard(w io.Writer, start, end, step, mode int) {
start, end = cmp.Or(start, 24), cmp.Or(end, 24) // Midnight hour is represented as 24
fmt.Fprintf(w, "TIME %5d%5d%5d%5d\n", start, end, step, mode)
}
func writeMonthCard(w io.Writer, year int, month time.Month) {
fmt.Fprintf(w, "MONTH %5d%5.2f\n", year, float64(month))
}
func writeSunspotCard(w io.Writer, sunspotNumber int) {
fmt.Fprintf(w, "SUNSPOT %5.2f\n", float64(sunspotNumber))
}
func writeLabelCard(w io.Writer, txName, rxName string) {
fmt.Fprintf(w, "LABEL %-20s%-20s\n", txName, rxName)
}
func writeCircuitCard(w io.Writer, txLat, txLon, rxLat, rxLon float64, path int) {
fmt.Fprintf(w, "CIRCUIT %5.2f%s%9.2f%s%9.2f%s%9.2f%s S %5d\n",
math.Abs(txLat), getLatHemi(txLat),
math.Abs(txLon), getLonHemi(txLon),
math.Abs(rxLat), getLatHemi(rxLat),
math.Abs(rxLon), getLonHemi(rxLon),
path)
}
func writeSystemCard(w io.Writer, requiredSnr, minTakeoffAngle, multipathDelay, multipathTolerance, localNoise float64) {
// Parameter order according to VOACAP documentation:
// fixed-factor, local-noise, multipath-delay, min-takeoff-angle, required-SNR, power-tolerance, delay-tolerance
//
// NOTE: There appears to be a mismatch in VOACAP where it shows "Minimum Angle= 0.100 degrees" in the output
// despite us setting minTakeoffAngle to 15.0. This suggests that VOACAP is incorrectly using the
// multipathDelay value (0.10) as the minimum angle in its output header. However, the actual
// calculations seem to be using the correct takeoff angle value, as we can see in the TANGLE output rows.
fmt.Fprintf(w, "SYSTEM %5.2f%5.0f%5.2f%5.0f%5.2f%5.2f%5.2f\n", 1.0, localNoise, multipathDelay, minTakeoffAngle, requiredSnr, multipathTolerance, multipathDelay)
}
func writeFprobCard(w io.Writer) {
fmt.Fprintf(w, "FPROB %5.2f%5.2f%5.2f%5.2f\n", 1.0, 1.0, 1.0, 0.0)
}
func writeAntennaCard(w io.Writer, antType, id, minFreq, maxFreq int, freqMHz float64, antFile string, bearing, powerW float64) {
powerKW := powerW / 1000.0
fmt.Fprintf(w, "ANTENNA %5d%5d%5d%5d%10.3f[%-21s]%5.1f%10.4f\n", antType, id, minFreq, maxFreq, freqMHz, antFile, bearing, powerKW)
}
func writeFrequencyCard(w io.Writer, freqMHz float64) {
fmt.Fprintf(w, "FREQUENCY %5.3f\n", freqMHz)
}
func writeMethodCard(w io.Writer, method, spec int) {
fmt.Fprintf(w, "METHOD %5d%5d\n", method, spec)
}
func writeExecuteCard(w io.Writer) {
fmt.Fprintln(w, "EXECUTE")
}
func writeQuitCard(w io.Writer) {
fmt.Fprintln(w, "QUIT")
}