
简介面向卫星通信、导航定位、天文观测与空间科学教学场景的星历推算与轨道预测工具包适合需要依据公开星历计算卫星高度、经纬度、方位角与俯仰角并规划天线指向或过境观测的爱好者与科研人员。压缩包共317个文件、约1.55MB以txt说明、lng语言包、htm帮助页和bmp界面/地图素材为主同时包含可执行程序、配置及数据文件兼顾软件运行、文档查阅与多语言使用。已有1368人学习下载说明其在实际追踪与教学演示中具有一定参考价值。借助其中的Orbitron相关组件与配套资料读者可完成星历导入、轨道外推、卫星位置可视化及过境预测并将结果用于地面站接收、观测计划安排或课堂讲解较完整地呈现从轨道参数到应用输出的实践链条。1. 卫星星历推算这件小事真正做起来全是细节地面站过境前十分钟值班的人最想知道的就一件事这颗卫星现在在哪儿八分钟以后从哪个方位角升起来。卫星星历推算工具解决的就是这个问题——喂进去两行根数TLE或者一组轨道六根数按时间往前外推吐出任意时刻的位置速度、经纬高、过境起止时刻。它并非轨道动力学教科书更像一个能跑起来的工程件命令行批量算、也能拉起一个轻量界面点着看近地和深空分支都覆盖时间步长、坐标系、输出格式都能改。适合做地面站排班、遥感任务规划、教学演示也适合那些需要在自家系统里嵌一段轨道外推能力的开发者。下面按我拆这份源码的顺序讲从目录结构一路讲到怎么把它改成你自己那款。2. 工程骨架从 TLE 文本到状态向量的六个模块这份工程最值得先看的地方并非算法而是它的分层。很多人写星历推算第一版都是一坨脚本读文件、算、打印全在一个main.py里跑通没问题但只要换一个数据源或者想加一种传播模型就得从头改。这份源码把它切成了三层传播内核、绑定层、调度与输出层层与层之间只通过「卫星对象 时刻 → 状态向量」这一个约定接口通信边界干净。2.1 技术选型内核用编译型语言外层用脚本语言选型的理由很朴素是算出来的。假设外推 24 小时、步长 1 秒那就是 86400 次传播调用再乘上星座里 60 颗星五百多万次。纯脚本语言实现 SGP4一次调用里几十次三角函数和开方跑完要按分钟计把内核换成编译型实现同样的量级可以压到秒级。所以这份工程的内核是一个无外部依赖的 C 模块只做三件事解析 TLE、按分钟偏移传播、做坐标系变换。它不碰文件、不碰网络、不打日志纯函数式方便单独做单元测试。外层是脚本语言负责编排读配置、遍历卫星、生成时间网格、写 CSV、画图、比对精密星历。这部分逻辑变动最频繁用脚本语言改起来快也不用每次重新编译。中间靠一层很薄的绑定把Sat对象和state(t)方法暴露出去绑定层只做类型转换和异常映射不写业务逻辑——这一点很重要绑定层一旦掺业务两边都会开始腐烂。依赖也刻意压得很低数值计算用一个数组库配置用 YAML命令行用标准库的 argparse画图是可选的。整套东西离线可跑扔到一台没有外网的内网机器上照样能出结果这在项目现场是硬需求。2.2 目录结构一份能直接对照的工程清单ephem/ ├── core/ # 传播内核编译型零外部依赖 │ ├── tle_parser.cpp # 定长字段解析 校验和 │ ├── sgp4.cpp # 近地分支 │ ├── deep_space.cpp # 深空分支周期 225 min │ └── coord.cpp # TEME / ECEF / 大地坐标互转 ├── bindings/ # 绑定层只做类型转换 ├── ephem_core/ # 脚本侧封装 │ ├── tle.py # TLE 对象与批量读入 │ ├── propagator.py # 时间网格、批量外推 │ ├── frames.py # 坐标系与时间系统工具 │ └── models.py # 传播模型注册表 ├── cli/ │ └── propagate.py # 命令行入口 ├── gui/ # 可选的轻量界面 ├── data/ │ ├── tle/ # 输入两行根数 │ └── ref/ # 精密星历用于精度比对 ├── tests/ │ ├── test_tle.py │ ├── test_sgp4_regression.py │ └── test_frames.py └── configs/ └── default.yaml # 步长、坐标系、输出字段看目录就能看出作者的取舍core和ephem_core是两个世界前者追求算得快、可复现后者追求改得动、接得上。data/ref这个目录尤其关键里面放的是精密星历样本所有精度相关的回归测试都靠它兜底没有它你改完传播器根本不知道自己改好还是改坏了。2.3 一次推算的完整数据流把数据流走一遍后面看代码就不会迷路命令行读参数 → 批量加载 TLE 文本 → 解析成卫星对象 → 由起始时刻和步长生成时间网格 → 逐个时刻调用传播内核拿到 TEME 系下的位置速度 → 按需转成 ECEF 或经纬高 → 落盘成 CSV 或 JSON。装依赖和第一次跑通大概是这样python -m venv .venv source .venv/bin/activate pip install -r requirements.txt pip install -e . # 这一步会触发内核编译第一次要等一两分钟 # 单星、6 小时、60 秒步长输出 CSV python cli/propagate.py \ --tle data/tle/sat_a.txt \ --start 2024-06-01T00:00:00Z \ --hours 6 \ --step 60 \ --frame ecef \ --out out/sat_a.csv参数里--start是外推起点必须带时区标记不带会被当成 UTC 处理这是最容易翻车的一处--step是秒为单位的步长过境预报一般 3060 秒够用做轨道可视化可以放到 10 秒--frame决定输出的坐标列可选teme、ecef、lla选lla会额外多出经度、纬度、高度三列。输出目录不会自动创建第一次跑如果报路径不存在先mkdir -p out。注意首次编译失败九成是编译器版本或 CMake 版本不满足要求先看报错里第一行提示的是哪个工具不要从最后一行的链接错误开始查。3. 核心算法拆解SGP4 传播器如何把两行根数算成 XYZ这一章是整份工程的重心。SGP4 本身是一个公开的解析模型公式在网上都能找到但真正决定这套东西能不能用的是三个容易被忽略的地方TLE 的定长解析、时间偏移的定义、坐标系与时间系统的对齐。公式抄错还能靠回归测试发现这三处错了往往表现为「结果差几百米看着像对的」最难查。3.1 TLE 解析别用 split用列位TLE 的字段是按字符列位固定的字段之间可能是空格也可能不是用空白分割会掉进坑里平均角速度那一段有时带前导空格偏心率是隐含小数点0012345表示0.0012345阻尼系数是压缩的指数格式12345-4表示0.12345e-4。正确做法是按列位切片。# ephem_core/tle.py def parse_tle(line1: str, line2: str) - dict: 按定长列位切 TLE不要用 split()。 if not checksum_ok(line1) or not checksum_ok(line2): # 校验和是最后一位整行数字求和 mod 10- 记 1其余非数字记 0 raise ValueError(TLE 校验和不匹配多半是复制时少了字符或换行被截断) epoch_year int(line1[18:20]) # 两位年50 以上按 19xx以下按 20xx epoch_day float(line1[20:32]) # 年内第几天带小数 return { norad_id: int(line2[2:7]), # 卫星编号 epoch: to_datetime(epoch_year, epoch_day), inclo: float(line2[8:16]), # 轨道倾角deg nodeo: float(line2[17:25]), # 升交点赤经deg ecco: float(0. line2[26:33].strip()), # 偏心率隐含小数点 argpo: float(line2[34:42]), # 近地点幅角deg mo: float(line2[43:51]), # 平近点角deg no_kozai: float(line2[52:63]), # 平均角速度rev/day bstar: parse_bstar(line1[53:61]), # 阻力系数压缩指数格式 }几个参数得说清楚。epoch是这条根数的历元时刻后面所有外推都是相对它算偏移历元错了整条轨道全错。no_kozai是平均角速度而不是瞬时角速度SGP4 会自己把它换算成半长轴和平均运动。bstar直接决定低轨卫星的衰减速度取值偏大会让外推几小时后高度掉得离谱。parse_bstar要单独处理正负号和指数位写成float(s.replace(-, e-))这种偷懒写法遇到正指数就会静默出错。3.2 传播主循环分钟偏移是唯一的时间输入SGP4 的接口设计得很克制卫星对象初始化一次之后每次传播只吃一个参数——相对历元的分钟偏移。所有时间网格、批量循环都是在这一层外面套出来的。# ephem_core/propagator.py import numpy as np def propagate_window(sat, t0, minutes180, step_s60): 从 t0 起按固定步长外推。 返回 [(utc, x, y, z, vx, vy, vz), ...]位置 m、速度 m/sTEME 系。 out [] for sec in range(0, minutes * 60 1, step_s): tsince sec / 60.0 # 相对 TLE 历元的分钟偏移 r, v, err sat.sgp4(tsince) # 内核返回 km、km/s以及错误码 t t0 timedelta(secondssec) if err ! 0: # 常见错误码1 平均角速度异常4 半长轴太小已再入6 偏心率越界 out.append((t, None, None, None, None, None, None)) continue out.append((t, *(np.asarray(r) * 1000.0), *(np.asarray(v) * 1000.0))) return out这段代码有两个决策值得抄。第一时间网格用整数秒累加不用浮点累加避免长时窗外推时累积出秒级漂移。第二内核返回错误码而不是抛异常——低轨卫星外推几周后必然会因为阻力项发散而返回错误码把这个当成正常分支处理批量跑星座的时候才不会因为一颗星的异常中断整个任务。sat.sgp4返回的是 TEME 坐标系真赤道平春分点下的量单位是 km。如果你直接拿着这组数当 WGS84 地心坐标去算经纬度会得到几十公里的系统性偏差这是新手最容易犯的错也是最难自查的错因为曲线看起来完全正常。3.3 坐标系与时间系统三层转换一个都不能省从 TEME 到能用的经纬高要经过 TEME → ECEF → 大地坐标两步时间上要确认输入是 UTC、UT1 还是 GPS 时。这份工程把这两块全收在frames.py里。# ephem_core/frames.py import numpy as np, math def teme_to_ecef(r_teme, v_teme, t_utc): TEME → ECEF。位置单位 m速度单位 m/s。 theta gmst_rad(t_utc) # 格林尼治平恒星时用 UT1 近似差 0.9 s c, s math.cos(theta), math.sin(theta) R np.array([[ c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]]) omega 7.292115e-5 # 地球自转角速度 rad/s r_ecef R np.asarray(r_teme) # 速度要额外扣掉牵连速度否则东西向分量会差出每秒几百米 v_ecef R np.asarray(v_teme) - np.cross([0.0, 0.0, omega], r_ecef) return r_ecef, v_eceftheta这个角是本体系最容易出偏差的地方用平恒星时GMST还是视恒星时GAST差别在角秒量级对应地面几百米如果连岁差章动模型都省了误差会到公里级。做地面站指向米级就够做精密定轨的后处理那必须上完整模型。经验上工程里统一用 GMST UT1 近似就够覆盖绝大多数场景把这条写进配置注释别让下一个人自己猜。大地坐标那一步用迭代法反解纬度别用简化公式。简化公式在纬度 45 度附近误差可以到几十米而迭代法三五轮就收敛到毫米级代价可以忽略。提示所有涉及时间的函数内部一律用带时区的 UTC 对象传递在入口处做一次转换。项目里凡是出现「裸的 datetime」早晚会有人把它当成北京时间传进来。4. 外推实战时间网格、批量输出与精度评估算法跑通只是及格线能不能在真实任务里用取决于外推时窗怎么定、输出怎么组织、结果怎么验证。这三件事恰好是文档里最常缺失的部分也是二次开发时最先要动的地方。4.1 外推步长与时窗精度衰减是有物理原因的TLE 的历元通常在生成时刻附近往后外推的误差大致随时间增长近地轨道 1 天内的位置误差一般在公里量级3 到 5 天会涨到十几公里再往后就没什么参考价值了。这不是代码 bug是模型本身的限制——SGP4 用的是简化的引力场和拟合出来的阻力项大气密度一变累积误差就上来了。实用建议是这样做地面站过境预报用最近 24 小时内的根数误差可以控制在几公里对应的过境时刻误差在几秒做任务规划的粗筛可以用 3 天内的根数接受十几公里要做碰撞预警这类精密场景SGP4 根本不该出现在链路上得换数值积分加精密星历。步长与时窗要匹配。过境预报关注的是地平线附近的几何30 秒步长足以把仰角曲线画得平滑做可见性统计60 秒甚至 120 秒都能接受因为统计的是「有没有」不是「精确在哪儿」。反过来做卫星姿态或者传感器推扫的仿真步长要压到 1 秒甚至更小不然相邻两点的地面轨迹间距会大得没法用。4.2 批量推算星座场景下别一颗一颗来单星推算的接口很干净但真实任务是几十上百颗星、几天的时窗。这份工程在cli/propagate.py里做了两件优化一是按卫星分批每批共享同一个时间网格省掉重复的时间对象构造二是输出用追加写避免几百万行先在内存里堆着再落盘。# cli/propagate.py 片段 def run_batch(sats, t0, hours, step_s, out_path): grid [t0 timedelta(secondss) for s in range(0, hours * 3600 1, step_s)] with open(out_path, w, newline) as f: w csv.writer(f) w.writerow([norad_id, utc, x_m, y_m, z_m, vx, vy, vz, lon_deg, lat_deg, alt_m]) for sat in sats: # 外层卫星内层时间 for t in grid: tsince (t - sat.epoch).total_seconds() / 60.0 r, v, err sat.sgp4(tsince) if err: continue # 出错的时刻直接跳过不写脏数据 r_e, v_e teme_to_ecef(r, v, t) lon, lat, alt ecef_to_lla(r_e) w.writerow([sat.norad_id, t.isoformat(), *r_e, *v_e, lon, lat, alt])注意tsince是用t - sat.epoch现算的不是用循环下标——因为不同卫星的历元不一样共用时间网格时必须逐星重算偏移。这一点如果写成固定偏移多星批量跑出来的结果会整体错开几个小时而且表面上看每条轨道都闭合得很漂亮属于典型的血泪教训。输出格式上CSV 够用但不是唯一选择。如果下游要接轨道交换可以按 OEM 风格的段头 状态行输出方便被别的工具直接读如果要接前端可视化就输出 JSON 数组字段名对齐前端约定。4.3 精度评估和精密星历对一遍 RMS改完传播器、换完坐标系怎么知道自己没改坏靠回归。data/ref里放的精密星历就是标尺比对方法很直接同一批时刻两边各算一个 ECEF 位置逐点求差的模再算均方根。# tests/test_sgp4_regression.py def rms_error(pred, ref): pred / ref 均为 N×3 的 ECEF 位置数组单位 m。 d np.asarray(pred) - np.asarray(ref) return float(np.sqrt((d ** 2).sum(axis1).mean())) def test_leo_24h_under_5km(): pred run_propagate(sample_tle, ref_times) err rms_error(pred, ref_positions) assert err 5000.0, f24h 外推 RMS 超限: {err:.1f} m阈值定多少合理近地轨道用当天根数外推 24 小时RMS 在 15 公里属于正常如果算出来只有几十米多半不是算法变好了而是你比对的两组数据用了同一个数据源等于自己和自己比——这种「假通过」比测试失败更危险。5. 避坑与排查五条踩过的坑和对应改法现象算出来的经纬度整体偏西十几度。原因几乎总是漏了恒星时旋转直接把 TEME 的三轴当成了地心地固坐标。解决方法是老老实实走teme_to_ecef并且把 GMST 计算单独写一个测试拿已知时刻对一组公开参考值验证到角秒量级。现象单星算得好好的批量跑星座时轨道全乱。原因是用同一个tsince序列套给了所有卫星忽略了每颗星历元不同。改法是逐星用(t - sat.epoch)现算偏移并在批处理入口打印每颗星的历元肉眼扫一遍有没有明显不一致的。现象外推超过两天高度开始往下掉最后返回错误码。这是 SGP4 的正常行为不是 bug——阻力项发散卫星被判为再入。解决办法是在批处理里把错误码当正常分支处理跳过并记录而不是让整个任务中断同时在配置里加一个最大外推时长上限超过就拒绝启动。现象同一套代码两个人跑出来的结果差几十公里。时间系统没对齐。常见的组合是一个人传本地时间当 UTC另一个人的数据源给的是 GPS 时与 UTC 差整秒数的跳秒。改法是所有对外接口强制要求带时区标记的 ISO 字符串解析失败直接报错绝不猜。现象内核跑起来就崩报内存越界。多半是把 TLE 里的偏心率字段当成了完整小数去解析ecco变成大于 1 的值SGP4 内部的迭代直接发散。改法是解析后立刻做一次物理合法性检查偏心率落在[0, 1)倾角落在[0, 180]平均角速度大于 0任一不满足就拒绝构造卫星对象把问题挡在传播之前。6. 二次开发换传播模型、接自己的数据源这份工程的骨架是围绕「一种传播模型 一种数据源」写的但扩展点留得很清楚models.py里那个注册表就是入口。# ephem_core/models.py MODELS {} def register(name): def deco(cls): MODELS[name] cls return cls return deco register(sgp4) class SGP4Model: def __init__(self, tle): self.sat build_sat(tle) def state(self, t): # 统一签名时刻 → (r, v, err) tsince (t - self.sat.epoch).total_seconds() / 60.0 return self.sat.sgp4(tsince)想加一个数值积分传播器只需要实现同样的state(t)签名用register(rk4)挂上去配置文件里把model改成rk4就切换完成上层的时间网格、坐标转换、输出逻辑一行都不用动。这个约定的价值在于模型之间的差异被压缩到了「时刻 → 状态向量」这一个函数里替换成本极低出问题也容易二分定位。加数据源同理。现在读的是本地 TLE 文件如果你想接自家的星历库写一个load_satellites(source) - list[Satellite]的分发函数按source的前缀决定走哪个读取器返回的对象只要带norad_id、epoch、sgp4三个属性后面的链路完全透明。改造之后怎么验证我一般强制走三个用例。第一用原始 SGP4 跑同一批时刻新模型的短期1 小时内结果应该在百米量级内吻合差太多说明坐标系或时间对齐出了问题。第二跑一遍data/ref的精度回归确认新模型的 RMS 不比原来差一个量级。第三挑一颗深空卫星周期大于 225 分钟单独跑因为近地和深空走的是不同分支很多改动只测了近地就上线深空那边静默坏掉。有件事我吃过亏改完传播器之后只看了曲线形状觉得「挺像的」就发布了结果下游地面站按这个结果转天线偏了半度。从那以后我每次动内核都强制先把回归测试和深空用例各跑一遍再提交曲线好看不算数RMS 数字才算数。希望这篇拆解能帮你少走两趟弯路。本文还有配套的精品资源点击获取