moongpx

    A spec-compliant GPX (GPS Exchange Format) library for MoonBit: parse, validate, serialize and migrate GPX 1.0 / 1.1 with line-accurate diagnostics.

    gpx
    gps
    track
    wgs84
    geo
    xml
    parser
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    43

    #moongpx

    MoonBit 的 GPX(GPS Exchange Format)规范实现:解析、校验、序列化、版本迁移。

    GPX 是 GPS 轨迹的通用交换格式。Strava、Garmin(佳明)、两步路、咕咚、高德、 各类户外运动手表导出的轨迹文件,基本都是 GPX。

    这个库让你能在 MoonBit 里读、写、校验、转换这些文件——严格按官方 XML Schema 实现, 纯函数,不联网、不读写文件。


    #状态

    v0.1.0 开发中(D1–D7 已完成:坐标类型 + 完整模型 + 解析器 + 序列化 + 独立校验 + GPX 1.0 迁移 + 轨迹统计)

    模块状态
    坐标 / 数值类型(latitudeType / longitudeType / degreesType / dgpsStationType)✅ 已完成
    根元素 + metadata + 子元素顺序校验✅ 已完成
    wpt / rte / trk / trkseg 完整模型(含 wptType 全部 19 字段)✅ 已完成
    extensions 无损保留(原始 XML 切片)✅ 已完成
    带行列号的诊断✅ 已完成
    序列化 + 往返一致性✅ 已完成
    独立的 validate_gpx(只校验不建模,含 warning 级语义检查)✅ 已完成
    GPX 1.0 解析 + 升级为 1.1 模型✅ 已完成
    轨迹统计(距离 / 爬升 / 包围盒 / 时长)✅ 已完成

    测试:moon test 163 / 163 通过。

    #已知限制(当前开发版)

    • 不支持带命名空间前缀的元素名:<g:gpx xmlns:g="..."> 会被判为"根元素不是 <gpx>"。 默认命名空间(xmlns="...",真实文件里的绝大多数写法)不受影响。 彻底解决需要换用底层库的 NamespaceReader,见「依赖行为约定」
    • parse_gpx 返回 Err 时,成功解析的部分会被丢弃。 需要"列出全部问题"时用 validate_gpx,它会连同 warning 一起报出
    • GPX 1.0 → 1.1 是单向的:反向 1.1 → 1.0 会丢 extensions 与 fix/sat 等精度字段,本库不提供


    #为什么需要它

    按规范正确处理 GPX,有一堆容易被忽略的坑:

    坑后果
    根元素子元素顺序被 schema 写死(metadata → wpt → rte → trk → extensions)顺序错了就是非法文档,但多数解析器不报错,静默给出错误结果
    经度上界是排他的(-180 ≤ lon < 180),纬度是闭区间(-90 ≤ lat ≤ 90)两端不对称:lat="90" 合法,lon="180" 非法
    version 是固定值 1.1写 1.10、1.1.0 都非法
    creator 属性必填忘了就不合规
    wpt/trkpt 有 19 个可选子元素且顺序固定位置 → 描述 → 精度三段顺序错了静默失败
    fix 是枚举:none/2d/3d/dgps/pps写 2D(大写)非法
    dgpsid 范围 0–1023越界非法
    GPX 1.0 与 1.1 不兼容老设备导出的 1.0 文件用 1.1 解析器直接读不了
    回写时 & 不转义 / extensions 被二次转义前者产出非法 XML,后者把用户的私有扩展数据变成字面文本
    回写时 \r 不转义成 &#13;被 XML 换行规范化折成 LF,数据静默改变
    xsd:dateTime 带时区偏移09:00+08:00 与 01:00Z 是同一时刻,但字符串比较会得出相反的结论
    相邻轨迹点完全重合设备卡顿时常见,不做检查会让距离 / 配速统计凭空多出一段

    前 8 条是合规问题(文档在校验器下不合格),后 2 条是数据质量问题 (文档合法,但算出来的结果不可信)。本库对两者分别给出 Error 与 Warning。


    #安装

    moon add 2973181701/moongpx


    #快速上手

    fn main {
    // 纬度:闭区间 [-90, 90]
    match @moongpx.Latitude::from_string("31.2304") {
    Ok(lat) => println(lat.to_gpx_string()) // "31.2304"
    Err(e) => println(e.message())
    }

    // 经度:半开区间 [-180, 180) —— 180 非法
    match @moongpx.Longitude::from_string("180") {
    Ok(_) => println("不应该到这里")
    Err(e) => println(e.message())
    // longitude out of range [-180, 180): 180
    }
    }


    #API

    #解析

    parse_gpx(String) -> Result[Gpx, Array[Diagnostic]]

    解析 GPX 文档。返回 Ok(Gpx) 表示结构完整且关键字段可用; 返回 Err(诊断列表) 表示存在无法继续的错误。

    诊断不抛异常——一个文件里的多个问题会一次性全部报出来。

    let src = "<gpx version=\"1.0\" creator=\"x\"><wpt lon=\"180\"/></gpx>"
    match @moongpx.parse_gpx(src) {
    Ok(gpx) => println(gpx.creator)
    Err(diags) => println(@moongpx.format_diagnostics(diags))
    }
    // 1:6 error E1003 version 必须是 "1.1"(schema 固定值),实际是 "1.0"
    // 1:29 error E1009 <wpt> 缺少必填属性 lat

    #序列化

    to_gpx(Gpx) -> String // 带 XML 声明与官方命名空间的完整文档
    to_gpx_fragment(Gpx) -> String // 不带声明与命名空间,便于嵌入别的 XML

    输出严格按 schema 的元素顺序,数字一律走 format_decimal(不含指数记法), 文本与属性自动转义。可直接作为 .gpx 文件使用。

    let gpx = match @moongpx.parse_gpx(src) {
    Ok(g) => g
    Err(d) => { println(@moongpx.format_diagnostics(d)); return }
    }
    println(@moongpx.to_gpx(gpx))

    <?xml version="1.0" encoding="UTF-8"?> <gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="moongpx"> <metadata> <name>测试轨迹</name> <bounds minlat="31" minlon="121" maxlat="31.5" maxlon="121.5"/> </metadata> <wpt lat="31.2304" lon="121.4737"> <name>起点</name> </wpt> </gpx>

    往返一致性是硬保证:

    parse_gpx(to_gpx(g)) == g // 模型完全相等
    to_gpx(parse_gpx(to_gpx(g))) == to_gpx(g) // 幂等

    #校验

    validate_gpx(String) -> Array[Diagnostic] // 全部诊断,空数组 = 干净
    is_valid_gpx(String) -> Bool // 连 warning 都没有才算通过
    has_schema_errors(String) -> Bool // 只看 error
    validate_gpx_report(String) -> String // 直接可打印的报告
    diagnostics_of_severity(Array[Diagnostic], Severity) -> Array[Diagnostic]

    和 parse_gpx 共用同一套检查,差别在视角:

    parse_gpxvalidate_gpx
    关心能不能用完不完美
    有 error 时返回 Err,丢掉已解析的部分照常报出,连同 warning 一起
    有 warning 时成功路径上丢弃报出
    返回Result[Gpx, ...]Array[Diagnostic](空 = 干净)

    不会因为发现一个问题就停下——一个文件里的多个问题会一次性全部报出。 唯一的例外是 XML 语法本身坏了,拿不到事件流,此时只能报一条 E1000。

    let src = "<gpx version=\"1.1\" creator=\"x\">" +
    "<wpt lat=\"1\" lon=\"1\"><ele>99999</ele><fix>bogus</fix></wpt>" +
    "<trk><name>空的</name></trk></gpx>"
    println(@moongpx.validate_gpx_report(src))
    // 共 3 条(1 错误 / 2 警告):
    // 1:69 error E1016 <fix> 取值非法:"bogus"(只允许 none / 2d / 3d / dgps / pps,小写)
    // 1:32 warning W1005 <ele> = 99999 m 超出地球表面合理范围(约 -11000 ~ 9000 m),请确认单位是否为米
    // 1:91 warning W1006 <trk> 没有任何 <trkseg>

    上面的文档里 <ele> 和 <trk> 只是可疑,所以 parse_gpx 照样返回 Ok—— 想看到它们必须用 validate_gpx:

    let ok_but_suspicious = "<gpx version=\"1.1\" creator=\"x\"><wpt lat=\"1\" lon=\"1\"><ele>99999</ele></wpt></gpx>"

    @moongpx.parse_gpx(ok_but_suspicious) is Ok(_) // true —— 文档合法
    @moongpx.has_schema_errors(ok_but_suspicious) // false
    @moongpx.is_valid_gpx(ok_but_suspicious) // false —— 但有可疑之处
    @moongpx.validate_gpx(ok_but_suspicious).length() // 1

    moon run cmd/main 里有完整的 11 段可运行演示(坐标区间 → 数值格式 → 解析 → 序列化 → 往返一致 → 诊断 → 独立校验 → 一次报出全部问题 → 1.0 迁移 → 轨迹统计)。

    实际用法通常是「先校验,再决定要不要解析」:

    if @moongpx.has_schema_errors(src) { // 不合格,把问题列给用户看,不往下走 println(@moongpx.validate_gpx_report(src)) } else { // 合格;warning 只是提醒,照常解析 match @moongpx.parse_gpx(src) { Ok(g) => use(g), Err(_) => () } }

    #GPX 1.0 与版本迁移

    parse_gpx_1_0(String) -> Result[Gpx, Array[Diagnostic]] // 解析 1.0 并升级为 1.1 模型
    parse_gpx_any(String) -> Result[Gpx, Array[Diagnostic]] // 按 version 属性自动分派
    detect_gpx_version(String) -> String? // 只读 version 属性,不校验

    GPX 1.0(2004)与 1.1 不兼容,而且不是子集关系:

    差异GPX 1.0GPX 1.1
    元数据位置name desc author email url urlname time keywords bounds 直接挂在根下全部包在 <metadata> 里
    链接<url> + <urlname><link href> + <link><text>
    作者<author> 与 <email> 都是纯文本personType(含 name / email / link)
    邮箱完整地址 me@example.com拆成 id + domain 两个必填属性
    朝向 / 速度<course> <speed> 是 trkpt 的正式子元素没有(改放 extensions)
    命名空间.../GPX/1/0.../GPX/1/1

    迁移映射:

    1.0→ 1.1
    根级 name desc time keywords boundsmetadata 对应字段
    <author>张三</author>metadata.author.name
    <email>me@example.com</email>Email { id: "me", domain: "example.com" }(按第一个 @ 拆)
    <url> + <urlname>Link { href, text }
    trkpt 的 <course> / <speed>Waypoint.extensions 原文

    parse_gpx_1_0 返回的模型 version 固定是 "1.1"——迁移是单向的。 反向 1.1 → 1.0 会丢 extensions 与精度字段,本库不做。

    两个入口互不越界:parse_gpx 只接受 1.1,parse_gpx_1_0 只接受 1.0。 拿到的文件版本不确定时用 parse_gpx_any。

    实现上 1.0 是独立解析路径(parse10.mbt),没有给 1.1 的解析函数加 版本分支——后者会让每个函数都拖着一个 match,且有误伤 1.1 的风险。

    #轨迹统计

    haversine_m(Waypoint, Waypoint) -> Double // 半正矢大圆距离
    path_distance_m(Array[Waypoint]) -> Double
    track_distance_m(Track) -> Double
    climb_m(Array[Waypoint], Double) -> (Double, Double) // (爬升, 下降)
    bounds_of(Array[Waypoint]) -> Bounds?
    track_stats(Track) -> TrackStats

    两个约定:

    1. 跨 trkseg 不相连。trkseg 表示 GPS 信号中断后重新捕获, 段间位移不是真实运动轨迹,计入距离会凭空多出一大截。
    2. 爬升必须带阈值。GPS 高程噪声在几米量级,朴素累加相邻点高差 会把来回抖动放大成几百米的假爬升——这是开源实现里最常见的 bug。 climb_m 默认阈值 3 m,传 0.0 可关闭过滤。

    let s = @moongpx.track_stats(track)
    // s.distance_m / s.ascend_m / s.descend_m / s.min_ele / s.max_ele
    // s.bounds / s.start_time / s.end_time / s.duration_s

    start_time / end_time 取的是真正的最早/最晚,不是首尾点—— 轨迹时间可能不单调(见 W1002),直接取首尾会算错。 时长按 xsd:dateTime 的时区折算后相减。

    #数据模型

    Gpx / Metadata / Waypoint / Route / Track / TrackSegment / Link / Person / Email / Copyright / Bounds, 字段与官方 schema 一一对应。

    Waypoint 覆盖 wptType 的全部 19 个字段(ele time magvar geoidheight name cmt desc src link sym type fix sat hdop vdop pdop ageofdgpsdata dgpsid extensions)。

    模型字段是只读的:包外既不能写结构体字面量,也不能改字段—— 所以程序化生成 GPX 要走构造函数:

    Waypoint::new(lat, lon).with_ele(Some(10.0)).with_time(Some("2020-01-01T00:00:00Z"))
    TrackSegment::new(points)
    Track::new(segments)
    Bounds::new(min_lat, min_lon, max_lat, max_lon)
    Gpx::new(creator)

    (给字段加 mut 这条路走不通:MoonBit 的 unused_mut 只看包内是否真的 写入过该字段,加不加 pub 都是错误级。构造函数是唯一解。)

    #诊断

    pub(all) enum Severity { Error; Warning }

    pub struct Diagnostic {
    severity : Severity
    code : String // 稳定代码,如 "E1009"
    message : String
    line : Int // 1-based
    column : Int // 1-based
    }

    format_diagnostics(Array[Diagnostic]) -> String
    has_errors(Array[Diagnostic]) -> Bool

    诊断代码集中定义,便于测试断言与检索:

    代码含义
    E1000XML 语法错误
    E1001根元素不是 <gpx>
    E1002 / E1003version 缺失 / 不是 "1.1"
    E1004creator 缺失
    E1005 / E1006根元素 / metadata 子元素顺序错误
    E1007metadata 重复出现
    E1009缺少必填属性
    E1010 / E1011非法 xsd:dateTime / xsd:decimal
    E1013 / E1014 / E1015wpt / rte / trk 子元素顺序错误
    E1016fix 取值非法
    E1017version 不是 "1.0"(parse_gpx_1_0 专用)

    警告级(不违反 schema,但语义可疑——不会被 parse_gpx 当成失败原因):

    代码含义
    W1001bounds 的 min 大于 max
    W1002同一 trkseg 内后一个点的时间早于前一个点
    W1003相邻两个点的 lat / lon / ele 完全相同
    W1004fix 与 sat 自相矛盾(如 fix=none 却 sat=9)
    W1005ele 超出地球表面合理范围(约 -11000 ~ 9000 m)
    W1006trk / trkseg / rte 没有任何点
    W1007点位落在 metadata/bounds 声明的范围之外

    W1002 的时间比较会折算时区——09:00+08:00 与 01:00Z 被认作同一时刻, 不会因为字符串长得不一样就判错。

    #坐标类型

    类型区间规范名
    Latitude[-90, 90] 闭区间latitudeType
    Longitude[-180, 180) 半开longitudeType
    Degrees[0, 360) 半开degreesType
    DgpsStationId[0, 1023] 整数dgpsStationType

    每个类型都提供:

    Latitude::from_string(String) -> Result[Latitude, GeoError]
    Latitude::from_double(Double) -> Result[Latitude, GeoError]
    Latitude::to_double(Self) -> Double
    Latitude::to_gpx_string(Self) -> String

    #数值工具

    parse_decimal(String) -> Result[Double, GeoError] // 严格 xsd:decimal
    format_decimal(Double) -> String // 保证不含指数记法

    #错误

    pub(all) enum GeoError {
    NotADecimal(String)
    LatitudeOutOfRange(String)
    LongitudeOutOfRange(String)
    DegreesOutOfRange(String)
    DgpsStationOutOfRange(String)
    }

    GeoError::message(Self) -> String

    每个变体都携带原始文本,报错信息里能直接看到是哪个值出了问题。


    #设计取舍

    #xsd:decimal 是严格的,不是「能解析成数字就行」

    GPX 的坐标类型基于 xsd:decimal,它的词法形式是:

    (\+|-)? ( [0-9]+ (\.[0-9]*)? | \.[0-9]+ )

    明确不接受指数记法(1e2)、NaN、INF——那些属于 xsd:double。 所以 parse_decimal 会拒绝它们,而不是丢给通用浮点解析器。

    #为什么需要 format_decimal

    MoonBit 的 Double::to_string 对 1e-7、1e+21 这类值会输出指数记法:

    1.0e-7 → "1e-7" ← 非法 xsd:decimal 1.0e21 → "1e+21" ← 非法 xsd:decimal

    也就是说,一个 lat="0.0000001" 的合法输入,如果直接回写,会产出非法 GPX 文档。 format_decimal 把指数形式展开成纯十进制("0.0000001"),保证输出始终合规。

    #Error 与 Warning:一条明确的界线

    GPX 的约束分两类,混在一起会让调用方无所适从。本库的判定标准是:

    Error = 把这份文档丢进官方 XSD 校验器,它会判不合格。 Warning = 校验器会放行,但人看一眼就知道有问题。

    举几个对照:

    现象级别为什么
    <wpt> 缺 lat 属性Errorschema 里 lat 是 use="required"
    <fix>bogus</fix>ErrorfixType 是枚举,没有这个值
    <ele>99999</ele>Warningxsd:decimal 接受任意小数,schema 不管物理合理性
    <fix>none</fix> 配 <sat>9</sat>Warning两个字段各自合法,放一起才荒谬
    轨迹点时间倒流Warningschema 里 time 只是可选字段,没有任何顺序约束
    <trk> 里没有 <trkseg>Warningtrkseg 的 minOccurs 是 0

    这条界线带来一个直接后果:warning 绝不能让 parse_gpx 失败。 一个海拔写着 99999 的文件依然是合法 GPX,拒绝解析它是越权。 所以 warning 只在 validate_gpx 里出现,parse_gpx 成功路径上会丢掉它们。

    反过来说,这也意味着只用 parse_gpx 会漏掉数据质量问题。 处理别人给的文件时,正确的顺序是先 validate_gpx 扫一遍,再决定要不要用。

    #复用生态 XML 解析器,不自造

    XML 词法层依赖 Milky2018/xml(Apache-2.0)。 本库的价值在 XML 之上的 GPX 语义层:schema 约束、顺序校验、坐标语义、版本迁移。 这也是 Rust gpx crate、Python gpxpy 的一致做法。

    #extensions 的无损保留:一个容易踩的坑

    GPX 允许用 extensions 挂载任意命名空间的第三方数据(Garmin、Strava 都有自己的扩展)。 这些内容本库不解析,但必须原样保留,否则用户的私有数据就丢了。

    实现方式是拿 XML 层的源码偏移量去切原始文本。这里有个陷阱:

    @xml 的 offset 是 UTF-16 码元偏移,不是 Unicode 码点偏移。

    含 emoji 的文本 String::length() 与最终 offset 一致,而码点数会少 1。 按码点切会切出乱码或吞掉相邻字符,而且因为 XML 结构本身没坏,这个 bug 极难发现。

    本库按码元宽度(Char > 0xFFFF 记 2)切分,并有含中文 + emoji 的回归用例锁住这个行为。

    #转义:不是「把特殊字符换掉」那么简单

    位置必须转义原因
    文本内容& < >不转义直接产出非法 XML
    属性值& < > " '同上,且引号会提前结束属性
    文本内容\r → &#13;XML 解析器把字面 CR 规范化成 LF,不转义等于静默改数据
    属性值\t \n → &#9; &#10;属性值规范化把字面 tab/换行折成空格
    extensions 内部什么都不转义那里已经是合法 XML,再转一次会变成 &lt;foo/&gt; 字面文本

    最后一行是最容易写错的地方:extensions 保存的是原始 XML 切片, 必须原样回写。本库有专门的用例(extensions 内部原文不转义)守着它。

    #「空字符串 ≡ 缺失」

    解析器把 <name></name> 读成 None(没有文本节点)。若序列化器把 Some("") 写成 <name></name>,往返就不对称了。所以统一约定:空字符串等同于缺失,不输出该元素。 这样 parse → to_gpx → parse 才能严格相等。


    #依赖行为约定

    XML 词法层的行为直接决定本库的正确性。以下几条不是"测别人的库", 而是固定我们自己的前提,写在 xml_behavior_test.mbt 里:

    依赖行为对本库的影响
    默认命名空间 xmlns="..." 被自动剥离根元素名仍是 gpx,正常解析
    命名空间前缀不剥离(<g:gpx> → 名字是 "g:gpx")已知限制,见上文
    预定义实体 &amp; &lt; &quot; 被解码序列化侧必须转义,两侧配对
    数字字符引用 &#13; 被解码为 CR序列化侧必须把 CR 转义回 &#13;
    CDATA 内容被取出为纯文本一次序列化后变成转义文本(值相同、字节不同)
    Double::to_string 对 1e-7 / 1e+21 输出指数这是 format_decimal 存在的唯一理由
    Double::to_string 对整数值省略小数点(90.0 → "90")往返比较的是模型,不受影响

    升级 Milky2018/xml 版本后这些用例若失败,说明本库的解析或序列化会随之出错, 必须重新评估,而不是等到线上才发现。


    #已知边界

    • 不做地图渲染、不涉及瓦片或可视化
    • 不做坐标系转换(WGS84 → GCJ-02 / BD-09)——那是坐标系规范,不是 GPX 规范
    • 不做二进制格式(FIT)、不做 TCX / KML 互转
    • 不联网、不读写文件(调用方自己把文本传进来)
    • 底层 XML 库是 document-buffered,超大文件(>100 MB)会整体载入内存
    • 不支持带命名空间前缀的元素名(<g:gpx>);默认命名空间正常
    • 不做 XML 数字签名校验(GPX 1.1 schema 允许,但实际文件几乎不用)


    #规范依据

    本项目为原创实现,依据上述公开标准编写,非移植任何第三方代码。


    #开发

    moon check # 类型检查 moon test # 单元测试 moon fmt # 格式化 moon run cmd/main # 可运行示例

    #关于 CI

    CI 配置放在 ci/ci.yml,不在 .github/workflows/ 下。启用只需一条命令:

    mkdir -p .github/workflows && cp ci/ci.yml .github/workflows/ci.yml

    为什么要绕这一下:GitHub 不允许 OAuth App 在没有 workflow scope 的情况下 创建或修改工作流文件(无论走 git push 还是 Contents API,都会被拒)。 把配置文件放在普通目录里可以让它照常纳入版本控制、可审阅, 等 CI 权限就绪后复制过去即可生效,不必重写历史。

    内容本身是完整的:moon check → moon fmt --check → moon test → moon run cmd/main。


    #许可证

    Apache-2.0

    Bounds

    pub struct Bounds {
    min_lat : Latitude
    min_lon : Longitude
    max_lat : Latitude
    max_lon : Longitude
    } derive(Eq,
    Debug
    )

    bounds 元素。四个属性全部必填,且都经过坐标范围校验。

    Bounds::new

    fn Bounds::new(min_lat : Latitude, min_lon : Longitude, max_lat : Latitude, max_lon : Longitude) -> Bounds

    构造包围盒。四个坐标都必须已通过各自类型的范围校验。
    pub struct Copyright {
    author : String
    year : String?
    license : String?
    } derive(Eq,
    Debug
    )

    copyright 元素。author 属性必填。

    Degrees

    pub struct Degrees {
    value : Double
    } derive(Eq,
    Debug
    )

    角度(GPX 里用于 magvar):半开区间 [0, 360)。

    Degrees::from_double

    fn Degrees::from_double(v : Double) -> Result[Degrees, GeoError]

    Degrees::from_string

    fn Degrees::from_string(text : String) -> Result[Degrees, GeoError]

    Degrees::to_double

    fn Degrees::to_double(self : Degrees) -> Double

    Degrees::to_gpx_string

    fn Degrees::to_gpx_string(self : Degrees) -> String

    DgpsStationId

    pub struct DgpsStationId {
    value : Int
    } derive(Eq,
    Debug
    )

    DGPS 基站 ID(GPX 的 dgpsid):闭区间 [0, 1023] 整数。

    DgpsStationId::from_int

    fn DgpsStationId::from_int(v : Int) -> Result[DgpsStationId, GeoError]

    DgpsStationId::from_string

    fn DgpsStationId::from_string(text : String) -> Result[DgpsStationId, GeoError]

    从文本解析。必须是十进制整数,不接受 12.0 这类形式。

    DgpsStationId::to_gpx_string

    fn DgpsStationId::to_gpx_string(self : DgpsStationId) -> String

    DgpsStationId::to_int

    fn DgpsStationId::to_int(self : DgpsStationId) -> Int

    Diagnostic

    pub struct Diagnostic {
    severity : Severity
    code : String
    message : String
    line : Int
    column : Int
    } derive(Eq,
    Debug
    )

    一条诊断记录。

    Diagnostic::error

    fn Diagnostic::error(code : String, message : String, line : Int, column : Int) -> Diagnostic

    Diagnostic::to_string

    fn Diagnostic::to_string(self : Diagnostic) -> String

    人类可读形式:行:列 级别 代码 说明

    Diagnostic::warning

    fn Diagnostic::warning(code : String, message : String, line : Int, column : Int) -> Diagnostic

    Email

    pub struct Email {
    id : String
    domain : String
    } derive(Eq,
    Debug
    )

    email 元素。schema 把它拆成 id 和 domain 两个都必填的属性 (GPX 1.0 里是一个完整的 <email> 文本节点,1.1 改了)。

    FixType

    pub(all) enum FixType {
    FixNone
    Fix2d
    Fix3d
    FixDgps
    FixPps
    } derive(Eq,
    Debug
    )

    fix 属性的枚举值。

    ⚠️ 大小写敏感:schema 里是小写的 none / 2d / 3d / dgps / pps, 写成 2D 是非法的。

    FixType::from_string

    fn FixType::from_string(text : String) -> FixType?

    从 schema 的字符串值解析。非法值返回 None。

    FixType::to_string

    fn FixType::to_string(self : FixType) -> String

    转回 schema 的字符串值。

    GeoError

    pub(all) enum GeoError {
    NotADecimal(String)
    LatitudeOutOfRange(String)
    LongitudeOutOfRange(String)
    DegreesOutOfRange(String)
    DgpsStationOutOfRange(String)
    } derive(Eq,
    Debug
    )

    坐标 / 数值的解析与范围校验失败原因。

    每个变体都携带原始文本,方便调用方直接拿去做报错信息。

    GeoError::message

    fn GeoError::message(self : GeoError) -> String

    人类可读的错误描述。

    Gpx

    pub struct Gpx {
    version : String
    creator : String
    metadata : Metadata?
    waypoints : Array[Waypoint]
    routes : Array[Route]
    tracks : Array[Track]
    extensions : String?
    } derive(Eq,
    Debug
    )

    GPX 文档根(gpxType)。

    Gpx::new

    fn Gpx::new(creator : String) -> Gpx

    构造一个空的 GPX 1.1 文档。version 按 schema 固定为 "1.1"。

    Latitude

    pub struct Latitude {
    value : Double
    } derive(Eq,
    Debug
    )

    纬度:闭区间 [-90, 90],两端都合法。

    Latitude::from_double

    fn Latitude::from_double(v : Double) -> Result[Latitude, GeoError]

    Latitude::from_string

    fn Latitude::from_string(text : String) -> Result[Latitude, GeoError]

    Latitude::to_double

    fn Latitude::to_double(self : Latitude) -> Double

    Latitude::to_gpx_string

    fn Latitude::to_gpx_string(self : Latitude) -> String

    pub struct Link {
    href : String
    text : String?
    mime_type : String?
    } derive(Eq,
    Debug
    )

    link 元素。href 属性必填。

    Longitude

    pub struct Longitude {
    value : Double
    } derive(Eq,
    Debug
    )

    经度:半开区间 [-180, 180)——上界排他,180 非法。

    Longitude::from_double

    fn Longitude::from_double(v : Double) -> Result[Longitude, GeoError]

    Longitude::from_string

    fn Longitude::from_string(text : String) -> Result[Longitude, GeoError]

    Longitude::to_double

    fn Longitude::to_double(self : Longitude) -> Double

    Longitude::to_gpx_string

    fn Longitude::to_gpx_string(self : Longitude) -> String

    Metadata

    pub struct Metadata {
    name : String?
    desc : String?
    author : Person?
    copyright : Copyright?
    links : Array[Link]
    time : String?
    keywords : String?
    bounds : Bounds?
    extensions : String?
    } derive(Eq,
    Debug
    )

    metadata 元素(GPX 1.1 新增;1.0 里这些字段直接挂在根下)。

    Person

    pub struct Person {
    name : String?
    email : Email?
    link : Link?
    } derive(Eq,
    Debug
    )

    person 元素,用于 metadata/author。

    Route

    pub struct Route {
    name : String?
    cmt : String?
    desc : String?
    src : String?
    links : Array[Link]
    number : Int?
    route_type : String?
    extensions : String?
    points : Array[Waypoint]
    } derive(Eq,
    Debug
    )

    rte 元素。

    Severity

    pub(all) enum Severity {
    Error
    Warning
    } derive(Eq,
    Debug
    )

    诊断级别。

    • Error:文档违反 schema,不合格
    • Warning:不违反 schema,但明显可疑

    Severity::to_string

    fn Severity::to_string(self : Severity) -> String

    Track

    pub struct Track {
    name : String?
    cmt : String?
    desc : String?
    src : String?
    links : Array[Link]
    number : Int?
    track_type : String?
    extensions : String?
    segments : Array[TrackSegment]
    } derive(Eq,
    Debug
    )

    trk 元素。

    Track::new

    fn Track::new(segments : Array[TrackSegment]) -> Track

    构造一条轨迹。

    TrackSegment

    pub struct TrackSegment {
    points : Array[Waypoint]
    extensions : String?
    } derive(Eq,
    Debug
    )

    trkseg 元素。

    TrackSegment::new

    fn TrackSegment::new(points : Array[Waypoint]) -> TrackSegment

    构造一个轨迹段。

    TrackStats

    pub struct TrackStats {
    point_count : Int
    distance_m : Double
    ascend_m : Double
    descend_m : Double
    min_ele : Double?
    max_ele : Double?
    bounds : Bounds?
    start_time : String?
    end_time : String?
    duration_s : Double?
    } derive(Eq,
    Debug
    )

    一条轨迹的统计结果。

    Waypoint

    pub struct Waypoint {
    lat : Latitude
    lon : Longitude
    ele : Double?
    time : String?
    magvar : Degrees?
    geoid_height : Double?
    name : String?
    cmt : String?
    desc : String?
    src : String?
    links : Array[Link]
    sym : String?
    wpt_type : String?
    fix : FixType?
    sat : Int?
    hdop : Double?
    vdop : Double?
    pdop : Double?
    age_of_dgps_data : Double?
    dgps_id : DgpsStationId?
    extensions : String?
    } derive(Eq,
    Debug
    )

    航点 / 轨迹点(wptType)。

    schema 规定子元素分三段且顺序固定: 位置信息 → 描述信息 → 精度信息。

    Waypoint::new

    fn Waypoint::new(lat : Latitude, lon : Longitude) -> Waypoint

    构造一个只有经纬度的最小航点,其余字段全为空。

    模型字段是只读的:包外既不能写结构体字面量,也不能改字段。 所以要程序化生成 GPX(而不只是回写解析出来的数据),必须走构造函数。 字段类型自带范围校验(Latitude 只能由 from_double / from_string 产出), 因此这里不需要再检查一遍。

    Waypoint::with_ele

    fn Waypoint::with_ele(self : Waypoint, ele : Double?) -> Waypoint

    返回设置了高程的副本,其余字段原样保留。

    Waypoint::with_name

    fn Waypoint::with_name(self : Waypoint, name : String?) -> Waypoint

    返回设置了名称的副本,其余字段原样保留。

    Waypoint::with_time

    fn Waypoint::with_time(self : Waypoint, time : String?) -> Waypoint

    返回设置了时间的副本,其余字段原样保留。

    bounds_of

    fn bounds_of(points : Array[Waypoint]) -> Bounds?

    一串点的包围盒。点集为空时返回 None。

    直接复用各点已校验过的 Latitude / Longitude,不重新做范围校验—— 输入既然是合法的 Waypoint,其极值必然仍在合法区间内。

    climb_m

    fn climb_m(points : Array[Waypoint], threshold : Double) -> (Double, Double)

    累计爬升与下降(米),返回 (爬升, 下降)。

    threshold > 0 时启用滞后过滤:高程变化先累积到 pending, 只有累积量超过阈值才计入总数并清零。方向反转时先结算再重新累积。 这样 3 米上 3 米下的来回抖动会被抵消掉,而真实的一次 50 米爬升不会。

    threshold <= 0 退化为朴素累加(每一步的高差都直接计入)。 没有 ele 的点被跳过,不影响累积。

    code_bounds_order

    let code_bounds_order : String

    bounds 的 min 大于 max。

    code_creator_missing

    let code_creator_missing : String

    code_duplicate_point

    let code_duplicate_point : String

    相邻两个轨迹点的 lat / lon / ele 完全相同。

    code_elevation_implausible

    let code_elevation_implausible : String

    ele 超出地球表面的合理范围(约 -11000 ~ 9000 m)。

    code_empty_segment

    let code_empty_segment : String

    trk / trkseg / rte 没有任何点。

    code_fix_sat_conflict

    let code_fix_sat_conflict : String

    fix 与 sat 自相矛盾(如 fix=none 却 sat=9,或 fix=3d 却 sat=0)。

    code_invalid_datetime

    let code_invalid_datetime : String

    code_invalid_decimal

    let code_invalid_decimal : String

    code_invalid_fix

    let code_invalid_fix : String

    code_metadata_duplicate

    let code_metadata_duplicate : String

    code_metadata_order

    let code_metadata_order : String

    code_missing_required_attribute

    let code_missing_required_attribute : String

    code_out_of_bounds

    let code_out_of_bounds : String

    点位落在 metadata/bounds 声明的范围之外。

    code_root_not_gpx

    let code_root_not_gpx : String

    诊断代码常量。

    集中在这里,避免各处写散落的魔法字符串,也方便测试与文档对照。

    code_root_order

    let code_root_order : String

    code_rte_order

    let code_rte_order : String

    code_time_not_monotonic

    let code_time_not_monotonic : String

    同一 trkseg 内后一个点的时间早于前一个点。

    code_trk_order

    let code_trk_order : String

    code_unexpected_attribute

    let code_unexpected_attribute : String

    code_version_missing

    let code_version_missing : String

    code_version_not_1_0

    let code_version_not_1_0 : String

    version 不是 "1.0"。parse_gpx_1_0 专用—— 与 E1003(不是 1.1)分开,报错信息能直接说清是哪个入口在要求什么。

    code_version_not_1_1

    let code_version_not_1_1 : String

    code_wpt_order

    let code_wpt_order : String

    code_xml_parse_error

    let code_xml_parse_error : String

    datetime_compare

    fn datetime_compare(a : String, b : String) -> Int?

    两个 xsd:dateTime 的先后:-1 = a 早于 b,0 = 相等,1 = a 晚于 b。

    任一方非法(或缺少可比信息)时返回 None——不做猜测。

    datetime_to_epoch

    fn datetime_to_epoch(text : String) -> Double?

    把 xsd:dateTime 转成可比较的数值:自 Unix 纪元起的秒数。

    为什么需要它:判断"轨迹点的时间是否递增"不能靠字符串字典序—— 2026-09-17T10:00:00Z 与 2026-09-17T18:00:00+08:00 是同一时刻, 但字典序会认为后者更大。有时区时折算到 UTC 再比。

    无时区时按 UTC 处理(xsd:dateTime 允许省略时区,含义是"未知本地时", 本库不做猜测,只保证可比)。

    自己先做一遍词法校验,非法字面量返回 None (parse_dt_parts 只抽数字、不查范围,必须先拦住 25:00:00 这类)。

    default_climb_threshold_m

    let default_climb_threshold_m : Double

    默认的爬升阈值(米)。

    低于这个幅度的高程变化被视为噪声,不计入累计爬升/下降。 climb_m 传 0.0 可关闭过滤(等价于朴素累加)。

    detect_gpx_version

    fn detect_gpx_version(src : String) -> String?

    探测文档的 version 属性值。无法解析时返回 None。

    只用来做入口分派,不校验取值是否合法——那是各解析入口的事。

    diagnostics_of_severity

    fn diagnostics_of_severity(diags : Array[Diagnostic], severity : Severity) -> Array[Diagnostic]

    按级别筛出诊断。

    earth_radius_m

    let earth_radius_m : Double

    平均地球半径(IUGG 推荐值),单位米。

    地球是椭球,任何单一半径都是近似。这个值在几百公里尺度内的误差 远小于 GPS 本身的误差,对轨迹统计够用。

    format_decimal

    fn format_decimal(v : Double) -> String

    把 Double 格式化成合法的 xsd:decimal 字面量。

    ⚠️ 不能直接用 Double::to_string:MoonBit 对 1e-7、1e+21 这类值会输出 指数记法,而 xsd:decimal 不允许指数。直接回写会产出非法 GPX 文档。 这里把指数形式展开成纯十进制。

    format_diagnostics

    fn format_diagnostics(diags : Array[Diagnostic]) -> String

    把诊断列表格式化成多行文本,每条一行。

    gpx_namespace

    let gpx_namespace : String

    GPX 1.1 的官方命名空间 URI。

    schema 是 elementFormDefault="qualified",所以合规文档的元素 必须在这个命名空间里。序列化时默认带上它。

    has_errors

    fn has_errors(diags : Array[Diagnostic]) -> Bool

    诊断列表中是否存在错误级问题。

    has_schema_errors

    fn has_schema_errors(src : String) -> Bool

    文档是否存在违反 schema 的问题(不看 warning)。

    haversine_m

    fn haversine_m(a : Waypoint, b : Waypoint) -> Double

    半正矢公式算出的两点大圆距离,单位米。

    比余弦定理数值稳定——在两点非常接近时(轨迹点通常就是这种情况) 余弦定理会因浮点精度丢失有效位。

    is_valid_datetime

    fn is_valid_datetime(text : String) -> Bool

    校验 xsd:dateTime 字面量。

    词法形式:

    -? YYYY(YYYY*) '-' MM '-' DD 'T' hh ':' mm ':' ss ('.' s+)? ( 'Z' | ('+'|'-') hh ':' mm )?

    注意:
    • 年份至少 4 位,可以是负数(公元前)
    • 分隔符 T 是大写字母,不接受空格
    • 时区可以省略;省略时表示"无时区"
    • 小数秒至少一位,位数不限
    • 闰秒允许 ss = 60

    is_valid_gpx

    fn is_valid_gpx(src : String) -> Bool

    文档是否完全合格:既没有 error,也没有 warning。

    这是最严格的口径。如果只想判断"能不能解析",用 parse_gpx(...).is_ok()。

    parse_decimal

    fn parse_decimal(text : String) -> Result[Double, GeoError]

    解析 xsd:decimal 字面量。

    GPX 的坐标、海拔、各类 dop 值都是这个类型。

    parse_gpx

    fn parse_gpx(src : String) -> Result[Gpx, Array[Diagnostic]]

    解析 GPX 文档。

    返回 Ok(Gpx) 表示 XML 结构完整、且没有违反 schema 的问题; 返回 Err(诊断列表) 表示存在错误。

    ⚠️ 成功路径上的 warning 会被丢弃——这是刻意的:parse_gpx 要的是 "能不能用",不是"完不完美"。想拿到包括 warning 在内的全部诊断, 用 validate_gpx。

    parse_gpx_1_0

    fn parse_gpx_1_0(src : String) -> Result[Gpx, Array[Diagnostic]]

    解析 GPX 1.0 文档,并升级为 1.1 数据模型。

    返回的 Gpx.version 固定是 "1.1"——迁移是单向的。 想原样输出 1.0 请用别的工具,本库不做 1.1 → 1.0(会丢 extensions 与精度字段)。

    与 parse_gpx 一样:成功路径上的 warning 会被丢弃, 要完整诊断用 validate_gpx。

    parse_gpx_any

    fn parse_gpx_any(src : String) -> Result[Gpx, Array[Diagnostic]]

    自动探测版本:1.0 走 parse_gpx_1_0,否则走 parse_gpx(1.1)。

    探测依据是根元素的 version 属性。拿不到(比如 XML 本身坏了) 时退化为按 1.1 解析,由 1.1 的逻辑报出真正的问题。

    path_distance_m

    fn path_distance_m(points : Array[Waypoint]) -> Double

    一串点的路径总长(米),逐段相加。

    to_gpx

    fn to_gpx(gpx : Gpx) -> String

    把 GPX 模型序列化为 XML 文本。

    根元素顺序严格按 schema:metadata → wpt* → rte* → trk* → extensions。

    输出带 XML 声明与官方命名空间,可直接作为 .gpx 文件使用。 保证 parse_gpx(to_gpx(g)) 与 g 完全相等(见 serialize_test.mbt)。

    to_gpx_fragment

    fn to_gpx_fragment(gpx : Gpx) -> String

    不带 XML 声明与命名空间的紧凑输出,便于嵌入到别的 XML 文档里。

    根元素属性顺序与 to_gpx 一致,只是省掉了 xmlns。

    track_distance_m

    fn track_distance_m(track : Track) -> Double

    一条轨迹的总距离(米)。等价于 track_stats(t).distance_m,但少建一个对象。

    track_stats

    fn track_stats(track : Track) -> TrackStats

    统计一条轨迹。

    距离与爬升逐 trkseg 计算再累加,不跨段相连(见模块顶部说明)。

    validate_gpx

    fn validate_gpx(src : String) -> Array[Diagnostic]

    校验 GPX 文档,返回全部诊断(error + warning)。

    • 返回空数组:文档干净
    • 非空:按发现顺序排列,每条带 1-based 行列号

    不会因为发现一个错误就停下——一个文件里的多个问题会一次性全部报出。 唯一的例外是 XML 语法本身坏了(拿不到事件流),此时只能报一条 E1000。

    validate_gpx_report

    fn validate_gpx_report(src : String) -> String

    校验并格式化成人话,便于直接打印或写进报告。

    没有问题时返回一句明确的"没问题",而不是空字符串—— 空字符串会让调用方分不清"干净"和"忘了填"。

    version

    fn version() -> String

    库版本号,与 moon.mod 中的 version 保持一致。