This commit is contained in:
Dongubak
2026-08-22 02:54:26 +09:00
parent be9e83d2f4
commit 11fa444ce7
6 changed files with 1281 additions and 23 deletions
+238
View File
@@ -0,0 +1,238 @@
# VRS(가상 기지국) → 단일기준국(실제 상시관측소) 전환
> 배경: GNSS 운용 담당자로부터 "가상 기지국(VRS) 말고 주변의 진짜 기지국을 우선으로 잡아라"는
> 조언을 받았고, 실제로 **RTK Fixed 도달 자체가 안 되는** 상황이라 VRS 사용을 중단하기로 결정.
> 조사·구현일 2026-08-22. 캐스터는 직접 조회해 확인했고, 코드는 로컬에서 컴파일·단위검증까지
> 마쳤다. 하드웨어 검증은 §7 절차로 로봇에서 해야 한다.
---
## 1. 결론 먼저
**UM982 모듈 설정 문제가 아니다.** 수신기에는 "어느 기지국을 잡을지" 고르는 명령이 없다
(Unicore N4 매뉴얼 §4.6 `CONFIG RTK` 확인). 기지국 선택은 전적으로 **NTRIP 마운트포인트**가
결정한다.
그리고 조사 중에 **Fixed 가 안 되는 유력한 원인**을 따로 찾았다 — §3. 마운트포인트를 바꿔야
하는 이유가 "기준 프레임 일관성" 뿐만이 아니게 됐다.
---
## 2. Fixed 실패의 유력 원인 — VRS 스트림과 UM982 의 위성군 불일치
### 2.1 두 자료를 대조하면 나온다
**(A) VRS 스트림에 실제로 들어있는 것** — [진단기록 §2.5](UM982-RTK-Fixed-진단기록_2026-07-30.md)
에서 파이썬으로 직접 접속해 파싱한 결과:
```
1005/1007 기준국 좌표/안테나
1030~1033 네트워크 RTK 보조
1075 / 1085 / 1095 / 1115 GPS / GLONASS / Galileo / QZSS MSM5
※ 북두(BeiDou) 보정 메시지는 이 마운트포인트에 없음
```
**(B) UM982 가 지원한다고 매뉴얼에 적힌 것** — Appendix 2 "Supported RTCM V3 messages":
| 위성군 | 매뉴얼 기재 | VRS 가 보내는 것 | 판정 |
|---|---|---|---|
| GPS | 1001~1004, **1074/1075** | 1075 | ✅ 쓸 수 있음 |
| GLONASS | 1009~1012, **1084/1085** | 1085 | ✅ 쓸 수 있음 |
| BeiDou | 1104, **1123~1127** | **없음** | ❌ 지원하는데 안 옴 |
| Galileo | **1091~1097 없음** (1094 만 base 출력 예시에 등장) | 1095 | ❌ 올 수 있는데 못 씀 |
| QZSS | **1111~1117 아예 없음** | 1115 | ❌ 올 수 있는데 못 씀 |
### 2.2 무슨 뜻인가
VRS 는 4개 위성군 보정을 보내지만, 그중 UM982 가 RTK 에 실제로 쓸 수 있는 건 **GPS 와 GLONASS
둘뿐일 가능성이 높다**. 동시에 UM982 가 잘 지원하는 **BeiDou 는 이 VRS 가 아예 안 보낸다**
한국에서 북두는 가시위성이 많은데 통째로 놓치는 셈이다.
진단기록에서 "위성 31개 잡히는데 Fixed 가 안 된다"고 했던 것과 앞뒤가 맞는다. **수신기가 추적하는
위성 수(nSV)와, 기지국 보정이 있어서 RTK 모호성 해석에 실제로 기여하는 위성 수는 다르다.**
`CONFIG SIGNALGROUP 4 5` 로 5개 위성군을 추적해도, 보정이 안 오거나 못 읽는 위성군은 RTK 에
기여하지 못한다.
> ⚠️ 이건 **가설**이다. 매뉴얼 Appendix 2 가 불완전할 가능성도 있다(1094 가 다른 절에는 나오는
> 걸 보면 목록 관리가 엉성하다). §7.1 로 확정할 것.
### 2.3 그래서 기준국 선택 기준이 바뀐다
소스테이블의 위성군 문자열(`GPS+GLO+GAL+BDS+QZS`)이 아니라 **실제로 오는 메시지 ID** 로 판단해야
한다. **BeiDou MSM(1124/1125)을 보내는 관측소가 진짜 이득**이고, Galileo/QZSS 는 있으나 마나다.
---
## 3. VRS 를 그만 써야 하는 나머지 이유
### 3.1 기준 프레임이 움직인다
VRS 는 GGA 로 보고한 위치 근처에 가상 기지국을 만들고, 로버가 임계거리를 넘으면 **가상 기지국을
새 위치로 재생성**한다. 그때마다 기준점이 바뀌어 해에 불연속이 생기고 RTK 재초기화가 일어날 수
있다. FAST-LIVO2-RTK 백엔드는 GNSS 해를 GTSAM 팩터로 넣으므로, 궤적 도중의 기준 프레임 변경은
그대로 제약조건 불일치가 된다. 한 스캔 세션 동안 기준점은 고정돼야 한다.
### 3.2 사후처리(PPK)가 불가능하다
VRS 스트림은 서버가 그 순간 합성한 것이라 재현이 안 된다. 실제 관측소를 쓰면
[gnssdata.or.kr](https://gnssdata.or.kr) 에서 해당 시간대 RINEX 를 받아 재처리할 수 있다.
### 3.3 대가 — 거리 의존성
단일기준국은 베이스라인이 멀수록 나빠진다(대략 1km당 1mm + 전리층 비상관). 실무상 10~20km 이내가
좋고, 30km 넘어가면 Fixed 가 눈에 띄게 어려워진다. → 드라이버가 베이스라인을 로깅하고 임계 초과
시 경고하도록 했다(§5).
---
## 4. 캐스터 실측 조사
`tools/ntrip_probe.py table` 로 소스테이블을 직접 받아 확인(조회는 인증 불필요).
| 캐스터 | 서버 | 결과 |
|---|---|---|
| RTS1.ngii.go.kr:2101 | Trimble Pivot 5.2 | STR 5개 **전부 `solution=1`(네트워크)**. VRS-RTCM23/31/34, VRS-CMRx, RTK-RTCM32 |
| RTS2.ngii.go.kr:2101 | Geo++ GNSMART | FKP-RTCM31, VRS-RTCM31/32, SSR-SSRG — **전부 네트워크** |
| **gnssdata.or.kr:2101** | NTRIP Caster 1.1 | **STR 562개 전부 `solution=0`(단일기준국), `nmea=0`, 고유 관측소 176곳** ✅ |
| gnss.eseoul.go.kr:2101 | Trimble Pivot 4.3 | VRS 외에 서울권 관측소별 `solution=0` 스트림 제공 (대안) |
**RTS1/RTS2 에는 단일기준국이 아예 없다.** 진짜 기준국은 GNSS 데이터 통합센터에 있다.
마운트포인트 명명 = `<4자리 관측소코드>-<포맷>` (예: `DBON-RTCM32`). 포맷은 `-RTCM32` 를 쓴다.
RTS1 의 `RTK-RTCM32` 는 이름상 단일기준국 서비스일 수 있으나 소스테이블은 네트워크로 신고한다.
표기는 못 믿는다 — VRS-RTCM34 도 `1004,1014/1015/1016` 을 신고하지만 실제로는 `1030~1033, MSM5`
가 온다. 계정을 그대로 쓸 수 있어 제일 간단한 선택지이므로 §7.2 로 정체를 확인할 가치가 있다.
⚠️ **인증**: gnssdata.or.kr 은 무인증 시 `401`. RTS1 계정(`hyuk6578`)이 여기서도 되는지 **미확인**
이고, 이게 최우선 확인 항목이다. 안 되면 별도 가입 필요(위치기준과 031-210-2655).
---
## 5. 구현 완료 내역
### 5.1 설정 — [`config/um982.yaml`](../src/um982_driver/config/um982.yaml)
```yaml
ntrip_host: "gnssdata.or.kr"
ntrip_mountpoint: "AUTO" # 최근접 단일기준국 자동 선택 (또는 "DBON-RTCM32" 처럼 고정)
ntrip_auto_format: "RTCM 3.2"
max_baseline_km: 30.0
gga_send_period: 0.0 # 단일기준국은 GGA 업링크 불필요
```
> 수정 후 **반드시 리빌드**. `install/` 은 복사본이라 리빌드 없이는 반영 안 된다
> (진단기록 §2.3 에서 이것 때문에 NTRIP 이 빈 계정으로 붙던 전례).
> `colcon build --packages-select um982_driver` 또는 `--symlink-install`.
### 5.2 드라이버 — [`um982_driver_node.cpp`](../src/um982_driver/src/um982_driver_node.cpp)
**① AUTO 최근접 기준국 선택**
소스테이블을 받아 `solution=0` 인 STR 만 후보로 두고 현재 위치에서 거리순 정렬, 가장 가까운
곳에 붙는다. 접속 실패 시 다음 후보로 자동 전환(관측소 점검/장애 대비, 최대 5곳).
"주변에 있는 진짜 기지국을 우선으로 잡는다"를 그대로 구현한 것.
**② GGA 대기 제거**
기존에는 첫 유효 GGA 가 나올 때까지 NTRIP 접속을 막았다. VRS 는 그래야 하지만 단일기준국은
`nmea=0` 이라 불필요하고, 이 대기 때문에 **수신기가 자력으로 fix 를 얻기 전까지 RTCM 이 한
바이트도 안 들어갔다**. 조건이 나쁜 곳에서 콜드스타트할 때 수렴이 늦어지는 원인.
이제 `gga_send_period <= 0` 이면 즉시 접속한다(AUTO 는 위치가 필요해 첫 GGA 까지만 기다림).
**③ RTCM 스니퍼 — 캐스터가 보낸 것**
주입 경로에서 RTCM3 프레임을 뜯어 30초마다 요약한다:
- 메시지 종류별 카운트
- 보정이 오는 위성군
- 네트워크 RTK 메시지(1014/1015/1016/1030~1035) 발견 시 **경고** → 단일기준국이 아님
- **매뉴얼 미기재 메시지 발견 시 경고** (§2 의 Galileo/QZSS 문제를 런타임에 바로 드러냄)
- 1005/1006 에서 기지국 ID·좌표·**베이스라인 km** 를 로깅, 좌표가 움직이면 VRS 로 판정해 경고
**④ RTCMSTATUS — 수신기가 받아들인 것**
기동 시 `RTCMSTATUSA ONCHANGED` 를 RAM 에만 설정(SAVECONFIG 안 함)하고, 수신기가 뱉는
`#RTCMSTATUSA` 를 파싱해 로깅한다. 매뉴얼 §7.3.56 기준 `<MsgID>,<MsgNum>,<BaseID>,<SatsNum>,<L1~L6>`.
**③과 ④를 대조하면 "캐스터는 보냈는데 수신기가 안 쓴" 위성군이 그대로 드러난다** — §2 가설의
결정적 검증 수단.
**⑤ 접속 거부 사유 로깅**
401(계정)과 404(마운트포인트 오타/미개방)를 구분해 찍는다.
### 5.3 발견해 고친 버그 — RTCM 파서 스톨
프레임 추출에서 `0xD3` 만 보고 동기를 잡으면, 관측치 페이로드 안의 우연한 `0xD3` 을 프레임
시작으로 착각한다. 그 가짜 길이(최대 1023B)만큼 데이터가 더 쌓일 때까지 **파서가 멈춘다**.
RTCM3 는 길이 상위 바이트의 reserved 6bit 가 항상 0 이므로 이걸로 걸러내도록 수정
(`(buf[j+1] & 0xFC) != 0` 이면 오동기). 파이썬 도구에도 동일 수정 적용.
### 5.4 로컬 검증
ROS 없는 맥에서 스텁 헤더로 `-Wall -Wextra` 컴파일 통과. RTCM 헬퍼는 단위테스트로 검증:
1005/1006 파싱·ECEF→위경도 왕복(1e-7도), 안테나고, 베이스라인 거리(파이썬 참조구현과 일치),
프레임 추출(연접/1바이트 분할/선행 쓰레기/가짜 0xD3/CRC 깨짐 복구), 메시지 분류 함수 — **전부 통과**.
※ 이건 로컬 검증이고, **실제 하드웨어·실제 스트림 검증은 안 됐다**(§7).
---
## 6. 조사 도구 — [`tools/ntrip_probe.py`](../tools/ntrip_probe.py)
표준 라이브러리만 쓰는 단독 실행 스크립트. 드라이버를 안 건드리고 캐스터를 조사할 때.
```bash
python3 ntrip_probe.py table gnssdata.or.kr 2101 --filter DBON
python3 ntrip_probe.py near gnssdata.or.kr 2101 <위도> <경도> --format RTCM3.2
python3 ntrip_probe.py sniff gnssdata.or.kr 2101 DBON-RTCM32 --user <ID> --pass <PW> --secs 60
```
`sniff` 판정 기준:
| 관찰 | 의미 |
|---|---|
| 1014/1015/1016/1030~1035 존재 | ❌ 네트워크 해 (MAC/FKP/VRS) |
| 1005/1006 좌표가 관측 중 이동 | ❌ VRS |
| 네트워크 메시지 없음 + 1005/1006 고정 | ✅ 단일기준국 |
| 1075/1085/1095/1115/1125 구성 | 어느 위성군 보정이 오는지 — **1124/1125(BDS) 유무가 핵심** |
---
## 7. 로봇에서 할 검증 (순서대로)
### 7.1 §2 가설 확정 — 먼저 이것부터
**아직 VRS 인 상태로** 드라이버를 새로 빌드해 띄우고 로그를 본다.
```
[um982_driver]: RTCM 누적 ... | 메시지: 1005x.. 1030x.. 1075x.. 1085x.. 1095x.. 1115x..
[um982_driver]: ⚠️ 매뉴얼 미기재 메시지: 1095(Galileo) 1115(QZSS) — 이 위성군은 RTK 에 안 쓰일 수 있다
[um982_driver]: 수신기 RTCM 수용: 1075,1234,0,9,...
```
**`수신기 RTCM 수용:` 줄에 1095/1115 가 안 나오면 §2 가설이 맞다.** 나오면 가설은 틀렸고
원인을 다시 찾아야 한다. 어느 쪽이든 이 로그 한 번이면 결판난다.
### 7.2 계정 확인
```bash
python3 tools/ntrip_probe.py sniff gnssdata.or.kr 2101 DBON-RTCM32 --user hyuk6578 --pass <PW> --secs 60
```
`401` 이면 별도 가입 필요. 그동안의 대안으로 RTS1 `RTK-RTCM32` 정체도 확인:
```bash
python3 tools/ntrip_probe.py sniff RTS1.ngii.go.kr 2101 RTK-RTCM32 \
--user hyuk6578 --pass <PW> --gga-from-latlon <위도> <경도> --secs 60
```
### 7.3 관측소 선정
`near` 로 작업 위치 기준 후보를 뽑고, `sniff` 로 **BDS(1124/1125)를 보내는 곳**을 고른다.
소스테이블 좌표는 대부분 소수점 2자리(약 1km)이고 4개 그룹은 여러 관측소가 같은 좌표로 잘못
적혀 있으니(GGEO/JJHG/SGWI/YNGU 가 모두 38.29,128.14), 실제 거리는 `sniff` 의 1005/1006 으로 확인.
### 7.4 전환 후 A/B
난간 위치(진단기록에서 Fixed 나왔던 조건)에서 VRS vs 단일기준국으로 Fixed 수렴시간·h_acc 비교.
안테나 위치가 여전히 지배적 변수이므로(진단기록 §3) **같은 위치에서** 비교해야 의미가 있다.
---
## 8. 미확정 사항
- [ ] §2 가설 — 수신기가 Galileo/QZSS MSM 을 실제로 버리는가 (7.1 로 확정)
- [ ] RTS1 계정이 gnssdata.or.kr 에서 통하는지
- [ ] RTS1 `RTK-RTCM32` 가 단일기준국인지
- [ ] 작업 위치 기준 최근접 관측소 확정
- [ ] AUTO 선택 로직의 실스트림 동작 (로컬 컴파일·단위테스트만 함)
---
## 9. 관련 파일
| 파일 | 내용 |
|---|---|
| [`tools/ntrip_probe.py`](../tools/ntrip_probe.py) | 소스테이블 조회 / 최근접 기준국 / 스트림 VRS 판정 |
| [`src/um982_driver/config/um982.yaml`](../src/um982_driver/config/um982.yaml) | 캐스터·마운트포인트·GGA 설정 |
| [`src/um982_driver/src/um982_driver_node.cpp`](../src/um982_driver/src/um982_driver_node.cpp) | AUTO 선택, RTCM 스니퍼, RTCMSTATUS 로깅 |
| [`docs/UM982-RTK-Fixed-진단기록_2026-07-30.md`](UM982-RTK-Fixed-진단기록_2026-07-30.md) | VRS 스트림 실제 구성(§2.5), 안테나 위치가 지배적(§3), 리빌드 함정(§2.3) |
| `docs/Unicore Reference Commands Manual ... R1.4.pdf` | Appendix 2 지원 RTCM 목록, §4.6 CONFIG RTK, §7.3.56 RTCMSTATUS |
+15 -3
View File
@@ -12,11 +12,16 @@ UM982(Unicore) → `gnss_comm/msg/GnssPVTSolnMsg` 드라이버 노드. FAST-LIVO
같은 노드 안에 있다: 같은 노드 안에 있다:
``` ```
NTRIP(VRS) ──RTCM3──▶ UM982 ──BESTNAV(binary)+GGA(ascii)──▶ um982_driver ──GnssPVTSolnMsg──▶ 백엔드 NTRIP(단일기준국) ──RTCM3──▶ UM982 ──BESTNAV(binary)+GGA(ascii)──▶ um982_driver ──GnssPVTSolnMsg──▶ 백엔드
▲ │ ▲ │
└────────────────────── GGA(VRS 유지) ───────────────────┘ └──────────── GGA (VRS 쓸 때만; 단일기준국은 불필요) ──────────┘
``` ```
> VRS(가상 기지국) 대신 실제 상시관측소를 쓴다. 배경·근거는
> [`docs/NTRIP-단일기준국-전환-조사.md`](../../docs/NTRIP-단일기준국-전환-조사.md).
> 드라이버가 주입 중인 RTCM 을 스니핑해 기지국 좌표·베이스라인·위성군 구성을 로깅하므로,
> 붙은 곳이 진짜 단일기준국인지 로그로 바로 확인된다.
## 수신기 사전 설정 ## 수신기 사전 설정
드라이버 실행 전에 UM982 가 아래를 출력하도록 저장돼 있어야 한다(리포 루트 `rtk/config_all.py`): 드라이버 실행 전에 UM982 가 아래를 출력하도록 저장돼 있어야 한다(리포 루트 `rtk/config_all.py`):
``` ```
@@ -55,7 +60,14 @@ ros2 run um982_driver um982_driver_node --ros-args \
## 파라미터 (config/um982.yaml) ## 파라미터 (config/um982.yaml)
- `serial_port`, `serial_baud`(460800), `gps_topic` - `serial_port`, `serial_baud`(460800), `gps_topic`
- `use_ntrip`, `ntrip_host/port/mountpoint/user/pass`, `gga_send_period` - `use_ntrip`, `ntrip_host/port/mountpoint/user/pass`
- `ntrip_mountpoint`: 관측소 코드 직접 지정(`"DBON-RTCM32"`) 또는 `"AUTO"`
(소스테이블에서 `solution=0` 인 최근접 기준국 자동 선택, 실패 시 다음 후보로 전환)
- `ntrip_auto_format`(기본 `"RTCM 3.2"`): AUTO 선택 시 포맷 필터
- `gga_send_period`: **0 이면 GGA 업링크 없이 즉시 접속**(단일기준국). VRS 쓸 때만 10.0
- `max_baseline_km`(기본 30): 기지국 거리 초과 시 경고
- `enable_rtcm_status_log`(기본 true): 기동 시 `RTCMSTATUSA ONCHANGED` 설정(RAM 만),
수신기가 실제로 받아들인 RTCM 을 로깅
- `publish_fixed_only`(기본 false), `inflate_cov_when_not_fixed`(기본 true) - `publish_fixed_only`(기본 false), `inflate_cov_when_not_fixed`(기본 true)
> 백엔드는 fix 품질을 거르지 않으므로, RTK Fixed 가 아닌 해는 `inflate_cov_when_not_fixed` > 백엔드는 fix 품질을 거르지 않으므로, RTK Fixed 가 아닌 해는 `inflate_cov_when_not_fixed`
+200
View File
@@ -0,0 +1,200 @@
# RTK(UM982) ROS 2 녹화 명세서
> 대상: UM982(WTRTK-982) RTK 수신 데이터를 ROS 2 로 발행·**녹화**하기 위한 이관/구성 명세.
> 구현체: `um982_driver` (C++ ROS 2 노드). 근거 문서: `rtk/UM982-명령어검토-및-바이너리포팅.md`.
> 검증: 국가 통합기준점 U0373/U0374 대비 수평 1.4~2.6cm (rtk/Log.md).
---
## 1. 개요 & 데이터 흐름
```
NTRIP(VRS,KGD2002) ──RTCM3──▶ UM982 ──BESTNAV(binary)+GGA(ascii)──▶ um982_driver
▲ │
└──────────────── GGA(VRS 유지, 10s) ◀───────────────────────────┘
│ GnssPVTSolnMsg (10Hz)
/ublox_driver/receiver_pvt
ros2 bag record ──▶ .db3/.mcap
```
- 시리얼 포트는 **한 프로세스만 점유** → RTCM 주입과 BESTNAV 파싱이 **한 노드**(`um982_driver`)에 통합.
- 녹화는 이 노드가 발행하는 토픽을 `ros2 bag record` 로 담는다.
---
## 2. 이관 대상 & 워크스페이스 구성 (무엇을 어떻게 옮기나)
**옮길 패키지 2개** (그 외 의존 없음 — 시리얼=termios, NTRIP=POSIX 소켓):
| 패키지 | 역할 | 위치(원본) |
|---|---|---|
| `gnss_comm` | `GnssPVTSolnMsg`/`GnssTimeMsg` 메시지 정의 | `FAST-LIVO2-RTK-ROS2/thirdparty/gnss_comm` |
| `um982_driver` | 드라이버 노드(파싱+NTRIP+발행) | `FAST-LIVO2-RTK-ROS2/src/um982_driver` |
**녹화 전용 최소 워크스페이스** (전체 FAST-LIVO2 빌드 불필요):
```
fhd_rtk_ws/
└── src/
├── gnss_comm/ ← thirdparty/gnss_comm 복사
└── um982_driver/ ← src/um982_driver 복사
```
```bash
cd fhd_rtk_ws
colcon build --packages-select gnss_comm um982_driver
source install/setup.bash
```
> ROS 2 Humble, C++17. `um982_driver` 는 `rclcpp`, `gnss_comm` 만 의존.
---
## 3. 사전 조건 — 수신기 설정 (필수)
드라이버 실행 전 UM982 가 아래를 **저장(SAVECONFIG)** 하고 있어야 한다. 리포 `rtk/config_all.py` 한 번 실행:
| 항목 | 값 | 이유 |
|---|---|---|
| `BESTNAVB COM3 0.1` | 10Hz 바이너리 | **파싱 소스** |
| `GPGGA COM3 1` | 1Hz | NTRIP VRS 업링크 |
| COM3 baud | `460800` | RTCM+로그 대역 |
| `MODE ROVER` | — | 로버 측위 |
| `CONFIG RTK RELIABILITY 3 1` | 기본 | 오확정 방지 |
> ⚠️ `FRESET` 시 baud 가 115200 으로 초기화됨 → `rtk/set_baud.py` 재실행 필요.
---
## 4. 토픽 & 메시지 명세
### 4.1 토픽
| 항목 | 값 |
|---|---|
| 토픽명 | `/ublox_driver/receiver_pvt` (파라미터 `gps_topic`) |
| 타입 | `gnss_comm/msg/GnssPVTSolnMsg` |
| 발행 주기 | BESTNAV 주기 = **10 Hz** (0.1s) |
| QoS | `KeepLast(2000)`, 기본 신뢰성(reliable) |
| 프레임/스탬프 | **메시지에 std_msgs/Header 없음.** 시간은 메시지 내부 `time.week/tow` |
### 4.2 `GnssPVTSolnMsg` 필드 (드라이버가 채우는 값)
| 필드 | 타입/단위 | 채움 | BESTNAV 소스 |
|---|---|---|---|
| `time.week` | uint32 (GPS week) | ✅ | 헤더 Wn |
| `time.tow` | float64 (초) | ✅ | 헤더 Ms÷1000 |
| `latitude` | float64 (deg) | ✅ | lat |
| `longitude` | float64 (deg) | ✅ | lon |
| `altitude` | float64 (m, **타원체고**) | ✅ | hgt(MSL)+undulation |
| `height_msl` | float64 (m) | ✅ | hgt |
| `h_acc` | float64 (m) | ✅ | √(latσ²+lonσ²) |
| `v_acc` | float64 (m) | ✅ | hgtσ |
| `vel_n/vel_e/vel_d` | float64 (m/s, NED) | ✅ | hor·cos(trk)/hor·sin(trk)/vert |
| `vel_acc` | float64 (m/s) | ✅ | Horspd std |
| `num_sv` | uint8 | ✅ | #solnSVs |
| `fix_type` | uint8 (0=no,3=3D) | ✅ | pos_type |
| `valid_fix` | bool | ✅ | pos_type≠NONE |
| `diff_soln` | bool | ✅ | pos_type∉{NONE,SINGLE} |
| `carr_soln` | uint8 (0/1/2) | ✅ | 48/49/50→2, 32/33/34→1, else 0 |
| `p_dop` | float64 | 0 | BESTNAV 미제공(필요시 PVTSLN) |
> `carr_soln`: **2=RTK Fixed(NARROW_INT 등), 1=RTK Float, 0=단독/DGPS**.
---
## 5. 드라이버 파라미터 명세 (`config/um982.yaml`)
| 파라미터 | 기본값 | 설명 |
|---|---|---|
| `serial_port` | `/dev/ttyUSB0` | UM982 COM3(USB). 리눅스 `/dev/ttyUSB*` |
| `serial_baud` | `460800` | 수신기 저장값과 일치 |
| `gps_topic` | `/ublox_driver/receiver_pvt` | 발행 토픽(백엔드 `gps.gps_topic` 와 일치) |
| `use_ntrip` | `true` | false 면 RTCM 주입 안 함(외부 보정 시) |
| `ntrip_host/port/mountpoint` | `RTS1.ngii.go.kr` / `2101` / `VRS-RTCM34` | NTRIP 접속 |
| `ntrip_user/pass` | `""` | ⚠️ 리포 평문 커밋 지양 — yaml/CLI/환경변수로 주입 |
| `gga_send_period` | `10.0` | VRS 유지용 GGA 재전송 주기(s) |
| `publish_fixed_only` | `false` | true=RTK Fixed 해만 발행 |
| `inflate_cov_when_not_fixed` | `true` | fix 아님/코스팅 시 h_acc·v_acc 부풀림 |
| `max_corr_age` | `30.0` | diff_age 초과 시 코스팅 간주(공분산↑). 0=비활성 |
> NTRIP 끊겨도 UM982 는 `RTK TIMEOUT`(기본 600s) 동안 Fixed 를 코스팅 유지 → `max_corr_age` 로 신뢰 낮춤.
---
## 6. 시간 의미 (중요)
- 메시지에 Header/stamp 가 **없다.** 시간은 `time.week`(GPS week) + `time.tow`(주 내 초).
- UNIX 복원: `unix = week·604800 + tow + 315964800 18(leap)` (백엔드 `optimization.cpp::gpsHandler` 와 동일).
- 유효 조건: 헤더 TimeStatus=`FINE` & week>1 (fix 전에는 week=1/UNKNOWN).
- `ros2 bag`**수신 시각(bag timestamp)** 을 별도로 기록하지만, **권위 시각은 메시지 내부 week/tow**. 센서와의 시각 동기는 이 값 기준.
---
## 7. 녹화 절차
### 7.1 실행
```bash
source install/setup.bash
ros2 launch um982_driver um982_driver.launch.py # 또는 아래 run
# 자격증명을 CLI 로 주입:
ros2 run um982_driver um982_driver_node --ros-args \
--params-file src/um982_driver/config/um982.yaml \
-p ntrip_user:=<id> -p ntrip_pass:=<pw>
```
콘솔에 `RTK FIXED` 전환 로그가 뜨고, `ros2 topic hz /ublox_driver/receiver_pvt` ≈ 10Hz 확인.
### 7.2 GNSS 단독 녹화
```bash
ros2 bag record -o rtk_$(date +%F_%H%M) /ublox_driver/receiver_pvt
```
### 7.3 센서 + GNSS 동시 녹화 (FAST-LIVO2 후처리용)
```bash
ros2 bag record -o run_$(date +%F_%H%M) \
/livox/lidar /livox/imu /left_camera/image \
/ublox_driver/receiver_pvt \
--storage mcap # 없으면 sqlite3(기본)
```
> 초기 수십 초 **RTK Fixed + 충분한 이동** 후 녹화(백엔드 SVD 정합 전제). `rtk/UM982-녹화-및-연동-구현.md` §7 참조.
### 7.4 (권장) 원시 BESTNAV 동시 녹화 — 재처리 대비
현재 드라이버는 파싱 결과만 발행한다. 파싱 로직을 나중에 고쳐 재생성하려면 **원시 바이트도 함께** 남기는 것을 권장(별도 옵션 필요 — §10).
---
## 8. 녹화 데이터 포맷 & 재생
- 저장: ROS 2 bag (`sqlite3` `.db3` 기본, 또는 `mcap` `.mcap`).
- 확인: `ros2 bag info run_YYYY-MM-DD_HHMM` → 토픽/메시지 수/기간.
- 재생: `ros2 bag play run_YYYY-MM-DD_HHMM``/ublox_driver/receiver_pvt` 재발행.
- FAST-LIVO2 백엔드는 이 토픽을 구독해 첫 fix 를 ENU 원점으로 잡고 GTSAM 융합(`gps.gps_topic` 일치 필요).
---
## 9. 품질/필터 (녹화되는 데이터의 신뢰)
- 백엔드에 fix 품질 필터가 **없으므로** 드라이버 단에서 처리:
- `publish_fixed_only=true` → RTK Fixed 해만 녹화(가장 보수적).
- `inflate_cov_when_not_fixed=true` + `max_corr_age` → fix 아님/코스팅 시 `h_acc/v_acc` 를 키워 후처리 가중 약화.
- CRC(Unicore Appendix 1) 통과 프레임만 발행 → 손상 프레임 녹화 안 됨.
---
## 10. 확장(옵션) — 원시 스트림 동시 녹화
재처리·디버깅을 위해 원시 BESTNAV/GGA 바이트를 별도 토픽으로 녹화하려면 드라이버에
`std_msgs/msg/ByteMultiArray`(또는 String) 발행부를 추가하면 된다(예: `/um982/raw`).
필요 시 요청 → 드라이버에 `publish_raw` 파라미터로 추가.
---
## 11. 검증 체크리스트
- [ ] `rtk/config_all.py` 로 수신기 설정 저장(BESTNAVB/GPGGA, 460800, MODE ROVER)
- [ ] `colcon build --packages-select gnss_comm um982_driver` 성공
- [ ] `serial_port` / NTRIP 자격증명(config or CLI) 확인
- [ ] `ros2 topic hz /ublox_driver/receiver_pvt` ≈ 10Hz, 콘솔 `RTK FIXED`
- [ ] `ros2 topic echo` 로 lat/lon/altitude, time.week(>1)/tow, carr_soln=2 확인
- [ ] `ros2 bag record``ros2 bag info` 로 토픽·기간 확인
- [ ] (동시녹화) 센서 4종 + GNSS 토픽 모두 bag 에 존재
```
+13 -4
View File
@@ -7,14 +7,23 @@ um982_driver:
# ── 발행 토픽 (백엔드 gps.gps_topic 와 일치) ── # ── 발행 토픽 (백엔드 gps.gps_topic 와 일치) ──
gps_topic: "/ublox_driver/receiver_pvt" gps_topic: "/ublox_driver/receiver_pvt"
# ── NTRIP (VRS) ── # ── NTRIP (단일기준국) ──
# RTS1/RTS2 는 마운트포인트가 전부 네트워크 해(VRS/FKP)뿐이다. 실제 상시관측소는
# GNSS 데이터 통합센터(gnssdata.or.kr) 에 있다 — 176곳, 전부 solution=0.
# 배경/근거: docs/NTRIP-단일기준국-전환-조사.md
use_ntrip: true use_ntrip: true
ntrip_host: "RTS1.ngii.go.kr" ntrip_host: "gnssdata.or.kr"
ntrip_port: 2101 ntrip_port: 2101
ntrip_mountpoint: "VRS-RTCM34" ntrip_mountpoint: "AUTO" # "AUTO" = 소스테이블에서 최근접 단일기준국 자동 선택.
# 고정하려면 관측소 코드 직접 지정(예: "DBON-RTCM32")
ntrip_auto_format: "RTCM 3.2" # AUTO 선택 시 포맷 필터(소스테이블 format 필드와 정확히 일치)
max_baseline_km: 30.0 # 기지국 거리 초과 시 경고. 단일기준국은 멀수록 Fixed 가 어렵다
ntrip_user: "" # ⚠️ 여기에 채우거나 launch 인자/환경변수로 주입(리포에 평문 저장 지양) ntrip_user: "" # ⚠️ 여기에 채우거나 launch 인자/환경변수로 주입(리포에 평문 저장 지양)
ntrip_pass: "" ntrip_pass: ""
gga_send_period: 10.0 # VRS 유지용 GGA 재전송 주기(초) gga_send_period: 0.0 # 0 = GGA 업링크 안 함. 단일기준국은 nmea=0 이라 불필요하고,
# 0 이면 첫 fix 를 기다리지 않고 즉시 접속한다.
# VRS 로 되돌릴 때만 10.0 으로. (AUTO 는 위치가 필요해
# 0 이어도 첫 GGA 까지는 기다린다)
# ── 품질 처리 (백엔드에 fix 품질 필터가 없음) ── # ── 품질 처리 (백엔드에 fix 품질 필터가 없음) ──
publish_fixed_only: false # true = RTK Fixed(carr_soln=2) 해만 발행 publish_fixed_only: false # true = RTK Fixed(carr_soln=2) 해만 발행
+410 -11
View File
@@ -24,10 +24,14 @@
#include <arpa/inet.h> #include <arpa/inet.h>
#include <algorithm> #include <algorithm>
#include <array>
#include <atomic> #include <atomic>
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <limits>
#include <map>
#include <mutex> #include <mutex>
#include <sstream>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
@@ -75,6 +79,163 @@ uint32_t block_crc32(const uint8_t *buf, size_t n) {
return crc; return crc;
} }
// ── RTCM3 스니핑 (주입 스트림 검증) ─────────────────────────────────────────
// 프레임: 0xD3 | 6bit reserved + 10bit len | payload | CRC-24Q 3B
// 목적 두 가지:
// (1) 붙은 마운트포인트가 실제 단일기준국인지 — 1005/1006 좌표 고정 + 네트워크 메시지 부재
// (2) UM982 가 실제로 쓸 수 있는 위성군 보정이 오는지 (아래 rtcm_documented 참고)
const std::array<uint32_t, 256> &crc24q_table() {
static const std::array<uint32_t, 256> t = [] {
std::array<uint32_t, 256> a{};
for (int i = 0; i < 256; ++i) {
uint32_t crc = static_cast<uint32_t>(i) << 16;
for (int j = 0; j < 8; ++j) {
crc <<= 1;
if (crc & 0x1000000) crc ^= 0x1864CFBUL;
}
a[i] = crc & 0xFFFFFF;
}
return a;
}();
return t;
}
uint32_t crc24q(const uint8_t *buf, size_t n) {
const auto &tab = crc24q_table();
uint32_t crc = 0;
for (size_t i = 0; i < n; ++i)
crc = ((crc << 8) & 0xFFFFFF) ^ tab[((crc >> 16) ^ buf[i]) & 0xFF];
return crc;
}
// MSB-first 비트 리더 (RTCM DF 필드)
struct Bits {
const uint8_t *b;
size_t pos{0};
explicit Bits(const uint8_t *p) : b(p) {}
uint64_t u(int n) {
uint64_t v = 0;
for (int i = 0; i < n; ++i) { v = (v << 1) | ((b[pos >> 3] >> (7 - (pos & 7))) & 1); ++pos; }
return v;
}
int64_t s(int n) {
uint64_t v = u(n);
return (v & (1ULL << (n - 1))) ? static_cast<int64_t>(v) - (1LL << n) : static_cast<int64_t>(v);
}
};
struct BasePos { int id; double x, y, z, ant_h; bool has_ant_h; };
// 1005(ARP) / 1006(ARP + 안테나고) → ECEF(m)
bool parse_base_pos(const uint8_t *pl, size_t len, BasePos &out) {
if (len < 19) return false;
Bits b(pl);
int msg = static_cast<int>(b.u(12));
if (msg != 1005 && msg != 1006) return false;
if (msg == 1006 && len < 21) return false;
out.id = static_cast<int>(b.u(12));
b.u(6); // ITRF realization year
b.u(1); b.u(1); b.u(1); b.u(1); // GPS/GLO/GAL indicator, reference station indicator
out.x = static_cast<double>(b.s(38)) * 1e-4;
b.u(1); b.u(1); // single receiver osc, reserved
out.y = static_cast<double>(b.s(38)) * 1e-4;
b.u(2); // quarter cycle indicator
out.z = static_cast<double>(b.s(38)) * 1e-4;
out.has_ant_h = (msg == 1006);
out.ant_h = out.has_ant_h ? static_cast<double>(b.u(16)) * 1e-4 : 0.0;
return true;
}
void ecef_to_lla(double x, double y, double z, double &lat, double &lon, double &alt) {
constexpr double a = 6378137.0, f = 1.0 / 298.257223563;
constexpr double e2 = f * (2.0 - f);
lon = std::atan2(y, x);
double p = std::hypot(x, y);
lat = std::atan2(z, p * (1.0 - e2));
double n = a;
for (int i = 0; i < 10; ++i) {
n = a / std::sqrt(1.0 - e2 * std::sin(lat) * std::sin(lat));
alt = p / std::cos(lat) - n;
lat = std::atan2(z, p * (1.0 - e2 * n / (n + alt)));
}
n = a / std::sqrt(1.0 - e2 * std::sin(lat) * std::sin(lat));
alt = p / std::cos(lat) - n;
lat *= 180.0 / M_PI;
lon *= 180.0 / M_PI;
}
double haversine_km(double lat1, double lon1, double lat2, double lon2) {
constexpr double R = 6371.0, D = M_PI / 180.0;
double dlat = (lat2 - lat1) * D, dlon = (lon2 - lon1) * D;
double h = std::sin(dlat / 2) * std::sin(dlat / 2)
+ std::cos(lat1 * D) * std::cos(lat2 * D) * std::sin(dlon / 2) * std::sin(dlon / 2);
return 2 * R * std::asin(std::sqrt(std::min(1.0, h)));
}
// 버퍼에서 CRC 가 맞는 완성 프레임만 뽑아 콜백에 넘기고, 소비분을 앞에서 제거한다.
// on_frame(msg_type, payload, payload_len). 미완성 프레임은 버퍼에 남겨 다음 recv 를 기다린다.
template <typename F>
void extract_rtcm_frames(std::vector<uint8_t> &buf, F &&on_frame) {
size_t i = 0;
while (true) {
size_t j = i;
while (j < buf.size() && buf[j] != 0xD3) ++j;
if (j + 3 > buf.size()) { i = j; break; }
// 길이 상위 바이트의 reserved 6bit 는 RTCM3 에서 항상 0. 이걸 안 보면 관측치 페이로드
// 안의 우연한 0xD3 을 프레임 시작으로 착각해, 그 가짜 길이만큼(최대 1023B) 데이터가
// 더 쌓일 때까지 파서가 멈춘다.
if ((buf[j + 1] & 0xFC) != 0) { i = j + 1; continue; }
size_t len = (static_cast<size_t>(buf[j + 1] & 0x03) << 8) | buf[j + 2];
size_t total = 3 + len + 3;
if (buf.size() - j < total) { i = j; break; }
const uint8_t *f = buf.data() + j;
uint32_t got = (static_cast<uint32_t>(f[total - 3]) << 16)
| (static_cast<uint32_t>(f[total - 2]) << 8)
| static_cast<uint32_t>(f[total - 1]);
if (crc24q(f, total - 3) != got) { i = j + 1; continue; } // 오정렬 → 다음 0xD3 부터
if (len >= 2) on_frame((static_cast<int>(f[3]) << 4) | (f[4] >> 4), f + 3, len);
i = j + total;
}
buf.erase(buf.begin(), buf.begin() + i);
}
// 네트워크 RTK(VRS/FKP/MAC) 전용 메시지 — 하나라도 있으면 단일기준국이 아니다
const char *network_msg_name(int t) {
switch (t) {
case 1014: return "Network Aux Station(MAC)";
case 1015: case 1016: case 1017: return "MAC correction diff";
case 1030: case 1031: return "Network RTK residual";
case 1032: return "Physical Ref Station Pos(VRS)";
case 1034: case 1035: return "FKP gradient";
default: return nullptr;
}
}
const char *msm_constellation(int t) {
if (t >= 1071 && t <= 1077) return "GPS";
if (t >= 1081 && t <= 1087) return "GLONASS";
if (t >= 1091 && t <= 1097) return "Galileo";
if (t >= 1101 && t <= 1107) return "SBAS";
if (t >= 1111 && t <= 1117) return "QZSS";
if (t >= 1121 && t <= 1127) return "BeiDou";
return nullptr;
}
// Unicore N4 매뉴얼 Appendix 2 "Supported RTCM V3 messages" 기재분.
// ⚠️ Galileo MSM(1091~1097)과 QZSS MSM(1111~1117)은 매뉴얼에 없다. 이 메시지로만 오는
// 위성군 보정은 수신기가 못 쓸 수 있고, 그러면 RTK 에 실제로 기여하는 위성이 확 준다.
bool rtcm_documented(int t) {
if (t >= 1001 && t <= 1004) return true; // GPS L1/L2
if (t >= 1009 && t <= 1012) return true; // GLONASS L1/L2
if (t == 1074 || t == 1075) return true; // GPS MSM4/5
if (t == 1084 || t == 1085) return true; // GLONASS MSM4/5
if (t >= 1123 && t <= 1127) return true; // BDS MSM3~7
if (t == 1104) return true; // BDS observables (Unicore)
if (t == 1005 || t == 1006 || t == 1007 || t == 1033) return true;
if (t == 1019 || t == 1020 || t == 1042 || t == 1045 || t == 1046) return true;
return false;
}
speed_t to_speed(int baud) { speed_t to_speed(int baud) {
switch (baud) { switch (baud) {
case 9600: return B9600; case 19200: return B19200; case 9600: return B9600; case 19200: return B19200;
@@ -117,6 +278,12 @@ class Um982Driver : public rclcpp::Node {
inflate_cov_ = declare_parameter<bool>("inflate_cov_when_not_fixed", true); inflate_cov_ = declare_parameter<bool>("inflate_cov_when_not_fixed", true);
max_corr_age_ = declare_parameter<double>("max_corr_age", 30.0); // 보정 나이 초과 시 코스팅으로 간주 max_corr_age_ = declare_parameter<double>("max_corr_age", 30.0); // 보정 나이 초과 시 코스팅으로 간주
gga_period_ = declare_parameter<double>("gga_send_period", 10.0); gga_period_ = declare_parameter<double>("gga_send_period", 10.0);
// ntrip_mountpoint 가 "AUTO" 면 소스테이블을 받아 현재 위치에서 가장 가까운
// 단일기준국(solution=0)을 고른다. 아래는 그 선택 기준.
auto_format_ = declare_parameter<std::string>("ntrip_auto_format", "RTCM 3.2");
max_baseline_km_ = declare_parameter<double>("max_baseline_km", 30.0); // 초과 시 경고만
if (ntrip_mp_ != "AUTO" && ntrip_mp_ != "auto") active_mp_ = ntrip_mp_;
pub_ = create_publisher<GnssPVTSolnMsg>(gps_topic_, rclcpp::QoS(rclcpp::KeepLast(2000))); pub_ = create_publisher<GnssPVTSolnMsg>(gps_topic_, rclcpp::QoS(rclcpp::KeepLast(2000)));
@@ -127,6 +294,13 @@ class Um982Driver : public rclcpp::Node {
RCLCPP_INFO(get_logger(), "UM982 %s @ %d → 발행 %s", RCLCPP_INFO(get_logger(), "UM982 %s @ %d → 발행 %s",
serial_port_.c_str(), serial_baud_, gps_topic_.c_str()); serial_port_.c_str(), serial_baud_, gps_topic_.c_str());
// RAM 에만 적용되는 로그 활성화(SAVECONFIG 안 하므로 재부팅 시 원복). 수신기가 어떤 RTCM 을
// 실제로 받아들이는지 보려면 이게 켜져 있어야 한다.
if (declare_parameter<bool>("enable_rtcm_status_log", true)) {
static constexpr char kCmd[] = "RTCMSTATUSA ONCHANGED\r\n";
::write(serial_fd_, kCmd, sizeof(kCmd) - 1);
}
running_ = true; running_ = true;
serial_thread_ = std::thread(&Um982Driver::serial_loop, this); serial_thread_ = std::thread(&Um982Driver::serial_loop, this);
if (use_ntrip_) ntrip_thread_ = std::thread(&Um982Driver::ntrip_loop, this); if (use_ntrip_) ntrip_thread_ = std::thread(&Um982Driver::ntrip_loop, this);
@@ -179,6 +353,7 @@ class Um982Driver : public rclcpp::Node {
// 동기 이전(head)에서 GGA 추출 // 동기 이전(head)에서 GGA 추출
size_t head_end = (sync == std::string::npos) ? buf.size() : sync; size_t head_end = (sync == std::string::npos) ? buf.size() : sync;
scan_gga(buf, search, head_end); scan_gga(buf, search, head_end);
scan_rtcm_status(buf, search, head_end);
if (sync == std::string::npos) { if (sync == std::string::npos) {
if (buf.size() > 2) buf.erase(buf.begin(), buf.end() - 2); // 동기 경계 보존 if (buf.size() > 2) buf.erase(buf.begin(), buf.end() - 2); // 동기 경계 보존
@@ -228,6 +403,26 @@ class Um982Driver : public rclcpp::Node {
} }
} }
// 수신기가 "받아들인" RTCM 을 보여주는 진단 로그(#RTCMSTATUSA, 매뉴얼 §7.3.56).
// 캐스터가 "보낸" 것(log_rtcm_summary)과 대조하면, 보냈는데 수신기가 안 쓰는 위성군이 드러난다.
// 출력 형식: #RTCMSTATUSA,...;<MsgID>,<MsgNum>,<BaseID>,<SatsNum>,<L1..L6>*<CRC>
void scan_rtcm_status(const std::vector<uint8_t> &buf, size_t begin, size_t end) {
static constexpr char kTag[] = "#RTCMSTATUSA";
constexpr size_t kTagLen = sizeof(kTag) - 1;
for (size_t p = begin; p + kTagLen < end; ++p) {
if (buf[p] != '#') continue;
if (std::memcmp(&buf[p], kTag, kTagLen) != 0) continue;
size_t e = p;
while (e < end && buf[e] != '\r' && buf[e] != '\n') ++e;
if (e >= end) break; // 라인 미완 → 다음 read 대기
std::string line(reinterpret_cast<const char *>(&buf[p]), e - p);
size_t semi = line.find(';');
RCLCPP_INFO_THROTTLE(get_logger(), *get_clock(), 10000, "수신기 RTCM 수용: %s",
semi == std::string::npos ? line.c_str() : line.c_str() + semi + 1);
p = e;
}
}
void handle_bestnav(const uint8_t *f) { void handle_bestnav(const uint8_t *f) {
int32_t pos_type = rd<int32_t>(f, OFF_POSTYPE); int32_t pos_type = rd<int32_t>(f, OFF_POSTYPE);
if (fixed_only_ && !is_rtk_fixed(pos_type)) return; if (fixed_only_ && !is_rtk_fixed(pos_type)) return;
@@ -242,6 +437,7 @@ class Um982Driver : public rclcpp::Node {
float undu = rd<float>(f, OFF_UNDU); float undu = rd<float>(f, OFF_UNDU);
m.latitude = lat; m.latitude = lat;
m.longitude = lon; m.longitude = lon;
if (pos_type != POS_NONE) { rover_lat_.store(lat); rover_lon_.store(lon); }
m.altitude = hgt + undu; // 타원체고 (백엔드 ENU 입력) m.altitude = hgt + undu; // 타원체고 (백엔드 ENU 입력)
m.height_msl = hgt; m.height_msl = hgt;
@@ -288,15 +484,42 @@ class Um982Driver : public rclcpp::Node {
// ── NTRIP ─────────────────────────────────────────────────────────────── // ── NTRIP ───────────────────────────────────────────────────────────────
void ntrip_loop() { void ntrip_loop() {
// 첫 유효 GGA 대기(VRS 부트스트랩) const bool auto_mp = (ntrip_mp_ == "AUTO" || ntrip_mp_ == "auto");
const bool send_gga = (gga_period_ > 0.0);
// GGA 대기가 필요한 경우는 둘뿐:
// · VRS/네트워크 마운트포인트 — GGA 를 보내야 스트림이 시작된다
// · AUTO — 가장 가까운 기준국을 고르려면 내 위치를 알아야 한다
// 단일기준국(nmea=0)에 고정 마운트포인트면 GGA 없이 즉시 붙어야 한다. 안 그러면
// 수신기가 자력으로 fix 를 얻기 전까지 RTCM 이 한 바이트도 안 들어간다.
if (send_gga || auto_mp) {
RCLCPP_INFO(get_logger(), "NTRIP: 첫 GGA 대기 중%s", auto_mp ? " (AUTO 기준국 선택용)" : "");
while (running_ && rclcpp::ok() && latest_gga().empty()) while (running_ && rclcpp::ok() && latest_gga().empty())
std::this_thread::sleep_for(std::chrono::milliseconds(200)); std::this_thread::sleep_for(std::chrono::milliseconds(200));
} else {
RCLCPP_INFO(get_logger(), "NTRIP: GGA 업링크 없음(단일기준국) → 즉시 접속");
}
if (!running_ || !rclcpp::ok()) return;
if (auto_mp && !resolve_auto_mountpoint())
RCLCPP_ERROR(get_logger(), "AUTO 기준국 선택 실패 — 재시도한다");
auto last_send = std::chrono::steady_clock::now(); auto last_send = std::chrono::steady_clock::now();
auto last_summary = std::chrono::steady_clock::now();
while (running_ && rclcpp::ok()) { while (running_ && rclcpp::ok()) {
if (ntrip_fd_ < 0) { if (ntrip_fd_ < 0) {
if (!ntrip_connect()) { std::this_thread::sleep_for(std::chrono::seconds(2)); continue; } if (auto_mp && active_mp_.empty() && !resolve_auto_mountpoint()) {
RCLCPP_INFO(get_logger(), "NTRIP 접속: %s:%d/%s", ntrip_host_.c_str(), ntrip_port_, ntrip_mp_.c_str()); std::this_thread::sleep_for(std::chrono::seconds(5));
continue;
}
if (!ntrip_connect()) {
// AUTO 면 다음 후보로 넘어간다(해당 관측소 점검/장애 대비)
if (auto_mp && !next_candidate())
RCLCPP_WARN(get_logger(), "후보 소진 → 소스테이블 재조회");
std::this_thread::sleep_for(std::chrono::seconds(2));
continue;
}
RCLCPP_INFO(get_logger(), "NTRIP 접속: %s:%d/%s", ntrip_host_.c_str(), ntrip_port_, active_mp_.c_str());
last_send = std::chrono::steady_clock::now(); last_send = std::chrono::steady_clock::now();
} }
uint8_t buf[4096]; uint8_t buf[4096];
@@ -304,16 +527,19 @@ class Um982Driver : public rclcpp::Node {
if (n > 0) { if (n > 0) {
::write(serial_fd_, buf, n); // RTCM 주입 ::write(serial_fd_, buf, n); // RTCM 주입
ntrip_bytes_total_ += n; ntrip_bytes_total_ += n;
RCLCPP_INFO_THROTTLE(get_logger(), *get_clock(), 5000, feed_rtcm(buf, static_cast<size_t>(n)); // 주입과 병행해 스트림 검증
"NTRIP 수신 누적 %ld bytes (최근 recv %zd bytes)",
ntrip_bytes_total_, n);
} else if (n == 0) { } else if (n == 0) {
RCLCPP_WARN(get_logger(), "NTRIP 연결 종료 → 재접속"); RCLCPP_WARN(get_logger(), "NTRIP 연결 종료 → 재접속");
::close(ntrip_fd_); ntrip_fd_ = -1; continue; ::close(ntrip_fd_); ntrip_fd_ = -1; continue;
} }
// VRS 유지용 GGA 주기 재전송
auto now = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now();
if (std::chrono::duration<double>(now - last_send).count() > gga_period_) { // 무엇이 실제로 들어오는지 30초마다 1줄 요약 (위성군 보정 누락 진단의 핵심)
if (std::chrono::duration<double>(now - last_summary).count() > 30.0) {
log_rtcm_summary();
last_summary = now;
}
// VRS 유지용 GGA 주기 재전송
if (send_gga && std::chrono::duration<double>(now - last_send).count() > gga_period_) {
std::string g = latest_gga(); std::string g = latest_gga();
if (!g.empty()) ::send(ntrip_fd_, g.data(), g.size(), MSG_NOSIGNAL); if (!g.empty()) ::send(ntrip_fd_, g.data(), g.size(), MSG_NOSIGNAL);
last_send = now; last_send = now;
@@ -321,6 +547,152 @@ class Um982Driver : public rclcpp::Node {
} }
} }
// ── AUTO: 소스테이블 → 최근접 단일기준국 ────────────────────────────────
// "주변에 있는 진짜 기지국을 우선으로 잡는다" 를 그대로 구현. solution 필드가 0 인
// STR(=단일기준국)만 후보로 두고, 소스테이블의 위경도로 거리순 정렬한다.
bool resolve_auto_mountpoint() {
double rlat = rover_lat_.load(), rlon = rover_lon_.load();
if (std::isnan(rlat)) {
RCLCPP_WARN(get_logger(), "AUTO: 로버 위치 미확보 — 기준국 선택 보류");
return false;
}
std::string table;
if (!fetch_sourcetable(table)) {
RCLCPP_ERROR(get_logger(), "AUTO: 소스테이블 조회 실패");
return false;
}
std::vector<std::pair<double, std::string>> cand;
std::istringstream ss(table);
std::string line;
while (std::getline(ss, line)) {
if (line.rfind("STR;", 0) != 0) continue;
std::vector<std::string> f;
std::string tok;
std::istringstream ls(line);
while (std::getline(ls, tok, ';')) f.push_back(tok);
if (f.size() < 13) continue;
if (f[12] != "0") continue; // solution=0 → 단일기준국만
if (!auto_format_.empty() && f[3] != auto_format_) continue;
double blat, blon;
try { blat = std::stod(f[9]); blon = std::stod(f[10]); } catch (...) { continue; }
if (blat == 0.0 && blon == 0.0) continue;
cand.emplace_back(haversine_km(rlat, rlon, blat, blon), f[1]);
}
if (cand.empty()) {
RCLCPP_ERROR(get_logger(), "AUTO: 조건(solution=0, format=%s)에 맞는 기준국 없음", auto_format_.c_str());
return false;
}
std::sort(cand.begin(), cand.end());
candidates_.clear();
for (size_t i = 0; i < cand.size() && i < 5; ++i) candidates_.push_back(cand[i].second);
cand_idx_ = 0;
active_mp_ = candidates_.front();
std::string others;
for (size_t i = 1; i < candidates_.size(); ++i) others += " " + candidates_[i];
RCLCPP_INFO(get_logger(), "AUTO 기준국 선택: %s (%.1f km) | 예비:%s",
active_mp_.c_str(), cand[0].first, others.empty() ? " 없음" : others.c_str());
RCLCPP_INFO(get_logger(), " ※ 소스테이블 좌표는 반올림/오기입이 있다. 실제 거리는 아래 1005/1006 로그로 확인할 것");
return true;
}
bool next_candidate() {
if (candidates_.empty() || cand_idx_ + 1 >= candidates_.size()) { active_mp_.clear(); return false; }
active_mp_ = candidates_[++cand_idx_];
RCLCPP_WARN(get_logger(), "AUTO: 다음 후보로 전환 → %s", active_mp_.c_str());
return true;
}
bool fetch_sourcetable(std::string &out) {
addrinfo hints{}, *res = nullptr;
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(ntrip_host_.c_str(), std::to_string(ntrip_port_).c_str(), &hints, &res) != 0) return false;
int fd = ::socket(res->ai_family, res->ai_socktype, res->ai_protocol);
timeval tv{10, 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
bool ok = (::connect(fd, res->ai_addr, res->ai_addrlen) == 0);
freeaddrinfo(res);
if (!ok) { ::close(fd); return false; }
// 소스테이블 조회는 인증 불필요
std::string req = "GET / HTTP/1.0\r\nUser-Agent: NTRIP um982_driver/1.0\r\n"
"Accept: */*\r\nConnection: close\r\n\r\n";
::send(fd, req.data(), req.size(), MSG_NOSIGNAL);
out.clear();
char buf[4096];
ssize_t r;
while ((r = ::recv(fd, buf, sizeof(buf), 0)) > 0) {
out.append(buf, static_cast<size_t>(r));
if (out.size() > (1u << 22)) break; // 4MB 안전장치
}
::close(fd);
return out.find("STR;") != std::string::npos;
}
// ── RTCM 스니핑 ─────────────────────────────────────────────────────────
void feed_rtcm(const uint8_t *data, size_t n) {
rtcm_buf_.insert(rtcm_buf_.end(), data, data + n);
extract_rtcm_frames(rtcm_buf_, [this](int mt, const uint8_t *pl, size_t len) {
++rtcm_types_[mt];
if (mt == 1005 || mt == 1006) {
BasePos bp;
if (parse_base_pos(pl, len, bp)) note_base(bp);
}
});
if (rtcm_buf_.size() > (1u << 16)) rtcm_buf_.clear(); // 안전장치
}
void note_base(const BasePos &bp) {
bool moved = base_known_ && (std::fabs(bp.x - base_x_) > 0.5 ||
std::fabs(bp.y - base_y_) > 0.5 ||
std::fabs(bp.z - base_z_) > 0.5);
if (base_known_ && !moved) return;
double dx = bp.x - base_x_, dy = bp.y - base_y_, dz = bp.z - base_z_;
double move_m = moved ? std::sqrt(dx * dx + dy * dy + dz * dz) : 0.0;
base_x_ = bp.x; base_y_ = bp.y; base_z_ = bp.z; base_known_ = true;
double blat, blon, balt;
ecef_to_lla(bp.x, bp.y, bp.z, blat, blon, balt);
double rlat = rover_lat_.load(), rlon = rover_lon_.load();
double km = std::isnan(rlat) ? -1.0 : haversine_km(rlat, rlon, blat, blon);
if (moved) {
// 실제 관측소는 움직이지 않는다. 이게 뜨면 가상 기지국(VRS)이다.
RCLCPP_WARN(get_logger(), "⚠️ 기지국 좌표 이동 %.1fm → 가상 기지국(VRS) 스트림. id=%d", move_m, bp.id);
}
if (km >= 0.0) {
RCLCPP_INFO(get_logger(), "기지국 id=%d %.7f, %.7f, %.2fm%s | 베이스라인 %.2f km",
bp.id, blat, blon, balt, bp.has_ant_h ? " (안테나고 포함)" : "", km);
if (km > max_baseline_km_)
RCLCPP_WARN(get_logger(), "⚠️ 베이스라인 %.1f km > %.1f km — 단일기준국은 거리가 멀수록 Fixed 가 어려워진다",
km, max_baseline_km_);
} else {
RCLCPP_INFO(get_logger(), "기지국 id=%d %.7f, %.7f, %.2fm", bp.id, blat, blon, balt);
}
}
// 30초마다: 어떤 RTCM 이 오는지 + 수신기가 쓸 수 있는 형태인지
void log_rtcm_summary() {
if (rtcm_types_.empty()) {
RCLCPP_WARN(get_logger(), "RTCM 미수신 (누적 %ld bytes)", ntrip_bytes_total_);
return;
}
std::ostringstream all, net, undoc, sats;
for (const auto &kv : rtcm_types_) {
all << " " << kv.first << "x" << kv.second;
if (const char *nm = network_msg_name(kv.first)) net << " " << kv.first << "(" << nm << ")";
if (const char *c = msm_constellation(kv.first)) {
sats << " " << c;
if (!rtcm_documented(kv.first)) undoc << " " << kv.first << "(" << c << ")";
}
}
RCLCPP_INFO(get_logger(), "RTCM 누적 %ld bytes | 메시지:%s", ntrip_bytes_total_, all.str().c_str());
if (!sats.str().empty())
RCLCPP_INFO(get_logger(), " 보정 오는 위성군:%s", sats.str().c_str());
if (!net.str().empty())
RCLCPP_WARN(get_logger(), " 네트워크 RTK 메시지 존재 → 단일기준국 아님:%s", net.str().c_str());
if (!undoc.str().empty())
RCLCPP_WARN(get_logger(), " ⚠️ 매뉴얼 미기재 메시지:%s — 이 위성군은 RTK 에 안 쓰일 수 있다", undoc.str().c_str());
}
bool ntrip_connect() { bool ntrip_connect() {
addrinfo hints{}, *res = nullptr; addrinfo hints{}, *res = nullptr;
hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM; hints.ai_family = AF_INET; hints.ai_socktype = SOCK_STREAM;
@@ -333,16 +705,29 @@ class Um982Driver : public rclcpp::Node {
if (!ok) { ::close(fd); return false; } if (!ok) { ::close(fd); return false; }
std::string auth = base64(ntrip_user_ + ":" + ntrip_pass_); std::string auth = base64(ntrip_user_ + ":" + ntrip_pass_);
std::string req = "GET /" + ntrip_mp_ + " HTTP/1.0\r\n" std::string req = "GET /" + active_mp_ + " HTTP/1.0\r\n"
"User-Agent: NTRIP um982_driver/1.0\r\nAccept: */*\r\n" "User-Agent: NTRIP um982_driver/1.0\r\nAccept: */*\r\n"
"Connection: close\r\nAuthorization: Basic " + auth + "\r\n\r\n"; "Connection: close\r\nAuthorization: Basic " + auth + "\r\n\r\n";
::send(fd, req.data(), req.size(), MSG_NOSIGNAL); ::send(fd, req.data(), req.size(), MSG_NOSIGNAL);
char resp[1024]; ssize_t r = ::recv(fd, resp, sizeof(resp) - 1, 0); char resp[1024]; ssize_t r = ::recv(fd, resp, sizeof(resp) - 1, 0);
if (r <= 0) { ::close(fd); return false; } if (r <= 0) { ::close(fd); return false; }
resp[r] = 0; resp[r] = 0;
if (!strstr(resp, "200 OK") && !strstr(resp, "ICY 200 OK")) { ::close(fd); return false; } if (!strstr(resp, "200 OK") && !strstr(resp, "ICY 200 OK")) {
// 401(계정 문제)과 404(마운트포인트 오타/미개방)를 구분해줘야 삽질이 없다
std::string head(resp, std::min<size_t>(static_cast<size_t>(r), 120));
size_t eol = head.find('\r');
RCLCPP_ERROR(get_logger(), "NTRIP 거부 [%s] ← %s:%d/%s",
head.substr(0, eol == std::string::npos ? head.size() : eol).c_str(),
ntrip_host_.c_str(), ntrip_port_, active_mp_.c_str());
::close(fd);
return false;
}
// 단일기준국(nmea=0)은 GGA 를 안 보내도 스트림이 나온다
if (gga_period_ > 0.0) {
std::string g = latest_gga(); std::string g = latest_gga();
if (!g.empty()) ::send(fd, g.data(), g.size(), MSG_NOSIGNAL); if (!g.empty()) ::send(fd, g.data(), g.size(), MSG_NOSIGNAL);
}
rtcm_buf_.clear();
ntrip_fd_ = fd; ntrip_fd_ = fd;
return true; return true;
} }
@@ -351,9 +736,10 @@ class Um982Driver : public rclcpp::Node {
// 파라미터 // 파라미터
std::string serial_port_, gps_topic_, ntrip_host_, ntrip_user_, ntrip_pass_, ntrip_mp_; std::string serial_port_, gps_topic_, ntrip_host_, ntrip_user_, ntrip_pass_, ntrip_mp_;
std::string auto_format_;
int serial_baud_{}, ntrip_port_{}; int serial_baud_{}, ntrip_port_{};
bool use_ntrip_{}, fixed_only_{}, inflate_cov_{}; bool use_ntrip_{}, fixed_only_{}, inflate_cov_{};
double gga_period_{}, max_corr_age_{}; double gga_period_{}, max_corr_age_{}, max_baseline_km_{};
rclcpp::Publisher<GnssPVTSolnMsg>::SharedPtr pub_; rclcpp::Publisher<GnssPVTSolnMsg>::SharedPtr pub_;
int serial_fd_{-1}; int serial_fd_{-1};
@@ -364,6 +750,19 @@ class Um982Driver : public rclcpp::Node {
std::mutex gga_mtx_; std::mutex gga_mtx_;
std::string latest_gga_; std::string latest_gga_;
int32_t last_type_{-999}; int32_t last_type_{-999};
// 로버 위치 — AUTO 기준국 선택과 베이스라인 계산용 (시리얼 스레드가 쓰고 NTRIP 스레드가 읽음)
std::atomic<double> rover_lat_{std::numeric_limits<double>::quiet_NaN()};
std::atomic<double> rover_lon_{std::numeric_limits<double>::quiet_NaN()};
// NTRIP 스레드 전용 (락 불필요)
std::string active_mp_; // 실제로 접속 중인 마운트포인트
std::vector<std::string> candidates_; // AUTO 후보(거리순)
size_t cand_idx_{0};
std::vector<uint8_t> rtcm_buf_;
std::map<int, long> rtcm_types_;
bool base_known_{false};
double base_x_{0}, base_y_{0}, base_z_{0};
}; };
int main(int argc, char **argv) { int main(int argc, char **argv) {
+400
View File
@@ -0,0 +1,400 @@
#!/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()