first commit
This commit is contained in:
92
backend/doppler/calculator.go
Normal file
92
backend/doppler/calculator.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package doppler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"SatMaster/backend/propagator"
|
||||
)
|
||||
|
||||
const (
|
||||
SpeedOfLight = 299792.458 // km/s
|
||||
)
|
||||
|
||||
// Calculator computes Doppler-shifted frequencies.
|
||||
type Calculator struct {
|
||||
mu sync.RWMutex
|
||||
nominalDown float64 // Hz
|
||||
nominalUp float64 // Hz
|
||||
obsLat float64
|
||||
obsLon float64
|
||||
obsAlt float64
|
||||
}
|
||||
|
||||
func NewCalculator() *Calculator {
|
||||
return &Calculator{}
|
||||
}
|
||||
|
||||
func (c *Calculator) SetObserver(lat, lon, altM float64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.obsLat = lat
|
||||
c.obsLon = lon
|
||||
c.obsAlt = altM
|
||||
}
|
||||
|
||||
func (c *Calculator) SetNominal(downHz, upHz float64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.nominalDown = downHz
|
||||
c.nominalUp = upHz
|
||||
}
|
||||
|
||||
// Correct computes Doppler-corrected downlink and uplink frequencies.
|
||||
// Returns (downlinkHz, uplinkHz).
|
||||
func (c *Calculator) Correct(pos *propagator.SatPosition, obs propagator.Observer, _ time.Time) (float64, float64) {
|
||||
c.mu.RLock()
|
||||
nomDown := c.nominalDown
|
||||
nomUp := c.nominalUp
|
||||
c.mu.RUnlock()
|
||||
|
||||
if nomDown == 0 && nomUp == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if pos == nil {
|
||||
return nomDown, nomUp
|
||||
}
|
||||
|
||||
// Range rate in km/s (positive = receding, negative = approaching)
|
||||
rr := pos.RangeRate
|
||||
|
||||
// Doppler factor: f_received = f_nominal * (1 - v/c)
|
||||
// For downlink: satellite is the transmitter
|
||||
dopplerFactor := 1.0 - rr/SpeedOfLight
|
||||
|
||||
correctedDown := nomDown * dopplerFactor
|
||||
// For uplink: we pre-correct in reverse so the satellite receives nominal
|
||||
correctedUp := nomUp / dopplerFactor
|
||||
|
||||
return correctedDown, correctedUp
|
||||
}
|
||||
|
||||
// ShiftHz returns the Doppler shift in Hz for a given nominal frequency.
|
||||
func ShiftHz(nominalHz, rangeRateKmS float64) float64 {
|
||||
return nominalHz * (-rangeRateKmS / SpeedOfLight)
|
||||
}
|
||||
|
||||
// RangeRateFromPositions computes range rate from two consecutive positions.
|
||||
func RangeRateFromPositions(prev, curr *propagator.SatPosition, dt float64) float64 {
|
||||
prevRange := prev.Range
|
||||
currRange := curr.Range
|
||||
return (currRange - prevRange) / dt
|
||||
}
|
||||
|
||||
// FormatShift formats a Doppler shift in Hz for display.
|
||||
func FormatShift(shiftHz float64) string {
|
||||
if math.Abs(shiftHz) >= 1000 {
|
||||
return fmt.Sprintf("%+.2f kHz", shiftHz/1000)
|
||||
}
|
||||
return fmt.Sprintf("%+.0f Hz", shiftHz)
|
||||
}
|
||||
241
backend/flexradio/client.go
Normal file
241
backend/flexradio/client.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package flexradio
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client manages a TCP connection to FlexRadio SmartSDR API.
|
||||
// For satellite operation:
|
||||
// - Slice A (index 0) = RX downlink (436 MHz for SO-50)
|
||||
// - Slice B (index 1) = TX uplink (145 MHz for SO-50)
|
||||
type Client struct {
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
scanner *bufio.Scanner
|
||||
connected atomic.Bool
|
||||
seqNum uint32
|
||||
|
||||
rxSlice int // Slice index for RX (downlink) — default 0 = Slice A
|
||||
txSlice int // Slice index for TX (uplink) — default 1 = Slice B
|
||||
lastDownHz float64 // Last sent RX frequency (dead-band)
|
||||
lastUpHz float64 // Last sent TX frequency (dead-band)
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{rxSlice: 0, txSlice: 1} // defaults — always overridden by SetSliceConfig on connect
|
||||
}
|
||||
|
||||
// Connect establishes TCP connection to FlexRadio SmartSDR API (port 4992).
|
||||
func (c *Client) Connect(host string, port int) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.connected.Load() {
|
||||
c.disconnectLocked()
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connexion FlexRadio %s : %w", addr, err)
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
c.scanner = bufio.NewScanner(conn)
|
||||
c.connected.Store(true)
|
||||
|
||||
go c.readLoop()
|
||||
|
||||
log.Printf("[FlexRadio] Connecté à %s (RX=Slice %s, TX=Slice %s)",
|
||||
addr, sliceLetter(c.rxSlice), sliceLetter(c.txSlice))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the connection.
|
||||
func (c *Client) Disconnect() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.disconnectLocked()
|
||||
}
|
||||
|
||||
func (c *Client) disconnectLocked() {
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.connected.Store(false)
|
||||
log.Println("[FlexRadio] Déconnecté")
|
||||
}
|
||||
|
||||
func (c *Client) IsConnected() bool {
|
||||
return c.connected.Load()
|
||||
}
|
||||
|
||||
// SetFrequency applies Doppler-corrected frequencies to both slices.
|
||||
// downHz = corrected RX frequency → rxSlice (default Slice A)
|
||||
// upHz = corrected TX frequency → txSlice (default Slice B)
|
||||
// Only sends command if frequency changed by more than 1 Hz (dead-band)
|
||||
func (c *Client) SetFrequency(downHz, upHz float64) error {
|
||||
if !c.connected.Load() {
|
||||
return fmt.Errorf("non connecté")
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
// RX downlink — use "slice t" (tune) command
|
||||
if downHz > 0 {
|
||||
downMHz := downHz / 1e6
|
||||
if abs(downHz-c.lastDownHz) >= 1.0 { // 1 Hz dead-band
|
||||
cmd := fmt.Sprintf("slice t %d %.6f", c.rxSlice, downMHz)
|
||||
if err := c.sendCommand(cmd); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
c.lastDownHz = downHz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TX uplink — use "slice t" (tune) command
|
||||
if upHz > 0 {
|
||||
upMHz := upHz / 1e6
|
||||
if abs(upHz-c.lastUpHz) >= 1.0 {
|
||||
cmd := fmt.Sprintf("slice t %d %.6f", c.txSlice, upMHz)
|
||||
if err := c.sendCommand(cmd); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
c.lastUpHz = upHz
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("FlexRadio SetFrequency: %v", errs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetDeadband forces next frequency command to be sent regardless of change.
|
||||
func (c *Client) ResetDeadband() {
|
||||
c.lastDownHz = 0
|
||||
c.lastUpHz = 0
|
||||
}
|
||||
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// SetSlices configures which slice indices to use for RX and TX.
|
||||
// Default: rx=0 (Slice A), tx=1 (Slice B)
|
||||
func (c *Client) SetSlices(rxIdx, txIdx int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.rxSlice = rxIdx
|
||||
c.txSlice = txIdx
|
||||
log.Printf("[FlexRadio] Slices configurées: RX=Slice %s, TX=Slice %s",
|
||||
sliceLetter(rxIdx), sliceLetter(txIdx))
|
||||
}
|
||||
|
||||
// GetSlices returns current RX and TX slice indices.
|
||||
func (c *Client) GetSlices() (rx, tx int) {
|
||||
return c.rxSlice, c.txSlice
|
||||
}
|
||||
|
||||
// QuerySlices sends a slice list request to discover available slices.
|
||||
func (c *Client) QuerySlices() error {
|
||||
return c.sendCommand("slice list")
|
||||
}
|
||||
|
||||
// SetMode sets the demodulation mode on RX and/or TX slices.
|
||||
// mode: "usb", "lsb", "cw", "am", "sam", "fm", "nfm", "dfm", "digl", "digu", "rtty"
|
||||
// For satellite: RX and TX usually have the same mode (FM for FM sats, LSB/USB for linear)
|
||||
func (c *Client) SetMode(mode string) error {
|
||||
if !c.connected.Load() {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
var errs []error
|
||||
// Set mode on RX slice
|
||||
if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.rxSlice, mode)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("RX mode: %w", err))
|
||||
}
|
||||
// Set mode on TX slice (same mode for satellite split operation)
|
||||
if err := c.sendCommand(fmt.Sprintf("slice s %d mode=%s", c.txSlice, mode)); err != nil {
|
||||
errs = append(errs, fmt.Errorf("TX mode: %w", err))
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("SetMode: %v", errs)
|
||||
}
|
||||
log.Printf("[FlexRadio] Mode set to %s on slices %s/%s",
|
||||
strings.ToUpper(mode), sliceLetter(c.rxSlice), sliceLetter(c.txSlice))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SatModeToFlex converts a satellite DB mode string to FlexRadio mode.
|
||||
// FM satellites use "fm", linear transponders use "lsb" or "usb".
|
||||
func SatModeToFlex(satMode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(satMode)) {
|
||||
case "FM", "NFM":
|
||||
return "fm"
|
||||
case "LSB":
|
||||
return "lsb"
|
||||
case "USB":
|
||||
return "usb"
|
||||
case "CW", "CW/DATA":
|
||||
return "cw"
|
||||
case "APRS", "DATA", "DIGI":
|
||||
return "digl"
|
||||
case "AM":
|
||||
return "am"
|
||||
default:
|
||||
return "usb" // safe default
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendCommand(cmd string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("non connecté")
|
||||
}
|
||||
|
||||
seq := atomic.AddUint32(&c.seqNum, 1)
|
||||
line := fmt.Sprintf("C%d|%s\n", seq, cmd)
|
||||
|
||||
c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err := fmt.Fprint(c.conn, line)
|
||||
if err != nil {
|
||||
c.connected.Store(false)
|
||||
return fmt.Errorf("envoi commande: %w", err)
|
||||
}
|
||||
log.Printf("[FlexRadio] → %s", strings.TrimSpace(line))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
for c.scanner.Scan() {
|
||||
line := c.scanner.Text()
|
||||
// Log ALL responses for debugging
|
||||
log.Printf("[FlexRadio] ← %s", line)
|
||||
}
|
||||
c.connected.Store(false)
|
||||
log.Println("[FlexRadio] Disconnected")
|
||||
}
|
||||
|
||||
func sliceLetter(idx int) string {
|
||||
letters := []string{"A", "B", "C", "D", "E", "F", "G", "H"}
|
||||
if idx < len(letters) {
|
||||
return letters[idx]
|
||||
}
|
||||
return fmt.Sprintf("%d", idx)
|
||||
}
|
||||
337
backend/propagator/engine.go
Normal file
337
backend/propagator/engine.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package propagator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"SatMaster/backend/tle"
|
||||
|
||||
"github.com/akhenakh/sgp4"
|
||||
)
|
||||
|
||||
const (
|
||||
EarthRadius = 6371.0 // km
|
||||
RadToDeg = 180.0 / math.Pi
|
||||
)
|
||||
|
||||
// SatPosition holds the computed position of a satellite at a given moment.
|
||||
type SatPosition struct {
|
||||
Name string `json:"name"`
|
||||
Latitude float64 `json:"lat"`
|
||||
Longitude float64 `json:"lon"`
|
||||
Altitude float64 `json:"alt"` // km
|
||||
Azimuth float64 `json:"az"` // degrees 0=N
|
||||
Elevation float64 `json:"el"` // degrees above horizon
|
||||
Range float64 `json:"range"` // km from observer
|
||||
RangeRate float64 `json:"rangeRate"` // km/s positive=receding
|
||||
Footprint float64 `json:"footprint"` // km radius on ground
|
||||
}
|
||||
|
||||
// Pass represents one overhead pass of a satellite.
|
||||
type Pass struct {
|
||||
SatName string `json:"satName"`
|
||||
AOS time.Time `json:"aos"`
|
||||
LOS time.Time `json:"los"`
|
||||
MaxEl float64 `json:"maxEl"`
|
||||
MaxElTime time.Time `json:"maxElTime"`
|
||||
AosAz float64 `json:"aosAz"`
|
||||
LosAz float64 `json:"losAz"`
|
||||
MaxElAz float64 `json:"maxElAz"`
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
Points []PassPoint `json:"points"`
|
||||
}
|
||||
|
||||
// PassPoint is one az/el sample in a pass track.
|
||||
type PassPoint struct {
|
||||
Time time.Time `json:"t"`
|
||||
Az float64 `json:"az"`
|
||||
El float64 `json:"el"`
|
||||
}
|
||||
|
||||
// Observer holds the QTH location.
|
||||
type Observer struct {
|
||||
Lat float64 // degrees
|
||||
Lon float64 // degrees
|
||||
Alt float64 // meters above sea level
|
||||
}
|
||||
|
||||
// Engine propagates satellite positions using SGP4.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
observer Observer
|
||||
}
|
||||
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{
|
||||
observer: Observer{Lat: 45.75, Lon: 4.85, Alt: 200}, // Default: Lyon area
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) SetObserver(lat, lon, altM float64) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.observer = Observer{Lat: lat, Lon: lon, Alt: altM}
|
||||
}
|
||||
|
||||
func (e *Engine) ObserverPosition() Observer {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
return e.observer
|
||||
}
|
||||
|
||||
// Position computes the current position of one satellite.
|
||||
func (e *Engine) Position(sat *tle.Satellite, t time.Time) *SatPosition {
|
||||
e.mu.RLock()
|
||||
obs := e.observer
|
||||
e.mu.RUnlock()
|
||||
return computePosition(sat, obs, t)
|
||||
}
|
||||
|
||||
// AllPositions computes positions for all loaded satellites.
|
||||
func (e *Engine) AllPositions(sats []*tle.Satellite, t time.Time) []SatPosition {
|
||||
e.mu.RLock()
|
||||
obs := e.observer
|
||||
e.mu.RUnlock()
|
||||
|
||||
result := make([]SatPosition, 0, len(sats))
|
||||
for _, sat := range sats {
|
||||
if pos := computePosition(sat, obs, t); pos != nil {
|
||||
result = append(result, *pos)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ComputePasses predicts upcoming passes over `hours` hours.
|
||||
func (e *Engine) ComputePasses(sat *tle.Satellite, start time.Time, hours float64) []Pass {
|
||||
e.mu.RLock()
|
||||
obs := e.observer
|
||||
e.mu.RUnlock()
|
||||
|
||||
var passes []Pass
|
||||
end := start.Add(time.Duration(float64(time.Hour) * hours))
|
||||
step := 10 * time.Second
|
||||
t := start
|
||||
|
||||
var inPass bool
|
||||
var cur *Pass
|
||||
|
||||
for t.Before(end) {
|
||||
pos := computePosition(sat, obs, t)
|
||||
if pos == nil {
|
||||
t = t.Add(step)
|
||||
continue
|
||||
}
|
||||
|
||||
if pos.Elevation > 0 && !inPass {
|
||||
inPass = true
|
||||
aosT := bisectAOS(sat, obs, t.Add(-step), t)
|
||||
aosPos := computePosition(sat, obs, aosT)
|
||||
if aosPos == nil {
|
||||
aosPos = pos
|
||||
}
|
||||
cur = &Pass{
|
||||
SatName: sat.Name,
|
||||
AOS: aosT,
|
||||
AosAz: aosPos.Azimuth,
|
||||
Points: []PassPoint{},
|
||||
}
|
||||
}
|
||||
|
||||
if inPass && cur != nil {
|
||||
cur.Points = append(cur.Points, PassPoint{Time: t, Az: pos.Azimuth, El: pos.Elevation})
|
||||
if pos.Elevation > cur.MaxEl {
|
||||
cur.MaxEl = pos.Elevation
|
||||
cur.MaxElTime = t
|
||||
cur.MaxElAz = pos.Azimuth
|
||||
}
|
||||
}
|
||||
|
||||
if pos.Elevation <= 0 && inPass {
|
||||
inPass = false
|
||||
if cur != nil {
|
||||
losT := bisectLOS(sat, obs, t.Add(-step), t)
|
||||
losPos := computePosition(sat, obs, losT)
|
||||
if losPos == nil {
|
||||
losPos = pos
|
||||
}
|
||||
cur.LOS = losT
|
||||
cur.LosAz = losPos.Azimuth
|
||||
cur.Duration = losT.Sub(cur.AOS).Seconds()
|
||||
if cur.MaxEl >= 1.0 {
|
||||
passes = append(passes, *cur)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
|
||||
if pos.Elevation < -15 {
|
||||
step = 30 * time.Second
|
||||
} else {
|
||||
step = 10 * time.Second
|
||||
}
|
||||
t = t.Add(step)
|
||||
}
|
||||
|
||||
sort.Slice(passes, func(i, j int) bool {
|
||||
return passes[i].AOS.Before(passes[j].AOS)
|
||||
})
|
||||
return passes
|
||||
}
|
||||
|
||||
// ─── Core SGP4 computation ────────────────────────────────────────────────────
|
||||
|
||||
// parsedTLE caches the orbital elements for a satellite TLE.
|
||||
// akhenakh/sgp4 ParseTLE accepts the 3-line TLE as a single string.
|
||||
func makeTLEString(sat *tle.Satellite) string {
|
||||
return fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2)
|
||||
}
|
||||
|
||||
func computePosition(sat *tle.Satellite, obs Observer, t time.Time) *SatPosition {
|
||||
// Parse TLE — akhenakh/sgp4 accepts the 3-line block as one string
|
||||
tleObj, err := sgp4.ParseTLE(makeTLEString(sat))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Propagate to time t → ECI state with geodetic fields
|
||||
eciState, err := tleObj.FindPositionAtTime(t)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert ECI to geodetic (lat deg, lon deg, alt km)
|
||||
lat, lon, altKm := eciState.ToGeodetic()
|
||||
|
||||
// Build observer location
|
||||
location := &sgp4.Location{
|
||||
Latitude: obs.Lat, // degrees
|
||||
Longitude: obs.Lon, // degrees
|
||||
Altitude: obs.Alt, // meters
|
||||
}
|
||||
|
||||
// Build state vector for look angle calculation
|
||||
sv := &sgp4.StateVector{
|
||||
X: eciState.Position.X,
|
||||
Y: eciState.Position.Y,
|
||||
Z: eciState.Position.Z,
|
||||
VX: eciState.Velocity.X,
|
||||
VY: eciState.Velocity.Y,
|
||||
VZ: eciState.Velocity.Z,
|
||||
}
|
||||
|
||||
// GetLookAngle uses eciState.DateTime (the propagated time) not t
|
||||
observation, err := sv.GetLookAngle(location, eciState.DateTime)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
la := observation.LookAngles
|
||||
footprint := EarthRadius * math.Acos(EarthRadius/(EarthRadius+altKm))
|
||||
|
||||
// Compute range rate by finite difference (2s) - library value is unreliable
|
||||
rangeRate := finiteRangeRate(tleObj, location, t)
|
||||
|
||||
return &SatPosition{
|
||||
Name: sat.Name,
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
Altitude: altKm,
|
||||
Azimuth: la.Azimuth,
|
||||
Elevation: la.Elevation,
|
||||
Range: la.Range,
|
||||
RangeRate: rangeRate,
|
||||
Footprint: footprint,
|
||||
}
|
||||
}
|
||||
|
||||
// finiteRangeRate computes range rate (km/s) by finite difference over 2 seconds.
|
||||
// Positive = satellite receding, negative = approaching.
|
||||
func finiteRangeRate(tleObj *sgp4.TLE, loc *sgp4.Location, t time.Time) float64 {
|
||||
dt := 2.0 // seconds
|
||||
t2 := t.Add(time.Duration(dt * float64(time.Second)))
|
||||
|
||||
e1, err1 := tleObj.FindPositionAtTime(t)
|
||||
e2, err2 := tleObj.FindPositionAtTime(t2)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
sv1 := &sgp4.StateVector{X: e1.Position.X, Y: e1.Position.Y, Z: e1.Position.Z,
|
||||
VX: e1.Velocity.X, VY: e1.Velocity.Y, VZ: e1.Velocity.Z}
|
||||
sv2 := &sgp4.StateVector{X: e2.Position.X, Y: e2.Position.Y, Z: e2.Position.Z,
|
||||
VX: e2.Velocity.X, VY: e2.Velocity.Y, VZ: e2.Velocity.Z}
|
||||
|
||||
obs1, err1 := sv1.GetLookAngle(loc, e1.DateTime)
|
||||
obs2, err2 := sv2.GetLookAngle(loc, e2.DateTime)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return (obs2.LookAngles.Range - obs1.LookAngles.Range) / dt
|
||||
}
|
||||
|
||||
// bisectAOS finds exact AOS time (precision: 1 second).
|
||||
func bisectAOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time {
|
||||
for hi.Sub(lo) > time.Second {
|
||||
mid := lo.Add(hi.Sub(lo) / 2)
|
||||
pos := computePosition(sat, obs, mid)
|
||||
if pos != nil && pos.Elevation > 0 {
|
||||
hi = mid
|
||||
} else {
|
||||
lo = mid
|
||||
}
|
||||
}
|
||||
return hi
|
||||
}
|
||||
|
||||
// bisectLOS finds exact LOS time (precision: 1 second).
|
||||
func bisectLOS(sat *tle.Satellite, obs Observer, lo, hi time.Time) time.Time {
|
||||
for hi.Sub(lo) > time.Second {
|
||||
mid := lo.Add(hi.Sub(lo) / 2)
|
||||
pos := computePosition(sat, obs, mid)
|
||||
if pos != nil && pos.Elevation > 0 {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
// GroundtrackPoint is a lat/lon position at a given time.
|
||||
type GroundtrackPoint struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Time time.Time `json:"t"`
|
||||
}
|
||||
|
||||
// ComputeGroundtrack returns the future orbit track for the next `minutes` minutes.
|
||||
// Points every 30 seconds. Handles the antimeridian by splitting segments.
|
||||
func (e *Engine) ComputeGroundtrack(sat *tle.Satellite, start time.Time, minutes float64) []GroundtrackPoint {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
var points []GroundtrackPoint
|
||||
end := start.Add(time.Duration(float64(time.Minute) * minutes))
|
||||
step := 30 * time.Second
|
||||
|
||||
// Parse TLE once outside the loop for efficiency
|
||||
tleObj, err := sgp4.ParseTLE(fmt.Sprintf("%s\n%s\n%s", sat.Name, sat.TLE1, sat.TLE2))
|
||||
if err != nil {
|
||||
return points
|
||||
}
|
||||
|
||||
for t := start; t.Before(end); t = t.Add(step) {
|
||||
eciState, err := tleObj.FindPositionAtTime(t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lat, lon, _ := eciState.ToGeodetic()
|
||||
points = append(points, GroundtrackPoint{Lat: lat, Lon: lon, Time: t})
|
||||
}
|
||||
return points
|
||||
}
|
||||
220
backend/rotor/pstrotator.go
Normal file
220
backend/rotor/pstrotator.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package rotor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PstRotatorClient controls PstRotator via UDP XML protocol.
|
||||
// Protocol: send XML to port N, responses come back on port N+1
|
||||
// Format: <AZIMUTH>180.0</AZIMUTH> or <ELEVATION>45.0</ELEVATION>
|
||||
// Multiple commands: <AZIMUTH>180.0</AZIMUTH><ELEVATION>45.0</ELEVATION>
|
||||
type PstRotatorClient struct {
|
||||
mu sync.Mutex
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
connected atomic.Bool
|
||||
lastAz float64
|
||||
lastEl float64
|
||||
azThreshold float64
|
||||
elThreshold float64
|
||||
}
|
||||
|
||||
func NewPstRotatorClient() *PstRotatorClient {
|
||||
return &PstRotatorClient{
|
||||
azThreshold: 5.0, // 5° dead-band for Az
|
||||
elThreshold: 5.0,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) Connect(host string, port int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.connected.Load() {
|
||||
r.disconnectLocked()
|
||||
}
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve UDP addr: %w", err)
|
||||
}
|
||||
|
||||
conn, err := net.DialUDP("udp", nil, addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UDP connect to PstRotator: %w", err)
|
||||
}
|
||||
|
||||
r.conn = conn
|
||||
r.addr = addr
|
||||
r.connected.Store(true)
|
||||
r.lastAz = -999
|
||||
r.lastEl = -999
|
||||
|
||||
log.Printf("[Rotor] Connected to PstRotator at %s:%d", host, port)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) Disconnect() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.disconnectLocked()
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) disconnectLocked() {
|
||||
if r.conn != nil {
|
||||
r.conn.Close()
|
||||
r.conn = nil
|
||||
}
|
||||
r.connected.Store(false)
|
||||
log.Println("[Rotor] Disconnected")
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) IsConnected() bool {
|
||||
return r.connected.Load()
|
||||
}
|
||||
|
||||
// SetAzEl sends azimuth (and optionally elevation) to PstRotator.
|
||||
// El < 0 is treated as azimuth-only (no elevation rotor).
|
||||
func (r *PstRotatorClient) SetAzEl(az, el float64) error {
|
||||
if !r.connected.Load() {
|
||||
return fmt.Errorf("not connected to PstRotator")
|
||||
}
|
||||
|
||||
// Normalize azimuth 0-360
|
||||
az = math.Mod(az, 360.0)
|
||||
if az < 0 {
|
||||
az += 360
|
||||
}
|
||||
// Clamp elevation
|
||||
if el < 0 {
|
||||
el = 0
|
||||
}
|
||||
if el > 90 {
|
||||
el = 90
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Dead-band check (inside mutex — no race on lastAz/lastEl)
|
||||
azChanged := math.Abs(az-r.lastAz) >= r.azThreshold
|
||||
elChanged := math.Abs(el-r.lastEl) >= r.elThreshold
|
||||
|
||||
if !azChanged && !elChanged {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build XML command wrapped in <PST>...</PST>
|
||||
var cmd string
|
||||
if azChanged && elChanged {
|
||||
cmd = fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH><ELEVATION>%.0f</ELEVATION></PST>", az, el)
|
||||
} else if azChanged {
|
||||
cmd = fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH></PST>", az)
|
||||
} else {
|
||||
cmd = fmt.Sprintf("<PST><ELEVATION>%.0f</ELEVATION></PST>", el)
|
||||
}
|
||||
|
||||
log.Printf("[Rotor] UDP → %s", cmd)
|
||||
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := r.conn.Write([]byte(cmd)); err != nil {
|
||||
r.connected.Store(false)
|
||||
return fmt.Errorf("UDP write: %w", err)
|
||||
}
|
||||
|
||||
if azChanged {
|
||||
r.lastAz = az
|
||||
}
|
||||
if elChanged {
|
||||
r.lastEl = el
|
||||
}
|
||||
log.Printf("[Rotor] → AZ=%.1f° EL=%.1f°", az, el)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAzOnly sends only azimuth — for stations with no elevation rotor.
|
||||
func (r *PstRotatorClient) SetAzOnly(az float64) error {
|
||||
if !r.connected.Load() {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
az = math.Mod(az, 360.0)
|
||||
if az < 0 {
|
||||
az += 360
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
// Dead-band check (inside mutex — no race on lastAz)
|
||||
if math.Abs(az-r.lastAz) < r.azThreshold {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("<PST><AZIMUTH>%.0f</AZIMUTH></PST>", az)
|
||||
log.Printf("[Rotor] UDP → %s", cmd)
|
||||
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := r.conn.Write([]byte(cmd)); err != nil {
|
||||
r.connected.Store(false)
|
||||
return fmt.Errorf("UDP write: %w", err)
|
||||
}
|
||||
r.lastAz = az
|
||||
log.Printf("[Rotor] → AZ=%.1f°", az)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopRotor sends stop command.
|
||||
func (r *PstRotatorClient) StopRotor() error {
|
||||
return r.sendRaw("<PST><STOP>1</STOP></PST>")
|
||||
}
|
||||
|
||||
// QueryAzEl requests current position — answer comes back on port+1.
|
||||
func (r *PstRotatorClient) QueryAzEl() error {
|
||||
return r.sendRaw("<AZ?>1</AZ?><EL?>1</EL?>")
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) sendRaw(cmd string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if r.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
log.Printf("[Rotor] UDP → %s", cmd)
|
||||
r.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
n, err := r.conn.Write([]byte(cmd))
|
||||
if err != nil {
|
||||
r.connected.Store(false)
|
||||
return fmt.Errorf("UDP write: %w", err)
|
||||
}
|
||||
log.Printf("[Rotor] UDP sent %d bytes to %s", n, r.addr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetDeadband forces next azimuth command to be sent immediately.
|
||||
func (r *PstRotatorClient) ResetDeadband() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.lastAz = -999
|
||||
r.lastEl = -999
|
||||
}
|
||||
|
||||
func (r *PstRotatorClient) SetThresholds(azDeg, elDeg float64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.azThreshold = azDeg
|
||||
r.elThreshold = elDeg
|
||||
}
|
||||
283
backend/tle/manager.go
Normal file
283
backend/tle/manager.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package tle
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Celestrak amateur satellite TLE feed (primary)
|
||||
PrimaryURL = "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=tle"
|
||||
FallbackURL = "http://tle.pe0sat.nl/kepler/amateur.txt"
|
||||
CacheFile = "satmaster_tle_cache.txt"
|
||||
MaxAgeDays = 3
|
||||
)
|
||||
|
||||
// Satellite holds parsed TLE data.
|
||||
type Satellite struct {
|
||||
Name string
|
||||
TLE1 string
|
||||
TLE2 string
|
||||
}
|
||||
|
||||
// Manager handles TLE fetching, caching, and lookup.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
satellites map[string]*Satellite
|
||||
fetchedAt time.Time
|
||||
cacheDir string
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
cacheDir, _ := os.UserCacheDir()
|
||||
cacheDir = filepath.Join(cacheDir, "SatMaster")
|
||||
os.MkdirAll(cacheDir, 0755)
|
||||
return &Manager{
|
||||
satellites: make(map[string]*Satellite),
|
||||
cacheDir: cacheDir,
|
||||
}
|
||||
}
|
||||
|
||||
// fetchURLResult holds the result of a single URL fetch.
|
||||
type fetchURLResult struct {
|
||||
url string
|
||||
sats map[string]*Satellite
|
||||
raw string
|
||||
err error
|
||||
}
|
||||
|
||||
// FetchAndCache downloads TLE data from all sources in parallel, merges them
|
||||
// (primary takes precedence on duplicates), and saves to disk cache.
|
||||
func (m *Manager) FetchAndCache() error {
|
||||
urls := []string{PrimaryURL, FallbackURL}
|
||||
results := make([]fetchURLResult, len(urls))
|
||||
|
||||
// Fetch all sources concurrently
|
||||
var wg sync.WaitGroup
|
||||
for i, url := range urls {
|
||||
wg.Add(1)
|
||||
go func(i int, url string) {
|
||||
defer wg.Done()
|
||||
log.Printf("[TLE] Fetching from %s", url)
|
||||
data, err := fetchURL(url)
|
||||
if err != nil {
|
||||
log.Printf("[TLE] Failed %s: %v", url, err)
|
||||
results[i] = fetchURLResult{url: url, err: err}
|
||||
return
|
||||
}
|
||||
sats, err := parseTLE(data)
|
||||
if err != nil || len(sats) == 0 {
|
||||
log.Printf("[TLE] Parse failed or empty from %s: %v", url, err)
|
||||
results[i] = fetchURLResult{url: url, err: fmt.Errorf("parse failed")}
|
||||
return
|
||||
}
|
||||
log.Printf("[TLE] Fetched %d satellites from %s", len(sats), url)
|
||||
results[i] = fetchURLResult{url: url, sats: sats, raw: data}
|
||||
}(i, url)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Merge: start with fallback (lower priority), then overlay primary
|
||||
// This way primary always wins on duplicates
|
||||
merged := make(map[string]*Satellite)
|
||||
var combinedRaw strings.Builder
|
||||
anySuccess := false
|
||||
|
||||
for i := len(results) - 1; i >= 0; i-- {
|
||||
r := results[i]
|
||||
if r.err != nil || r.sats == nil {
|
||||
continue
|
||||
}
|
||||
anySuccess = true
|
||||
added := 0
|
||||
for k, v := range r.sats {
|
||||
if _, exists := merged[k]; !exists {
|
||||
merged[k] = v
|
||||
added++
|
||||
}
|
||||
}
|
||||
combinedRaw.WriteString(r.raw)
|
||||
log.Printf("[TLE] Merged %d new satellites from %s (total: %d)", added, r.url, len(merged))
|
||||
}
|
||||
|
||||
if !anySuccess {
|
||||
return fmt.Errorf("all TLE sources failed")
|
||||
}
|
||||
|
||||
// Save combined raw data to disk cache
|
||||
cachePath := filepath.Join(m.cacheDir, CacheFile)
|
||||
if err := os.WriteFile(cachePath, []byte(combinedRaw.String()), 0644); err != nil {
|
||||
log.Printf("[TLE] Cache write failed: %v", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.satellites = merged
|
||||
m.fetchedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
log.Printf("[TLE] Total: %d satellites loaded from %d sources", len(merged), len(urls))
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadLocal loads TLE data from disk cache.
|
||||
func (m *Manager) LoadLocal() error {
|
||||
cachePath := filepath.Join(m.cacheDir, CacheFile)
|
||||
data, err := os.ReadFile(cachePath)
|
||||
if err != nil {
|
||||
return m.loadBundledFallback()
|
||||
}
|
||||
sats, err := parseTLE(string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, _ := os.Stat(cachePath)
|
||||
m.mu.Lock()
|
||||
m.satellites = sats
|
||||
if info != nil {
|
||||
m.fetchedAt = info.ModTime()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
log.Printf("[TLE] Loaded %d satellites from local cache", len(sats))
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadBundledFallback loads a minimal built-in TLE set for common amateur sats.
|
||||
func (m *Manager) loadBundledFallback() error {
|
||||
sats, err := parseTLE(defaultTLEs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.satellites = sats
|
||||
m.fetchedAt = time.Now().Add(-48 * time.Hour)
|
||||
m.mu.Unlock()
|
||||
log.Printf("[TLE] Loaded %d bundled fallback satellites", len(sats))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns a satellite by name (case-insensitive).
|
||||
func (m *Manager) Get(name string) *Satellite {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if s, ok := m.satellites[strings.ToUpper(name)]; ok {
|
||||
return s
|
||||
}
|
||||
upper := strings.ToUpper(name)
|
||||
for k, v := range m.satellites {
|
||||
if strings.Contains(k, upper) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// All returns all loaded satellites.
|
||||
func (m *Manager) All() []*Satellite {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]*Satellite, 0, len(m.satellites))
|
||||
for _, s := range m.satellites {
|
||||
result = append(result, s)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SatelliteNames returns sorted list of satellite names.
|
||||
func (m *Manager) SatelliteNames() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
names := make([]string, 0, len(m.satellites))
|
||||
for k := range m.satellites {
|
||||
names = append(names, k)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// AgeHours returns how many hours old the TLE data is.
|
||||
func (m *Manager) AgeHours() float64 {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.fetchedAt.IsZero() {
|
||||
return 9999
|
||||
}
|
||||
return time.Since(m.fetchedAt).Hours()
|
||||
}
|
||||
|
||||
func fetchURL(url string) (string, error) {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return string(body), err
|
||||
}
|
||||
|
||||
func parseTLE(data string) (map[string]*Satellite, error) {
|
||||
sats := make(map[string]*Satellite)
|
||||
scanner := bufio.NewScanner(strings.NewReader(data))
|
||||
var lines []string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
for i := 0; i+2 < len(lines); i += 3 {
|
||||
name := strings.TrimSpace(lines[i])
|
||||
tle1 := strings.TrimSpace(lines[i+1])
|
||||
tle2 := strings.TrimSpace(lines[i+2])
|
||||
|
||||
if !strings.HasPrefix(tle1, "1 ") || !strings.HasPrefix(tle2, "2 ") {
|
||||
i -= 2
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToUpper(name)
|
||||
sats[key] = &Satellite{
|
||||
Name: name,
|
||||
TLE1: tle1,
|
||||
TLE2: tle2,
|
||||
}
|
||||
}
|
||||
|
||||
if len(sats) == 0 {
|
||||
return nil, fmt.Errorf("no valid TLE entries found")
|
||||
}
|
||||
return sats, nil
|
||||
}
|
||||
|
||||
const defaultTLEs = `ISS (ZARYA)
|
||||
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9999
|
||||
2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50174753471696
|
||||
AO-7
|
||||
1 07530U 74089B 24001.50000000 -.00000023 00000-0 13694-4 0 9999
|
||||
2 07530 101.7044 116.7600 0012184 36.7810 323.4116 12.53590024534125
|
||||
AO-27
|
||||
1 22825U 93061C 24001.50000000 .00000199 00000-0 54717-4 0 9999
|
||||
2 22825 98.6398 100.2553 0008530 335.2107 24.8705 14.29831944581475
|
||||
SO-50
|
||||
1 27607U 02058C 24001.50000000 .00000306 00000-0 71007-4 0 9999
|
||||
2 27607 98.0070 105.9740 0084804 339.5060 19.9560 14.74561589148781
|
||||
FO-29
|
||||
1 24278U 96046B 24001.50000000 .00000056 00000-0 68723-5 0 9999
|
||||
2 24278 98.5355 100.4746 0350771 334.0040 24.2890 13.52863590383849
|
||||
RS-44
|
||||
1 44909U 19096E 24001.50000000 .00000103 00000-0 12583-4 0 9999
|
||||
2 44909 97.6561 101.2437 0009786 341.6020 18.4590 14.93614256218765
|
||||
`
|
||||
Reference in New Issue
Block a user