~adigitoleo/PlateMotionRequests.jl

d863183ce69ea9fa399784189b016ae167f9bc94 — adigitoleo 6 months ago e786228 + cfd7317
Merge branch 'dev' into next
A .build.yml => .build.yml +21 -0
@@ 0,0 1,21 @@
image: ubuntu/lts
tasks:
    - prepare: |
        cd PlateMotionRequests.jl
        JULIA_VERSION='1.7.2'
        JULIA_VERSION_SHORT='1.7'
        wget "https://julialang-s3.julialang.org/bin/linux/x64/$JULIA_VERSION_SHORT/julia-$JULIA_VERSION-linux-x86_64.tar.gz"
        wget "https://julialang-s3.julialang.org/bin/linux/x64/$JULIA_VERSION_SHORT/julia-$JULIA_VERSION-linux-x86_64.tar.gz.asc"
        wget https://julialang.org/assets/juliareleases.asc
        wget "https://julialang-s3.julialang.org/bin/checksums/julia-$JULIA_VERSION.sha256"
        sha256sum -c --ignore-missing "julia-$JULIA_VERSION.sha256"
        gpg --import juliareleases.asc
        gpg --verify "julia-$JULIA_VERSION-linux-x86_64.tar.gz.asc"
        tar -xf "julia-$JULIA_VERSION-linux-x86_64.tar.gz"
        ln -s "julia-$JULIA_VERSION/bin/julia"
    - build: |
        cd PlateMotionRequests.jl
        ./julia --project -e 'using Pkg; Pkg.instantiate()'
    - test: |
        cd PlateMotionRequests.jl
        ./julia --project test/runtests.jl

M CONTRIBUTING.md => CONTRIBUTING.md +2 -5
@@ 83,11 83,8 @@ Each component should consist of no more than two lines. For example:
Version releases should adhere to [semantic versioning](https://semver.org/).
Code that is on `next` may undergo a few patch version increments before making it to `main`,
so stable releases might skip a few tags.
Online documentation is only built for stable versions,
so releasing stable versions requires administrator access to the online GitHub repository.

The provided pre-commit hook script can be used to verify the last git tag
against `Project.toml`.
Online documentation is only built for stable versions.
Releasing new versions requires admin access to the GitHub repository.

### Releasing a new version on `next` (unstable)


M Project.toml => Project.toml +3 -0
@@ 3,6 3,9 @@ uuid = "d13234e1-ff52-466f-9770-1615bbbda812"
authors = ["adigitoleo <adigitoleo@dissimulo.com>"]
version = "3.0.0"

[compat]
julia = "1.7.2"

[deps]
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"

M README.md => README.md +8 -11
@@ 1,16 1,19 @@
# PlateMotionRequests

A Julia package for plate motion data requests using the UNAVCO Plate Motion Calculator[^server].
A Julia package for plate motion data requests using the [UNAVCO Plate Motion Calculator](https://www.unavco.org/software/geodetic-utilities/plate-motion-calculator/plate-motion-calculator.html).

The package is open source, and the code is available[^repo] for free
under the Zero-Clause BSD license[^license].
There is also a website with online documentation[^docs].
The package is open source, and the [code is available](https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl)
under the [Zero-Clause BSD license](https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl/blob/main/LICENSE).
There is also a website with [online documentation](https://adigitoleo.github.io/PlateMotionRequests.jl/).

**Versions prior to `2.0.1` were experimental and should be avoided if possible.**


## Installation

This package currently requires Julia 1.7.2 or above.
CI status (Linux): [![builds.sr.ht status](https://builds.sr.ht/~adigitoleo/PlateMotionRequests.jl.svg)](https://builds.sr.ht/~adigitoleo/PlateMotionRequests.jl?)

From the Julia shell, switch to package mode with `]` and run

    add https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl


@@ 82,13 85,7 @@ Please use the public mailing list for feedback and discussion:
[~adigitoleo/platemotionrequests.jl-devel@lists.sr.ht](mailto:~adigitoleo/platemotionrequests.jl-devel@lists.sr.ht)

Contributions are handled via patches sent to the same mailing list.
Contributor guidelines are provided with the source code repository[^repo] (in the file `CONTRIBUTING.md`).
Contributor guidelines are provided with the source code repository (in the file `CONTRIBUTING.md`).
The file `TODO.md` lists some ideas for planned features.
If you want to work on one of them,
send an email first to check if an implementation is already underway.


[^repo]: <https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl>
[^license]: <https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl/blob/main/LICENSE>
[^docs]: <https://adigitoleo.github.io/PlateMotionRequests.jl/>
[^server]: <https://www.unavco.org/software/geodetic-utilities/plate-motion-calculator/plate-motion-calculator.html>

M TODO.md => TODO.md +0 -1
@@ 2,7 2,6 @@

- Handle HTTP errors?
- New methods for `platemotion` that accept `Matrix` or array of tuples for lat/lon input.
- More tests
- Rate limits? Prevent accidental spamming of requests to the server.
- Caching?


M docs/src/assets/platemotion.png => docs/src/assets/platemotion.png +0 -0
M src/PlateMotionRequests.jl => src/PlateMotionRequests.jl +35 -8
@@ 212,6 212,10 @@ struct ReadError <: Exception
    msg::String
end

struct WriteError <: Exception
    msg::String
end

struct SamplingError <: Exception
    supplied::Any
    msg::String


@@ 266,6 270,9 @@ Write plate motion table to `file` as tab-delimited text columns or NetCDF.
The **experimental** NetCDF method will be used if `file` ends with a `.nc` extension.
For tab-delimited output, the first line is a header containing the column names.

Throws a `PlateMotionRequests.WriteError` if the table header is not recognised,
or if attempting to write a NetCDF file of irregularly sampled data.

!!! note

    Irregular sampling of latitude or longitude values is not supported for NetCDF output.


@@ 275,11 282,27 @@ See also: [`read_platemotion`](@ref).
"""
function write_platemotion(file, table)
    if splitext(file)[2] == ".nc"
        write_netcdf(file, table)
        try
            write_netcdf(file, table)
        catch e
            if e isa SamplingError
                throw(WriteError(e.msg))
            end
        end
    else
        open(file, "w") do io
            println(io, join(String.(columnnames(table)), '\t'))
            writedlm(io, table, '\t')
        if columnnames(table) in map(fieldnames, (FormatASCII, FormatASCIIxyz, FormatPsvelo))
            open(file, "w") do io
                println(io, join(String.(columnnames(table)), '\t'))
                writedlm(io, table, '\t')
            end
        else
            throw(
                WriteError(
                    "table column names must match a supported format." *
                    " You've supplied a table with the following columns:" *
                    join(String.(columnnames(table)), ", "),
                ),
            )
        end
    end
end


@@ 350,6 373,7 @@ end
    verify_regularity(x, y)

Verify regularity of both input arrays and return a tuple of step sizes.
Throws a `PlateMotionRequests.SamplingError` for irregular data.

"""
function verify_regularity(x, y)


@@ 439,16 463,19 @@ Expects a single tab-delimited header line,
with column names that match one of the supported formats.
See [`platemotion`](@ref) for details.

May throw a `PlateMotionRequests.ReadError`.

"""
function read_platemotion(file)
    data, header = readdlm(file, '\t', Any, '\n', header = true)
    isformat(format) = Set(header) == Set(String.(fieldnames(format)))
    isformat(format) = Set(strip.(header)) == Set(String.(fieldnames(format)))
    cleanstrings(data) = map(s -> s isa AbstractString ? strip(s) : s, data)
    if isformat(FormatASCII)
        table = as_table(FormatASCII, data)
        table = as_table(FormatASCII, cleanstrings(data))
    elseif isformat(FormatASCIIxyz)
        table = as_table(FormatASCIIxyz, data)
        table = as_table(FormatASCIIxyz, cleanstrings(data))
    elseif isformat(FormatPsvelo)
        table = as_table(FormatPsvelo, data)
        table = as_table(FormatPsvelo, cleanstrings(data))
    else
        throw(
            ReadError(

A test/data/parsed_rightaligned_GSRMv2_regular.dat => test/data/parsed_rightaligned_GSRMv2_regular.dat +19 -0
@@ 0,0 1,19 @@
  lon	  lat	velocity_east	velocity_north	plate_and_reference	    model
110.0	-35.0	        41.58	         56.94	            AU(NNR)	GSRM v2.1
110.0	-25.0	        42.45	         56.97	            AU(NNR)	GSRM v2.1
110.0	-15.0	        42.03	         56.99	            AU(NNR)	GSRM v2.1
110.0	 -5.0	        23.36	         -7.34	            EU(NNR)	GSRM v2.1
110.0	  5.0	        26.03	         -7.34	            EU(NNR)	GSRM v2.1
110.0	 15.0	        27.92	         -7.34	            EU(NNR)	GSRM v2.1
110.0	 25.0	        28.97	         -7.34	            EU(NNR)	GSRM v2.1
110.0	 35.0	        29.15	         -7.34	            EU(NNR)	GSRM v2.1
110.0	 45.0	        28.43	         -7.33	            EU(NNR)	GSRM v2.1
120.0	-35.0	        35.79	         58.98	            AU(NNR)	GSRM v2.1
120.0	-25.0	        38.18	         59.01	            AU(NNR)	GSRM v2.1
120.0	-15.0	        39.42	         59.03	            AU(NNR)	GSRM v2.1
120.0	 -5.0	        23.49	         -9.92	            EU(NNR)	GSRM v2.1
120.0	  5.0	         25.9	         -9.92	            EU(NNR)	GSRM v2.1
120.0	 15.0	        27.54	         -9.91	            EU(NNR)	GSRM v2.1
120.0	 25.0	        28.34	         -9.91	            EU(NNR)	GSRM v2.1
120.0	 35.0	        28.28	         -9.91	            EU(NNR)	GSRM v2.1
120.0	 45.0	        27.37	          -9.9	            EU(NNR)	GSRM v2.1

M test/runtests.jl => test/runtests.jl +116 -6
@@ 757,7 757,7 @@ function create_mock_tables()
end


@testset "platemotion" begin
@testset "response parsing" begin
    # Test format validation
    for val in (42, "a", 'a', "ASCII")
        @test_throws _pmr.OptionError _pmr.validate_format(val)


@@ 787,10 787,120 @@ end
    @test _pmr.parse!(psvelo_gsrm_regular, "psvelo") == mock_psvelo_gsrm_regular
end

@testset "write_platemotion" begin
    # TODO
end
@testset "I/O" begin
    filename(name) = joinpath(@__DIR__(), name)
    mock_ascii_all_models_irregular,
    mock_ascii_gsrm_regular,
    mock_ascii_xyz_gsrm_regular,
    mock_psvelo_gsrm_regular = create_mock_tables()

    @testset "text files" begin
        try
            write_platemotion(
                filename("ASCII_all_models_irregular.dat"),
                mock_ascii_all_models_irregular,
            )
            @test read_platemotion(filename("ASCII_all_models_irregular.dat")) ==
                  mock_ascii_all_models_irregular
            write_platemotion(filename("ASCII_GSRMv2_regular.dat"), mock_ascii_gsrm_regular)
            @test read_platemotion(filename("ASCII_GSRMv2_regular.dat")) ==
                  mock_ascii_gsrm_regular
            write_platemotion(
                filename("ASCII_XYZ_GSRMv2_regular.dat"),
                mock_ascii_xyz_gsrm_regular,
            )
            @test read_platemotion(filename("ASCII_XYZ_GSRMv2_regular.dat")) ==
                  mock_ascii_xyz_gsrm_regular
            write_platemotion(
                filename("psvelo_GSRMv2_regular.dat"),
                mock_psvelo_gsrm_regular,
            )
            @test read_platemotion(filename("psvelo_GSRMv2_regular.dat")) ==
                  mock_psvelo_gsrm_regular
            writedlm(filename("malformed.dat"), Table(a = [1], b = [1]), '\t')
            @test_throws _pmr.ReadError read_platemotion(filename("malformed.dat"))
            @test_throws _pmr.WriteError write_platemotion(
                filename("tmp.dat"),
                Table(a = [1], b = [1]),
            )
            @test read_platemotion(
                filename("data/parsed_rightaligned_GSRMv2_regular.dat"),
            ) == mock_ascii_gsrm_regular
        finally
            rm(filename("tmp.dat"), force = true)
            rm(filename("ASCII_all_models_irregular.dat"), force = true)
            rm(filename("ASCII_GSRMv2_regular.dat"), force = true)
            rm(filename("ASCII_XYZ_GSRMv2_regular.dat"), force = true)
            rm(filename("psvelo_GSRMv2_regular.dat"), force = true)
            rm(filename("malformed.dat"), force = true)
        end
    end

@testset "read_platemotion" begin
    # TODO
    @testset "NetCDF" begin
        try
            write_platemotion(filename("ASCII_GSRMv2_regular.nc"), mock_ascii_gsrm_regular)
            ds = NCDataset(filename("ASCII_GSRMv2_regular.nc"))
            # Check global attributes.
            for key in ("Conventions", "title", "institution", "references", "comment")
                haskey(ds, key)
            end
            @test ds.attrib["Conventions"] == "CF-1.9"
            @test ds.attrib["title"] == "Tectonic plate motions"
            @test ds.attrib["institution"] == "https://www.unavco.org/"
            @test ds.attrib["references"] ==
                  "See <https://www.unavco.org/software/geodetic-utilities/plate-motion-calculator/plate-motion-calculator.html#references>"
            @test ds.attrib["comment"] ==
                  "Produced using https://git.sr.ht/~adigitoleo/PlateMotionRequests.jl\n"
            # Check dimensions.
            @test haskey(ds.dim, "lat")
            @test haskey(ds.dim, "lon")
            @test ds["lat"][:] == collect(-35:10:45)
            @test ds["lon"][:] == [110, 120]
            # Check variables.
            for key in ("velocity_east", "velocity_north", "plate_and_reference", "model")
                @test haskey(ds, key)
            end
            @test ds["velocity_east"][:] == [
                41.58 35.79
                42.45 38.18
                42.03 39.42
                23.36 23.49
                26.03 25.90
                27.92 27.54
                28.97 28.34
                29.15 28.28
                28.43 27.37
            ]
            @test ds["velocity_north"][:] == [
                56.94 58.98
                56.97 59.01
                56.99 59.03
                -7.34 -9.92
                -7.34 -9.92
                -7.34 -9.91
                -7.34 -9.91
                -7.34 -9.91
                -7.33 -9.90
            ]
            @test all(x -> x == "GSRM v2.1", ds["model"][:])
            @test ds["plate_and_reference"] == [
                "AU(NNR)" "AU(NNR)"
                "AU(NNR)" "AU(NNR)"
                "AU(NNR)" "AU(NNR)"
                "EU(NNR)" "EU(NNR)"
                "EU(NNR)" "EU(NNR)"
                "EU(NNR)" "EU(NNR)"
                "EU(NNR)" "EU(NNR)"
                "EU(NNR)" "EU(NNR)"
                "EU(NNR)" "EU(NNR)"
            ]
            @test_throws _pmr.WriteError write_platemotion(
                filename("ASCII_all_models_irregular.nc"),
                mock_ascii_all_models_irregular,
            )
        finally
            rm(filename("ASCII_GSRMv2_regular.nc"), force = true)
            rm(filename("ASCII_all_models_irregular.nc"), force = true)
        end
    end
end

D tools/pre-commit => tools/pre-commit +0 -26
@@ 1,26 0,0 @@
#!/bin/python3
# Copy or link this script into .git/hooks/
# It runs automatically in the project root directory (parent of .git/ directory).
import subprocess
import pathlib

# Run tests.
if pathlib.Path("test/runtests.jl").is_file():
    subprocess.run(["julia", "--project", "test/runtests.jl"], check=True)

# Check if last tag and version from Project.toml match.
LAST_TAG = subprocess.run(
    ["git", "describe", "--tags", "--abbrev=0"], check = True, capture_output = True,
).stdout.decode().strip()
print(f"Last tag: {LAST_TAG}")
with open("Project.toml") as file:
    for line in file:
        if line.startswith("version"):
            TOML_VERSION = line.split("=")[1].strip().strip('"')
            break
print(f"Project.toml version: v{TOML_VERSION}")
if LAST_TAG != f"v{TOML_VERSION}":
    print("Stopping commit: last git tag and Project.toml version don't match.")
    print("Use git commit --no-verify to commit anyway if this is a release.")
    print("Remember to create the tag afterwards.")
    raise SystemExit(1)