1
0
mirror of https://github.com/gnss-sdr/gnss-sdr synced 2025-09-10 23:06:03 +00:00

skyplot improvements

Add new --format optional argument. Allowed options: pdf, eps, png, svg. Default: pdf
Add new --version / -v optional arguments, show program version and exit
Improve --help / -h message
Improve error handling
Generate PDF files with embedded fonts, ready for journal submission
Improve argument handling
Improve documentation
Add explicit z-order control for cleaner display of labels in multiconstellation files
This commit is contained in:
Carles Fernandez
2025-08-22 12:42:36 +02:00
parent 15a7a9bae3
commit 1d31c8427d
2 changed files with 248 additions and 191 deletions

View File

@@ -16,18 +16,19 @@ showing satellite visibility over time.
## Features ## Features
- Processes RINEX navigation files. - Processes RINEX navigation files.
- Calculates satellite positions using broadcast ephemeris.
- Plots satellite tracks in azimuth-elevation coordinates.
- Customizable observer location.
- Color-codes satellites by constellation (GPS, Galileo, GLONASS, BeiDou).
- Elevation mask set to 5°, configurable via the `--elev-mask` option.
- Outputs high-quality image in PDF format. EPS, PNG, and SVG formats are also
available via the `--format` option.
- Non-interactive mode for CI jobs with the `--no-show` option.
- Constellations to plot can be configured via the `--system` option.
- Optionally uses an OBS file to limit plot to the receiver observation time - Optionally uses an OBS file to limit plot to the receiver observation time
(`--use-obs`). (`--use-obs`).
- When enabled, the tool looks for a matching file by replacing the last - When enabled, the tool looks for a matching file by replacing the last
character of the NAV filename with O/o and uses it if found. character of the NAV filename with `O`/`o` and uses it if found.
- Calculates satellite positions using broadcast ephemeris.
- Plots satellite tracks in azimuth-elevation coordinates.
- Elevation mask set to 5°, configurable via the `--elev-mask` flag.
- Color-codes satellites by constellation (GPS, Galileo, GLONASS, BeiDou).
- Constellations to plot can be configured via the `--system` flag.
- Customizable observer location.
- Outputs high-quality image in PDF format.
- Non-interactive mode for CI jobs (with `--no-show` flag).
## Requirements ## Requirements
@@ -41,21 +42,27 @@ showing satellite visibility over time.
### Basic Command ### Basic Command
``` ```
./skyplot.py <RINEX_FILE> [LATITUDE] [LONGITUDE] [ALTITUDE] [--use-obs] [--elev-mask] [--system ...] [--no-show] ./skyplot.py <RINEX_FILE> [LATITUDE] [LONGITUDE] [ALTITUDE]
[--elev-mask ELEV_MASK]
[--format {pdf,eps,png,svg}]
[--no-show]
[--system SYSTEM [SYSTEM ...]]
[--use-obs]
``` ```
### Arguments ### Arguments
| Argument | Type | Units | Description | Default | | Argument | Type | Units | Description | Default |
| ---------------- | -------- | ----------- | ---------------------- | -------- | | ---------------- | -------- | ----------- | ------------------------ | -------- |
| `RINEX_NAV_FILE` | Required | - | RINEX nav file path | - | | `RINEX_NAV_FILE` | Required | - | RINEX nav file path | - |
| `LATITUDE` | Optional | degrees (°) | North/South position | 41.275°N | | `LATITUDE` | Optional | degrees (°) | North/South position | 41.275°N |
| `LONGITUDE` | Optional | degrees (°) | East/West position | 1.9876°E | | `LONGITUDE` | Optional | degrees (°) | East/West position | 1.9876°E |
| `ALTITUDE` | Optional | meters (m) | Height above sea level | 80.0 m | | `ALTITUDE` | Optional | meters (m) | Height above sea level | 80.0 m |
| `--use-obs` | Optional | - | Use RINEX obs data | - |
| `--elev-mask` | Optional | degrees (°) | Elevation mask | 5° | | `--elev-mask` | Optional | degrees (°) | Elevation mask | 5° |
| `--system` | Optional | - | Systems to plot | All | | `--format` | Optional | - | Output {pdf,eps,png,svg} | pdf |
| `--no-show` | Optional | - | Do not show plot | - | | `--no-show` | Optional | - | Do not show plot | - |
| `--system` | Optional | - | Systems to plot | All |
| `--use-obs` | Optional | - | Use RINEX obs data | - |
### Examples ### Examples
@@ -84,6 +91,10 @@ showing satellite visibility over time.
``` ```
./skyplot.py brdc0010.22n -33.4592 -70.6453 520.0 --system G E ./skyplot.py brdc0010.22n -33.4592 -70.6453 520.0 --system G E
``` ```
- Get a PNG file:
```
./skyplot.py brdc0010.22n -33.4592 -70.6453 520.0 --format png
```
## Output ## Output
@@ -91,8 +102,12 @@ The script generates a PDF file named `skyplot_<RINEX_FILE>.pdf` (with dots in
`<RINEX_FILE>` replaced by `_`) with: `<RINEX_FILE>` replaced by `_`) with:
- Satellite trajectories over all epochs in the file. - Satellite trajectories over all epochs in the file.
- NAV file - ephemeris time range (default) - NAV file - ephemeris time range (default).
- Receiver observation if `--use-obs` is specified and OBS file is found - Receiver observation if `--use-obs` is specified and OBS file is found.
- Color-coded by constellation. - Color-coded by constellation.
- Observer location in title. - Observer location in title.
- Time range in footer. - Time range in footer.
- Embedded fonts that display consistently across all systems and generate
publication-ready figures.
- EPS, PNG, and SVG output formats available via `--format eps`, `--format png`,
and `--format svg`.

View File

@@ -2,10 +2,17 @@
""" """
skyplot.py skyplot.py
Reads a RINEX navigation file and generates a skyplot. Optionally, a RINEX observation file can Reads a RINEX navigation file and generates a skyplot. Optionally, a RINEX
also be read to match the skyplot to the receiver processing time. observation file can also be read to match the skyplot to the receiver
processing time.
Usage: python skyplot.py <RINEX_NAV_FILE> [observer_lat] [observer_lon] [observer_alt] [--use-obs] Usage:
skyplot.py <RINEX_NAV_FILE> [observer_lat] [observer_lon] [observer_alt]
[--elev-mask ELEV_MASK]
[--format {pdf,eps,png,svg}]
[--no-show]
[--system SYSTEM [SYSTEM ...]]
[--use-obs]
----------------------------------------------------------------------------- -----------------------------------------------------------------------------
@@ -25,9 +32,15 @@ from datetime import datetime, timedelta
from math import atan2, cos, sin, sqrt from math import atan2, cos, sin, sqrt
from pathlib import Path from pathlib import Path
import matplotlib.pyplot as plt try:
import numpy as np import matplotlib.pyplot as plt
import numpy as np
except ImportError:
print("Error: This script requires matplotlib and numpy.")
print("Install them with: pip install matplotlib numpy")
sys.exit(1)
__version__ = "1.0.0"
def _read_obs_time_bounds(obs_path): def _read_obs_time_bounds(obs_path):
"""Return (start_time, end_time) from a RINEX 2/3 OBS file by scanning epoch lines. """Return (start_time, end_time) from a RINEX 2/3 OBS file by scanning epoch lines.
@@ -360,9 +373,15 @@ def ecef_to_az_el(x, y, z, obs_lat, obs_lon, obs_alt):
def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt, def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt,
footer_text=None, filename=None, footer_text=None, filename=None,
show_plot=True, start_time=None, show_plot=True, start_time=None,
end_time=None, elev_mask=5.0): end_time=None, elev_mask=5.0,
output_format="pdf"):
"""Plot trajectories for all visible satellites""" """Plot trajectories for all visible satellites"""
plt.rcParams["font.family"] = "Times New Roman" plt.rcParams['pdf.fonttype'] = 42 # TrueType fonts
plt.rcParams['ps.fonttype'] = 42 # TrueType fonts
plt.rcParams['font.family'] = 'serif'
plt.rcParams['font.serif'] = ['Times New Roman', 'Times', 'DejaVu Serif']
plt.rcParams['mathtext.fontset'] = 'dejavuserif' # For math text
plt.rcParams['svg.fonttype'] = 'none' # Make SVG text editable
fig = plt.figure(figsize=(8, 8)) fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111, projection='polar') ax = fig.add_subplot(111, projection='polar')
ax.tick_params(labelsize=16, pad=7) ax.tick_params(labelsize=16, pad=7)
@@ -442,13 +461,13 @@ def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt,
for az_seg, el_seg in segments: for az_seg, el_seg in segments:
theta = np.radians(az_seg) theta = np.radians(az_seg)
r = 90 - np.array(el_seg) r = 90 - np.array(el_seg)
ax.plot(theta, r, '-', color=color, alpha=0.7, linewidth=2.5) ax.plot(theta, r, '-', color=color, alpha=0.7, linewidth=2.5, zorder=1)
# Arrow at end # Arrow at end
if len(theta) >= 2: if len(theta) >= 2:
dx = theta[-1] - theta[-2] dx = theta[-1] - theta[-2]
dy = r[-1] - r[-2] dy = r[-1] - r[-2]
arrow_length_factor = 1.3 arrow_length_factor = 1.8
extended_theta = theta[-2] + dx * arrow_length_factor extended_theta = theta[-2] + dx * arrow_length_factor
extended_r = r[-2] + dy * arrow_length_factor extended_r = r[-2] + dy * arrow_length_factor
ax.annotate('', ax.annotate('',
@@ -461,13 +480,15 @@ def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt,
'linewidth': 1.5, 'linewidth': 1.5,
'shrinkA': 0, 'shrinkA': 0,
'shrinkB': 0 'shrinkB': 0
}) },
zorder=2)
# Label at midpoint of the segment # Label at midpoint of the segment
mid_idx = len(theta)//2 mid_idx = len(theta)//2
ax.text(theta[mid_idx], r[mid_idx], prn, ax.text(theta[mid_idx], r[mid_idx], prn,
fontsize=12, ha='center', va='center', fontsize=12, ha='center', va='center',
bbox={"facecolor": "white", "alpha": 0.8, "pad": 2}) bbox={"facecolor": "white", "alpha": 0.8, "pad": 2},
zorder=3)
# Legend for present systems # Legend for present systems
legend_elements = [ legend_elements = [
@@ -503,11 +524,11 @@ def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt,
if filename: if filename:
filename_no_path = Path(filename).name filename_no_path = Path(filename).name
filename_no_dots = filename_no_path.replace('.', '_') filename_no_dots = filename_no_path.replace('.', '_')
output_name = f"skyplot_{filename_no_dots}.pdf" output_name = f"skyplot_{filename_no_dots}.{output_format}"
else: else:
output_name = "skyplot.pdf" output_name = f"skyplot.{output_format}"
plt.savefig(output_name, format='pdf', bbox_inches='tight') plt.savefig(output_name, format=output_format, bbox_inches='tight')
print(f"Image saved as {output_name}") print(f"Image saved as {output_name}")
if show_plot: if show_plot:
plt.show() plt.show()
@@ -517,6 +538,7 @@ def plot_satellite_tracks(satellites, obs_lat, obs_lon, obs_alt,
def main(): def main():
"""Generate the skyplot""" """Generate the skyplot"""
try:
# Set system names and codes # Set system names and codes
system_name_to_code = { system_name_to_code = {
'GPS': 'G', 'GPS': 'G',
@@ -530,66 +552,80 @@ def main():
# Set up argument parser # Set up argument parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Generate GNSS skyplot from RINEX navigation file', description='Generate a GNSS skyplot from a RINEX navigation file',
add_help=False epilog="Example: skyplot.py brdc0010.22n -33.4592 -70.6453 520.0 --format png --system G E --elev-mask 10 --no-show"
)
# Positional arguments
parser.add_argument(
'filename',
help='RINEX navigation file path'
)
parser.add_argument(
'lat', nargs='?', type=float, default=41.2750,
help='Observer latitude in degrees (default: 41.275° N)'
)
parser.add_argument(
'lon', nargs='?', type=float, default=1.9876,
help='Observer longitude in degrees (default: 1.9876° E)'
)
parser.add_argument(
'alt', nargs='?', type=float, default=80.0,
help='Observer altitude in meters (default: 80.0 m)'
)
# Optional arguments
parser.add_argument(
'--elev-mask',
type=float,
default=5.0,
help='Elevation mask in degrees for plotting satellite tracks (default: 5°)'
)
parser.add_argument(
'--format',
type=str,
default="pdf",
choices=["pdf", "eps", "png", "svg"],
help='Output file format for plot (default: pdf)'
) )
# Add the no-show flag
parser.add_argument( parser.add_argument(
'--no-show', '--no-show',
action='store_true', action='store_true',
help='Run without displaying plot window' help='Run without displaying plot window'
) )
# Add the use-obs flag
parser.add_argument(
'--system',
nargs='+',
help='Only plot satellites from these systems (e.g., G R E or GPS GLONASS Galileo)'
)
parser.add_argument( parser.add_argument(
'--use-obs', '--use-obs',
action='store_true', action='store_true',
help='Use corresponding RINEX observation file to bound the skyplot to the receiver time window' help='Use corresponding RINEX observation file to bound the skyplot to the receiver time window'
) )
# Add the elev-mask flag.
parser.add_argument( parser.add_argument(
'--elev-mask', type=float, default=5.0, "-v", "--version",
help='Elevation mask in degrees for plotting satellite tracks (default: 5°)' action="version",
version=f"%(prog)s {__version__}",
help="Show program version and exit"
) )
# Add the system flag.
parser.add_argument(
'--system',
nargs='+',
help='Only plot satellites from these systems (e.g., G R E or GPS Galileo GLONASS)'
)
# Parse known args (this ignores other positional args)
args, remaining_args = parser.parse_known_args()
# Handle help manually # Parse all arguments with full validation
if '-h' in remaining_args or '--help' in remaining_args: args = parser.parse_args()
print("""
Usage: python skyplot.py <RINEX_FILE> [LATITUDE] [LONGITUDE] [ALTITUDE] [--use-obs] [--elev-mask] [--system ...] [--no-show]
Example: # Convert coordinates to radians
python skyplot.py brdc0010.22n 41.275 1.9876 80.0 --use-obs --no-show --elev-mask=10 --system GPS Galileo obs_lat = np.radians(args.lat)
""") obs_lon = np.radians(args.lon)
sys.exit(0) obs_alt = args.alt
filename = args.filename
if len(remaining_args) < 1:
print("Error: RINEX file required")
sys.exit(1)
filename = remaining_args[0]
# Default observer location (Castelldefels, Barcelona, Spain)
obs_lat = np.radians(41.2750)
obs_lon = np.radians(1.9876)
obs_alt = 80.0
# Override with command line arguments if provided
if len(remaining_args) >= 4:
try:
obs_lat = np.radians(float(remaining_args[1]))
obs_lon = np.radians(float(remaining_args[2]))
if len(remaining_args) >= 5:
obs_alt = float(remaining_args[3])
except ValueError:
print("Invalid observer coordinates. Using defaults.")
# Read RINEX file # Read RINEX file
print(f"Reading {filename}...") print(f"Reading {filename}...")
@@ -597,11 +633,11 @@ Example:
satellites = read_rinex_nav(filename) satellites = read_rinex_nav(filename)
except FileNotFoundError: except FileNotFoundError:
print(f"Error: File '{filename}' not found.") print(f"Error: File '{filename}' not found.")
return return 1
if not satellites: if not satellites:
print("No satellite data found in the file.") print("No satellite data found in the file.")
return return 1
if args.system: if args.system:
systems_upper = set() systems_upper = set()
@@ -616,7 +652,7 @@ Example:
if not satellites: if not satellites:
print(f"No satellites found for systems: {', '.join(sorted(systems_upper))}") print(f"No satellites found for systems: {', '.join(sorted(systems_upper))}")
return return 1
# Print summary information # Print summary information
all_epochs = sorted(list(set( all_epochs = sorted(list(set(
@@ -686,8 +722,14 @@ Example:
show_plot=not args.no_show, show_plot=not args.no_show,
start_time=use_start, start_time=use_start,
end_time=use_end, end_time=use_end,
elev_mask=args.elev_mask elev_mask=args.elev_mask,
output_format=args.format
) )
except Exception as e:
print(f"Error: {str(e)}")
return 1
return 0
if __name__ == "__main__": if __name__ == "__main__":