Add Phase 1 experimenter tools: MCP server, H21cm, beacon logger, arc survey
Four new tools transforming the SkyWalker-1 from satellite TV receiver into a general-purpose RF observatory: - skywalker-mcp: FastMCP server exposing 20 tools, 4 resources, 2 prompts. Thread-safe DeviceBridge with motor safety (continuous drive opt-in), input validation on all frequency/symbol rate/step parameters, try/finally on TS capture, path traversal sanitization, and reduced lock scope so emergency motor halt isn't blocked during long surveys. - h21cm.py: Hydrogen 21 cm drift-scan radiometer at 1420.405 MHz with Doppler velocity calculation, control band comparison, and CSV output. - beacon_logger.py: Long-term Ku-band beacon SNR/AGC logger with auto-relock, dual CSV/JSONL output, signal handlers, and systemd unit generation. - arc_survey.py: Multi-satellite orbital arc census with USALS motor control, per-slot catalog persistence, resume support, and defensive motor halt on all error/interrupt paths. Documentation: experimenter's roadmap guide + 4 tool reference pages (48 pages total).
This commit is contained in:
parent
6c00f941eb
commit
a9dcf84c38
15 changed files with 4374 additions and 0 deletions
451
tools/arc_survey.py
Normal file
451
tools/arc_survey.py
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-satellite arc survey for the Genpix SkyWalker-1.
|
||||
|
||||
Automated "satellite census": points the dish motor to each known GEO
|
||||
longitude, runs a full-band carrier survey at each position, and aggregates
|
||||
results into a comprehensive sky map. The diff capability tracks changes
|
||||
between survey runs.
|
||||
|
||||
Usage:
|
||||
python arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
|
||||
python arc_survey.py --observer-lon -96.8 --file slots.json
|
||||
python arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
|
||||
python arc_survey.py --resume arc-survey-2026-02-17.json
|
||||
|
||||
The tool saves progress after each orbital slot, so interrupted surveys
|
||||
can be resumed. Each slot's catalog is saved individually, and a summary
|
||||
report covers the entire arc.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import SkyWalker1, usals_angle
|
||||
from survey_engine import SurveyEngine
|
||||
from carrier_catalog import CarrierCatalog, CATALOG_DIR
|
||||
|
||||
|
||||
# Common North American GEO orbital slots
|
||||
NA_ORBITAL_SLOTS = {
|
||||
"129W": -129.0, "125W": -125.0, "123W": -123.0, "121W": -121.0,
|
||||
"119W": -119.0, "118.7W": -118.7, "116.8W": -116.8, "114.9W": -114.9,
|
||||
"113W": -113.0, "111.1W": -111.1, "110W": -110.0, "107.3W": -107.3,
|
||||
"105W": -105.0, "103W": -103.0, "101W": -101.0, "99W": -99.0,
|
||||
"97W": -97.0, "95W": -95.0, "93W": -93.0, "91W": -91.0,
|
||||
"89W": -89.0, "87W": -87.0, "85W": -85.0, "83W": -83.0,
|
||||
"82W": -82.0, "79W": -79.0, "77W": -77.0, "75W": -75.0,
|
||||
"72.7W": -72.7, "70W": -70.0, "67W": -67.0, "65W": -65.0,
|
||||
"63W": -63.0, "61.5W": -61.5, "58W": -58.0, "55.5W": -55.5,
|
||||
}
|
||||
|
||||
ARC_SURVEY_DIR = CATALOG_DIR.parent / "arc-surveys"
|
||||
|
||||
|
||||
class ArcSurvey:
|
||||
"""Multi-position orbital arc survey with persistence and resume."""
|
||||
|
||||
def __init__(self, sw: SkyWalker1, observer_lon: float,
|
||||
observer_lat: float = 0.0, settle_time: float = 15.0):
|
||||
self.sw = sw
|
||||
self.observer_lon = observer_lon
|
||||
self.observer_lat = observer_lat
|
||||
self.settle_time = settle_time
|
||||
|
||||
def survey_slot(self, name: str, sat_lon: float,
|
||||
coarse_step: float = 5.0,
|
||||
band: str = "", pol: str = "",
|
||||
callback=None) -> CarrierCatalog:
|
||||
"""Survey a single orbital slot: move dish, wait, run survey."""
|
||||
|
||||
# Calculate motor angle
|
||||
angle = usals_angle(self.observer_lon, sat_lon, self.observer_lat)
|
||||
direction = "west" if angle < 0 else "east"
|
||||
|
||||
if callback:
|
||||
callback("moving", 0,
|
||||
f"Moving to {name} ({sat_lon:.1f}), "
|
||||
f"angle {abs(angle):.1f} deg {direction}")
|
||||
|
||||
# Command the motor
|
||||
self.sw.motor_goto_x(self.observer_lon, sat_lon)
|
||||
|
||||
# Wait for motor to settle (larger angles need more time)
|
||||
settle = max(self.settle_time, abs(angle) * 0.3)
|
||||
if callback:
|
||||
callback("settling", 20, f"Settling {settle:.0f}s...")
|
||||
time.sleep(settle)
|
||||
|
||||
# Verify we have signal (check AGC for any RF energy)
|
||||
sig = self.sw.signal_monitor()
|
||||
if callback:
|
||||
callback("signal_check", 30,
|
||||
f"AGC1={sig['agc1']}, power={sig['power_db']:.1f} dB")
|
||||
|
||||
# Run the six-stage survey
|
||||
def survey_cb(stage, pct, msg):
|
||||
overall_pct = 30 + int(pct * 0.7)
|
||||
if callback:
|
||||
callback(stage, overall_pct, msg)
|
||||
|
||||
engine = SurveyEngine(self.sw, callback=survey_cb)
|
||||
catalog = engine.run_full_scan(
|
||||
coarse_step=coarse_step,
|
||||
ts_capture_secs=2.0,
|
||||
)
|
||||
|
||||
catalog.name = f"{name} ({sat_lon:.1f})"
|
||||
catalog.band = band
|
||||
catalog.pol = pol
|
||||
catalog.notes = (f"Arc survey position: {name}, "
|
||||
f"observer: {self.observer_lon:.2f} lon, "
|
||||
f"motor angle: {angle:.2f} deg")
|
||||
|
||||
if callback:
|
||||
callback("complete", 100,
|
||||
f"{name}: {len(catalog.carriers)} carriers, "
|
||||
f"{sum(1 for c in catalog.carriers if c.locked)} locked")
|
||||
|
||||
return catalog
|
||||
|
||||
def run_arc(self, slots: list[tuple[str, float]],
|
||||
coarse_step: float = 5.0,
|
||||
band: str = "", pol: str = "",
|
||||
save_individual: bool = True,
|
||||
resume_state: dict | None = None) -> dict:
|
||||
"""Survey an entire arc of orbital slots.
|
||||
|
||||
slots: list of (name, sat_lon) tuples
|
||||
resume_state: previous arc survey state dict for resuming
|
||||
|
||||
Returns a complete arc survey result dict.
|
||||
"""
|
||||
ARC_SURVEY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Initialize or resume state
|
||||
if resume_state:
|
||||
state = resume_state
|
||||
completed_names = set(state.get("completed_slots", {}).keys())
|
||||
else:
|
||||
state = {
|
||||
"started": datetime.now(timezone.utc).isoformat(),
|
||||
"observer_lon": self.observer_lon,
|
||||
"observer_lat": self.observer_lat,
|
||||
"total_slots": len(slots),
|
||||
"completed_slots": {},
|
||||
"skipped_slots": {},
|
||||
"summary": {
|
||||
"total_carriers": 0,
|
||||
"total_locked": 0,
|
||||
"total_services": 0,
|
||||
},
|
||||
}
|
||||
completed_names = set()
|
||||
|
||||
state_path = ARC_SURVEY_DIR / f"arc-survey-{date_str}.json"
|
||||
|
||||
for i, (name, sat_lon) in enumerate(slots):
|
||||
if name in completed_names:
|
||||
print(f" [{i+1}/{len(slots)}] Skipping {name} (already surveyed)")
|
||||
continue
|
||||
|
||||
print(f"\n [{i+1}/{len(slots)}] Surveying {name} ({sat_lon:.1f} lon)")
|
||||
|
||||
def progress_cb(stage, pct, msg):
|
||||
print(f" [{pct:3d}%] {stage}: {msg}")
|
||||
|
||||
try:
|
||||
catalog = self.survey_slot(
|
||||
name, sat_lon,
|
||||
coarse_step=coarse_step,
|
||||
band=band, pol=pol,
|
||||
callback=progress_cb,
|
||||
)
|
||||
|
||||
# Save individual catalog
|
||||
if save_individual:
|
||||
slot_filename = f"arc-{date_str}-{name.replace('.', '_')}.json"
|
||||
cat_path = catalog.save(slot_filename)
|
||||
print(f" Saved: {cat_path}")
|
||||
|
||||
# Update state
|
||||
carrier_count = len(catalog.carriers)
|
||||
locked_count = sum(1 for c in catalog.carriers if c.locked)
|
||||
service_count = sum(len(c.services) for c in catalog.carriers)
|
||||
|
||||
state["completed_slots"][name] = {
|
||||
"sat_lon": sat_lon,
|
||||
"completed": datetime.now(timezone.utc).isoformat(),
|
||||
"carriers": carrier_count,
|
||||
"locked": locked_count,
|
||||
"services": service_count,
|
||||
"catalog_file": slot_filename if save_individual else None,
|
||||
}
|
||||
state["summary"]["total_carriers"] += carrier_count
|
||||
state["summary"]["total_locked"] += locked_count
|
||||
state["summary"]["total_services"] += service_count
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n Survey interrupted at {name}")
|
||||
try:
|
||||
self.sw.motor_halt()
|
||||
except Exception:
|
||||
pass
|
||||
state["interrupted_at"] = name
|
||||
_save_state(state, state_path)
|
||||
print(f" Motor halted. Progress saved to {state_path}")
|
||||
print(f" Resume with: python arc_survey.py --resume {state_path}")
|
||||
return state
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error at {name}: {e}")
|
||||
try:
|
||||
self.sw.motor_halt()
|
||||
except Exception:
|
||||
pass
|
||||
state["skipped_slots"][name] = {
|
||||
"sat_lon": sat_lon,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# Save state after each slot for resume capability
|
||||
_save_state(state, state_path)
|
||||
|
||||
# Final summary
|
||||
state["completed"] = datetime.now(timezone.utc).isoformat()
|
||||
_save_state(state, state_path)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def _save_state(state: dict, path: Path) -> None:
|
||||
"""Save arc survey state to JSON."""
|
||||
with open(path, 'w') as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
def parse_slot_string(slot_str: str) -> list[tuple[str, float]]:
|
||||
"""Parse a comma-separated slot string like '97W,99W,101W'.
|
||||
|
||||
Accepts formats: '97W', '97.5W', '3E', '-97', '-97.5'
|
||||
"""
|
||||
slots = []
|
||||
for part in slot_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
if part in NA_ORBITAL_SLOTS:
|
||||
slots.append((part, NA_ORBITAL_SLOTS[part]))
|
||||
elif part.upper().endswith('W'):
|
||||
lon = -float(part[:-1])
|
||||
slots.append((part.upper(), lon))
|
||||
elif part.upper().endswith('E'):
|
||||
lon = float(part[:-1])
|
||||
slots.append((part.upper(), lon))
|
||||
else:
|
||||
lon = float(part)
|
||||
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
|
||||
slots.append((name, lon))
|
||||
|
||||
return slots
|
||||
|
||||
|
||||
def generate_arc_range(start_lon: float, stop_lon: float,
|
||||
step: float) -> list[tuple[str, float]]:
|
||||
"""Generate orbital slots at regular intervals across an arc."""
|
||||
slots = []
|
||||
lon = start_lon
|
||||
while lon <= stop_lon:
|
||||
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
|
||||
slots.append((name, lon))
|
||||
lon += step
|
||||
return slots
|
||||
|
||||
|
||||
def print_summary(state: dict) -> None:
|
||||
"""Print a human-readable arc survey summary."""
|
||||
print(f"\n Arc Survey Summary")
|
||||
print(f" ==================")
|
||||
print(f" Observer: {state['observer_lon']:.2f} lon")
|
||||
print(f" Slots surveyed: {len(state['completed_slots'])} / {state['total_slots']}")
|
||||
print(f" Total carriers: {state['summary']['total_carriers']}")
|
||||
print(f" Total locked: {state['summary']['total_locked']}")
|
||||
print(f" Total services: {state['summary']['total_services']}")
|
||||
|
||||
if state.get("skipped_slots"):
|
||||
print(f" Skipped: {len(state['skipped_slots'])}")
|
||||
|
||||
print(f"\n Per-slot results:")
|
||||
for name, info in sorted(state["completed_slots"].items(),
|
||||
key=lambda x: x[1]["sat_lon"]):
|
||||
lock_str = f"{info['locked']}/{info['carriers']}"
|
||||
svc_str = f"{info['services']} svc" if info['services'] else ""
|
||||
print(f" {name:>8s} ({info['sat_lon']:+7.1f}): "
|
||||
f"{lock_str:>7s} locked {svc_str}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="arc_survey.py",
|
||||
description="Multi-satellite arc survey for SkyWalker-1",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
# Survey specific slots (North American arc)
|
||||
%(prog)s --observer-lon -96.8 --slots "97W,99W,101W,103W"
|
||||
|
||||
# Survey an arc range at 3-degree intervals
|
||||
%(prog)s --observer-lon -96.8 --arc -120 -60 --step 3
|
||||
|
||||
# Load slots from a JSON file
|
||||
%(prog)s --observer-lon -96.8 --file my-slots.json
|
||||
|
||||
# Resume an interrupted survey
|
||||
%(prog)s --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
|
||||
|
||||
# List common North American orbital slots
|
||||
%(prog)s --list-slots
|
||||
|
||||
slot file format (JSON):
|
||||
[
|
||||
{"name": "97W", "lon": -97.0},
|
||||
{"name": "99W", "lon": -99.0}
|
||||
]
|
||||
|
||||
notes:
|
||||
- Motor settle time scales with angle (min 15s, + 0.3s per degree)
|
||||
- Each slot takes 5-15 minutes depending on carrier density
|
||||
- Progress is saved after each slot; Ctrl-C to pause safely
|
||||
- Individual catalogs saved to ~/.skywalker1/surveys/
|
||||
- Arc survey state saved to ~/.skywalker1/arc-surveys/
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='store_true')
|
||||
parser.add_argument('--observer-lon', type=float,
|
||||
help="Observer longitude (negative=west, e.g. -96.8)")
|
||||
parser.add_argument('--observer-lat', type=float, default=0.0,
|
||||
help="Observer latitude (default: 0.0)")
|
||||
|
||||
source = parser.add_mutually_exclusive_group()
|
||||
source.add_argument('--slots', type=str,
|
||||
help="Comma-separated slot list (e.g. '97W,99W,101W')")
|
||||
source.add_argument('--file', type=str,
|
||||
help="JSON file with slot definitions")
|
||||
source.add_argument('--arc', nargs=2, type=float, metavar=('START', 'STOP'),
|
||||
help="Arc range in degrees longitude")
|
||||
source.add_argument('--resume', type=str,
|
||||
help="Resume from a saved arc survey state file")
|
||||
source.add_argument('--list-slots', action='store_true',
|
||||
help="List common NA orbital slots and exit")
|
||||
|
||||
parser.add_argument('--step', type=float, default=3.0,
|
||||
help="Step size for --arc mode (default: 3.0 degrees)")
|
||||
parser.add_argument('--coarse-step', type=float, default=5.0,
|
||||
help="Coarse sweep step in MHz (default: 5.0)")
|
||||
parser.add_argument('--settle-time', type=float, default=15.0,
|
||||
help="Minimum motor settle time in seconds (default: 15)")
|
||||
parser.add_argument('--pol', type=str, default="",
|
||||
help="Polarization label (H/V, for catalog metadata)")
|
||||
parser.add_argument('--band', type=str, default="",
|
||||
help="Band label (low/high, for catalog metadata)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_slots:
|
||||
print("Common North American GEO orbital slots:")
|
||||
for name in sorted(NA_ORBITAL_SLOTS, key=lambda n: NA_ORBITAL_SLOTS[n]):
|
||||
lon = NA_ORBITAL_SLOTS[name]
|
||||
print(f" {name:>8s} {lon:+7.1f}")
|
||||
return
|
||||
|
||||
# Determine slot list
|
||||
resume_state = None
|
||||
|
||||
if args.resume:
|
||||
with open(args.resume) as f:
|
||||
resume_state = json.load(f)
|
||||
observer_lon = resume_state["observer_lon"]
|
||||
observer_lat = resume_state.get("observer_lat", 0.0)
|
||||
# Reconstruct slots from state
|
||||
all_slot_names = (
|
||||
list(resume_state.get("completed_slots", {}).keys()) +
|
||||
list(resume_state.get("skipped_slots", {}).keys())
|
||||
)
|
||||
# We need the original slot list — reconstruct from completed + remaining
|
||||
slots = []
|
||||
for name, info in resume_state.get("completed_slots", {}).items():
|
||||
slots.append((name, info["sat_lon"]))
|
||||
for name, info in resume_state.get("skipped_slots", {}).items():
|
||||
slots.append((name, info["sat_lon"]))
|
||||
# Sort by longitude
|
||||
slots.sort(key=lambda x: x[1])
|
||||
print(f"Resuming arc survey: {len(resume_state.get('completed_slots', {}))} "
|
||||
f"of {len(slots)} slots completed")
|
||||
|
||||
else:
|
||||
if not args.observer_lon and args.observer_lon != 0:
|
||||
parser.error("--observer-lon is required (or use --resume)")
|
||||
|
||||
observer_lon = args.observer_lon
|
||||
observer_lat = args.observer_lat
|
||||
|
||||
if args.slots:
|
||||
slots = parse_slot_string(args.slots)
|
||||
elif args.file:
|
||||
with open(args.file) as f:
|
||||
data = json.load(f)
|
||||
slots = [(d["name"], d["lon"]) for d in data]
|
||||
elif args.arc:
|
||||
start, stop = sorted(args.arc)
|
||||
slots = generate_arc_range(start, stop, args.step)
|
||||
else:
|
||||
parser.error("Specify --slots, --file, --arc, or --resume")
|
||||
|
||||
if not slots:
|
||||
print("No orbital slots to survey", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Arc Survey")
|
||||
print(f" Observer: {observer_lon:.2f} lon, {observer_lat:.2f} lat")
|
||||
print(f" Orbital slots: {len(slots)}")
|
||||
for name, lon in slots:
|
||||
angle = usals_angle(observer_lon, lon, observer_lat)
|
||||
direction = "W" if angle < 0 else "E"
|
||||
print(f" {name:>8s} {lon:+7.1f} (motor: {abs(angle):.1f} deg {direction})")
|
||||
print()
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as sw:
|
||||
sw.ensure_booted()
|
||||
|
||||
survey = ArcSurvey(
|
||||
sw, observer_lon, observer_lat,
|
||||
settle_time=args.settle_time,
|
||||
)
|
||||
|
||||
state = survey.run_arc(
|
||||
slots,
|
||||
coarse_step=args.coarse_step,
|
||||
band=args.band, pol=args.pol,
|
||||
resume_state=resume_state,
|
||||
)
|
||||
|
||||
print_summary(state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
376
tools/beacon_logger.py
Normal file
376
tools/beacon_logger.py
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Long-term satellite beacon logger for the Genpix SkyWalker-1.
|
||||
|
||||
Locks onto a stable Ku-band transponder and logs SNR/AGC at configurable
|
||||
intervals for hours, days, or weeks. Produces propagation datasets useful
|
||||
for rain fade analysis, diurnal thermal drift measurement, antenna mount
|
||||
stability assessment, and ITU propagation model validation.
|
||||
|
||||
Usage:
|
||||
python beacon_logger.py --freq 12015 --sr 20000 # log to stdout
|
||||
python beacon_logger.py --freq 12015 --sr 20000 -o log.csv # log to CSV
|
||||
python beacon_logger.py --freq 12015 --sr 20000 --daemon # background mode
|
||||
python beacon_logger.py --generate-systemd # print unit file
|
||||
|
||||
The tool automatically re-locks on signal loss and logs statistics per
|
||||
reporting interval (min/max/mean/stddev of SNR over each window).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
import csv
|
||||
import math
|
||||
import json
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
|
||||
|
||||
|
||||
def compute_stats(values: list[float]) -> dict:
|
||||
"""Compute min/max/mean/stddev for a list of measurements."""
|
||||
if not values:
|
||||
return {"min": 0, "max": 0, "mean": 0, "stddev": 0, "count": 0}
|
||||
|
||||
n = len(values)
|
||||
mean = sum(values) / n
|
||||
variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0
|
||||
return {
|
||||
"min": round(min(values), 3),
|
||||
"max": round(max(values), 3),
|
||||
"mean": round(mean, 3),
|
||||
"stddev": round(math.sqrt(variance), 3),
|
||||
"count": n,
|
||||
}
|
||||
|
||||
|
||||
class BeaconLogger:
|
||||
"""Persistent signal logger with auto-relock and statistics."""
|
||||
|
||||
def __init__(self, sw: SkyWalker1, freq_khz: int, sr_sps: int,
|
||||
mod_index: int = 0, fec_index: int = 5,
|
||||
sample_interval: float = 1.0, report_interval: float = 60.0):
|
||||
self.sw = sw
|
||||
self.freq_khz = freq_khz
|
||||
self.sr_sps = sr_sps
|
||||
self.mod_index = mod_index
|
||||
self.fec_index = fec_index
|
||||
self.sample_interval = sample_interval
|
||||
self.report_interval = report_interval
|
||||
|
||||
self._running = False
|
||||
self._relock_count = 0
|
||||
self._total_samples = 0
|
||||
|
||||
def tune_and_lock(self) -> bool:
|
||||
"""Tune to the beacon frequency and check for lock."""
|
||||
self.sw.tune(self.sr_sps, self.freq_khz, self.mod_index, self.fec_index)
|
||||
time.sleep(0.5)
|
||||
sig = self.sw.signal_monitor()
|
||||
return sig.get("locked", False)
|
||||
|
||||
def run(self, duration_secs: float, csv_path: str | None = None,
|
||||
json_path: str | None = None, quiet: bool = False) -> None:
|
||||
"""Main logging loop.
|
||||
|
||||
Samples signal at sample_interval, computes statistics over
|
||||
report_interval, outputs to CSV/JSON/stdout.
|
||||
"""
|
||||
self._running = True
|
||||
|
||||
# Register signal handlers for clean shutdown
|
||||
def _stop(signum, frame):
|
||||
self._running = False
|
||||
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
|
||||
# Initial tune
|
||||
locked = self.tune_and_lock()
|
||||
if not locked:
|
||||
print(f"Warning: no lock at {self.freq_khz} kHz, will keep trying",
|
||||
file=sys.stderr)
|
||||
|
||||
# Open CSV
|
||||
csv_file = None
|
||||
csv_writer = None
|
||||
if csv_path:
|
||||
csv_file = open(csv_path, 'w', newline='')
|
||||
csv_writer = csv.writer(csv_file)
|
||||
csv_writer.writerow([
|
||||
"timestamp", "elapsed_s", "snr_db", "agc1", "agc2",
|
||||
"power_db", "locked", "relock_count",
|
||||
])
|
||||
|
||||
# Open JSON log (append mode, one JSON object per report line)
|
||||
json_file = None
|
||||
if json_path:
|
||||
json_file = open(json_path, 'a')
|
||||
|
||||
start_time = time.time()
|
||||
last_report = start_time
|
||||
window_snr = []
|
||||
window_power = []
|
||||
window_agc1 = []
|
||||
lock_count_window = 0
|
||||
sample_count_window = 0
|
||||
|
||||
try:
|
||||
while self._running and (time.time() - start_time) < duration_secs:
|
||||
now = time.time()
|
||||
elapsed = now - start_time
|
||||
|
||||
# Sample
|
||||
try:
|
||||
sig = self.sw.signal_monitor()
|
||||
except Exception as e:
|
||||
if not quiet:
|
||||
print(f" USB error: {e}", file=sys.stderr)
|
||||
time.sleep(self.sample_interval)
|
||||
continue
|
||||
|
||||
self._total_samples += 1
|
||||
sample_count_window += 1
|
||||
|
||||
snr_db = sig["snr_db"]
|
||||
agc1 = sig["agc1"]
|
||||
agc2 = sig["agc2"]
|
||||
power_db = sig["power_db"]
|
||||
locked = sig["locked"]
|
||||
|
||||
if locked:
|
||||
lock_count_window += 1
|
||||
window_snr.append(snr_db)
|
||||
window_power.append(power_db)
|
||||
window_agc1.append(agc1)
|
||||
|
||||
# Write raw sample to CSV
|
||||
if csv_writer:
|
||||
csv_writer.writerow([
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
f"{elapsed:.1f}",
|
||||
f"{snr_db:.3f}",
|
||||
agc1, agc2,
|
||||
f"{power_db:.3f}",
|
||||
int(locked),
|
||||
self._relock_count,
|
||||
])
|
||||
csv_file.flush()
|
||||
|
||||
# Auto-relock
|
||||
if not locked:
|
||||
if not quiet:
|
||||
print(f" [{elapsed:.0f}s] Signal lost, attempting relock...",
|
||||
file=sys.stderr)
|
||||
if self.tune_and_lock():
|
||||
self._relock_count += 1
|
||||
if not quiet:
|
||||
print(f" [{elapsed:.0f}s] Relocked (count: {self._relock_count})",
|
||||
file=sys.stderr)
|
||||
|
||||
# Periodic report
|
||||
if now - last_report >= self.report_interval:
|
||||
report = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"elapsed_s": round(elapsed, 1),
|
||||
"samples": sample_count_window,
|
||||
"lock_pct": round(100 * lock_count_window / max(sample_count_window, 1), 1),
|
||||
"snr": compute_stats(window_snr),
|
||||
"power": compute_stats(window_power),
|
||||
"agc1": compute_stats(window_agc1),
|
||||
"relock_count": self._relock_count,
|
||||
}
|
||||
|
||||
if not quiet:
|
||||
snr_s = report["snr"]
|
||||
print(f" [{elapsed:7.0f}s] SNR {snr_s['mean']:5.1f} dB "
|
||||
f"(min {snr_s['min']:.1f}, max {snr_s['max']:.1f}, "
|
||||
f"std {snr_s['stddev']:.2f}) "
|
||||
f"lock {report['lock_pct']:.0f}% "
|
||||
f"relocks {self._relock_count}")
|
||||
|
||||
if json_file:
|
||||
json_file.write(json.dumps(report) + "\n")
|
||||
json_file.flush()
|
||||
|
||||
# Reset window
|
||||
window_snr.clear()
|
||||
window_power.clear()
|
||||
window_agc1.clear()
|
||||
lock_count_window = 0
|
||||
sample_count_window = 0
|
||||
last_report = now
|
||||
|
||||
time.sleep(self.sample_interval)
|
||||
|
||||
finally:
|
||||
if csv_file:
|
||||
csv_file.close()
|
||||
if json_file:
|
||||
json_file.close()
|
||||
|
||||
total_elapsed = time.time() - start_time
|
||||
if not quiet:
|
||||
print(f"\n Session complete: {self._total_samples} samples in "
|
||||
f"{total_elapsed:.0f}s, {self._relock_count} relocks")
|
||||
|
||||
|
||||
def generate_systemd_unit(args) -> str:
|
||||
"""Generate a systemd unit file for daemon operation."""
|
||||
cmd_parts = ["python3", os.path.abspath(__file__)]
|
||||
cmd_parts.extend(["--freq", str(args.freq)])
|
||||
cmd_parts.extend(["--sr", str(args.sr)])
|
||||
if args.output:
|
||||
cmd_parts.extend(["--output", os.path.abspath(args.output)])
|
||||
if args.json_output:
|
||||
cmd_parts.extend(["--json-output", os.path.abspath(args.json_output)])
|
||||
cmd_parts.extend(["--duration", str(args.duration)])
|
||||
cmd_parts.extend(["--sample-interval", str(args.sample_interval)])
|
||||
cmd_parts.extend(["--report-interval", str(args.report_interval)])
|
||||
cmd_parts.append("--quiet")
|
||||
|
||||
return f"""[Unit]
|
||||
Description=SkyWalker-1 Beacon Logger ({args.freq} kHz)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart={' '.join(cmd_parts)}
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="beacon_logger.py",
|
||||
description="Long-term satellite beacon logger for SkyWalker-1",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
%(prog)s --freq 12015 --sr 20000 # Ku-band beacon, stdout
|
||||
%(prog)s --freq 12015 --sr 20000 -o beacon.csv # log to CSV
|
||||
%(prog)s --freq 12015 --sr 20000 --json-output beacon.jsonl # per-minute JSON
|
||||
%(prog)s --freq 12015 --sr 20000 --duration 86400 # 24-hour log
|
||||
%(prog)s --freq 12015 --sr 20000 --daemon # background
|
||||
%(prog)s --generate-systemd --freq 12015 --sr 20000 # print unit file
|
||||
|
||||
The --freq is in kHz (IF frequency), not MHz. For Ku-band with a universal
|
||||
LNB at LO 10750 MHz, a transponder at 12015 MHz has IF = 12015 - 10750 = 1265 MHz,
|
||||
so you'd use --freq 1265000.
|
||||
|
||||
For IF frequencies, multiply MHz by 1000 (e.g., 1265 MHz = 1265000 kHz).
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='store_true')
|
||||
parser.add_argument('--freq', type=int, required=True,
|
||||
help="IF frequency in kHz (e.g., 1265000 for 1265 MHz)")
|
||||
parser.add_argument('--sr', type=int, default=20000000,
|
||||
help="Symbol rate in sps (default: 20000000)")
|
||||
parser.add_argument('--mod', type=str, default="qpsk",
|
||||
help="Modulation type (default: qpsk)")
|
||||
parser.add_argument('--fec', type=str, default="auto",
|
||||
help="FEC rate (default: auto)")
|
||||
|
||||
parser.add_argument('--output', '-o', type=str, default=None,
|
||||
help="CSV output file (raw samples)")
|
||||
parser.add_argument('--json-output', type=str, default=None,
|
||||
help="JSONL output file (per-interval statistics)")
|
||||
|
||||
parser.add_argument('--duration', type=float, default=3600,
|
||||
help="Logging duration in seconds (default: 3600)")
|
||||
parser.add_argument('--sample-interval', type=float, default=1.0,
|
||||
help="Seconds between samples (default: 1.0)")
|
||||
parser.add_argument('--report-interval', type=float, default=60.0,
|
||||
help="Seconds between summary reports (default: 60)")
|
||||
|
||||
parser.add_argument('--pol', type=str, default=None, choices=['H', 'V'],
|
||||
help="LNB polarization (H=18V, V=13V)")
|
||||
parser.add_argument('--band', type=str, default=None, choices=['low', 'high'],
|
||||
help="LNB band (low=no tone, high=22kHz)")
|
||||
|
||||
parser.add_argument('--daemon', action='store_true',
|
||||
help="Run as daemon (suppress stdout)")
|
||||
parser.add_argument('--quiet', action='store_true',
|
||||
help="Suppress progress output to stderr")
|
||||
parser.add_argument('--generate-systemd', action='store_true',
|
||||
help="Print a systemd unit file and exit")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.generate_systemd:
|
||||
print(generate_systemd_unit(args))
|
||||
return
|
||||
|
||||
# Resolve modulation/FEC indices
|
||||
mod_entry = MODULATIONS.get(args.mod)
|
||||
if mod_entry is None:
|
||||
print(f"Unknown modulation '{args.mod}'. Valid: {list(MODULATIONS.keys())}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
mod_idx = mod_entry[0]
|
||||
|
||||
fec_group = MOD_FEC_GROUP.get(args.mod, "dvbs")
|
||||
fec_table = FEC_RATES.get(fec_group, {})
|
||||
fec_idx = fec_table.get(args.fec, fec_table.get("auto", 0))
|
||||
|
||||
quiet = args.daemon or args.quiet
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as sw:
|
||||
sw.ensure_booted()
|
||||
|
||||
# Configure LNB
|
||||
if args.pol:
|
||||
sw.set_lnb_voltage(args.pol.upper() in ("H", "L"))
|
||||
if args.band:
|
||||
sw.set_22khz_tone(args.band == "high")
|
||||
|
||||
freq_mhz = args.freq / 1000.0
|
||||
sr_msps = args.sr / 1e6
|
||||
|
||||
if not quiet:
|
||||
print(f"Beacon Logger")
|
||||
print(f" Frequency: {freq_mhz:.3f} MHz IF ({args.freq} kHz)")
|
||||
print(f" Symbol rate: {sr_msps:.3f} Msps")
|
||||
print(f" Modulation: {args.mod}, FEC: {args.fec}")
|
||||
print(f" Sample interval: {args.sample_interval}s")
|
||||
print(f" Report interval: {args.report_interval}s")
|
||||
print(f" Duration: {args.duration}s ({args.duration/3600:.1f}h)")
|
||||
if args.output:
|
||||
print(f" CSV output: {args.output}")
|
||||
if args.json_output:
|
||||
print(f" JSON output: {args.json_output}")
|
||||
print()
|
||||
|
||||
logger = BeaconLogger(
|
||||
sw, args.freq, args.sr,
|
||||
mod_index=mod_idx, fec_index=fec_idx,
|
||||
sample_interval=args.sample_interval,
|
||||
report_interval=args.report_interval,
|
||||
)
|
||||
logger.run(
|
||||
duration_secs=args.duration,
|
||||
csv_path=args.output,
|
||||
json_path=args.json_output,
|
||||
quiet=quiet,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
357
tools/h21cm.py
Normal file
357
tools/h21cm.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hydrogen 21 cm drift-scan radiometer for the Genpix SkyWalker-1.
|
||||
|
||||
Detects neutral hydrogen emission at 1420.405 MHz — directly in the IF range
|
||||
with no LNB required. Connect an L-band antenna (patch, helical, or horn)
|
||||
directly to the F-connector.
|
||||
|
||||
The Milky Way's spiral arms create a velocity-dispersed emission profile
|
||||
detectable even with the BCM4500's ~346 kHz RBW. Earth's rotation provides
|
||||
a natural drift-scan across the sky.
|
||||
|
||||
Usage:
|
||||
python h21cm.py # single sweep, print spectrum
|
||||
python h21cm.py --drift --duration 3600 # 1-hour drift scan
|
||||
python h21cm.py --drift --motor-step 5 # step motor between sweeps
|
||||
python h21cm.py --output data.csv # log to CSV
|
||||
|
||||
The c in 21 cm stands for centimeters. The frequency (1420.405 MHz) comes from
|
||||
the hyperfine transition in neutral hydrogen — when the electron's spin flips
|
||||
relative to the proton. This is the most fundamental spectral line in radio
|
||||
astronomy, and you can detect it with a $30 DVB-S dongle.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
import csv
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from skywalker_lib import SkyWalker1, agc_to_power_db
|
||||
|
||||
|
||||
# Physical constants
|
||||
H1_FREQ_MHZ = 1420.405751 # Hydrogen 21 cm rest frequency
|
||||
C_KM_S = 299792.458 # Speed of light
|
||||
|
||||
|
||||
def freq_to_velocity(freq_mhz: float) -> float:
|
||||
"""Convert observed frequency to radial velocity via Doppler shift.
|
||||
|
||||
v = c * (f_rest - f_obs) / f_rest
|
||||
|
||||
Positive velocity = receding (redshifted, lower frequency).
|
||||
Negative velocity = approaching (blueshifted, higher frequency).
|
||||
"""
|
||||
return C_KM_S * (H1_FREQ_MHZ - freq_mhz) / H1_FREQ_MHZ
|
||||
|
||||
|
||||
def sweep_h1_band(sw: SkyWalker1, center_mhz: float = H1_FREQ_MHZ,
|
||||
span_mhz: float = 4.0, step_mhz: float = 0.5,
|
||||
dwell_ms: int = 50, averages: int = 1) -> dict:
|
||||
"""Sweep the hydrogen line band and return power measurements.
|
||||
|
||||
Higher dwell_ms and multiple averages improve SNR for this weak signal.
|
||||
Default 50ms dwell is 5x longer than typical satellite sweeps.
|
||||
|
||||
Returns dict with frequencies, powers, velocities, and statistics.
|
||||
"""
|
||||
start = center_mhz - span_mhz / 2
|
||||
stop = center_mhz + span_mhz / 2
|
||||
|
||||
# Accumulate multiple sweeps for averaging
|
||||
all_powers = None
|
||||
for avg in range(averages):
|
||||
freqs, powers, raw = sw.sweep_spectrum(
|
||||
start, stop, step_mhz=step_mhz, dwell_ms=dwell_ms,
|
||||
sr_ksps=1000, mod_index=0, fec_index=5,
|
||||
)
|
||||
if all_powers is None:
|
||||
all_powers = [0.0] * len(powers)
|
||||
for i in range(len(powers)):
|
||||
all_powers[i] += powers[i]
|
||||
|
||||
# Average
|
||||
avg_powers = [p / averages for p in all_powers]
|
||||
|
||||
# Calculate velocities
|
||||
velocities = [freq_to_velocity(f) for f in freqs]
|
||||
|
||||
# Baseline: edges of the band should be "empty" (no hydrogen)
|
||||
edge_count = max(2, len(avg_powers) // 5)
|
||||
baseline = (sum(avg_powers[:edge_count]) + sum(avg_powers[-edge_count:])) / (2 * edge_count)
|
||||
|
||||
# Excess power above baseline
|
||||
excess = [p - baseline for p in avg_powers]
|
||||
|
||||
# Find peak excess (the hydrogen line center)
|
||||
peak_idx = max(range(len(excess)), key=lambda i: excess[i])
|
||||
peak_freq = freqs[peak_idx]
|
||||
peak_excess = excess[peak_idx]
|
||||
peak_velocity = velocities[peak_idx]
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"freqs_mhz": freqs,
|
||||
"powers_db": avg_powers,
|
||||
"velocities_km_s": velocities,
|
||||
"excess_db": excess,
|
||||
"baseline_db": baseline,
|
||||
"peak_freq_mhz": peak_freq,
|
||||
"peak_excess_db": peak_excess,
|
||||
"peak_velocity_km_s": peak_velocity,
|
||||
"averages": averages,
|
||||
"dwell_ms": dwell_ms,
|
||||
}
|
||||
|
||||
|
||||
def sweep_control_band(sw: SkyWalker1, step_mhz: float = 0.5,
|
||||
dwell_ms: int = 50) -> dict:
|
||||
"""Sweep a control band (1430-1434 MHz) where no hydrogen is expected.
|
||||
|
||||
Comparing the control band to the hydrogen band reveals whether a
|
||||
detected power bump is real emission or just system noise variation.
|
||||
"""
|
||||
freqs, powers, _ = sw.sweep_spectrum(
|
||||
1430.0, 1434.0, step_mhz=step_mhz, dwell_ms=dwell_ms,
|
||||
sr_ksps=1000, mod_index=0, fec_index=5,
|
||||
)
|
||||
mean_power = sum(powers) / len(powers) if powers else 0
|
||||
return {
|
||||
"control_freqs_mhz": freqs,
|
||||
"control_powers_db": powers,
|
||||
"control_mean_db": mean_power,
|
||||
}
|
||||
|
||||
|
||||
def print_spectrum(result: dict, show_velocity: bool = True) -> None:
|
||||
"""Print an ASCII spectrum of the hydrogen band."""
|
||||
freqs = result["freqs_mhz"]
|
||||
excess = result["excess_db"]
|
||||
velocities = result["velocities_km_s"]
|
||||
baseline = result["baseline_db"]
|
||||
|
||||
# Scale for display
|
||||
max_excess = max(excess) if excess else 1.0
|
||||
min_excess = min(excess)
|
||||
span = max(max_excess - min_excess, 0.5)
|
||||
|
||||
print(f"\n Hydrogen 21 cm Spectrum")
|
||||
print(f" Baseline: {baseline:.2f} dB | Peak excess: {result['peak_excess_db']:.2f} dB")
|
||||
print(f" Peak at {result['peak_freq_mhz']:.3f} MHz ({result['peak_velocity_km_s']:+.1f} km/s)")
|
||||
print()
|
||||
|
||||
bar_width = 50
|
||||
for i in range(len(freqs)):
|
||||
f = freqs[i]
|
||||
e = excess[i]
|
||||
v = velocities[i]
|
||||
|
||||
# Normalize to bar width
|
||||
filled = int((e - min_excess) / span * bar_width)
|
||||
filled = max(0, min(filled, bar_width))
|
||||
bar = '#' * filled + '-' * (bar_width - filled)
|
||||
|
||||
# Mark the hydrogen rest frequency
|
||||
marker = " *" if abs(f - H1_FREQ_MHZ) < 0.3 else " "
|
||||
|
||||
if show_velocity:
|
||||
print(f" {f:8.3f} MHz {v:+7.1f} km/s [{bar}] {e:+.2f} dB{marker}")
|
||||
else:
|
||||
print(f" {f:8.3f} MHz [{bar}] {e:+.2f} dB{marker}")
|
||||
|
||||
print()
|
||||
print(" * = hydrogen rest frequency (1420.405 MHz)")
|
||||
|
||||
|
||||
def drift_scan(sw: SkyWalker1, duration_secs: float, interval_secs: float,
|
||||
step_mhz: float, dwell_ms: int, averages: int,
|
||||
motor_step: int, output_path: str | None) -> None:
|
||||
"""Run a drift scan: repeated sweeps over time.
|
||||
|
||||
Earth's rotation naturally scans the sky. Each sweep captures the
|
||||
hydrogen profile at the current sky position. Over hours, you trace
|
||||
out the galactic plane.
|
||||
"""
|
||||
csv_writer = None
|
||||
csv_file = None
|
||||
header_written = False
|
||||
|
||||
if output_path:
|
||||
csv_file = open(output_path, 'w', newline='')
|
||||
csv_writer = csv.writer(csv_file)
|
||||
|
||||
start_time = time.time()
|
||||
scan_num = 0
|
||||
|
||||
try:
|
||||
while time.time() - start_time < duration_secs:
|
||||
scan_num += 1
|
||||
elapsed = time.time() - start_time
|
||||
remaining = duration_secs - elapsed
|
||||
|
||||
print(f"\n--- Scan #{scan_num} (elapsed {elapsed:.0f}s, "
|
||||
f"remaining {remaining:.0f}s) ---")
|
||||
|
||||
# Motor step between scans (for declination scanning)
|
||||
if motor_step and scan_num > 1:
|
||||
print(f" Stepping motor {motor_step} steps east...")
|
||||
sw.motor_drive_east(motor_step)
|
||||
time.sleep(1.0)
|
||||
|
||||
result = sweep_h1_band(sw, step_mhz=step_mhz,
|
||||
dwell_ms=dwell_ms, averages=averages)
|
||||
print_spectrum(result, show_velocity=True)
|
||||
|
||||
# Write CSV
|
||||
if csv_writer:
|
||||
if not header_written:
|
||||
csv_writer.writerow([
|
||||
"timestamp", "scan_num", "freq_mhz", "power_db",
|
||||
"excess_db", "velocity_km_s", "baseline_db",
|
||||
])
|
||||
header_written = True
|
||||
|
||||
for i in range(len(result["freqs_mhz"])):
|
||||
csv_writer.writerow([
|
||||
result["timestamp"],
|
||||
scan_num,
|
||||
f"{result['freqs_mhz'][i]:.3f}",
|
||||
f"{result['powers_db'][i]:.3f}",
|
||||
f"{result['excess_db'][i]:.3f}",
|
||||
f"{result['velocities_km_s'][i]:.1f}",
|
||||
f"{result['baseline_db']:.3f}",
|
||||
])
|
||||
csv_file.flush()
|
||||
|
||||
# Wait for next scan
|
||||
if remaining > interval_secs:
|
||||
print(f" Next scan in {interval_secs:.0f}s...")
|
||||
time.sleep(interval_secs)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n Drift scan interrupted")
|
||||
finally:
|
||||
if csv_file:
|
||||
csv_file.close()
|
||||
print(f" Data saved to {output_path}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="h21cm.py",
|
||||
description="Hydrogen 21 cm line radiometer for SkyWalker-1",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
examples:
|
||||
%(prog)s # single sweep, print spectrum
|
||||
%(prog)s --averages 8 # 8x averaging for better SNR
|
||||
%(prog)s --drift --duration 3600 # 1-hour drift scan
|
||||
%(prog)s --drift --motor-step 5 # step motor between sweeps
|
||||
%(prog)s --output h21cm-data.csv # log to CSV
|
||||
%(prog)s --control # include control band comparison
|
||||
|
||||
notes:
|
||||
- Connect an L-band antenna directly to the F-connector (no LNB)
|
||||
- LNB power is disabled automatically for direct input
|
||||
- Hydrogen emission is weak; use --averages 4-16 for best results
|
||||
- The --dwell option increases per-step integration time (default 50ms)
|
||||
- Earth rotation provides natural sky drift at ~15 deg/hour
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help="Show raw USB traffic")
|
||||
parser.add_argument('--center', type=float, default=H1_FREQ_MHZ,
|
||||
help=f"Center frequency in MHz (default: {H1_FREQ_MHZ})")
|
||||
parser.add_argument('--span', type=float, default=4.0,
|
||||
help="Frequency span in MHz (default: 4.0)")
|
||||
parser.add_argument('--step', type=float, default=0.5,
|
||||
help="Frequency step in MHz (default: 0.5)")
|
||||
parser.add_argument('--dwell', type=int, default=50,
|
||||
help="Dwell time per step in ms (default: 50)")
|
||||
parser.add_argument('--averages', type=int, default=1,
|
||||
help="Number of sweeps to average (default: 1)")
|
||||
parser.add_argument('--output', '-o', type=str, default=None,
|
||||
help="CSV output file path")
|
||||
parser.add_argument('--control', action='store_true',
|
||||
help="Include control band (1430-1434 MHz) for comparison")
|
||||
parser.add_argument('--no-velocity', action='store_true',
|
||||
help="Don't show velocity axis in spectrum display")
|
||||
|
||||
drift_group = parser.add_argument_group('drift scan')
|
||||
drift_group.add_argument('--drift', action='store_true',
|
||||
help="Enable drift scan mode (repeated sweeps)")
|
||||
drift_group.add_argument('--duration', type=float, default=3600,
|
||||
help="Drift scan duration in seconds (default: 3600)")
|
||||
drift_group.add_argument('--interval', type=float, default=60,
|
||||
help="Seconds between sweeps (default: 60)")
|
||||
drift_group.add_argument('--motor-step', type=int, default=0,
|
||||
help="Motor steps between sweeps (0=no motor, default: 0)")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
with SkyWalker1(verbose=args.verbose) as sw:
|
||||
sw.ensure_booted()
|
||||
|
||||
# Disable LNB power for direct input
|
||||
sw.start_intersil(on=False)
|
||||
print("LNB power disabled (direct L-band input mode)")
|
||||
|
||||
if args.drift:
|
||||
drift_scan(sw, duration_secs=args.duration,
|
||||
interval_secs=args.interval,
|
||||
step_mhz=args.step, dwell_ms=args.dwell,
|
||||
averages=args.averages, motor_step=args.motor_step,
|
||||
output_path=args.output)
|
||||
else:
|
||||
# Single sweep
|
||||
print(f"\nSweeping {args.center - args.span/2:.1f} - "
|
||||
f"{args.center + args.span/2:.1f} MHz "
|
||||
f"(step={args.step} MHz, dwell={args.dwell}ms, "
|
||||
f"avg={args.averages}x)")
|
||||
|
||||
result = sweep_h1_band(sw, center_mhz=args.center,
|
||||
span_mhz=args.span, step_mhz=args.step,
|
||||
dwell_ms=args.dwell, averages=args.averages)
|
||||
print_spectrum(result, show_velocity=not args.no_velocity)
|
||||
|
||||
if args.control:
|
||||
print(" Control band (1430-1434 MHz, no hydrogen expected):")
|
||||
ctrl = sweep_control_band(sw, step_mhz=args.step, dwell_ms=args.dwell)
|
||||
print(f" Control mean: {ctrl['control_mean_db']:.2f} dB")
|
||||
print(f" H1 baseline: {result['baseline_db']:.2f} dB")
|
||||
diff = result["peak_excess_db"]
|
||||
print(f" H1 peak excess above baseline: {diff:+.2f} dB")
|
||||
|
||||
# Write single sweep to CSV if requested
|
||||
if args.output:
|
||||
with open(args.output, 'w', newline='') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow([
|
||||
"freq_mhz", "power_db", "excess_db",
|
||||
"velocity_km_s", "baseline_db",
|
||||
])
|
||||
for i in range(len(result["freqs_mhz"])):
|
||||
writer.writerow([
|
||||
f"{result['freqs_mhz'][i]:.3f}",
|
||||
f"{result['powers_db'][i]:.3f}",
|
||||
f"{result['excess_db'][i]:.3f}",
|
||||
f"{result['velocities_km_s'][i]:.1f}",
|
||||
f"{result['baseline_db']:.3f}",
|
||||
])
|
||||
print(f" Data saved to {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue