决定今晚出不出去,我一般先看云。云量可以,再翻光污染地图;找到一块够黑的地方,还要看路能不能开到山脚;最后打开星图,确认想拍的东西今晚能升多高,这台镜子装不装得下。
这几步我做了很多年,每一步都有工具。问题是这些工具互相不知道对方存在:光污染地图不知道路通不通,星图不知道我带的什么镜子,天气也不知道我要去的地方在哪儿。
这套工具链没有一开始就想清楚要做成什么样。它是按顺序回答上面那些问题的,第一个是:这个地方够不够黑。
先有一张光污染地图
2025 年 3 月底,我先写的是一个很小的计算服务:给目标、时间和坐标,算高度、方位和升落。四月初把光污染分析加了进来,读 VIIRS 的 GeoTIFF,把辐射度换算成波特尔等级。
到这一步,它能回答两个问题:这个东西今晚升多高,这个地方够不够黑。但都是一个点一个点地查。真正想要的是另一件事:给一个范围,告诉我里面哪些地方值得去。这个想法后来变成了另一个项目。
黑的地方,不一定去得了
2025 年 8 月底,我加了山峰搜索。原因很直接:光污染地图上最黑的那块,往往在山里,或者在离公路很远的地方。我需要知道那里有没有一个能停车、能架三脚架的位置。
九月初又加了天文台和观景平台,项目也从 MountainPeakFinder 改名成 StarGazingPlaceFinder。名字变了,问题也变了:从“这座山有多高”变成“这个地方能不能去”。
路网可达性是这时候进来的。最早的判断很粗:离公路 5 公里以内就算可达。后来改成 200 米。不是因为数据变准了,是因为我背着赤道仪、三脚架和电源,5 公里和 200 米不是一回事。
海拔也不是越高越好。光污染低、视野开阔、车能到,这三件事经常不在同一个点上。地点评分就是在这时候成形的:光污染等级、海拔、道路距离,各自算分,再合成一个数。
那时候地点这边的光污染还是 KML 图像瓦片,颜色是渲染过的,只能根据颜色反推等级。MCP 那边一开始就用 GeoTIFF,但那是单点查询;区域分析这条线为了省事,用的是瓦片。
到这里,工具能回答“哪里值得去”了。但它还是一个点一个点地查。
五十个点,最多等了八分钟
2026 年 6 月,我想让它一次分析一整片区域。第一次跑 50 个候选点,用了 200 到 500 秒。
我原以为是数据量大:光污染要读栅格,海拔要查库,路网要算连通性。拆开看才发现,最贵的一步是每个点都重新去 Overpass 下载一次路网。同一片区域,50 个点,下载了 50 次同一张图。单点用 OSMnx 走一遍要 143 秒,区域大一点还会超时。
那次还遇到一个更没道理的错误:Overpass 主站返回 406 Not Acceptable。请求本身没错,是缺了 User-Agent 和 Accept: application/json。加上之后才正常。
真正的改法是换数据源。我把 OSM 的路网导进本地 PostGIS,直接从 planet_osm_line 查。单点连通性变成一次 kNN 查询,50 毫秒;一片 95 平方公里的区域建一次 NetworkX 图,大概 15 秒,区域内所有点共用这张图。
再加上海拔批量查询、光污染栅格批量读、四个线程并行,50 个点从 200–500 秒降到 3–5 秒。
这次之后我改了一个习惯:先量每一步的耗时,再决定优化哪里。那次真正慢的不是计算,是重复下载。
换了更准的数据,答案反而更错
2026 年 6 月,我把地点这条线的光污染从 KML 换成了 VIIRS DNB 2025 的 GeoTIFF。KML 是上过色的图,GeoTIFF 里是原始辐射度,可以直接算,看起来是一次干净的升级。
换完之后,工具告诉我:崇明东滩,波特尔 1–2 级。
那个地方离上海城区不到一小时车程。我知道不对。
查下来,问题不在数据精度,在模型少了一项。VIIRS 测的是向上的灯光辐射,而城市附近的天光有很大一部分来自远处灯光在大气里的散射。只算头顶的辐射度,会把城市边缘算得比实际暗很多。
补的办法是对辐射度做高斯模糊,模拟天光扩散,sigma 按公里配置;再把扩散部分按权重加回去。同一个点从 1–2 级回到 4–5 级,和肉眼看到的一致。
这件事之后,我不再默认“换了更好的数据”就等于“结论更对”。数据越精确,模型缺的那一项越容易被掩盖。
地图供应商会改规则
地点这一层还有一件事:把地名变成坐标。
OSM 的路网我导进本地 PostGIS 之后够用了,真正撑不住的是中文地名。很多乡镇、村、山口在 Nominatim 里查不到,或者查到同名的地方。我接了高德的地理编码接口,中文地名的解析结果稳定得多。
后来高德调整了政策,个人开发者不再能免费使用这个服务。
代码里的高德从默认链路退成了显式选项:必须在 GEOCODER_PROVIDERS 里写上 amap,并且配了 AMAP_KEY,才会走付费接口。默认链路改成离线的中国行政区划表 → Photon → Nominatim。行政区划表是三千二百多行,覆盖省、市、区县,坐标是 GCJ-02。
这不是换一个 API 的事。地址解析是天气查询的入口:地名解析不出来,后面查天气、排计划全都做不了。所以这条链路从一开始就写成了级联,一个源不通,后面还有;高德只是其中一个可以付费打开的选项。
天气决定这一晚有没有意义
地点再黑,云一盖就什么都没有。
天气在我的流程里不是参考信息,是出门的前提。地点可以下个月再去,天气错过就是错过。
最早的版本只接一家天气源。后来发现两个问题:云量预报本身就是概率,不同模式给出的结果经常差得很远;任何一家也都会超时、限流或者临时抽风。
现在服务里同时接了三家:Open-Meteo(免 key)、和风天气(需要 host 和 key)、wttr。provider=all 时三家并行查询,每家的原始结果都保留在返回里;摘要按 Open-Meteo 优先,缺的字段用其他源补;哪家失败就记进 failed_providers,不让整次查询失败。同时,ai 调用时候也会得到这个结果,让外部知道查询是不是失败了;
我没有把三家平均。平均会把分歧抹掉,而分歧恰恰是我要看的:如果两家说晴、一家说八成云,这一晚就是有赌的成分。三家都说晴,也不代表一定晴,但至少我知道这个判断有多少支撑。
多源的作用不是把预报变准,是让我知道这条预报有多可信。
把地点、天气和路放在一张表里
有了地点和天气,还要回答:今晚这几个候选点,去哪个。
2026 年 6 月底,我把这件事做成了一个组合工具。它先搜候选地点,再对每个地点并发查天气和夜间目标,最后排序。
分数是两个部分加权:地点分占 0.65,天气分占 0.35。
地点分来自光污染等级、海拔和路网可达性。天气分从云量(100 减云量)、能见度(每公里 1.5 分,最多 20 分)、风速(超过 20 km/h 开始扣)、降水概率和月相(照明超过 70% 开始扣)算出来。
这个比例是我拍的:地点是我愿意多开一小时车去换的东西,天气是那一晚的运气。比例可以改,但要改在一个地方,不能散在十个函数里。
结果不只给一个数字,还给理由:波特尔几级、当前云量多少、能见度多少、最佳窗口是几点到几点、月面照明多少。分数可以争,理由必须能看懂。看不懂的分数,我不会拿它做决定。
选定设备之后,今晚拍什么
地点定了,下一个问题是目标。
望远镜模式先做光学匹配:用焦距和传感器尺寸算视场,用目标角尺寸判断装不装得下;表面亮度决定要不要更长曝光;滤镜类型决定这个目标是宽带还是窄带更合适;再加上当晚的高度和月光。
评分里视场占 40,表面亮度 30,滤镜 20,高度 10。装不下的目标会标出来,可以走马赛克。
马赛克按重叠率算网格,重叠率 5% 到 40% 可调,输出行列数、总块数和每块中心的 RA/Dec。大目标不再只能“拍不全”或者“换短焦”。
然后是时间。把每个候选目标的高度曲线插值到分钟,每分钟选当前分数最高的那个,再把连续相同的选择合并成时段。月亮没落就等,落山后再加 10 分钟缓冲;整夜都有月亮,就提示改窄带;暗时空档超过一小时,单独给一条警告。
前端用 Aladin Lite 把这些画出来:目标位置、视场框、马赛克网格都能看到;点一个时段,天图跳到对应的目标。
这套匹配一开始很慢。目录里一万多个目标,每个都算高度曲线,一次要十几秒。后来把 O(n²) 的名字查找换成字典,3900 万次比较降到 3600 次;把高度曲线推迟到截断之后算;再用 numpy 批量做坐标变换。13.73 秒降到 141 毫秒,结果不变。
两套入口,一份计算
同样的能力,给人用是网页,给 AI 用是 MCP。
网页是 FastAPI + Leaflet + Aladin Lite;MCP 把能力包成十几件工具。两套入口带来的第一个问题是:同一套天文计算写了两遍。改一处,另一处不一定跟着改。
2026 年 7 月,我把纯计算的部分拆成 stargazing-core,发到 PyPI,两边锁版本引用。它不连数据库、不发网络请求,只做坐标、时间、光学、目标匹配、拍摄计划和马赛克。
回头看,这套东西自然分成了三层:
| |
| 层 | 管什么 | 不管什么 |
|---|---|---|
stargazing-core | 天体位置、升落、月相、光学、目标匹配、拍摄计划、马赛克 | 数据库、网络请求、调用方是谁 |
stargazing-place-finder | 光污染、海拔、路网、地点评分 | 网页还是 AI 在调用 |
mcp-stargazing | 天气、地名解析、组合规划、MCP 协议 | 重复实现天文计算 |
MCP 这一层的麻烦不在算法,在返回值的形状:结果要能 JSON 序列化、时间统一带时区、大结果分页并给稳定的 resource_id、同步计算放线程里跑。工具是给模型读的,形状不对,模型就会反复试错。
和 AI 一起写,留下的三个坑
这套东西大部分是我和 AI 一起写的。它写得快,但有几类问题很隐蔽,都是“看起来能跑”。
第一类是散在函数签名里的默认值。road_radius_km=10、min_height_diff=100、max_distance_to_road_km=0.2、zoom=10,单看都合理。改一个数字,所有没显式传参的调用方一起变,而 diff 里只有一行。后来这些值收进配置对象,调用方要么显式传,要么用同一个来源。
第二类是静默 fallback。高程查询有一条四级链:OSM 标签 → PostGIS → Open-Elevation → 0.0。最后一级的意思是:查不到海拔,这座山就变成海平面,评分照算,不报错。
更典型的一次在前端。望远镜模式要拿观测点的经纬度,代码写的是 window._stargazingMap,可这个变量根本不存在。表达式永远是 undefined,于是每次都退回硬编码的 (40, 116)。页面不报错,目标列表也照常出,只是在给北京算。
我怎么发现的?换了一个地点,结果没变。
后来改成先读跳转时记下的地点,再退回地图中心;高程和评分的 fallback 也都加了日志,返回的 _meta 里写清楚实际走了哪一级。这类错误最难查,因为症状不是异常,是“结果不太对”。
第三类是数据结构。最早坐标是 (lat, lon) 元组,谁在前谁在后靠约定;dataclass 也不校验。后来换成 Pydantic:GeoPoint 限制经纬度范围,GeoBounds 校验 north ≥ south,TimeInfo 拒绝不带时区的时间。非法输入在入口就失败,不会一路流到 SQL 里变成一个空结果。
这些模型后来放进了 core,网页和 MCP 共用同一套定义。
现在
一个容器,两个进程:MCP 在 3001,网页在 5001,共用一套虚拟环境。网页上看到的计划和 AI 调出来的计划,是同一个函数算的。
出门前我把范围和时间丢进去,先看候选点的分数和理由;选定地点,再选望远镜,排目标;装不下的目标看马赛克方案。
它还没有解决所有事。天气仍然是预报,seeing 和透明度没有实测;路网数据有滞后;评分权重还是我拍的。但结果不对的时候,我能顺着三层往回找,知道是哪一层的假设错了。
这大概就是它和四个 App 之间最大的区别。