1
0
mirror of https://github.com/gnss-sdr/gnss-sdr synced 2025-09-07 13:27:59 +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
try:
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import numpy as np 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__":