gnsskit 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gnsskit
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: RTKLIB-based GNSS post-processing toolkit: RINEX tools, RTKLIB wrappers, static baselines and network adjustment, kinematic trajectories
5
5
  Keywords: gnss,gps,rtklib,ppk,surveying,least-squares
6
6
  Author: Francesco Ioli
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gnsskit"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "RTKLIB-based GNSS post-processing toolkit: RINEX tools, RTKLIB wrappers, static baselines and network adjustment, kinematic trajectories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gnsskit"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  description = "RTKLIB-based GNSS post-processing toolkit: RINEX tools, RTKLIB wrappers, static baselines and network adjustment, kinematic trajectories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -236,18 +236,26 @@ def read_visibility(obs: Path | str) -> pd.DataFrame:
236
236
  """Satellites tracked at each epoch of a RINEX 3 observation file.
237
237
 
238
238
  A satellite counts as tracked if its record has at least one non-blank observation.
239
- Event records (epoch flag != 0) are skipped and counted in `attrs["n_events"]`.
239
+ Event records (epoch flag 2-6) are skipped and counted in `attrs["n_events"]`. The marker
240
+ names are collected from the header and from the events inside the data: receivers such
241
+ as Leica write the point name typed on site as a new site occupation (flag 3) followed by
242
+ a `MARKER NAME` line, while the header holds an automatic name.
240
243
 
241
244
  Args:
242
245
  obs: RINEX 3 observation file.
243
246
 
244
247
  Returns:
245
248
  DataFrame with columns `t` (epoch, as written in the file) and `sat` (e.g. "G05"),
246
- one row per tracked satellite and epoch. `attrs` holds `marker` and `n_events`.
249
+ one row per tracked satellite and epoch. `attrs` holds `marker` (header MARKER NAME,
250
+ or the file name if missing), `markers` (list of (start, name): every marker name in
251
+ file order, start None for the header one, else the first epoch after the event) and
252
+ `n_events`.
247
253
  """
248
254
  times: list[pd.Timestamp] = []
249
255
  sats: list[str] = []
250
256
  marker, n_events = Path(obs).stem, 0
257
+ markers: list[tuple[pd.Timestamp | None, str]] = []
258
+ pending: str | None = None # marker name of an event, until the next epoch
251
259
  t = None
252
260
  with Path(obs).open() as f:
253
261
  for line in f:
@@ -255,18 +263,24 @@ def read_visibility(obs: Path | str) -> pd.DataFrame:
255
263
  break
256
264
  if "MARKER NAME" in line[60:] and line[:60].strip():
257
265
  marker = line[:60].strip()
266
+ markers.append((None, marker))
258
267
  for line in f:
259
268
  if line.startswith(">"):
260
- if line[31:32] != "0":
269
+ if line[31:32] not in ("0", "1"): # 0 ok, 1 power failure before: both data
261
270
  t = None
262
271
  n_events += 1
263
272
  continue
264
273
  t = _epoch_time(line[1:])
274
+ if pending is not None:
275
+ markers.append((t, pending))
276
+ pending = None
277
+ elif t is None and "MARKER NAME" in line[60:] and line[:60].strip():
278
+ pending = line[:60].strip()
265
279
  elif t is not None and line[:1] in SYSTEMS and line[3:].strip():
266
280
  times.append(t)
267
281
  sats.append(line[:3])
268
282
  df = pd.DataFrame({"t": times, "sat": sats})
269
- df.attrs.update(marker=marker, n_events=n_events)
283
+ df.attrs.update(marker=marker, markers=markers, n_events=n_events)
270
284
  return df
271
285
 
272
286
 
@@ -279,7 +293,9 @@ def obs_summary(obs: Path | str, gap_factor: float = 1.5) -> dict[str, object]:
279
293
  is a gap.
280
294
 
281
295
  Returns:
282
- Dict with `marker`, `first`, `last`, `duration_s`, `interval_s`, `n_epochs`,
296
+ Dict with `marker` (header MARKER NAME), `markers` (all the marker names, header and
297
+ site-occupation events, in file order), `first`, `last`, `duration_s`, `interval_s`,
298
+ `n_epochs`,
283
299
  `n_gaps`, `max_gap_s`, `n_events`, `mean_sats` (per epoch) and `n_sats` (unique
284
300
  satellites per system).
285
301
 
@@ -295,6 +311,7 @@ def obs_summary(obs: Path | str, gap_factor: float = 1.5) -> dict[str, object]:
295
311
  gaps = step[step > gap_factor * interval] if len(step) else step
296
312
  return {
297
313
  "marker": vis.attrs["marker"],
314
+ "markers": [name for _, name in vis.attrs["markers"]],
298
315
  "first": epochs.iloc[0],
299
316
  "last": epochs.iloc[-1],
300
317
  "duration_s": float((epochs.iloc[-1] - epochs.iloc[0]).total_seconds()),
@@ -423,7 +440,12 @@ def signal_stats(obs: Path | str) -> pd.DataFrame:
423
440
 
424
441
 
425
442
  def plot_visibility(
426
- obs_files: Sequence[Path | str], out: Path | str | None = None, dpi: int = 150
443
+ obs_files: Sequence[Path | str],
444
+ out: Path | str | None = None,
445
+ dpi: int = 150,
446
+ start: pd.Timestamp | None = None,
447
+ end: pd.Timestamp | None = None,
448
+ common_xlim: bool = True,
427
449
  ) -> Figure:
428
450
  """Satellite visibility plot (as in RTKPLOT), one panel per observation file.
429
451
 
@@ -433,6 +455,12 @@ def plot_visibility(
433
455
  obs_files: RINEX 3 observation files.
434
456
  out: If given, the figure is also saved to this path.
435
457
  dpi: Resolution of the saved figure.
458
+ start: Plot only from this time on, in the time system of the files (GPST).
459
+ None for the start of the data.
460
+ end: Plot only up to this time, same time system. None for the end of the data.
461
+ common_xlim: Give all the panels the same time axis, from the first to the last epoch
462
+ of all the files (within `start`/`end`), so that the common windows line up.
463
+ If False each panel spans its own data.
436
464
 
437
465
  Returns:
438
466
  The matplotlib figure.
@@ -440,28 +468,88 @@ def plot_visibility(
440
468
  try:
441
469
  import matplotlib.dates as mdates
442
470
  from matplotlib.figure import Figure
471
+ from matplotlib.lines import Line2D
443
472
  except ImportError as e: # pragma: no cover
444
473
  raise ImportError("plot_visibility needs matplotlib: pip install 'gnsskit[plot]'") from e
445
474
 
475
+ visibility = []
476
+ for path in obs_files:
477
+ vis = read_visibility(path)
478
+ keep = pd.Series(True, index=vis.index)
479
+ if start is not None:
480
+ keep &= vis.t >= start
481
+ if end is not None:
482
+ keep &= vis.t <= end
483
+ visibility.append(vis[keep])
484
+
485
+ def xlim(times: pd.Series) -> tuple[pd.Timestamp, pd.Timestamp] | None:
486
+ """Axis range: `start`/`end` if given, else the first and last epoch."""
487
+ if times.empty and (start is None or end is None):
488
+ return None
489
+ lo = start if start is not None else times.min()
490
+ hi = end if end is not None else times.max()
491
+ return lo, hi
492
+
493
+ common = xlim(pd.concat([v.t for v in visibility])) if common_xlim else None
494
+
446
495
  colors = {"G": "tab:blue", "R": "tab:red", "E": "tab:green", "C": "tab:orange",
447
496
  "J": "tab:purple", "S": "tab:gray", "I": "tab:brown"} # fmt: skip
497
+ names = {"G": "GPS", "R": "GLONASS", "E": "Galileo", "C": "BeiDou", "J": "QZSS",
498
+ "S": "SBAS", "I": "NavIC"} # fmt: skip
448
499
  fig = Figure(figsize=(12, 3.5 * len(obs_files))) # no pyplot: no GUI backend needed
449
500
  axes = fig.subplots(len(obs_files), 1, squeeze=False)
450
- for ax, path in zip(axes[:, 0], obs_files):
451
- vis = read_visibility(path)
501
+ for ax, path, vis in zip(axes[:, 0], obs_files, visibility):
452
502
  sats = sorted(vis.sat.unique())
453
503
  for i, sat in enumerate(sats):
454
504
  t = vis.t[vis.sat == sat]
455
505
  ax.plot(t, [i] * len(t), "|", ms=3, color=colors[sat[0]])
456
506
  ax.set_yticks(range(len(sats)), sats, fontsize=5)
507
+ limits = common if common_xlim else xlim(vis.t)
508
+ if limits is not None and limits[0] < limits[1]:
509
+ ax.set_xlim(*limits)
457
510
  ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
511
+ ax.set_xlabel("GPST", fontsize=7, loc="right")
458
512
  span = f"{vis.t.min():%H:%M:%S} - {vis.t.max():%H:%M:%S}" if len(vis) else "no data"
459
- ax.set_title(
460
- f"{Path(path).name} [{vis.attrs['marker']}] {span} ({len(sats)} sats)",
461
- fontsize=9,
462
- )
513
+ site = ", ".join(name for _, name in vis.attrs["markers"]) or vis.attrs["marker"]
514
+ ax.set_title(f"{Path(path).name} [{site}] {span} ({len(sats)} sats)", fontsize=9)
515
+ for t0, name in vis.attrs["markers"]: # new site occupations inside the data
516
+ if t0 is not None and (limits is None or limits[0] <= t0 <= limits[1]):
517
+ ax.axvline(t0, color="k", ls="--", lw=1)
518
+ ax.annotate(name, (t0, 1), xycoords=("data", "axes fraction"), xytext=(3, -3),
519
+ textcoords="offset points", va="top", fontsize=7, weight="bold") # fmt: skip
463
520
  ax.grid(alpha=0.3)
464
- fig.tight_layout()
521
+ if vis.empty:
522
+ continue
523
+ ax.axvspan(vis.t.min(), vis.t.max(), color="0.5", alpha=0.08, lw=0) # file span
524
+
525
+ # satellites tracked per epoch, on a right-hand axis
526
+ n_sats = vis.groupby("t").size()
527
+ ax_n = ax.twinx()
528
+ ax_n.plot(n_sats.index, n_sats.to_numpy(), color="k", lw=0.8, alpha=0.6)
529
+ ax_n.set_ylim(0, 1.15 * n_sats.max())
530
+ ax_n.set_ylabel("sats per epoch", fontsize=7)
531
+ ax_n.tick_params(labelsize=6)
532
+
533
+ # sampling and tracking of the plotted window
534
+ epochs = n_sats.index.to_series()
535
+ step = epochs.diff().dt.total_seconds()
536
+ interval = step.median() if len(epochs) > 1 else float("nan")
537
+ gaps = int((step > 1.5 * interval).sum()) if len(epochs) > 1 else 0
538
+ per_sys = " ".join(
539
+ f"{s}{sum(x[0] == s for x in sats)}" for s in sorted({x[0] for x in sats})
540
+ )
541
+ info = (
542
+ f"interval {interval:g} s, {len(epochs)} epochs, {gaps} gaps\n"
543
+ f"sats {per_sys}, per epoch {n_sats.min()}-{n_sats.max()} (mean {n_sats.mean():.1f})"
544
+ )
545
+ ax.text(0.005, 0.98, info, transform=ax.transAxes, va="top", ha="left", fontsize=6,
546
+ bbox={"boxstyle": "round", "fc": "white", "ec": "0.7", "alpha": 0.85}) # fmt: skip
547
+
548
+ used = sorted({s[0] for v in visibility for s in v.sat.unique()}, key=list(colors).index)
549
+ handles = [Line2D([], [], color=colors[s], lw=3, label=names[s]) for s in used]
550
+ handles.append(Line2D([], [], color="k", lw=0.8, alpha=0.6, label="sats per epoch"))
551
+ fig.legend(handles=handles, loc="upper center", ncol=len(handles), fontsize=8, frameon=False)
552
+ fig.tight_layout(rect=(0, 0, 1, 1 - 0.25 / fig.get_figheight()))
465
553
  if out is not None:
466
554
  fig.savefig(out, dpi=dpi)
467
555
  return fig
File without changes
File without changes
File without changes
File without changes
File without changes