OpenCelliD 基站定位数据实战:字段、清洗与高效查询 做定位相关项目的人多半都碰到过这种场面用户手机上报了一串MCC/MNC/LAC/CIDGPS 没开、Wi-Fi 没扫到你手里只有这几个数字却要在几百米到几公里的误差范围内猜出他在哪。能兜底的选项其实不多OpenCelliD 就是最常被翻出来的那一个——一份把手机基站位置、覆盖半径、信号强度汇总成表格的开放数据库。它不是运营商放出来的官方台账而是靠众包测量一点点攒起来的全球基站底图免费、可下载、覆盖广代价是精度参差、脏数据不少。这篇文档就是我这几年反复用它的笔记整理字段怎么读、数据从哪拿、几十 GB 的 CSV 怎么跑得动、哪些位置能直接用、哪些坑一定会踩。1. 14 个字段逐个拆OpenCelliD 的数据到底记了什么这份库的本质是一张扁平表没有几何对象、没有空间索引就是一行一行的 CSV每行描述某个国家、某个运营商、某个制式下的一个小区。你可以把它理解成通讯世界的门牌号台账门牌号cell是有的但这户人家具体住在哪一栋楼往往只有一个大概的估计。所有后续分析的质量都取决于你有没有真正读懂这些字段的含义和边界。1.1 字段清单与取值范围不同批次的导出文件字段顺序和数量会略有出入动手前先head一下确认但核心字段基本稳定字段含义典型取值容易踩的坑radio无线制式GSM / UMTS / LTE / NR / CDMA同一个物理站点会以多种制式重复出现mcc国家码460、310、262 等是字符串不是数字net有前导零net网络码MNC00、01、11 等读成 int 后00变成0join 直接全错area位置区 / 跟踪区0 - 65535GSM 里叫 LACLTE 里叫 TAC语义不同cell小区标识GSM 两字节LTE 的 ECI 可达 28 位位数大过一遍 Excel 就被转成科学计数unit单元 / 扰码UMTS 的 PSC、LTE 的 PCI部分制式为空不能当主键单独用lon/lat经纬度WGS84通常 5 - 6 位小数是聚合估计值不等于塔的实测坐标range估计覆盖半径米0 到数万0通常表示无法估计不是零半径samples累计测量样本数通常 ≥ 1数值越大位置越稳1意味着只有一次上报changeable位置是否可能变动0 / 11多为微站、车载站或临时站created首次入库时间戳10 位整数是 UTC 秒不是毫秒updated最近更新时间戳10 位整数用它判断基站是否还有效averageSignal平均接收电平dBm通常为负数0表示无数据0不是信号为 0别直接参与均值把这些字段在心里分个类会清晰很多radio/mcc/net/area/cell/unit是身份lon/lat/range是空间samples/changeable/created/updated/averageSignal是质量。身份字段用来做关联空间字段用来算距离质量字段用来做加权和过滤——这三类混着用是最常见的翻车起点。1.2 主键不是 cell而是这个六元组新手最容易犯的错是把cell当成唯一键。实际上唯一标识一个小区的是(radio, mcc, net, area, cell, unit)这个六元组。原因很直白cell只是一个 16 位或 28 位的编号它在不同的area下完全独立在不同运营商的网络里更是毫无关系。同一个号码在全球范围内重复出现的概率极高。UMTS 的情况更绕一点同一个物理小区会因为扰码PSC不同而在表里出现多行unit字段就是用来区分它们的。LTE 下unit是 PCI同一个站点的三个扇区 ECI 各不相同但 PCI 因为规划复用在同一片区域里重复出现是常态。所以我做关联时从来不用cell单字段去 join一定带上制式、国家码、网络码和区域码。只要漏掉其中一个结果集就会以笛卡尔积的方式膨胀几百万行变几亿行跑一晚上都出不来。另一个细节是net必须按字符串处理。国内某运营商的 MNC 就是00你一旦用默认的 dtype 读进来00会变成0之后和运营商对照表 join 全部落空然后你会花两个小时怀疑是数据源出了问题——实际上问题在自己那行read_csv。这个坑我踩过一次后来所有涉及mcc/net的读入都强制dtype{mcc:string,net:string}。1.3 range、samples、averageSignal 的可信度分层这三个字段看起来最科学实际可信度差别很大必须分开对待。samples是这三个里最可靠的它代表这个小区被采集到的次数。我的经验阈值是samples 10的坐标可以当作相对稳定的参考samples 1的记录坐标本质上等于某个采集者在那儿打过一次电话误差可能是几公里。range是覆盖半径估计常见做法是取该小区所有上报点之间的距离分布分位数所以它和采集密度强相关——采集密集的城区range往往偏小采集稀疏的郊区range反而可能被高估。还有一种情况是数据里出现range 0这通常意味着无法估计某些批次会用1000这种固定值兜底看起来像是真实估计实际只是个占位符。如果你非要自己算覆盖半径可以用对数距离路损模型做一个粗糙标定PL(d) PL(d0) 10·n·log10(d/d0)其中n在密集城区取 3 到 4郊区取 2 到 3d0通常取 1 米。但真实环境里的穿透损耗、天线倾角、建筑遮挡都不可控这个公式只能给出数量级不能当精确值。我的做法是把range当作上界用来画缓冲区而不是当作真实覆盖边界。averageSignal是最容易误用的一个。它是采集时刻的接收电平平均受机型天线增益、握持方式、是否在室内影响极大0表示没有数据而不是信号为零。拿它跨机型、跨运营商比较绝对值基本没有意义我只用它做同一小区内部的时间趋势或者按制式做分布对比比如看某运营商的 LTE 采样电平整体分布是不是偏低而且一律用分位数而不是均值。2. 拿数据的三种姿势离线包、BigQuery、实时反查接口数据获取方式决定了你后面所有工作的形态。同一个数据集按国家下离线包、走 BigQuery 直接 SQL、还是调实时反查接口对应的是三种完全不同的项目节奏。选错了不是不能用而是会在某个环节突然卡住比如跑了两周的分析脚本因为配额用完而中断。2.1 按国家下载批量分析的首选注册账号之后可以按国家或按运营商筛选打包下载拿到的是 gzip 压缩的 CSV。这是做批量分析最省事的路子因为文件已经在源头按范围切好了你不用担心把全球几千万行都拖到本地。我的命名习惯是按快照月份落盘方便做版本对比data/opencellid/202503/cell_towers_460.csv.gz。这样半年后拿到新快照可以直接用文件名 diff 出哪些小区的坐标变了、哪些新增、哪些消失。mkdir -p data/opencellid/202503 # 大文件一定用 -c 断点续传浏览器下载断一次就得重来 wget -c -O data/opencellid/202503/cell_towers_460.csv.gz 下载链接 # 先抽样看三行确认字段顺序和分隔符 zcat data/opencellid/202503/cell_towers_460.csv.gz | head -n 3全量文件解压后是几十 GB 级别、几千万行这里有两个必须提醒的点一是别用 Excel 打开cell字段会被转成科学计数法而且行数上限根本装不下二是别指望一次全读进内存下一节会专门讲怎么处理。更新节奏大致是半年一次如果你要做基站增删的时序分析务必在元数据里记下快照日期否则两次快照之间的差异会被误解成基站突然迁移。2.2 BigQuery 镜像表不想落盘就走这条路公共数据集里能搜到 OpenCelliD 的镜像表优势是可以直接写 SQL不用落盘还能和自家数仓里的表做 join。对于只想验证一个想法的场景这比下载几十 GB 文件再洗一遍快得多。-- 表名和字段名以你实际搜到的为准先 LIMIT 看结构 SELECT mcc, net, area, cell, lat, lon, range, samples FROM bigquery-public-data.opencellid.xxx WHERE mcc 460 AND radio LTE LIMIT 100;用之前有两个动作一定要做先把字段名和官方 CSV 对一遍镜像表的列名偶尔会被改成驼峰或者缩写再确认一下快照时间镜像表的更新一般比官方下载滞后。计费是按扫描字节算的SELECT *全表扫一次很肉疼所以永远做列裁剪能加分区条件就加。2.3 实时反查接口单点查询用这个业务侧最常见的需求是给我一个LAC/CID告诉我坐标。这时候用官方提供的实时查询接口最合适请求体里带上制式、国家码、网络码和小区列表一次可以查多个。import time import requests def query_cells(token, mcc, mnc, cells, retries4): url https://接口域名/v2/process payload { token: token, radio: lte, mcc: mcc, mnc: mnc, cells: [{lac: c[area], cid: c[cell]} for c in cells], address: 0, } for i in range(retries): r requests.post(url, jsonpayload, timeout10) if r.status_code 200: return r.json() time.sleep(2 ** i) # 指数退避别并发硬刚 raise RuntimeError(查询失败检查配额或网络)三个实操要点。第一一定要做本地缓存基站的LAC/CID组合长期稳定同一个小区被查第二次的概率很高落一张(mcc,net,area,cell) - (lat,lon,range,ts)的缓存表能把请求量砍掉一大半。第二批量查询必须限速用令牌桶或者简单的sleep都行把并发打到几十上百的结果通常是整批失败。第三把接口返回的置信度类字段和本地samples一起判断接口返回的坐标不一定比离线表更准尤其是新站。2.4 署名与商用边界先看许可再动手这份数据以署名类开放许可发布免费使用的前提是保留来源署名衍生数据的分享方式也要遵循同样规则。落到实操上就是报表页脚、地图角落、论文的数据来源章节里留一行数据来源OpenCelliD这是一个几乎零成本的合规动作但很多人会忘。商用场景要单独看条款。把免费包直接嵌进对外提供的产品里、或者基于它做成收费服务通常需要单独授权别想当然地认为开放数据等于随便用。我自己的处理方式是只要项目涉及对外营收先让法务过一遍许可条款再决定是用免费包还是走商业授权。还有一点值得说清楚这份数据是基站级别的不包含任何用户级别的信息所以在做数据合规评审时它可以明确归到公开基础设施数据这一类。但如果你的项目要把它和自家用户上报日志 join哪怕只是临时关联风险等级也会完全变化必须提前做脱敏和聚合最好在链路设计阶段就把这一步固化成流程。3. 清洗那些长得像正常数据的脏记录用这份数据的项目里真正耗时间的从来不是建模而是把数据洗到能用。麻烦的地方在于脏数据不会以明显的形态出现——它不会给你空值或者 9999 这种一眼假的占位符而是一个看起来完全合理的经纬度落在离真实基站两公里外的某个小区里。3.1 重复行与坐标漂移同一主键出现多行的情况很常见来源有几个不同时期的快照被合并、同小区不同 PSC、以及采集批次不同导致的重复入库。处理方法我一般按优先级排序后去重import pandas as pd df pd.read_csv( path, dtype{mcc: string, net: string, cell: int64, area: int32}, ) key [radio, mcc, net, area, cell, unit] df ( df.sort_values([samples, updated], ascendingFalse) .drop_duplicates(subsetkey, keepfirst) .reset_index(dropTrue) )排序逻辑是samples大的优先采样多位置更稳samples相同时取updated更新的。这个顺序看起来随意实际影响不小——如果反过来优先取最新的你会经常拿到samples1的新记录精度反而更差。坐标漂移是更隐蔽的问题。同一个 eNodeB 下的三个扇区理论上应该共站但表里它们的坐标可能散开几百米。这不是基站分散而是每个扇区的采集点分布不同造成的。判断方法是按 eNodeB 聚合后看组内坐标的标准差如果超过 300 米我会把整组统一到以samples加权的质心位置。这一步做完站点级的定位精度提升很明显。3.2 不可能坐标的筛法经纬度为 0、落在远海、落在境外这三类是必须处理的。我的流程是先做粗略 bbox 过滤再对可疑点做精确判断避免一上来就跑昂贵的空间计算。粗略过滤很直接按目标区域给一个经纬度范围超出范围的先标记出来。精确判断用陆地多边形做点在多边形内分析但要注意——沿海基站确实可能落在海里几百米直接删会误伤真实的近海覆盖站所以我会给陆地多边形加 5 公里的缓冲区落在缓冲区内的保留但打上近海标签。import geopandas as gpd from shapely.geometry import Point land gpd.read_file(ne_10m_land.shp).to_crs(4326) land_buf land.copy() land_buf[geometry] land_buf.geometry.buffer(0.05) # 约 5km按纬度调整 pts gpd.GeoDataFrame( df, geometry[Point(xy) for xy in zip(df.lon, df.lat)], crs4326, ) joined gpd.sjoin(pts, land_buf[[geometry]], howleft, predicatewithin) inland joined[joined.index_right.notna()].drop(columnsindex_right)还有一个容易忽略的点是坐标精度截断。有些下游系统会把经纬度四舍五入到 3 位小数那是大约 100 米的误差和单基站定位本身的误差叠加之后会直接毁掉整个误差评估。存储和传递过程中保持原始精度只在展示层做格式化。3.3 LTE 的 ECI 拆解与站点聚合LTE 的cell字段是 ECI结构是ECI eNodeB ID × 256 扇区号。这意味着你可以用一个位移操作把扇区聚合成逻辑站点df_lte df[df.radio LTE].copy() df_lte[enodeb] df_lte[cell] 8 df_lte[sector] df_lte[cell] 0xFF sites ( df_lte.groupby([mcc, net, area, enodeb], as_indexFalse) .agg(lat(lat, median), lon(lon, median), sectors(sector, nunique), samples(samples, sum)) )用median而不是mean是关键细节三个扇区中如果有一个坐标偏得很离谱均值会被拉走中位数不会。做完这一步你会得到一张站点级的表行数大约只剩三分之一后续做最近邻查询、覆盖分析都快得多。但要提醒一句256这个魔数只适用于 LTE。NR 的 NCI 结构位数不同不能照搬位移量。动手前先看数据里cell的取值分布如果最大值远超2^28说明结构不一样得重新推导。GSM 和 UMTS 也没有这个层级只能靠area加坐标聚类来近似站点聚类半径给 200 到 500 米比较合理。3.4 用 created/updated 判断数据新鲜度created和updated都是 UTC 秒级时间戳转成北京时间记得加偏移别用毫秒去解析会得到一个 1970 年代的日期。我自己的阈值是这样定的updated距今超过两年打stale标签在定位场景里降权created很新但samples很小的记录打unverified标签同样降权。前者往往是已经拆除或改频的站后者多半是新站或者采集误差。这两个标签不会直接删数据而是在查询排序时作为惩罚项参与打分。直接删掉的风险是某些偏远区域本来就数据稀疏删完之后整片区域没有可用的兜底基站定位服务直接失效。宁可降权不要删除这是我踩过几次坑之后定下来的原则。4. 几十 GB 的 CSV 怎么跑得动第一次拿到全量文件的时候我试图用pd.read_csv直接读结果内存瞬间打满机器卡死。后来把整条链路重新设计了一遍现在从原始 CSV 到可查询的在线服务整个流程不到一小时跑完。4.1 pandas 的 dtype 与分块的两个关键参数默认 dtype 是内存杀手cell会被推断成int64mcc/net会被推断成object几千万行下来直接吃掉好几个 GB。显式指定类型之后内存占用通常能降到原来的三分之一左右。dtypes { radio: category, mcc: string, net: string, area: uint32, cell: uint64, unit: uint16, lon: float64, lat: float64, range: float32, samples: int32, changeable: uint8, created: int64, updated: int64, averageSignal: int16, } for chunk in pd.read_csv(path, dtypedtypes, usecolslist(dtypes), chunksize2_000_000): process(chunk)usecols这个参数经常被忽略。分析场景里你很少需要全部字段把用不到的列在读入阶段就砍掉省的不仅是内存还有 IO。分块处理完之后落成 parquet压缩率相当可观体积通常能小一个数量级后续反复读取的成本也低得多。4.2 DuckDB我现在的默认中转站如果只允许我推荐一个工具我会选 DuckDB。单机多线程直接read_csv_auto读 gzip CSV 或者 parquet几千万行的GROUP BY通常几秒到几十秒就出结果而且清洗逻辑写成 SQL 比 pandas 链式调用好维护得多。-- 一次读入转成列存后面所有查询都基于它 CREATE TABLE cells AS SELECT * FROM read_csv_auto(data/opencellid/202503/*.csv.gz, header true); -- 按六元组聚合成一行坐标取中位数 CREATE TABLE cleaned AS SELECT radio, mcc, net, area, cell, unit, median(lat) AS lat, median(lon) AS lon, max(range) AS range, sum(samples) AS samples, max(updated) AS updated FROM cells GROUP BY ALL; -- 只保留 LTE 的站点级视图 CREATE TABLE lte_sites AS SELECT mcc, net, area, cell 8 AS enodeb, median(lat) AS lat, median(lon) AS lon, sum(samples) AS samples, count(*) AS n_sectors FROM cells WHERE radio LTE GROUP BY ALL;DuckDB 还有个好处是可以把处理结果直接导出成 parquet然后按mcc分区后续做单省查询时只需要扫一个文件速度极快。4.3 空间索引怎么选geohash、H3 还是 PostGIS离线分析和在线查询的需求不一样索引选型也应该分开考虑。方案适用场景代价geohash 精度 7约 150 米纯 Python 项目、快速分桶、按区域切片有边界效应邻域查询要算周边 8 个格H3 分辨率 8 / 9栅格聚合、热力图、跨数据集对齐需额外依赖六边形和经纬度不直接对应PostGIS GiST 索引精确半径查询、范围连接、线上服务要起数据库几千万行建索引耗时DuckDB spatial 扩展单机分析、不想起服务生态还在成长复杂几何操作支持有限我的组合方案是离线分析用 parquet 按 geohash 分区DuckDB 直接查线上服务用 PostGIS 建 GiST 索引查询走ST_DWithin做半径搜索。这个组合跑了两年多稳定性和性能都能接受。有一个容易忽略的细节是 geohash 分桶的精度选择。150 米左右的精度适合城市但如果你要查的是郊区的大范围基站格太小会导致跨格查询变多反而慢。我的做法是按业务区域分别设参数——城区用精度 7郊区用精度 6配置文件里分开写。5. 落到项目里的四种典型用法讲完数据处理回到真正的问题这份数据到底能干什么。我做过和见过的用法主要集中在四类每一类的精度预期和实现难点都不一样。5.1 定位兜底与误差估计这是最核心的用法。链路大致是拿到手机上报的MCC/MNC/LAC/CID先按六元组精确查表命中就直接返回坐标用range作为搜索半径的先验没命中就退一步用(mcc, net, area)加同城约束做近似匹配。误差要如实评估。城市密集区单小区定位的误差通常在几百米到两公里之间郊区会更大别对外宣传成精准定位。想提升精度有几个手段一是利用多小区上报服务小区加邻区做加权质心权重可以用信号强度或者覆盖半径的倒数二是加连续性约束用上一次可信的 GPS 位置或惯性数据做卡尔曼滤波避免定位结果在两次上报之间跳到十几公里外三是把range作为半径先验超出半径的候选直接排除。import math def weighted_centroid(cands): cands: [{lat:..,lon:..,range:..,weight:..}, ...] total sum(c[weight] for c in cands) or 1.0 lat sum(c[lat] * c[weight] for c in cands) / total lon sum(c[lon] * c[weight] for c in cands) / total # 用最大半径作为不确定度下界避免给出虚假的高精度 acc max(c.get(range, 500) for c in cands) return lat, lon, acc注意最后那个acc的计算它取的是所有候选里最大的range而不是最小。取最小会让不确定度看起来很小实际是把风险藏起来了。对外输出定位结果时一定要带这个半径让调用方自己判断能不能用。5.2 轨迹纠偏和停留点识别手机 GPS 轨迹在城区会有明显漂移尤其在高架、隧道出口和密集楼宇之间。把最近的基站位置作为辅助约束可以显著改善吸附效果——基站的坐标虽然粗但它是真的在那儿的一个弱证据比单纯靠几何平滑更符合物理。另一个更实用的技巧是用基站切换序列做粗粒度轨迹。把连续相同的cell压缩成驻留段之后轨迹点数能减少一到两个数量级计算量直接降下来。基站的切换天然就是一个人移动的粗略刻画。实操里有个坑切换序列会有乒乓效应两个小区之间来回跳几次如果不处理会被切成一堆几十秒的碎片段。加一个最小驻留时长阈值比如 30 秒过滤掉这些碎片效果立刻好很多。这个阈值不要设太大超过 5 分钟会把真实的快速通行段也吃掉。5.3 覆盖范围分析range 到底能说明什么用range画缓冲区做覆盖图是最直观的用法但有几个技术细节决定了这张图是能看还是能用。第一别在 WGS84 下直接做buffer。经纬度是角度单位不同纬度上同样的度数对应的实际距离差很多直接缓冲出来的圆是变形的。正确做法是先投影到等距投影比如按区域选 UTM 带或者本地 Albers做完缓冲再转回经纬度。第二range的分布本身值得先看一眼。按制式和运营商分组后做分位数统计你会发现差异很大——城区的 LTE 站range中位数可能只有几百米郊区 GSM 站可能上万米。这些差异不完全是物理覆盖差异很大一部分是采集密度造成的统计偏差。制式样本量级range 分位数示意结构说明LTE大P50 小、P90 中等城区密集采集点多range 偏小UMTS中P50 中等逐步退网更新频率下降GSM大P50 明显偏大广覆盖站多采集稀疏NR小分布不稳定新站为主样本不足表里的具体数值需要你自己按快照统计结构和分组方式可以直接套用。averageSignal也可以放进同一张表但只做同制式内的相对比较不要跨制式比绝对值。5.4 与 POI、路网、栅格数据融合基站数据单独看价值有限一旦和其他数据源融合能解决的问题就多了。基站加 POI统计每个站点覆盖范围内的 POI 密度和类别构成能大致判断这是商业区站、住宅区站还是工业区站。这个特征在做基站画像、做区域活跃度推断的时候很好用。基站加路网把基站投影到最近的道路段上可以做路侧覆盖串联分析用于判断某段道路的覆盖连续性。投影时注意用投影坐标系算距离别在经纬度上直接算欧氏距离。基站加栅格数据人口分布、夜间灯光这类可以做站点分布与需求分布的对比作为规划参考。这里必须强调参考两个字——影响基站选址的因素太多运营商的工程参数、频谱规划、地形遮挡都不在你手里这份数据只适合做宏观量级的对比。融合的技术关键其实只有一个统一坐标系和统一主键。所有数据都对齐到同一个格网H3 或者 geohash用格网 ID 做 join能省掉大量昂贵的距离计算。我做过一次对比同样规模的融合任务用格网 join 比用最近邻距离 join 快了一个数量级以上。6. 可视化从快速预览到出图的两套工具链可视化这件事看起来简单但千万级点云往上一怼大部分工具都会卡住。我通常会分两步走先用轻量方案快速预览确认数据形态没问题再进重型工具出图。6.1 千万级点的快速预览方案kepler.gl 这类基于 WebGL 的工具直接拖 CSV 进去就能看几百万行以内体验还比较流畅点图层按radio做定性着色一眼就能看出制式分布。数据量再大就先预聚合用 DuckDB 把点聚合到 H3 分辨率 7 或 8 的格子里出图只画聚合结果行数能压到几万速度飞快。folium 只适合小范围场景几千个点以内还行上万就开始卡。QGIS 是另一条路用添加分隔文本图层导入 CSV注意 X/Y 字段要分别指定lon和lat坐标系选 EPSG:4326这两个设置错了地图上什么都看不到。6.2 出图时最容易搞错的坐标系OpenCelliD 的坐标是 WGS84。如果你的底图用的是国内的地图服务通常需要转换成 GCJ-02 或 BD-09不转的话整体会偏移几百米。这个偏移量级和单基站定位本身的误差是同一个数量级非常容易被误判成数据不准然后你会花大量时间去排查数据最后发现是坐标系没转。转换用现成的算法库就行别自己写近似公式——火星坐标的转换有多段偏移自己实现容易在边界处出问题。另外出图时的配色建议按range分档用连续色带按radio用定性色带图例一定要标单位range的单位是米别写成公里。7. 跑过几个项目之后我总结的这些细节前面讲了流程和方法最后说几个零散但很致命的细节。这些东西在文档里一般看不到但每一个我都实际踩过。mcc和net的前导零前面提过一次还要再强调这两个字段必须按字符串处理从读入、join 到导出全程不能转数字。这一条能帮你省掉至少半天排查时间。cell字段不要过 Excel。它的位数超过了浮点精确表示的范围一转就丢精度而且不可逆。需要人工看数据的时候用 DuckDB 或者csvkit之类的命令行工具。range 0不等于半径为零。它表示无法估计你在做缓冲分析的时候要把这类记录单独处理我一般是给一个按制式和区域统计出来的中位数作为兜底值。samples是累计样本数不同快照之间不可直接相减。统计口径可能调整过直接做差值会得到负数或者离谱的增量。跨快照比较时只看有没有和位置变了多少不要比样本数。时间戳是秒不是毫秒。这个我见过不止一个项目搞错把 10 位时间戳当毫秒解析得到 1970 年的日期然后整张表的时间过滤条件全部失效数据看起来全都是旧的。area的理论边界是在同一个运营商的网络内唯一但实际数据里确实出现过同一个area值在不同城市都有大量记录的情况多半是采集错误。做城市级分析时一定要加经纬度范围约束不能只靠area来切分城市。最后关于 MCC/MNC 到运营商的映射我的建议是维护一张独立的维表而不是硬编码在代码里。这张表会随着运营商合并、虚拟运营商入网而变动放在配置文件里改的时候不用重新部署。我现在维护的流程很简单每半年拉一次新快照跑一遍清洗脚本结果按mcc落成 parquet再灌一份进 PostGIS。整个流程不到一小时跑完能撑住后面半年的查询量。这份数据的价值其实不在准而在全——它是少数能覆盖到偏远地区的开放基站底图。把它当成一个粗粒度的先验再叠加你自己的高精度数据效果往往比单独依赖任何一方都好。