Files
fhd_rtk_ws/tools/ntrip_probe.py
T
Dongubak 11fa444ce7 upload
2026-08-22 02:54:26 +09:00

401 lines
15 KiB
Python

#!/usr/bin/env python3
"""NTRIP 캐스터 조사/검증 도구 — 마운트포인트가 '진짜 단일기준국'인지 판별한다.
VRS(가상 기지국)와 단일기준국(실제 상시관측소)은 같은 RTCM3 스트림으로 오지만
메시지 구성이 다르다. 이 도구는 스트림을 직접 뜯어서 구분한다.
table 캐스터 소스테이블 출력 (인증 불필요)
near 내 좌표에서 가까운 기준국 마운트포인트 정렬
sniff 마운트포인트에 접속해 RTCM 메시지 종류/기지국 좌표/베이스라인을 관찰
→ 기지국 좌표가 시간에 따라 움직이면 VRS, 고정이면 실제 기지국
사용 예:
python3 ntrip_probe.py table gnssdata.or.kr 2101
python3 ntrip_probe.py near gnssdata.or.kr 2101 37.5665 126.9780
python3 ntrip_probe.py sniff RTS1.ngii.go.kr 2101 VRS-RTCM34 \
--user ID --pass PW --gga-from-latlon 37.5665 126.9780 --secs 60
"""
import argparse
import base64
import math
import socket
import sys
import time
from collections import Counter
# ── RTCM3 프레이밍 ──────────────────────────────────────────────────────────
# 0xD3 | 6bit reserved + 10bit length | payload | 3byte CRC-24Q
_CRC24_TAB = []
def _init_crc24():
poly = 0x1864CFB
for i in range(256):
crc = i << 16
for _ in range(8):
crc <<= 1
if crc & 0x1000000:
crc ^= poly
_CRC24_TAB.append(crc & 0xFFFFFF)
_init_crc24()
def crc24q(data: bytes) -> int:
crc = 0
for b in data:
crc = ((crc << 8) & 0xFFFFFF) ^ _CRC24_TAB[((crc >> 16) ^ b) & 0xFF]
return crc
class Bits:
"""MSB-first 비트 리더 (RTCM DF 필드용)."""
def __init__(self, buf: bytes):
self.buf = buf
self.pos = 0
def u(self, n: int) -> int:
v = 0
for _ in range(n):
byte = self.buf[self.pos >> 3]
v = (v << 1) | ((byte >> (7 - (self.pos & 7))) & 1)
self.pos += 1
return v
def s(self, n: int) -> int:
v = self.u(n)
return v - (1 << n) if v & (1 << (n - 1)) else v
def parse_1005_1006(payload: bytes):
"""1005/1006 → (station_id, X, Y, Z, antenna_height|None). 단위 m."""
b = Bits(payload)
msg = b.u(12)
if msg not in (1005, 1006):
return None
sid = b.u(12)
b.u(6) # ITRF realization year
b.u(1); b.u(1); b.u(1) # GPS / GLONASS / Galileo indicator
b.u(1) # reference station indicator
x = b.s(38) * 1e-4
b.u(1); b.u(1) # single receiver osc / reserved
y = b.s(38) * 1e-4
b.u(2) # quarter cycle indicator
z = b.s(38) * 1e-4
h = b.u(16) * 1e-4 if msg == 1006 else None
return sid, x, y, z, h
def ecef_to_lla(x, y, z):
a, f = 6378137.0, 1 / 298.257223563
e2 = f * (2 - f)
lon = math.atan2(y, x)
p = math.hypot(x, y)
lat = math.atan2(z, p * (1 - e2))
for _ in range(10):
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
alt = p / math.cos(lat) - n
lat = math.atan2(z, p * (1 - e2 * n / (n + alt)))
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
return math.degrees(lat), math.degrees(lon), p / math.cos(lat) - n
def haversine_km(lat1, lon1, lat2, lon2):
r = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
h = (math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2)
return 2 * r * math.asin(math.sqrt(h))
# 네트워크 RTK(=VRS/FKP/MAC) 임을 드러내는 메시지들
NETWORK_MSGS = {
1014: "Network Auxiliary Station Data (MAC)",
1015: "GPS Ionospheric Correction Differences (MAC)",
1016: "GPS Geometric Correction Differences (MAC)",
1017: "GPS Combined Correction Differences (MAC)",
1030: "GPS Network RTK Residual",
1031: "GLONASS Network RTK Residual",
1032: "Physical Reference Station Position (VRS)",
1034: "GPS Network FKP Gradient",
1035: "GLONASS Network FKP Gradient",
}
# ── NTRIP ───────────────────────────────────────────────────────────────────
def ntrip_open(host, port, mount=None, user="", pw="", timeout=15):
s = socket.create_connection((host, port), timeout=timeout)
path = "/" + (mount or "")
req = f"GET {path} HTTP/1.0\r\nUser-Agent: NTRIP ntrip_probe/1.0\r\nAccept: */*\r\n"
if user or pw:
auth = base64.b64encode(f"{user}:{pw}".encode()).decode()
req += f"Authorization: Basic {auth}\r\n"
req += "Connection: close\r\n\r\n"
s.sendall(req.encode())
return s
def read_header(s):
"""응답 헤더를 소진하고 (상태줄, 남은 바이트) 반환."""
buf = b""
while b"\r\n\r\n" not in buf and b"\n\n" not in buf:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
if buf.startswith(b"ICY 200 OK") and len(buf) > 12: # NTRIP1 짧은 응답
break
if len(buf) > 65536:
break
sep = b"\r\n\r\n" if b"\r\n\r\n" in buf else b"\n\n"
head, _, rest = buf.partition(sep)
status = head.split(b"\r\n")[0].decode("latin1", "replace")
return status, head, rest
def fetch_sourcetable(host, port):
s = ntrip_open(host, port)
data = b""
try:
while True:
chunk = s.recv(8192)
if not chunk:
break
data += chunk
finally:
s.close()
return data.decode("latin1", "replace")
def parse_str_rows(text, host=""):
# 캐스터가 짧은 간격의 재요청을 조용히 끊는 일이 있다 — 빈 결과를 정상으로 오인하면 안 된다.
if "STR;" not in text:
print(f"# ⚠️ 소스테이블 응답에 STR 항목이 없음 ({len(text)} bytes)"
+ (f" — {host} 재시도해볼 것" if host else ""), file=sys.stderr)
if text[:200].strip():
print("# 응답 앞부분: " + repr(text[:200]), file=sys.stderr)
rows = []
for line in text.splitlines():
if not line.startswith("STR;"):
continue
f = line.split(";")
if len(f) < 13:
continue
try:
lat, lon = float(f[9]), float(f[10])
except ValueError:
lat = lon = None
rows.append({
"mount": f[1], "format": f[3], "details": f[4], "nav": f[6],
"network": f[7], "lat": lat, "lon": lon,
"nmea": f[11], "solution": f[12],
"generator": f[13] if len(f) > 13 else "",
"auth": f[15] if len(f) > 15 else "",
})
return rows
def nmea_gga(lat, lon, alt=50.0):
def dm(v, deg_w):
d = int(abs(v))
m = (abs(v) - d) * 60
return f"{d:0{deg_w}d}{m:09.6f}"
t = time.gmtime()
body = (f"GPGGA,{t.tm_hour:02d}{t.tm_min:02d}{t.tm_sec:02d}.00,"
f"{dm(lat,2)},{'N' if lat>=0 else 'S'},"
f"{dm(lon,3)},{'E' if lon>=0 else 'W'},"
f"1,10,1.0,{alt:.1f},M,20.0,M,,")
ck = 0
for c in body:
ck ^= ord(c)
return f"${body}*{ck:02X}\r\n".encode()
# ── 서브커맨드 ──────────────────────────────────────────────────────────────
def cmd_table(a):
text = fetch_sourcetable(a.host, a.port)
rows = parse_str_rows(text, a.host)
print(f"# {a.host}:{a.port} — STR {len(rows)}개")
print(f"{'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'NET':<12} {'NMEA':<5} {'SOL':<4} {'종류'}")
for r in rows:
if a.filter and a.filter.upper() not in r["mount"].upper():
continue
kind = "네트워크(VRS/FKP/MAC)" if r["solution"] == "1" else "단일기준국"
print(f"{r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} "
f"{r['network'][:12]:<12} {r['nmea']:<5} {r['solution']:<4} {kind}")
net = sum(1 for r in rows if r["solution"] == "1")
print(f"\n요약: 단일기준국 {len(rows)-net}개 / 네트워크 {net}개")
print(" · SOL=0 → 실제 기준국 1곳의 관측치(단일기준국)")
print(" · SOL=1 → 망 보간 해(VRS/FKP/MAC). NMEA=1 이면 GGA 업링크 필요")
def cmd_near(a):
rows = parse_str_rows(fetch_sourcetable(a.host, a.port), a.host)
cand = []
for r in rows:
if r["lat"] is None or (r["lat"] == 0 and r["lon"] == 0):
continue
if a.format and a.format.upper() not in r["format"].upper().replace(" ", ""):
continue
cand.append((haversine_km(a.lat, a.lon, r["lat"], r["lon"]), r))
cand.sort(key=lambda t: t[0])
print(f"# 기준 위치 {a.lat:.5f}, {a.lon:.5f} — 가까운 순 {a.n}개"
+ (f" (포맷 필터: {a.format})" if a.format else ""))
print(f"{'거리km':>8} {'MOUNTPOINT':<18} {'FORMAT':<9} {'NAV':<34} {'SOL'}")
for d, r in cand[:a.n]:
print(f"{d:8.1f} {r['mount']:<18} {r['format']:<9} {r['nav'][:34]:<34} {r['solution']}")
print("\n주의: 소스테이블 좌표는 반올림/오기입이 흔하다(0.1도 = 약 11km).")
print(" 최종 확인은 sniff 로 1005/1006 기지국 실좌표를 봐야 한다.")
def cmd_sniff(a):
s = ntrip_open(a.host, a.port, a.mount, a.user, getattr(a, "pw"))
status, head, rest = read_header(s)
print(f"# 접속: {a.host}:{a.port}/{a.mount}")
print(f"# 응답: {status}")
if "200" not in status:
print(head.decode("latin1", "replace"))
return 1
gga = None
if a.gga_from_latlon:
gga = nmea_gga(a.gga_from_latlon[0], a.gga_from_latlon[1])
s.sendall(gga)
print(f"# GGA 업링크: {gga.decode().strip()}")
s.settimeout(5)
buf = bytearray(rest)
types = Counter()
bases = {} # station_id -> 마지막 좌표
base_moves = [] # (t, sid, 이동거리 m)
total = 0
t0 = time.time()
last_gga = t0
while time.time() - t0 < a.secs:
try:
chunk = s.recv(4096)
except socket.timeout:
print(" (수신 없음 5초)")
continue
if not chunk:
print("# 스트림 종료")
break
total += len(chunk)
buf += chunk
i = 0
while True:
j = buf.find(b"\xd3", i)
if j < 0 or len(buf) - j < 3:
break
# 길이 상위 바이트의 reserved 6bit 는 항상 0. 이걸 안 보면 페이로드 안의
# 우연한 0xD3 을 프레임 시작으로 착각해 최대 1023B 동안 파서가 멈춘다.
if buf[j + 1] & 0xFC:
i = j + 1
continue
length = ((buf[j + 1] & 0x03) << 8) | buf[j + 2]
frame_len = 3 + length + 3
if len(buf) - j < frame_len:
break
frame = bytes(buf[j:j + frame_len])
got = int.from_bytes(frame[-3:], "big")
if crc24q(frame[:-3]) != got:
i = j + 1 # 오정렬 → 다음 0xD3 부터
continue
payload = frame[3:3 + length]
if length >= 2:
mt = (payload[0] << 4) | (payload[1] >> 4)
types[mt] += 1
if mt in (1005, 1006):
p = parse_1005_1006(payload)
if p:
sid, x, y, z, h = p
prev = bases.get(sid)
if prev and (abs(prev[0] - x) > 0.5 or abs(prev[1] - y) > 0.5
or abs(prev[2] - z) > 0.5):
d = math.dist(prev[:3], (x, y, z))
base_moves.append((time.time() - t0, sid, d))
print(f" ⚠️ 기지국 좌표 이동: id={sid} {d:.1f} m (t+{time.time()-t0:.0f}s)")
bases[sid] = (x, y, z, h)
i = j + frame_len
del buf[:i]
if gga and time.time() - last_gga > 10:
s.sendall(gga)
last_gga = time.time()
s.close()
dur = time.time() - t0
print(f"\n## 결과 ({dur:.0f}초, {total} bytes, {total/max(dur,1):.0f} B/s)")
print("\n### RTCM 메시지 종류")
for mt, c in sorted(types.items()):
tag = f" ← {NETWORK_MSGS[mt]}" if mt in NETWORK_MSGS else ""
print(f" {mt:<5} x{c:<6}{tag}")
print("\n### 기지국(1005/1006) 좌표")
if not bases:
print(" 없음 — 1005/1006 이 안 왔다. 관측 시간을 늘려보라(권장 60초 이상).")
for sid, (x, y, z, h) in bases.items():
lat, lon, alt = ecef_to_lla(x, y, z)
line = f" id={sid} {lat:.7f}, {lon:.7f}, {alt:.2f}m"
if h is not None:
line += f" (안테나고 {h:.3f}m)"
print(line)
if a.gga_from_latlon:
d = haversine_km(a.gga_from_latlon[0], a.gga_from_latlon[1], lat, lon)
print(f" 베이스라인 {d:.2f} km")
print("\n### 판정")
net_found = sorted(set(types) & set(NETWORK_MSGS))
if net_found:
print(f" ❌ 네트워크 RTK 스트림 — 네트워크 전용 메시지 {net_found} 존재")
elif base_moves:
print(f" ❌ 기지국 좌표가 {len(base_moves)}회 이동 — VRS(가상 기지국)")
elif bases:
print(" ✅ 단일기준국으로 보임 — 네트워크 메시지 없음 + 기지국 좌표 고정")
else:
print(" ? 판정 불가 — 1005/1006 미수신")
print("\n ※ VRS 는 GGA 를 보낸 위치 근처에 가상 기지국을 만든다. 정지 상태로 짧게 보면")
print(" 좌표가 고정으로 보일 수 있으므로, 네트워크 메시지 유무를 1차 근거로 삼을 것.")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("table", help="소스테이블 출력")
p.add_argument("host"); p.add_argument("port", type=int)
p.add_argument("--filter", help="마운트포인트 이름 부분일치 필터")
p.set_defaults(func=cmd_table)
p = sub.add_parser("near", help="내 위치에서 가까운 기준국 정렬")
p.add_argument("host"); p.add_argument("port", type=int)
p.add_argument("lat", type=float); p.add_argument("lon", type=float)
p.add_argument("-n", type=int, default=10)
p.add_argument("--format", help="포맷 필터 (예: RTCM3.2)")
p.set_defaults(func=cmd_near)
p = sub.add_parser("sniff", help="스트림을 뜯어 VRS/단일기준국 판정")
p.add_argument("host"); p.add_argument("port", type=int); p.add_argument("mount")
p.add_argument("--user", default=""); p.add_argument("--pass", dest="pw", default="")
p.add_argument("--gga-from-latlon", nargs=2, type=float, metavar=("LAT", "LON"),
help="GGA 업링크 (VRS/nmea=1 마운트포인트에 필요)")
p.add_argument("--secs", type=int, default=60)
p.set_defaults(func=cmd_sniff)
a = ap.parse_args()
sys.exit(a.func(a) or 0)
if __name__ == "__main__":
main()