├── .github
└── workflows
│ ├── coverage.yml
│ └── goigc.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── Makefile
├── README.md
├── binder
├── README.md
├── environment.yml
├── gliding-data-starter.ipynb
├── heatmap.png
├── postBuild
└── start
├── cmd
└── goigc
│ ├── crawl.go
│ ├── parse.go
│ ├── phases.go
│ ├── root.go
│ └── root_test.go
├── deployments
└── docker
│ ├── Dockerfile
│ └── Dockerfile-alpine
├── doc.go
├── docs
└── images
│ └── track.png
├── go.mod
├── pkg
├── igc
│ ├── bruteforce.go
│ ├── bruteforce_test.go
│ ├── crawler.go
│ ├── doc.go
│ ├── doc_test.go
│ ├── flight.go
│ ├── optimize.go
│ ├── optimize_test.go
│ ├── parse.go
│ ├── parse_test.go
│ ├── phase.go
│ ├── phase_test.go
│ ├── point.go
│ ├── point_test.go
│ ├── track.go
│ └── track_test.go
├── netcoupe
│ ├── crawler.go
│ ├── crawler_test.go
│ └── doc.go
└── version
│ └── version.go
├── scripts
├── bench.sh
├── changelog.sh
├── coverage.sh
├── pprof.sh
└── validate-license.sh
└── testdata
├── optimize
├── optimize-long-flight-1.igc
└── optimize-short-flight-1.igc
├── parse
├── parse-0-basic-flight.1.igc
├── parse-0-basic-flight.1.igc.golden
├── parse-0-basic-header.1.igc
├── parse-0-basic-header.1.igc.golden
├── parse-0-benchmark-0.igc
├── parse-0-benchmark-0.igc.golden
├── parse-0-invalid-record.0.igc
├── parse-a-no-uniqueid.1.igc
├── parse-a-no-uniqueid.1.igc.golden
├── parse-a-too-short.0.igc
├── parse-b-bad-fix-validity.0.igc
├── parse-b-bad-gnss-altitude.0.igc
├── parse-b-bad-pressure-altitude.0.igc
├── parse-b-wrong-size.0.igc
├── parse-c-bad-num-lines.0.igc
├── parse-c-invalid-declaration-date.0.igc
├── parse-c-invalid-declaration-date.0.igc.golden
├── parse-c-invalid-finish.0.igc
├── parse-c-invalid-flight-date.0.igc
├── parse-c-invalid-flight-date.0.igc.golden
├── parse-c-invalid-landing.0.igc
├── parse-c-invalid-num-of-tps.0.igc
├── parse-c-invalid-start.0.igc
├── parse-c-invalid-takeoff.0.igc
├── parse-c-invalid-task-number.0.igc
├── parse-c-invalid-tp.0.igc
├── parse-c-wrong-size-first-line.0.igc
├── parse-d-wrong-size.0.igc
├── parse-e-invalid-date.0.igc
├── parse-e-wrong-size.0.igc
├── parse-f-invalid-date.0.igc
├── parse-f-invalid-num-satellites.0.igc
├── parse-f-invalid-num-satellites.0.igc.golden
├── parse-f-wrong-size.0.igc
├── parse-h-bad-date.0.igc
├── parse-h-bad-fix-accuracy.0.igc
├── parse-h-bad-timezone.0.igc
├── parse-h-date-too-short.0.igc
├── parse-h-fix-accuracy-too-short.0.igc
├── parse-h-fix-accuracy-too-short.0.igc.golden
├── parse-h-gps-datum-too-short.0.igc
├── parse-h-invalid-pats.0.igc
├── parse-h-too-short.0.igc
├── parse-h-tzo-timezone.1.igc
├── parse-h-tzo-timezone.1.igc.golden
├── parse-h-unknown-field.0.igc
├── parse-i-invalid-value-for-field-number.0.igc
├── parse-i-wrong-size-with-fields.0.igc
├── parse-i-wrong-size.0.igc
├── parse-j-invalid-value-for-field-number.0.igc
├── parse-j-invalid-value-for-field-number.1.igc.golden
├── parse-j-wrong-size-with-fields.0.igc
├── parse-j-wrong-size.0.igc
├── parse-k-invalid-date.0.igc
├── parse-k-wrong-size-with-fields.0.igc
└── parse-k-wrong-size.0.igc
├── phases
├── phases-long-flight-1.golden.igc
├── phases-long-flight-1.igc
├── phases-long-flight-1.igc.golden.kml
├── phases-short-flight-1.golden.igc
├── phases-short-flight-1.igc
└── phases-short-flight-1.igc.golden.kml
└── simplify
├── simplify-short-flight-1.igc
└── simplify-short-flight-1.igc.golden
/.github/workflows/coverage.yml:
--------------------------------------------------------------------------------
1 | name: coverage
2 | on:
3 | push:
4 | branches:
5 | - '*'
6 | jobs:
7 | test:
8 | name: Test with Coverage
9 | runs-on: ubuntu-latest
10 | env:
11 | GOVER: 1.13.3
12 | GOPROXY: https://proxy.golang.org
13 | steps:
14 | - name: Set up Go ${{ env.GOVER }}
15 | uses: actions/setup-go@v1
16 | with:
17 | go-version: ${{ env.GOVER }}
18 | - name: Check out code
19 | uses: actions/checkout@master
20 | - name: Run make test-coverage
21 | run: |
22 | make test-coverage
23 | - name: Send coverage
24 | env:
25 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
26 | run: |
27 | GO111MODULE=off go get github.com/mattn/goveralls
28 | $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github
29 |
--------------------------------------------------------------------------------
/.github/workflows/goigc.yml:
--------------------------------------------------------------------------------
1 | #
2 | # Copyright The ezgliding Authors.
3 | #
4 | #
5 | # Licensed under the Apache License, Version 2.0 (the "License");
6 | # you may not use this file except in compliance with the License.
7 | # You may obtain a copy of the License at
8 | #
9 | # http://www.apache.org/licenses/LICENSE-2.0
10 | #
11 | # Unless required by applicable law or agreed to in writing, software
12 | # distributed under the License is distributed on an "AS IS" BASIS,
13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | # See the License for the specific language governing permissions and
15 | # limitations under the License.
16 | #
17 |
18 | name: goigc
19 |
20 | on:
21 | push:
22 | branches:
23 | - '*'
24 | tags:
25 | - 'v*'
26 | paths-ignore:
27 | - 'CONTRIBUTING.md'
28 | - 'README.md'
29 | - 'docs/**'
30 | jobs:
31 | build:
32 | name: Build binaries
33 | runs-on: ubuntu-latest
34 | env:
35 | GOVER: 1.13.3
36 | GOPROXY: https://proxy.golang.org
37 | steps:
38 | - name: Set up Go ${{ env.GOVER }}
39 | uses: actions/setup-go@v1
40 | with:
41 | go-version: ${{ env.GOVER }}
42 | - name: Install golangci-lint
43 | if: matrix.target_arch != 'arm'
44 | run: |
45 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "${{ env.GOROOT }}/bin" v1.21.0
46 | - name: Check out code
47 | uses: actions/checkout@v1
48 | - name: Make test
49 | run: |
50 | make test
51 | - name: Make build for cross platform binaries
52 | run: |
53 | make build-cross
54 | - name: Make changelog
55 | if: startsWith(github.ref, 'refs/tags/v')
56 | run: |
57 | sudo apt-get update
58 | sudo apt-get install ruby
59 | make changelog
60 | env:
61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62 | - name: Release cross platform binaries
63 | uses: softprops/action-gh-release@v1
64 | if: startsWith(github.ref, 'refs/tags/v')
65 | with:
66 | files: |
67 | _dist/**
68 | body_path: CHANGELOG.md
69 | env:
70 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
71 | - name: Build docker images
72 | run: |
73 | make docker
74 | - name: Push docker images
75 | if: startsWith(github.ref, 'refs/tags/v')
76 | run: |
77 | sudo docker login -u ${{ secrets.DOCKER_REGISTRY_ID }} -p ${{ secrets.DOCKER_REGISTRY_PASS }}
78 | make docker-push
79 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | *.swo
3 | .coverage/
4 | _dist/
5 | bin/
6 | CHANGELOG.md
7 | go.sum
8 | profile.cov
9 | vendor/
10 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Development
2 |
3 | go get github.com/mitchellh/gox
4 | go get golang.org/x/tools/cmd/goimports
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 |
3 | Apache License
4 | Version 2.0, January 2004
5 | http://www.apache.org/licenses/
6 |
7 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8 |
9 | 1. Definitions.
10 |
11 | "License" shall mean the terms and conditions for use, reproduction,
12 | and distribution as defined by Sections 1 through 9 of this document.
13 |
14 | "Licensor" shall mean the copyright owner or entity authorized by
15 | the copyright owner that is granting the License.
16 |
17 | "Legal Entity" shall mean the union of the acting entity and all
18 | other entities that control, are controlled by, or are under common
19 | control with that entity. For the purposes of this definition,
20 | "control" means (i) the power, direct or indirect, to cause the
21 | direction or management of such entity, whether by contract or
22 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
23 | outstanding shares, or (iii) beneficial ownership of such entity.
24 |
25 | "You" (or "Your") shall mean an individual or Legal Entity
26 | exercising permissions granted by this License.
27 |
28 | "Source" form shall mean the preferred form for making modifications,
29 | including but not limited to software source code, documentation
30 | source, and configuration files.
31 |
32 | "Object" form shall mean any form resulting from mechanical
33 | transformation or translation of a Source form, including but
34 | not limited to compiled object code, generated documentation,
35 | and conversions to other media types.
36 |
37 | "Work" shall mean the work of authorship, whether in Source or
38 | Object form, made available under the License, as indicated by a
39 | copyright notice that is included in or attached to the work
40 | (an example is provided in the Appendix below).
41 |
42 | "Derivative Works" shall mean any work, whether in Source or Object
43 | form, that is based on (or derived from) the Work and for which the
44 | editorial revisions, annotations, elaborations, or other modifications
45 | represent, as a whole, an original work of authorship. For the purposes
46 | of this License, Derivative Works shall not include works that remain
47 | separable from, or merely link (or bind by name) to the interfaces of,
48 | the Work and Derivative Works thereof.
49 |
50 | "Contribution" shall mean any work of authorship, including
51 | the original version of the Work and any modifications or additions
52 | to that Work or Derivative Works thereof, that is intentionally
53 | submitted to Licensor for inclusion in the Work by the copyright owner
54 | or by an individual or Legal Entity authorized to submit on behalf of
55 | the copyright owner. For the purposes of this definition, "submitted"
56 | means any form of electronic, verbal, or written communication sent
57 | to the Licensor or its representatives, including but not limited to
58 | communication on electronic mailing lists, source code control systems,
59 | and issue tracking systems that are managed by, or on behalf of, the
60 | Licensor for the purpose of discussing and improving the Work, but
61 | excluding communication that is conspicuously marked or otherwise
62 | designated in writing by the copyright owner as "Not a Contribution."
63 |
64 | "Contributor" shall mean Licensor and any individual or Legal Entity
65 | on behalf of whom a Contribution has been received by Licensor and
66 | subsequently incorporated within the Work.
67 |
68 | 2. Grant of Copyright License. Subject to the terms and conditions of
69 | this License, each Contributor hereby grants to You a perpetual,
70 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
71 | copyright license to reproduce, prepare Derivative Works of,
72 | publicly display, publicly perform, sublicense, and distribute the
73 | Work and such Derivative Works in Source or Object form.
74 |
75 | 3. Grant of Patent License. Subject to the terms and conditions of
76 | this License, each Contributor hereby grants to You a perpetual,
77 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
78 | (except as stated in this section) patent license to make, have made,
79 | use, offer to sell, sell, import, and otherwise transfer the Work,
80 | where such license applies only to those patent claims licensable
81 | by such Contributor that are necessarily infringed by their
82 | Contribution(s) alone or by combination of their Contribution(s)
83 | with the Work to which such Contribution(s) was submitted. If You
84 | institute patent litigation against any entity (including a
85 | cross-claim or counterclaim in a lawsuit) alleging that the Work
86 | or a Contribution incorporated within the Work constitutes direct
87 | or contributory patent infringement, then any patent licenses
88 | granted to You under this License for that Work shall terminate
89 | as of the date such litigation is filed.
90 |
91 | 4. Redistribution. You may reproduce and distribute copies of the
92 | Work or Derivative Works thereof in any medium, with or without
93 | modifications, and in Source or Object form, provided that You
94 | meet the following conditions:
95 |
96 | (a) You must give any other recipients of the Work or
97 | Derivative Works a copy of this License; and
98 |
99 | (b) You must cause any modified files to carry prominent notices
100 | stating that You changed the files; and
101 |
102 | (c) You must retain, in the Source form of any Derivative Works
103 | that You distribute, all copyright, patent, trademark, and
104 | attribution notices from the Source form of the Work,
105 | excluding those notices that do not pertain to any part of
106 | the Derivative Works; and
107 |
108 | (d) If the Work includes a "NOTICE" text file as part of its
109 | distribution, then any Derivative Works that You distribute must
110 | include a readable copy of the attribution notices contained
111 | within such NOTICE file, excluding those notices that do not
112 | pertain to any part of the Derivative Works, in at least one
113 | of the following places: within a NOTICE text file distributed
114 | as part of the Derivative Works; within the Source form or
115 | documentation, if provided along with the Derivative Works; or,
116 | within a display generated by the Derivative Works, if and
117 | wherever such third-party notices normally appear. The contents
118 | of the NOTICE file are for informational purposes only and
119 | do not modify the License. You may add Your own attribution
120 | notices within Derivative Works that You distribute, alongside
121 | or as an addendum to the NOTICE text from the Work, provided
122 | that such additional attribution notices cannot be construed
123 | as modifying the License.
124 |
125 | You may add Your own copyright statement to Your modifications and
126 | may provide additional or different license terms and conditions
127 | for use, reproduction, or distribution of Your modifications, or
128 | for any such Derivative Works as a whole, provided Your use,
129 | reproduction, and distribution of the Work otherwise complies with
130 | the conditions stated in this License.
131 |
132 | 5. Submission of Contributions. Unless You explicitly state otherwise,
133 | any Contribution intentionally submitted for inclusion in the Work
134 | by You to the Licensor shall be under the terms and conditions of
135 | this License, without any additional terms or conditions.
136 | Notwithstanding the above, nothing herein shall supersede or modify
137 | the terms of any separate license agreement you may have executed
138 | with Licensor regarding such Contributions.
139 |
140 | 6. Trademarks. This License does not grant permission to use the trade
141 | names, trademarks, service marks, or product names of the Licensor,
142 | except as required for reasonable and customary use in describing the
143 | origin of the Work and reproducing the content of the NOTICE file.
144 |
145 | 7. Disclaimer of Warranty. Unless required by applicable law or
146 | agreed to in writing, Licensor provides the Work (and each
147 | Contributor provides its Contributions) on an "AS IS" BASIS,
148 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
149 | implied, including, without limitation, any warranties or conditions
150 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
151 | PARTICULAR PURPOSE. You are solely responsible for determining the
152 | appropriateness of using or redistributing the Work and assume any
153 | risks associated with Your exercise of permissions under this License.
154 |
155 | 8. Limitation of Liability. In no event and under no legal theory,
156 | whether in tort (including negligence), contract, or otherwise,
157 | unless required by applicable law (such as deliberate and grossly
158 | negligent acts) or agreed to in writing, shall any Contributor be
159 | liable to You for damages, including any direct, indirect, special,
160 | incidental, or consequential damages of any character arising as a
161 | result of this License or out of the use or inability to use the
162 | Work (including but not limited to damages for loss of goodwill,
163 | work stoppage, computer failure or malfunction, or any and all
164 | other commercial damages or losses), even if such Contributor
165 | has been advised of the possibility of such damages.
166 |
167 | 9. Accepting Warranty or Additional Liability. While redistributing
168 | the Work or Derivative Works thereof, You may choose to offer,
169 | and charge a fee for, acceptance of support, warranty, indemnity,
170 | or other liability obligations and/or rights consistent with this
171 | License. However, in accepting such obligations, You may act only
172 | on Your own behalf and on Your sole responsibility, not on behalf
173 | of any other Contributor, and only if You agree to indemnify,
174 | defend, and hold each Contributor harmless for any liability
175 | incurred by, or claims asserted against, such Contributor by reason
176 | of your accepting any such warranty or additional liability.
177 |
178 | END OF TERMS AND CONDITIONS
179 |
180 | APPENDIX: How to apply the Apache License to your work.
181 |
182 | To apply the Apache License to your work, attach the following
183 | boilerplate notice, with the fields enclosed by brackets "[]"
184 | replaced with your own identifying information. (Don't include
185 | the brackets!) The text should be enclosed in the appropriate
186 | comment syntax for the file format. We also recommend that a
187 | file or class name and description of purpose be included on the
188 | same "printed page" as the copyright notice for easier
189 | identification within third-party archives.
190 |
191 | Copyright [yyyy] [name of copyright owner]
192 |
193 | Licensed under the Apache License, Version 2.0 (the "License");
194 | you may not use this file except in compliance with the License.
195 | You may obtain a copy of the License at
196 |
197 | http://www.apache.org/licenses/LICENSE-2.0
198 |
199 | Unless required by applicable law or agreed to in writing, software
200 | distributed under the License is distributed on an "AS IS" BASIS,
201 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
202 | See the License for the specific language governing permissions and
203 | limitations under the License.
204 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Copyright The ezgliding Authors.
2 | #
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | #
16 | # Based on the helm Makefile:
17 | # https://github.com/helm/helm/blob/master/Makefile
18 |
19 | BINDIR := $(CURDIR)/bin
20 | DIST_DIRS := find * -type d -exec
21 | TARGETS := darwin/amd64 linux/amd64 linux/386 linux/arm linux/arm64 linux/ppc64le windows/amd64
22 | BINNAME ?= goigc
23 |
24 | GOPATH = $(shell go env GOPATH)
25 | GOROOT ?= /usr/share/go
26 | GOX = $(GOPATH)/bin/gox
27 | GOIMPORTS = $(GOPATH)/bin/goimports
28 |
29 | # go option
30 | PKG := ./...
31 | TAGS :=
32 | TESTS := .
33 | TESTFLAGS :=
34 | LDFLAGS := -w -s
35 | GOFLAGS :=
36 | SRC := $(shell find . -type f -name '*.go' -print)
37 |
38 | # Required for globs to work correctly
39 | SHELL = /bin/bash
40 |
41 | GIT_BRANCH = $(shell git rev-parse --abbrev-ref HEAD)
42 | GIT_COMMIT = $(shell git rev-parse HEAD)
43 | GIT_SHA = $(shell git rev-parse --short HEAD)
44 | GIT_TAG = $(shell git describe --tags --abbrev=0 --exact-match 2>/dev/null)
45 | GIT_DIRTY = $(shell test -n "`git status --porcelain`" && echo "dirty" || echo "clean")
46 |
47 | ifdef VERSION
48 | BINARY_VERSION = $(VERSION)
49 | endif
50 | BINARY_VERSION ?= ${GIT_TAG}
51 |
52 | BASE_PKG = github.com/ezgliding/goigc
53 | # Only set Version if building a tag or VERSION is set
54 | ifneq ($(BINARY_VERSION),)
55 | LDFLAGS += -X ${BASE_PKG}/pkg/version.version=${BINARY_VERSION}
56 | endif
57 |
58 | # Clear the "unreleased" string in BuildMetadata
59 | ifneq ($(GIT_TAG),)
60 | LDFLAGS += -X ${BASE_PKG}/pkg/version.metadata=
61 | endif
62 | LDFLAGS += -X ${BASE_PKG}/pkg/version.commit=${GIT_COMMIT}
63 | LDFLAGS += -X ${BASE_PKG}/pkg/version.treestate=${GIT_DIRTY}
64 |
65 | .PHONY: all
66 | all: build
67 |
68 | # ------------------------------------------------------------------------------
69 | # build
70 |
71 | .PHONY: build
72 | build: $(BINDIR)/$(BINNAME)
73 |
74 | $(BINDIR)/$(BINNAME): $(SRC)
75 | GO111MODULE=on go build $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BINDIR)/$(BINNAME) ./cmd/goigc
76 |
77 | # ------------------------------------------------------------------------------
78 | # test
79 |
80 | .PHONY: test
81 | test: build
82 | test: TESTFLAGS += -race -v
83 | test: test-style
84 | test: test-unit
85 |
86 | .PHONY: test-unit
87 | test-unit:
88 | @echo
89 | @echo "==> Running unit tests <=="
90 | GO111MODULE=on go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS)
91 |
92 | .PHONY: test-golden
93 | test-golden:
94 | @echo
95 | @echo "==> Running unit tests with golden flag <=="
96 | # TODO(rochaporto): drop this mv once issue below is solved in corba
97 | # https://github.com/golang/go/issues/31859
98 | GO111MODULE=on mv cmd /tmp; go test $(GOFLAGS) -run $(TESTS) $(PKG) $(TESTFLAGS) -update || true; mv /tmp/cmd .
99 |
100 | .PHONY: test-coverage
101 | test-coverage:
102 | @echo
103 | @echo "==> Running unit tests with coverage <=="
104 | @ ./scripts/coverage.sh
105 |
106 | .PHONY: test-style
107 | test-style:
108 | GO111MODULE=on golangci-lint run
109 | @scripts/validate-license.sh
110 |
111 | .PHONY: coverage
112 | coverage:
113 | @scripts/coverage.sh
114 |
115 | .PHONY: format
116 | format: $(GOIMPORTS)
117 | GO111MODULE=on go list -f '{{.Dir}}' ./... | xargs $(GOIMPORTS) -w
118 |
119 | # ------------------------------------------------------------------------------
120 | # dependencies
121 |
122 | # If go get is run from inside the project directory it will add the dependencies
123 | # to the go.mod file. To avoid that we change to a directory without a go.mod file
124 | # when downloading the following dependencies
125 |
126 | $(GOX):
127 | (cd /; GO111MODULE=on go get -u github.com/mitchellh/gox)
128 |
129 | $(GOIMPORTS):
130 | (cd /; GO111MODULE=on go get -u golang.org/x/tools/cmd/goimports)
131 |
132 | # ------------------------------------------------------------------------------
133 | # release
134 |
135 | .PHONY: build-cross
136 | build-cross: LDFLAGS += -extldflags "-static"
137 | build-cross: $(GOX)
138 | GO111MODULE=on CGO_ENABLED=0 $(GOX) -parallel=3 -output="_dist/{{.OS}}-{{.Arch}}/$(BINNAME)_{{.OS}}_{{.Arch}}" -osarch='$(TARGETS)' $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' ./cmd/goigc
139 |
140 | .PHONY: dist
141 | dist:
142 | ( \
143 | cd _dist && \
144 | $(DIST_DIRS) cp ../LICENSE {} \; && \
145 | $(DIST_DIRS) cp ../README.md {} \; && \
146 | $(DIST_DIRS) tar -zcf goigc-${VERSION}-{}.tar.gz {} \; && \
147 | $(DIST_DIRS) zip -r goigc-${VERSION}-{}.zip {} \; \
148 | )
149 |
150 | .PHONY: checksum
151 | checksum:
152 | for f in _dist/*.{gz,zip} ; do \
153 | shasum -a 256 "$${f}" | awk '{print $$1}' > "$${f}.sha256" ; \
154 | done
155 |
156 | .PHONY: changelog
157 | changelog:
158 | @./scripts/changelog.sh
159 |
160 | # ------------------------------------------------------------------------------
161 | # docker
162 | DOCKER_TAG=${GIT_BRANCH}
163 | ifneq ($(GIT_TAG),)
164 | DOCKER_TAG = ${GIT_TAG}
165 | endif
166 |
167 | .PHONY: docker
168 | docker: build-cross
169 | sudo docker build -t ezgliding/goigc:${DOCKER_TAG} -f deployments/docker/Dockerfile .
170 | sudo docker build -t ezgliding/goigc:${DOCKER_TAG}-alpine -f deployments/docker/Dockerfile-alpine .
171 |
172 | .PHONY: docker-push
173 | docker-push: docker
174 | sudo docker push ezgliding/goigc:${DOCKER_TAG}
175 | sudo docker push ezgliding/goigc:${DOCKER_TAG}-alpine
176 |
177 | # ------------------------------------------------------------------------------
178 | .PHONY: doc
179 | doc:
180 | godoc -http=:6060 -goroot $(GOROOT) 2>&1 &
181 | browse http://localhost:6060
182 |
183 | # ------------------------------------------------------------------------------
184 | .PHONY: clean
185 | clean:
186 | @rm -rf $(BINDIR) ./_dist
187 |
188 | .PHONY: info
189 | info:
190 | @echo "Version: ${VERSION}"
191 | @echo "Git Tag: ${GIT_TAG}"
192 | @echo "Git Commit: ${GIT_COMMIT}"
193 | @echo "Git Tree State: ${GIT_DIRTY}"
194 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # goigc
2 |
3 | [](https://github.com/ezgliding/goigc/actions?workflow=goigc)
4 | [](https://coveralls.io/github/ezgliding/goigc?branch=master)
5 | [](https://goreportcard.com/report/github.com/ezgliding/goigc)
6 | [](https://godoc.org/github.com/ezgliding/goigc)
7 | [](https://opensource.org/licenses/Apache-2.0)
8 |
9 | goigc is a portable utility to parse and analyse gliding flights, supporting
10 | [igc](http://www.fai.org/component/phocadownload/category/?download=5745:igc-flight-recorder-specification-edition-2-with-al1-2011-5-31) as well as other popular
11 | gps formats.
12 |
13 | You can use goigc to:
14 | * Parse and view metadata for flights in multiple formats (igc, gpx, ...)
15 | * Analyse flight phases, thermalling, straight flight, percentages, etc
16 | * Convert flights between all formats
17 | * Run flight optimization following popular competition rules (olc, netcoupe,
18 | ...)
19 |
20 | ## Install
21 |
22 | Binary downloads of the goigc client can be found in the
23 | [releases page](https://github.com/ezgliding/goigc/releases).
24 |
25 | ## Community and Contributing
26 |
27 | ## Roadmap
28 |
29 | The goigc roadmap uses [github
30 | milestones](https://github.com/ezgliding/goigc/milestones) to track the
31 | progress of the project.
32 |
--------------------------------------------------------------------------------
/binder/README.md:
--------------------------------------------------------------------------------
1 | # Gliding Dataset
2 |
3 | [](http://mybinder.org/v2/gh/ezgliding/goigc/master?filepath=binder/gliding-data-starter.ipynb)
4 |
5 | This binder contains an open dataset with over 100k gliding flights and more
6 | than 6 million gliding phases, along with sample notebooks to better
7 | understand its data.
8 |
9 | Launch it via the badge above.
10 |
11 | 
12 |
13 | An alternative version is available on
14 | [kaggle](https://www.kaggle.com/rochaporto/gliding-data-starter/notebook).
15 |
16 | ## Contributing
17 |
18 | If you come up with nice visualizations or data analysis that you would like to
19 | share please do so opening pull requests.
20 |
21 |
--------------------------------------------------------------------------------
/binder/environment.yml:
--------------------------------------------------------------------------------
1 | name: goigc
2 | channels:
3 | - conda-forge
4 | dependencies:
5 | - python
6 | - gdown
7 | - matplotlib
8 | - numpy
9 | - pandas
10 | - pip
11 | - pip:
12 | - git+https://github.com/python-visualization/branca
13 | - git+https://github.com/sknzl/folium@update-css-url-to-https
14 |
--------------------------------------------------------------------------------
/binder/gliding-data-starter.ipynb:
--------------------------------------------------------------------------------
1 | {"cells":[{"metadata":{},"cell_type":"markdown","source":"# Introduction\n\nThis kernel gives a starter over the contents of the Gliding Data dataset.\n\nThe dataset includes metadata and calculated phases for over 100000 gliding flights from 2016 to 2019, mostly in the region of France but also Belgium, Switzerland and others. In total there are more than 6 million flights phases recorded.\n\n## Gliding\n\nGliding is a leisure aviation activity and sport where pilots fly unpowered aircraft known as gliders or sailplanes.\n\nThe principle of gliding is to climb using some sort of lift and convert the altitude gained into distance. Repeating this process allows for very long distance flights, often above 500km or even 1000km - the current [free distance world record](https://www.fai.org/record/7605) being just over 3000km in Patagonia. This is the same process birds follow to reduce the amount of effort required to travel great distances.\n\nThe most used sources of lift include:\n* thermals: where the air rises due to heating from the sun, often marked by cumulus clouds at the top\n* wave: created by strong winds and stable air layers facing a mountain or a high hill, often marked by lenticular clouds\n\nwith thermalling being by far the most common, and this dataset reflects that.\n\n\n\n## Data Recorders\n\nWhen flying pilots often carry some sort of GPS recording device which generates a track of points collected every few seconds. Each point contains fields for latitude, longitude and altitude (pressure and GPS) among several others, often stored in [IGC](http://www.ukiws.demon.co.uk/GFAC/documents/tech_spec_gnss.pdf) format. Often these tracks are uploaded to online competitions where they are shared with other pilots, and can be easily visualized.\n\n\n\nThe data available in this dataset was scraped from the online competition [Netcoupe](https://netcoupe.net). The scraping, parsing and analysis was done using [goigc](https://github.com/ezgliding/goigc), an open source flight parser and analyser."},{"metadata":{},"cell_type":"markdown","source":"# Getting Started\n\nLet's start by loading the different files in the dataset and taking a peek at the records.\n\nThe available files include:\n* flight_websource: the metadata exposed in the online competition website, including additional information to what is present in the flight track\n* flight_track: the metadata collected directly from the GPS/IGC flight track\n* phases: the flight phases (cruising, circling) calculated from the GPS/IGC flight track (this is a large file, we load a subset below)\n* handicaps: a mostly static file with the different handicaps attributed to each glider type by the IGC, important when calculating flight performances"},{"metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true},"cell_type":"code","source":"import datetime\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\nimport random\n\nimport os\nfor dirname, _, filenames in os.walk('/tmp/gliding-data'):\n for filename in filenames:\n print(os.path.join(dirname, filename))","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"d629ff2d2480ee46fbb7e2d37f6b5fab8052498a","_cell_guid":"79c7e3d0-c299-4dcb-8224-4455121ee9b0","trusted":true},"cell_type":"code","source":"flight_websource = pd.read_csv(\"/tmp/gliding-data/flight_websource.csv\")\nflight_track = pd.read_csv(\"/tmp/gliding-data/flight_track.csv\")\nflight_phases = pd.read_csv(\"/tmp/gliding-data/phases.csv\", skiprows=lambda i: i>0 and random.random() > 0.5)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"# Flight Metadata\n\nThere's a lot of information contained in the two flight metadata files (websource and track)."},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_websource.head(1)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_track.head(1)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"The most useful information comes from the *websource* file as this information is passed directly by the pilot when submitting the flight to the online competition. Things like *Country* or *Region* provide useful statistics on how popular the sport is in different areas. As an example, what are the most popular regions considering the total number of flights? What about the most popular *Takeoff* location?"},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_websource.groupby(['Country', 'Region'])['Region'].value_counts().sort_values(ascending=False).head(3)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_websource.groupby(['Country', 'Year', 'Takeoff'])['Takeoff'].value_counts().sort_values(ascending=False).head(3)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"The three regions above match the Alps area, which is an expected result given this is a gliding Meca. The second result shows *Vinon* as the most popular takeoff in 2016, a big club also in the Southern Alps. But it's interesting to notice that more recently a club near Montpellier took over as the one with the most activity in terms of number of flights.\n\nGliding is a seasonal activity peaking in summer months. "},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_websource['DayOfWeek'] = flight_websource.apply(lambda r: datetime.datetime.strptime(r['Date'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%A\"), axis=1)\nflight_websource.groupby(['DayOfWeek'])['DayOfWeek'].count().plot.bar()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_websource['Month'] = flight_websource.apply(lambda r: datetime.datetime.strptime(r['Date'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%m\"), axis=1)\nflight_websource.groupby(['Month'])['Month'].count().plot.bar()","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"## Merging Data\n\nWe can get additional flight metadata information from the *flight_track* file, but you can expect this data to be less reliable as it's often the case that the metadata in the flight recorder is not updated before a flight. It is very useful though to know more about what type of recorder was used, calibration settings, etc. It is also the source of the flight tracks used to generate the data in the *phases* file.\n\nIn some cases we want to handle columns from both flight metadata files, so it's useful to join the two sets. We can rely on the ID for this purpose."},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_all = pd.merge(flight_websource, flight_track, how='left', on='ID')\nflight_all.head(1)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"# Flight Phases\n\nIn addition to the flight metadata provided in the files above, by analysing the GPS flight tracks we can generate a lot more interesting data.\n\nHere we take a look at flight phases, calculated using the [goigc](https://github.com/ezglding/goigc) tool. As described earlier to travel further glider pilots use thermals to gain altitude and then convert that altitude into distance. In the *phases* file we have a record of each individual phase detected for each of the 100k flights, and we'll focus on:\n* Circling (5): phase where a glider is gaining altitude by circling in an area of rising air\n* Cruising (3): phase where a glider is flying straight converting altitude into distance\n\nThese are indicated by the integer field *Type* below. Each phase has a set of additional fields with relevant statistics for each phase type: while circling the average climb rate (vario) and duration are interesting; while cruising the distance covered and LD (glide ratio) are more interesting."},{"metadata":{"trusted":true},"cell_type":"code","source":"flight_phases.head(1)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"# Data Preparation\n\nAs a quick example of what is possible with this kind of data let's take try to map all *circling* phases as a HeatMap.\n\nFirst we need to do some treatment of the data: convert coordinates from radians to degrees, filter out unreasonable values (climb rates above 15m/s are due to errors in the recording device), convert the date to the expected format and desired grouping. In this case we're grouping all thermal phases by week."},{"metadata":{"trusted":true},"cell_type":"code","source":"phases = pd.merge(flight_phases, flight_websource[['TrackID', 'Distance', 'Speed']], on='TrackID')\nphases['Lat'] = np.rad2deg(phases['CentroidLatitude'])\nphases['Lng'] = np.rad2deg(phases['CentroidLongitude'])\n\nphases_copy = phases[phases.Type==5][phases.AvgVario<10][phases.AvgVario>2].copy()\nphases_copy.head(2)\n\n#phases_copy['AM'] = phases_copy.apply(lambda r: datetime.datetime.strptime(r['StartTime'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%p\"), axis=1)\n#phases_copy['Day'] = phases_copy.apply(lambda r: datetime.datetime.strptime(r['StartTime'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%j\"), axis=1)\n#phases_copy['Week'] = phases_copy.apply(lambda r: datetime.datetime.strptime(r['StartTime'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%W\"), axis=1)\n#phases_copy['Month'] = phases_copy.apply(lambda r: r['StartTime'][5:7], axis=1)\n#phases_copy['Year'] = phases_copy.apply(lambda r: r['StartTime'][0:4], axis=1)\n#phases_copy['YearMonth'] = phases_copy.apply(lambda r: r['StartTime'][0:7], axis=1)\n#phases_copy['YearMonthDay'] = phases_copy.apply(lambda r: r['StartTime'][0:10], axis=1)\n\n# use the corresponding function above to update the grouping to something other than week\nphases_copy['Group'] = phases_copy.apply(lambda r: datetime.datetime.strptime(r['StartTime'], \"%Y-%m-%dT%H:%M:%SZ\").strftime(\"%W\"), axis=1)\nphases_copy.head(1)","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"## Visualization\n\nOnce we have the data ready we can visualize it over a map. We rely on folium for this."},{"metadata":{"trusted":true},"cell_type":"code","source":"# This is a workaround for this known issue:\n# https://github.com/python-visualization/folium/issues/812#issuecomment-582213307\n!pip install git+https://github.com/python-visualization/branca\n!pip install git+https://github.com/sknzl/folium@update-css-url-to-https","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"import folium\nfrom folium import plugins\nfrom folium import Choropleth, Circle, Marker\nfrom folium.plugins import HeatMap, HeatMapWithTime, MarkerCluster\n\n# folium.__version__ # should be '0.10.1+8.g4ea1307'\n# folium.branca.__version__ # should be '0.4.0+4.g6ac241a'","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"### Single HeatMap"},{"metadata":{"trusted":true},"cell_type":"code","source":"# we use a smaller sample to improve the visualization\n# a better alternative is to group entries by CellID, an example of this will be added later\nphases_single = phases_copy.sample(frac=0.01, random_state=1)\nm_5 = folium.Map(location=[47.06318, 5.41938], tiles='stamen terrain', zoom_start=7)\nHeatMap(\n phases_single[['Lat','Lng','AvgVario']], gradient={0.5: 'blue', 0.7: 'yellow', 1: 'red'},\n min_opacity=5, max_val=phases_single.AvgVario.max(), radius=4, max_zoom=7, blur=4, use_local_extrema=False).add_to(m_5)\n\nm_5","execution_count":null,"outputs":[]},{"metadata":{},"cell_type":"markdown","source":"### HeatMap over Time\n\nAnother cool possibility is to visualize the same data over time.\n\nIn this case we're grouping weekly and playing the data over one year.\n\nBoth the most popular areas and times of the year are pretty clear from this animation."},{"metadata":{"trusted":true},"cell_type":"code","source":"m_5 = folium.Map(location=[47.06318, 5.41938], tiles='stamen terrain', zoom_start=7)\n\ngroups = phases_copy.Group.sort_values().unique()\ndata = []\nfor g in groups:\n data.append(phases_copy.loc[phases_copy.Group==g,['Group','Lat','Lng','AvgVario']].groupby(['Lat','Lng']).sum().reset_index().values.tolist())\n \nHeatMapWithTime(\n data,\n index = list(phases_copy.Group.sort_values().unique()),\n gradient={0.1: 'blue', 0.3: 'yellow', 0.8: 'red'},\n auto_play=True, scale_radius=False, display_index=True, radius=4, min_speed=1, max_speed=6, speed_step=1,\n min_opacity=1, max_opacity=phases_copy.AvgVario.max(), use_local_extrema=True).add_to(m_5)\n\nm_5","execution_count":null,"outputs":[]},{"metadata":{"trusted":true},"cell_type":"code","source":"","execution_count":null,"outputs":[]}],"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"pygments_lexer":"ipython3","nbconvert_exporter":"python","version":"3.6.4","file_extension":".py","codemirror_mode":{"name":"ipython","version":3},"name":"python","mimetype":"text/x-python"}},"nbformat":4,"nbformat_minor":4}
--------------------------------------------------------------------------------
/binder/heatmap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ezgliding/goigc/2923ffc4206b52ff157ca1a679285af9515f7414/binder/heatmap.png
--------------------------------------------------------------------------------
/binder/postBuild:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | mkdir -p /tmp/gliding-data
4 |
5 | cd /tmp/gliding-data
6 | gdown https://drive.google.com/uc?id=1_EcCg6CquBdewwJ9-mHvCH939ot31RE9
7 |
8 | cd $HOME
9 |
--------------------------------------------------------------------------------
/binder/start:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | cd /tmp/gliding-data
4 | unzip gliding-data
5 |
6 | cd $HOME
7 |
8 | exec "$@"
9 |
--------------------------------------------------------------------------------
/cmd/goigc/crawl.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 | package main
17 |
18 | import (
19 | "encoding/json"
20 | "fmt"
21 | "io/ioutil"
22 | "os"
23 | "time"
24 |
25 | "github.com/spf13/cobra"
26 |
27 | "github.com/ezgliding/goigc/pkg/igc"
28 | "github.com/ezgliding/goigc/pkg/netcoupe"
29 | )
30 |
31 | func init() {
32 | crawlCmd.Flags().String("source", "netcoupe", "online web source to crawl")
33 | rootCmd.AddCommand(crawlCmd)
34 | }
35 |
36 | var crawlCmd = &cobra.Command{
37 | Use: "crawl START END PATH",
38 | Short: "crawls flights from the given web source",
39 | Long: `Crawls the given web source for gliding flights between START and END date.
40 |
41 | Expected format for start and end dates is 2006-01-02.
42 |
43 | Results are stored under PATH with the following structure.
44 |
45 | PATH/YEAR
46 | /DD-MM-YYYY.json ( one json file per day with flight metadata )
47 | /flights
48 | /TRACKID ( one file with the flight track in the original format )
49 | `,
50 | Args: cobra.ExactArgs(3),
51 | RunE: func(cmd *cobra.Command, args []string) error {
52 |
53 | start, err := time.Parse("2006-01-02", args[0])
54 | if err != nil {
55 | return err
56 | }
57 | end, err := time.Parse("2006-01-02", args[1])
58 | if err != nil {
59 | return err
60 | }
61 | if start.Year() != end.Year() {
62 | return fmt.Errorf("Start and end year must be the same")
63 | }
64 | basePath := args[2]
65 | err = os.MkdirAll(fmt.Sprintf("%v/%v/flights", basePath, start.Year()), os.ModePerm)
66 | if err != nil {
67 | return err
68 | }
69 |
70 | var n netcoupe.Netcoupe = netcoupe.NewNetcoupeYear(start.Year())
71 | current := start
72 | for ; end.After(current.AddDate(0, 0, -1)); current = current.AddDate(0, 0, 1) {
73 | var flights []igc.Flight
74 | dbFile := fmt.Sprintf("%v/%v/%v.json", basePath, current.Year(), current.Format("02-01-2006"))
75 | if _, err := os.Stat(dbFile); os.IsNotExist(err) {
76 | flights, err = n.Crawl(current, current)
77 | if err != nil {
78 | return err
79 | }
80 | jsonFlights, err := json.MarshalIndent(flights, "", " ")
81 | if err != nil {
82 | return err
83 | }
84 | err = ioutil.WriteFile(dbFile, jsonFlights, 0644)
85 | if err != nil {
86 | return err
87 | }
88 | } else {
89 | b, err := ioutil.ReadFile(dbFile)
90 | if err != nil {
91 | return err
92 | }
93 | err = json.Unmarshal(b, &flights)
94 | if err != nil {
95 | return err
96 | }
97 | }
98 |
99 | for _, f := range flights {
100 | flightFile := fmt.Sprintf("%v/%v/flights/%v", basePath, current.Year(), f.TrackID)
101 | if _, err := os.Stat(flightFile); os.IsNotExist(err) {
102 | url := fmt.Sprintf("%v%v", n.TrackBaseUrl(), f.TrackID)
103 | data, err := n.Get(url)
104 | if err != nil {
105 | return err
106 | }
107 | err = ioutil.WriteFile(flightFile, data, 0644)
108 | if err != nil {
109 | return err
110 | }
111 | }
112 | }
113 | }
114 | return nil
115 | },
116 | }
117 |
--------------------------------------------------------------------------------
/cmd/goigc/parse.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 | package main
17 |
18 | import (
19 | "fmt"
20 | "io/ioutil"
21 |
22 | "github.com/spf13/cobra"
23 |
24 | "github.com/ezgliding/goigc/pkg/igc"
25 | )
26 |
27 | func init() {
28 | // TODO(rochaporto): not yet supported, only igc
29 | parseCmd.Flags().String("format", "", "input file format - auto detection by default")
30 | parseCmd.Flags().Bool("no-points", false, "do not include individual points")
31 | parseCmd.Flags().String("output-format", "yaml", "output format for display")
32 | parseCmd.Flags().String("output-file", "/dev/stdout", "output file to write to")
33 | rootCmd.AddCommand(parseCmd)
34 | }
35 |
36 | var parseCmd = &cobra.Command{
37 | Use: "parse FILE",
38 | Short: "parses information about the given flight",
39 | Long: "",
40 | Args: cobra.ExactArgs(1),
41 | RunE: func(cmd *cobra.Command, args []string) error {
42 | outputFile, err := cmd.Flags().GetString("output-file")
43 | if err != nil {
44 | return err
45 | }
46 | outputFormat, err := cmd.Flags().GetString("output-format")
47 | if err != nil {
48 | return err
49 | }
50 |
51 | trk, err := igc.ParseLocation(args[0])
52 | if err != nil {
53 | return err
54 | }
55 |
56 | noPoints, _ := cmd.Flags().GetBool("no-points")
57 | if noPoints {
58 | trk = igc.Track{Header: trk.Header}
59 | }
60 | result, err := trk.Encode(outputFormat)
61 | if err != nil {
62 | return err
63 | }
64 | if outputFile == "/dev/stdout" {
65 | fmt.Printf("%v", string(result))
66 | } else {
67 | err = ioutil.WriteFile(outputFile, result, 0644)
68 | if err != nil {
69 | return err
70 | }
71 | }
72 |
73 | return nil
74 | },
75 | }
76 |
--------------------------------------------------------------------------------
/cmd/goigc/phases.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 | package main
17 |
18 | import (
19 | "fmt"
20 | "io/ioutil"
21 |
22 | "github.com/ezgliding/goigc/pkg/igc"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | func init() {
27 | phasesCmd.Flags().String("output-format", "yaml", "output format for display")
28 | phasesCmd.Flags().String("output-file", "/dev/stdout", "output file to write to")
29 | rootCmd.AddCommand(phasesCmd)
30 | }
31 |
32 | var phasesCmd = &cobra.Command{
33 | Use: "phases FILE",
34 | Short: "compute phases for the given flight",
35 | Long: "",
36 | Args: cobra.ExactArgs(1),
37 | RunE: func(cmd *cobra.Command, args []string) error {
38 | outputFile, err := cmd.Flags().GetString("output-file")
39 | if err != nil {
40 | return err
41 | }
42 | outputFormat, err := cmd.Flags().GetString("output-format")
43 | if err != nil {
44 | return err
45 | }
46 |
47 | trk, err := igc.ParseLocation(args[0])
48 | if err != nil {
49 | return err
50 | }
51 | result, err := trk.EncodePhases(outputFormat)
52 | if err != nil {
53 | return err
54 | }
55 | if outputFile == "/dev/stdout" {
56 | fmt.Printf("%v", string(result))
57 | } else {
58 | err = ioutil.WriteFile(outputFile, result, 0644)
59 | if err != nil {
60 | return err
61 | }
62 | }
63 |
64 | return nil
65 | },
66 | }
67 |
--------------------------------------------------------------------------------
/cmd/goigc/root.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 | package main
17 |
18 | import (
19 | "fmt"
20 | "os"
21 |
22 | "github.com/ezgliding/goigc/pkg/version"
23 | "github.com/spf13/cobra"
24 | )
25 |
26 | var (
27 | rootCmd = &cobra.Command{
28 | Use: "goigc",
29 | Short: "goigc is a parser and analyser for gliding flights",
30 | Long: "",
31 | Version: fmt.Sprintf("%v %.7v %v %v", version.Version(), version.Commit(),
32 | version.BuildTime().Format("02/01/06 15:04:05"), version.Metadata()),
33 | Hidden: true,
34 | SilenceUsage: true,
35 | PersistentPreRun: func(cmd *cobra.Command, args []string) {
36 | cmd.SilenceErrors, _ = cmd.Flags().GetBool("silent")
37 | },
38 | }
39 | )
40 |
41 | func init() {
42 | }
43 |
44 | func Execute() {
45 | if err := rootCmd.Execute(); err != nil {
46 | os.Exit(1)
47 | }
48 | }
49 |
50 | func main() {
51 | rootCmd.PersistentFlags().Bool("silent", false, "do not print any errors")
52 | rootCmd.SetVersionTemplate(
53 | `{{with .Name}}{{printf "%s " .}}{{end}}{{printf "%s" .Version}}
54 | `)
55 | Execute()
56 | }
57 |
--------------------------------------------------------------------------------
/cmd/goigc/root_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package main
18 |
19 | import (
20 | "bytes"
21 | "os"
22 | "testing"
23 | )
24 |
25 | func TestRootCmd(t *testing.T) {
26 |
27 | tests := []struct {
28 | name string
29 | args []string
30 | envars map[string]string
31 | }{
32 | {
33 | name: "defaults",
34 | args: []string{""},
35 | },
36 | }
37 |
38 | cmd := rootCmd
39 |
40 | for _, tt := range tests {
41 | t.Run(tt.name, func(t *testing.T) {
42 | buf := new(bytes.Buffer)
43 | for k, v := range tt.envars {
44 | os.Setenv(k, v)
45 | }
46 | cmd.SetOutput(buf)
47 | cmd.SetArgs(tt.args)
48 | err := cmd.Execute()
49 | if err != nil {
50 | t.Fatal()
51 | }
52 |
53 | })
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/deployments/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | # Copyright The ezgliding Authors.
2 | #
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | #
16 | FROM alpine:3 as alpine
17 | RUN apk add -U --no-cache ca-certificates
18 |
19 | FROM scratch
20 | WORKDIR /
21 | COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
22 | ADD _dist/linux-amd64/goigc_linux_amd64 /usr/bin/goigc
23 | USER 1000
24 | ENTRYPOINT ["goigc"]
25 | CMD ["--help"]
26 |
--------------------------------------------------------------------------------
/deployments/docker/Dockerfile-alpine:
--------------------------------------------------------------------------------
1 | # Copyright The ezgliding Authors.
2 | #
3 | #
4 | # Licensed under the Apache License, Version 2.0 (the "License");
5 | # you may not use this file except in compliance with the License.
6 | # You may obtain a copy of the License at
7 | #
8 | # http://www.apache.org/licenses/LICENSE-2.0
9 | #
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | #
16 | FROM alpine:3
17 | RUN apk add -U --no-cache ca-certificates bash
18 | ADD _dist/linux-amd64/goigc_linux_amd64 /usr/bin/goigc
19 | WORKDIR /
20 | USER 1000
21 | ENTRYPOINT ["goigc"]
22 | CMD ["--help"]
23 |
--------------------------------------------------------------------------------
/doc.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | // Package goigc contains functionality to parse and analyse gliding flights.
18 | //
19 | // goigc contains the following packages:
20 | //
21 | // The cmd packages contains command line utilities, namely the goigc binary.
22 | //
23 | // The pkg/igc provides the parsing and analysis code for the igc format.
24 | //
25 | package goigc
26 |
27 | // blank imports help docs.
28 | import (
29 | // pkg/igc package
30 | _ "github.com/ezgliding/goigc/pkg/igc"
31 | // pkg/version package
32 | _ "github.com/ezgliding/goigc/pkg/version"
33 | )
34 |
--------------------------------------------------------------------------------
/docs/images/track.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ezgliding/goigc/2923ffc4206b52ff157ca1a679285af9515f7414/docs/images/track.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/ezgliding/goigc
2 |
3 | require (
4 | github.com/PuerkitoBio/goquery v1.5.1 // indirect
5 | github.com/antchfx/htmlquery v1.2.2 // indirect
6 | github.com/antchfx/xmlquery v1.2.3 // indirect
7 | github.com/antchfx/xpath v1.1.5 // indirect
8 | github.com/gobwas/glob v0.2.3 // indirect
9 | github.com/gocolly/colly v1.2.0
10 | github.com/golang/geo v0.0.0-20181008215305-476085157cff
11 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
12 | github.com/kennygrant/sanitize v1.2.4 // indirect
13 | github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
14 | github.com/sirupsen/logrus v1.5.0
15 | github.com/spf13/cobra v0.0.5
16 | github.com/temoto/robotstxt v1.1.1 // indirect
17 | github.com/twpayne/go-kml v1.2.0
18 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect
19 | google.golang.org/appengine v1.6.5 // indirect
20 | gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2
21 | )
22 |
23 | go 1.13
24 |
--------------------------------------------------------------------------------
/pkg/igc/bruteforce.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "fmt"
21 | "time"
22 | )
23 |
24 | // NewBruteForceOptimizer returns a BruteForceOptimizer with the given characteristics.
25 | //
26 | func NewBruteForceOptimizer(cache bool) Optimizer {
27 | return &bruteForceOptimizer{cache: cache}
28 | }
29 |
30 | type bruteForceOptimizer struct {
31 | cache bool
32 | }
33 |
34 | func (b *bruteForceOptimizer) Optimize(track Track, nPoints int, score Score) (Task, error) {
35 | time.Sleep(5 * time.Second)
36 | switch nPoints {
37 |
38 | case 1:
39 | return b.optimize1(track, score)
40 | case 2:
41 | return b.optimize2(track, score)
42 | default:
43 | return Task{}, fmt.Errorf("%v turn points not supported by this optimizer", nPoints)
44 | }
45 | }
46 |
47 | func (b *bruteForceOptimizer) optimize1(track Track, score Score) (Task, error) {
48 |
49 | var optimalDistance float64
50 | var distance float64
51 | var task Task
52 | var optimalTask Task
53 |
54 | for i := 0; i < len(track.Points)-2; i++ {
55 | for j := i + 1; j < len(track.Points)-1; j++ {
56 | for z := j + 1; z < len(track.Points); z++ {
57 | task = Task{
58 | Start: track.Points[i],
59 | Turnpoints: []Point{track.Points[j]},
60 | Finish: track.Points[z],
61 | }
62 | distance = task.Distance()
63 | if distance > optimalDistance {
64 | optimalDistance = distance
65 | optimalTask = Task(task)
66 | }
67 | }
68 | }
69 | }
70 | return optimalTask, nil
71 | }
72 |
73 | func (b *bruteForceOptimizer) optimize2(track Track, score Score) (Task, error) {
74 |
75 | var optimalDistance float64
76 | var distance float64
77 | var optimalTask Task
78 |
79 | for i := 0; i < len(track.Points)-3; i++ {
80 | for j := i + 1; j < len(track.Points)-2; j++ {
81 | for w := j + 1; w < len(track.Points)-1; w++ {
82 | for z := w + 1; z < len(track.Points); z++ {
83 | task := Task{
84 | Start: track.Points[i],
85 | Turnpoints: []Point{track.Points[j], track.Points[w]},
86 | Finish: track.Points[z],
87 | }
88 | distance = task.Distance()
89 | if distance > optimalDistance {
90 | optimalDistance = distance
91 | optimalTask = task
92 | }
93 | }
94 | }
95 | }
96 | }
97 | return optimalTask, nil
98 | }
99 |
--------------------------------------------------------------------------------
/pkg/igc/bruteforce_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "fmt"
21 | "path/filepath"
22 | "testing"
23 | )
24 |
25 | func TestBruteForceOptimize(t *testing.T) {
26 | opt := NewBruteForceOptimizer(false)
27 |
28 | for _, test := range optimizeTests {
29 | for tp, expected := range test.result {
30 | if tp > 1 {
31 | continue
32 | }
33 | t.Run(fmt.Sprintf("%v/%v", test.name, tp), func(t *testing.T) {
34 | track, err := ParseLocation(filepath.Join("testdata/optimize", fmt.Sprintf("%v.igc", test.name)))
35 | if err != nil {
36 | t.Fatal(err)
37 | }
38 | task, err := opt.Optimize(track, tp, Distance)
39 | if err != nil {
40 | t.Fatal(err)
41 | }
42 | result := task.Distance()
43 | if !test.valid(result, tp) {
44 | t.Errorf("expected %v got %v", expected, result)
45 | }
46 | })
47 | }
48 | }
49 | }
50 |
51 | func BenchmarkBruteForceOptimize(b *testing.B) {
52 | opt := NewBruteForceOptimizer(false)
53 |
54 | for _, test := range benchmarkTests {
55 | for tp, expected := range test.result {
56 | if tp > 1 {
57 | continue
58 | }
59 | track, err := ParseLocation(filepath.Join("testdata/optimize", fmt.Sprintf("%v.igc", test.name)))
60 | if err != nil {
61 | b.Fatal(err)
62 | }
63 | b.Run(fmt.Sprintf("%v/%v", test.name, tp), func(b *testing.B) {
64 | task, err := opt.Optimize(track, tp, Distance)
65 | if err != nil {
66 | b.Fatal(err)
67 | }
68 | result := task.Distance()
69 | if !test.valid(result, tp) {
70 | b.Errorf("expected %v got %v", expected, result)
71 | }
72 | })
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/pkg/igc/crawler.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "time"
21 | )
22 |
23 | type FlightID string
24 |
25 | type Crawler interface {
26 | Crawl(time.Time, time.Time) ([]Flight, error)
27 | }
28 |
--------------------------------------------------------------------------------
/pkg/igc/doc.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 | //
15 |
16 | /*
17 | Package igc provides means to parse and analyse files in the IGC format.
18 |
19 | This format is defined by the International Gliding Commission (IGC) and
20 | was created to set a standard for recording gliding flights.
21 |
22 | The full specification is available in Appendix A of the IGC FR Specification:
23 | http://www.fai.org/component/phocadownload/category/?download=11005
24 |
25 | Calculation of the optimal flight distance considering multiple turnpoints and
26 | FAI triangles are available via Optimizers. Available Optimizers include brute
27 | force, montecarlo method, genetic algorithms, etc.
28 |
29 | */
30 | package igc
31 |
--------------------------------------------------------------------------------
/pkg/igc/doc_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 | //
15 |
16 | package igc
17 |
18 | import (
19 | "fmt"
20 | )
21 |
22 | // Parse and ParseLocation return a Track object.
23 | func Example_parse() {
24 | // We can parse passing a file location
25 | track, _ := ParseLocation("sample-flight.igc")
26 |
27 | // Or similarly giving directly the contents
28 | contents := `
29 | AFLA001Some Additional Data
30 | HFDTE010203
31 | HFFXA500
32 | HFPLTPilotincharge:EZ PILOT
33 | `
34 | track, _ = Parse(contents)
35 |
36 | // Accessing track metadata
37 | fmt.Printf("Track Pilot: %v", track.Pilot)
38 | fmt.Printf("Track Points %v", len(track.Pilot))
39 | }
40 |
41 | // Calculate the total track distance using the Points.
42 | //
43 | // This is not a very useful metric (you should look at one of the Optimizers)
44 | // instead, but it is a good example of how to use the Point data in the Track.
45 | func Example_totaldistance() {
46 | track, _ := ParseLocation("sample-flight.igc")
47 | totalDistance := 0.0
48 | for i := 0; i < len(track.Points)-1; i++ {
49 | totalDistance += track.Points[i].Distance(track.Points[i+1])
50 | }
51 | fmt.Printf("Distance was %v", totalDistance)
52 | }
53 |
54 | // Calculate the optimal track distance for the multiple possible tasks using the Brute Force Optimizer:
55 | func Example_optimize() {
56 | track, _ := ParseLocation("sample-flight.igc")
57 |
58 | // In this case we use a brute force optimizer
59 | o := NewBruteForceOptimizer(false)
60 | r, _ := o.Optimize(track, 1, Distance)
61 | fmt.Printf("Optimization result was: %v", r)
62 | }
63 |
--------------------------------------------------------------------------------
/pkg/igc/flight.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "time"
21 | )
22 |
23 | // Flight represents a flight submission in an online competition.
24 | //
25 | // It includes all the flight metadata available in the online competition,
26 | // including computed data like speed or distance. It also includes a url to
27 | // the flight track file (usually in IGC format).
28 | type Flight struct {
29 | URL string
30 | ID string
31 | Pilot string
32 | Club string
33 | Date time.Time
34 | Takeoff string
35 | Region string
36 | Country string
37 | Distance float64
38 | Points float64
39 | Glider string
40 | Type string
41 | TrackURL string
42 | TrackID string
43 | CompetitionID string
44 | CompetitionURL string
45 | Speed float64
46 | Comments string
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/igc/optimize.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | // Score functions calculate a score for the given Task.
20 | //
21 | // The main use of these functions is in passing them to the Optimizers, so
22 | // they can evaluate each Task towards different goals.
23 | //
24 | // Example functions include the total distance between all turn points or an
25 | // online competition (netcoupe, online contest) score which takes additional
26 | // metrics of each leg into account.
27 | type Score func(task Task) float64
28 |
29 | // Distance returns the sum of distances between each of the points in the Task.
30 | //
31 | // The sum is made calculating the distances between each two consecutive Points.
32 | func Distance(task Task) float64 {
33 | return task.Distance()
34 | }
35 |
36 | // Optimizer returns an optimal Task for the given turnpoints and Score function.
37 | //
38 | // Available score functions include MaxDistance and MaxPoints, but it is
39 | // possible to pass the Optimizer a custom function.
40 | //
41 | // Optimizers might not support a high number of turnpoints. As an example, the
42 | // BruteForceOptimizer does not perform well with nPoints > 2, and might decide
43 | // to return an error instead of attempting to finalize the optimization
44 | // indefinitely.
45 | type Optimizer interface {
46 | Optimize(track Track, nPoints int, score Score) (Task, error)
47 | }
48 |
--------------------------------------------------------------------------------
/pkg/igc/optimize_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "math"
21 | "testing"
22 | )
23 |
24 | const (
25 | errorMargin float64 = 0.02
26 | )
27 |
28 | type optimizeTest struct {
29 | name string
30 | result map[int]float64
31 | //margin float64
32 | }
33 |
34 | func (t *optimizeTest) validWithMargin(result float64, nTp int, errorMargin float64) bool {
35 | difference := math.Abs(t.result[nTp] - result)
36 | maxDifference := t.result[nTp] * errorMargin
37 | return difference < maxDifference
38 | }
39 |
40 | func (t *optimizeTest) valid(result float64, nTp int) bool {
41 | return t.validWithMargin(result, nTp, errorMargin)
42 | }
43 |
44 | var benchmarkTests = []optimizeTest{
45 | {
46 | name: "optimize-short-flight-1",
47 | result: map[int]float64{1: 35.44619896425489},
48 | },
49 | }
50 |
51 | var optimizeTests = []optimizeTest{
52 | // {
53 | // name: "optimize-short-flight-1",
54 | // result: map[int]float64{1: 35.44619896425489, 2: 0.0, 3: 507.80108709626626},
55 | // },
56 | }
57 |
58 | type distanceTest struct {
59 | t string
60 | task Task
61 | distance float64
62 | }
63 |
64 | var distanceTests = []distanceTest{
65 | {
66 | t: "all-points-the-same-distance-zero",
67 | task: Task{
68 | Start: NewPointFromDMD("4453183N", "00512633E"),
69 | Turnpoints: []Point{NewPointFromDMD("4453183N", "00512633E")},
70 | Finish: NewPointFromDMD("4453183N", "00512633E"),
71 | },
72 | distance: 0.0,
73 | },
74 | {
75 | t: "valid-task-sequence",
76 | task: Task{
77 | Start: NewPointFromDMD("4453183N", "00512633E"),
78 | Turnpoints: []Point{
79 | NewPointFromDMD("4353800N", "00615200E"),
80 | NewPointFromDMD("4506750N", "00633950E"),
81 | NewPointFromDMD("4424783N", "00644500E"),
82 | },
83 | Finish: NewPointFromDMD("4505550N", "00502883E"),
84 | },
85 | distance: 507.80108709626626,
86 | },
87 | }
88 |
89 | func TestDistance(t *testing.T) {
90 | var result float64
91 | for _, test := range distanceTests {
92 | t.Run(test.t, func(t *testing.T) {
93 | result = Distance(test.task)
94 | if result != test.distance {
95 | t.Errorf("expected %v got %v", test.distance, result)
96 | }
97 | })
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/pkg/igc/parse.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "fmt"
21 | "io/ioutil"
22 | "net/http"
23 | "strconv"
24 | "strings"
25 | "time"
26 | )
27 |
28 | const (
29 | // TimeFormat is the golang time.Parse format for IGC time.
30 | TimeFormat = "150405"
31 | // DateFormat is the golang time.Parse format for IGC time.
32 | DateFormat = "020106"
33 | )
34 |
35 | // ParseLocation returns a Track object corresponding to the given file.
36 | //
37 | // It calls Parse internatlly, so the file content should be in IGC format.
38 | func ParseLocation(location string) (Track, error) {
39 | var content []byte
40 | resp, err := http.Get(location)
41 | // case http
42 | if err == nil {
43 | defer resp.Body.Close()
44 | content, err = ioutil.ReadAll(resp.Body)
45 | if err != nil {
46 | return Track{}, err
47 | }
48 | } else { // case file
49 | resp, err := ioutil.ReadFile(location)
50 | if err != nil {
51 | return Track{}, err
52 | }
53 | content = resp
54 | }
55 |
56 | return Parse(string(content))
57 | }
58 |
59 | // ParseCleanLocation returns a cleaned up Track object.
60 | //
61 | // See ParseLocation().
62 | func ParseCleanLocation(location string) (Track, error) {
63 | t, err := ParseLocation(location)
64 | if err != nil {
65 | return Track{}, err
66 | }
67 | return t.Cleanup()
68 | }
69 |
70 | // ParseClean returns a cleaned up Track object.
71 | //
72 | // See Parse().
73 | func ParseClean(content string) (Track, error) {
74 | t, err := Parse(content)
75 | if err != nil {
76 | return Track{}, err
77 | }
78 | return t.Cleanup()
79 | }
80 |
81 | // Parse returns a Track object corresponding to the given content.
82 | //
83 | // The value of content should be a text string with all the flight data
84 | // in the IGC format.
85 | func Parse(content string) (Track, error) {
86 | f := NewTrack()
87 | var err error
88 | p := parser{}
89 | lines := strings.Split(content, "\n")
90 | for i := range lines {
91 | line := strings.TrimSpace(lines[i])
92 | // ignore empty lines
93 | if len(strings.Trim(line, " ")) < 1 {
94 | continue
95 | }
96 | switch line[0] {
97 | case 'A':
98 | err = p.parseA(line, &f)
99 | case 'B':
100 | err = p.parseB(line, &f)
101 | case 'C':
102 | if !p.taskDone {
103 | err = p.parseC(lines[i:], &f)
104 | }
105 | case 'D':
106 | err = p.parseD(line, &f)
107 | case 'E':
108 | err = p.parseE(line, &f)
109 | case 'F':
110 | err = p.parseF(line, &f)
111 | case 'G':
112 | err = p.parseG(line, &f)
113 | case 'H':
114 | err = p.parseH(line, &f)
115 | case 'I':
116 | err = p.parseI(line)
117 | case 'J':
118 | err = p.parseJ(line)
119 | case 'K':
120 | err = p.parseK(line, &f)
121 | case 'L':
122 | err = p.parseL(line, &f)
123 | default:
124 | err = fmt.Errorf("invalid record :: %v", line)
125 | }
126 | if err != nil {
127 | return f, err
128 | }
129 | }
130 |
131 | return f, nil
132 | }
133 |
134 | type field struct {
135 | start int64
136 | end int64
137 | tlc string
138 | }
139 |
140 | type parser struct {
141 | IFields []field
142 | JFields []field
143 | taskDone bool
144 | numSat int
145 | }
146 |
147 | func (p *parser) parseA(line string, f *Track) error {
148 | if len(line) < 4 {
149 | return fmt.Errorf("line too short :: %v", line)
150 | }
151 | f.Manufacturer = line[1:4]
152 | if len(line) >= 7 {
153 | f.UniqueID = line[4:7]
154 | f.AdditionalData = line[7:]
155 | }
156 | return nil
157 | }
158 |
159 | func (p *parser) parseB(line string, f *Track) error {
160 | if len(line) < 35 {
161 | return fmt.Errorf("line too short :: %v", line)
162 | }
163 | pt := NewPointFromDMD(
164 | line[7:15], line[15:24])
165 |
166 | var err error
167 | pt.Time, err = time.Parse(TimeFormat, line[1:7])
168 | if err != nil {
169 | if !strings.Contains(err.Error(), "out of range") {
170 | return err
171 | }
172 | }
173 | pt.Time = pt.Time.AddDate(f.Date.Year(), int(f.Date.Month()), f.Date.Day())
174 | if line[24] == 'A' || line[24] == 'V' {
175 | pt.FixValidity = line[24]
176 | } else {
177 | return fmt.Errorf("invalid fix validity :: %v", line)
178 | }
179 | pt.PressureAltitude, err = strconv.ParseInt(line[25:30], 10, 64)
180 | if err != nil {
181 | return err
182 | }
183 | pt.GNSSAltitude, err = strconv.ParseInt(line[30:35], 10, 64)
184 | if err != nil {
185 | return err
186 | }
187 | for _, f := range p.IFields {
188 | if int64(len(line)) < f.end {
189 | return fmt.Errorf("wrong line size :: %v", line)
190 | }
191 | pt.IData[f.tlc] = line[f.start-1 : f.end]
192 | }
193 | pt.NumSatellites = p.numSat
194 | f.Points = append(f.Points, pt)
195 | return nil
196 | }
197 |
198 | func (p *parser) parseC(lines []string, f *Track) error {
199 | line := lines[0]
200 | if len(line) < 25 {
201 | return fmt.Errorf("wrong line size :: %v", line)
202 | }
203 | var err error
204 | var nTP int
205 | if nTP, err = strconv.Atoi(line[23:25]); err != nil {
206 | return fmt.Errorf("invalid number of turnpoints :: %v", line)
207 | }
208 | if len(lines) < 5+nTP {
209 | return fmt.Errorf("invalid number of C record lines :: %v", lines)
210 | }
211 | if f.Task.DeclarationDate, err = time.Parse(DateFormat+TimeFormat, lines[0][1:13]); err != nil {
212 | f.Task.DeclarationDate = time.Time{}
213 | }
214 | if f.Task.Date, err = time.Parse(DateFormat, lines[0][13:19]); err != nil {
215 | f.Task.Date = time.Time{}
216 | }
217 | if f.Task.Number, err = strconv.Atoi(line[19:23]); err != nil {
218 | return err
219 | }
220 | f.Task.Description = line[25:]
221 | if f.Task.Takeoff, err = p.taskPoint(lines[1]); err != nil {
222 | return err
223 | }
224 | if f.Task.Start, err = p.taskPoint(lines[2]); err != nil {
225 | return err
226 | }
227 | for i := 0; i < nTP; i++ {
228 | var tp Point
229 | if tp, err = p.taskPoint(lines[3+i]); err != nil {
230 | return err
231 | }
232 | f.Task.Turnpoints = append(f.Task.Turnpoints, tp)
233 | }
234 | if f.Task.Finish, err = p.taskPoint(lines[3+nTP]); err != nil {
235 | return err
236 | }
237 | if f.Task.Landing, err = p.taskPoint(lines[4+nTP]); err != nil {
238 | return err
239 | }
240 | p.taskDone = true
241 | return nil
242 | }
243 |
244 | func (p *parser) taskPoint(line string) (Point, error) {
245 | if len(line) < 18 {
246 | return Point{}, fmt.Errorf("line too short :: %v", line)
247 | }
248 | pt := NewPointFromDMD(
249 | line[1:9], line[9:18])
250 | pt.Description = line[18:]
251 | return pt, nil
252 | }
253 |
254 | func (p *parser) parseD(line string, f *Track) error {
255 | if len(line) < 6 {
256 | return fmt.Errorf("line too short :: %v", line)
257 | }
258 | if line[1] == '2' {
259 | f.DGPSStationID = line[2:6]
260 | }
261 | return nil
262 | }
263 |
264 | func (p *parser) parseE(line string, f *Track) error {
265 | if len(line) < 10 {
266 | return fmt.Errorf("line too short :: %v", line)
267 | }
268 | t, err := time.Parse(TimeFormat, line[1:7])
269 | if err != nil {
270 | return err
271 | }
272 | f.Events = append(f.Events, Event{Time: t, Type: line[7:10], Data: line[10:]})
273 | return nil
274 | }
275 |
276 | func (p *parser) parseF(line string, f *Track) error {
277 | if len(line) < 7 {
278 | return fmt.Errorf("line too short :: %v", line)
279 | }
280 | t, err := time.Parse(TimeFormat, line[1:7])
281 | if err != nil {
282 | return err
283 | }
284 | ids := []string{}
285 | for i := 7; i < len(line)-1; i = i + 2 {
286 | ids = append(ids, line[i:i+2])
287 | }
288 | f.Satellites = append(f.Satellites, Satellite{Time: t, Ids: ids})
289 | p.numSat = len(ids)
290 | return nil
291 | }
292 |
293 | func (p *parser) parseG(line string, f *Track) error {
294 | f.Signature = f.Signature + line[1:]
295 | return nil
296 | }
297 |
298 | func (p *parser) parseH(line string, f *Track) error {
299 | var err error
300 | if len(line) < 5 {
301 | return fmt.Errorf("line too short :: %v", line)
302 | }
303 |
304 | switch line[2:5] {
305 | case "DTE":
306 | if len(line) < 11 {
307 | return fmt.Errorf("line too short :: %v", line)
308 | }
309 | if len(line) > 12 {
310 | f.Date, err = time.Parse(DateFormat, line[10:16])
311 | } else {
312 | f.Date, err = time.Parse(DateFormat, line[5:11])
313 | }
314 | case "FXA":
315 | if len(line) < 6 {
316 | return fmt.Errorf("line too short :: %v", line)
317 | }
318 | f.FixAccuracy, err = strconv.ParseInt(line[5:], 10, 64)
319 | case "PLT":
320 | f.Pilot = stripUpTo(line[5:], ":")
321 | case "CM2":
322 | f.Crew = stripUpTo(line[5:], ":")
323 | case "GTY", "GYT":
324 | f.GliderType = stripUpTo(line[5:], ":")
325 | case "GID":
326 | f.GliderID = stripUpTo(line[5:], ":")
327 | case "DTM":
328 | if len(line) < 8 {
329 | return fmt.Errorf("line too short :: %v", line)
330 | }
331 | f.GPSDatum = stripUpTo(line[5:], ":")
332 | case "RFW":
333 | f.FirmwareVersion = stripUpTo(line[5:], ":")
334 | case "RHW":
335 | f.HardwareVersion = stripUpTo(line[5:], ":")
336 | case "FTY":
337 | f.FlightRecorder = stripUpTo(line[5:], ":")
338 | case "GPS":
339 | f.GPS = line[5:]
340 | case "PRS":
341 | f.PressureSensor = stripUpTo(line[5:], ":")
342 | case "CID":
343 | f.CompetitionID = stripUpTo(line[5:], ":")
344 | case "CCL":
345 | f.CompetitionClass = stripUpTo(line[5:], ":")
346 | case "TZN", "TZO":
347 | z, err := strconv.ParseFloat(
348 | strings.TrimLeft(stripUpTo(line[5:], ":"), " "), 64)
349 | if err != nil {
350 | return err
351 | }
352 | f.Timezone = int(z)
353 | case "ATS":
354 | ats, err := strconv.ParseFloat(stripUpTo(line[5:], ":"), 64)
355 | if err != nil {
356 | return err
357 | }
358 | f.AltimeterPressure = ats / 100
359 | case "DB1":
360 | f.PilotBirth, err = time.Parse(DateFormat, stripUpTo(line[5:], ":"))
361 | if f.PilotBirth.After(time.Now()) {
362 | f.PilotBirth = f.PilotBirth.AddDate(-100, 0, 0)
363 | }
364 | case "MOP":
365 | f.MOPSensor = stripUpTo(line[5:], ":")
366 | case "SIT":
367 | f.Site = stripUpTo(line[5:], ":")
368 | case "OOI":
369 | f.Observation = stripUpTo(line[5:], ":")
370 | case "SOF":
371 | f.SoftwareVersion = stripUpTo(line[5:], ":")
372 | case "FSP":
373 | f.Specification = stripUpTo(line[5:], ":")
374 | case "ALG":
375 | f.GNSSModel = stripUpTo(line[5:], ":")
376 | case "ALP":
377 | f.PressureModel = stripUpTo(line[5:], ":")
378 | case "UNT":
379 | // seen once, not sure what it's supposed to mean
380 | default:
381 | err = fmt.Errorf("unknown record :: %v", line)
382 | }
383 |
384 | return err
385 | }
386 |
387 | func (p *parser) parseI(line string) error {
388 | if len(line) < 3 {
389 | return fmt.Errorf("line too short :: %v", line)
390 | }
391 | n, err := strconv.ParseInt(line[1:3], 10, 0)
392 | if err != nil {
393 | return fmt.Errorf("invalid number of I fields :: %v", line)
394 | }
395 | if len(line) != int(n*7+3) {
396 | return fmt.Errorf("wrong line size :: %v", line)
397 | }
398 | for i := 0; i < int(n); i++ {
399 | s := i*7 + 3
400 | start, _ := strconv.ParseInt(line[s:s+2], 10, 0)
401 | end, _ := strconv.ParseInt(line[s+2:s+4], 10, 0)
402 | tlc := line[s+4 : s+7]
403 | p.IFields = append(p.IFields, field{start: start, end: end, tlc: tlc})
404 | }
405 | return nil
406 | }
407 |
408 | func (p *parser) parseJ(line string) error {
409 | if len(line) < 3 {
410 | return fmt.Errorf("line too short :: %v", line)
411 | }
412 | n, err := strconv.ParseInt(line[1:3], 10, 0)
413 | if err != nil {
414 | return fmt.Errorf("invalid number of J fields :: %v", line)
415 | }
416 | if len(line) != int(n*7+3) {
417 | return fmt.Errorf("wrong line size :: %v", line)
418 | }
419 | for i := 0; i < int(n); i++ {
420 | s := i*7 + 3
421 | start, _ := strconv.ParseInt(line[s:s+2], 10, 0)
422 | end, _ := strconv.ParseInt(line[s+2:s+4], 10, 0)
423 | tlc := line[s+4 : s+7]
424 | p.JFields = append(p.JFields, field{start: start, end: end, tlc: tlc})
425 | }
426 | return nil
427 | }
428 |
429 | func (p *parser) parseK(line string, f *Track) error {
430 | if len(line) < 7 {
431 | return fmt.Errorf("line too short :: %v", line)
432 | }
433 | t, err := time.Parse(TimeFormat, line[1:7])
434 | if err != nil {
435 | return err
436 | }
437 | fields := make(map[string]string)
438 | for _, f := range p.JFields {
439 | fields[f.tlc] = line[f.start-1 : f.end]
440 | }
441 | f.K = append(f.K, K{Time: t, Fields: fields})
442 | return nil
443 | }
444 |
445 | func (p *parser) parseL(line string, f *Track) error {
446 | f.Logbook = append(f.Logbook, line[1:])
447 | return nil
448 | }
449 |
450 | func stripUpTo(s string, sep string) string {
451 | i := strings.Index(s, sep)
452 | if i == -1 {
453 | return s
454 | }
455 | return s[i+1:]
456 | }
457 |
--------------------------------------------------------------------------------
/pkg/igc/parse_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "encoding/json"
21 | "flag"
22 | "fmt"
23 | "io/ioutil"
24 | "path/filepath"
25 | "strconv"
26 | "strings"
27 | "testing"
28 | )
29 |
30 | var update = flag.Bool("update", false, "update golden test data")
31 |
32 | func runTest(t *testing.T, ok bool, in, out string) {
33 |
34 | data, err := ioutil.ReadFile(in)
35 | if err != nil {
36 | t.Error(err)
37 | return
38 | }
39 | result, err := Parse(string(data))
40 | if err != nil && !ok {
41 | return
42 | } else if err != nil {
43 | t.Error(err)
44 | }
45 | resultJSON, err := json.MarshalIndent(result, "", " ")
46 | if err != nil {
47 | t.Fatalf("%v :: %+v", err, result)
48 | }
49 |
50 | // update golden if flag is passed
51 | if *update {
52 | if err = ioutil.WriteFile(out, resultJSON, 0644); err != nil {
53 | t.Fatal(err)
54 | }
55 | }
56 |
57 | expectedJSON, err := ioutil.ReadFile(out)
58 | if err != nil {
59 | t.Fatal(err)
60 | }
61 |
62 | if string(resultJSON) != string(expectedJSON) {
63 | t.Errorf("expected\n%+v\ngot\n%+v", string(expectedJSON), string(resultJSON))
64 | }
65 | }
66 |
67 | func TestParse(t *testing.T) {
68 | // testdata/parse file name format is testname.[1|0].igc
69 | match, err := filepath.Glob("../../testdata/parse/parse-*.igc")
70 | if err != nil {
71 | t.Fatal(err)
72 | }
73 | for _, in := range match {
74 | t.Run(in, func(t *testing.T) {
75 | parts := strings.Split(in, ".")
76 | ok, _ := strconv.ParseBool(parts[len(parts)-2])
77 | out := fmt.Sprintf("%v.golden", in)
78 |
79 | runTest(t, ok, in, out)
80 | })
81 | }
82 | }
83 |
84 | func TestParseLocationMissing(t *testing.T) {
85 | _, err := ParseLocation("does-not-exist")
86 | if err == nil {
87 | t.Errorf("no error returned for missing file")
88 | }
89 | }
90 |
91 | func TestParseLocationEmpty(t *testing.T) {
92 | _, err := ParseLocation("")
93 | if err == nil {
94 | t.Errorf("no error returned empty string location")
95 | }
96 | }
97 |
98 | func TestStripUpToMissing(t *testing.T) {
99 | s := "nocolonhere"
100 | r := stripUpTo(s, ":")
101 | if r != s {
102 | t.Errorf("expected %v got %v", s, r)
103 | }
104 | }
105 |
106 | // Parse a given file and get a Track object.
107 | func Example_parselocation() {
108 | track, _ := ParseLocation("sample-flight.igc")
109 |
110 | fmt.Printf("Track Pilot: %v", track.Pilot)
111 | fmt.Printf("Track Points %v", len(track.Pilot))
112 | }
113 |
114 | // Parse directly flight contents and get a Track object.
115 | func Example_parsecontent() {
116 | // We could pass here a string with the full contents in IGC format
117 | track, _ := Parse(`
118 | AFLA001Some Additional Data
119 | HFDTE010203
120 | HFFXA500
121 | HFPLTPilotincharge:EZ PILOT
122 | `)
123 |
124 | fmt.Printf("Track Pilot: %v", track.Pilot)
125 | fmt.Printf("Track Points %v", len(track.Pilot))
126 | }
127 |
128 | func BenchmarkParse(b *testing.B) {
129 | c, err := ioutil.ReadFile("../../testdata/parse/parse-0-benchmark-0.igc")
130 | if err != nil {
131 | b.Errorf("failed to load sample flight :: %v", err)
132 | }
133 | content := string(c)
134 | b.ResetTimer()
135 | for i := 0; i < b.N; i++ {
136 | _, _ = Parse(content)
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/pkg/igc/phase.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 | package igc
17 |
18 | import (
19 | "bytes"
20 | "encoding/csv"
21 | "encoding/json"
22 | "fmt"
23 | "image/color"
24 | "math"
25 | "time"
26 |
27 | "github.com/golang/geo/s2"
28 | kml "github.com/twpayne/go-kml"
29 | "gopkg.in/yaml.v3"
30 | )
31 |
32 | // PhaseType represents a flight phase.
33 | //
34 | // Possible values include Towing, PossibleCruising/Cruising,
35 | // PossibleCircling/Circling, Unknown.
36 | type PhaseType int
37 |
38 | const (
39 | Unknown PhaseType = 0
40 | Towing PhaseType = 1
41 | PossibleCruising PhaseType = 2
42 | Cruising PhaseType = 3
43 | PossibleCircling PhaseType = 4
44 | Circling PhaseType = 5
45 | )
46 |
47 | // CirclingType indicates Left, Right or Mixed circling.
48 | type CirclingType int
49 |
50 | const (
51 | Mixed CirclingType = 0
52 | Left CirclingType = 1
53 | Right CirclingType = 2
54 | )
55 |
56 | const (
57 | // MinTurnRate is the min rate to consider circling
58 | MinTurnRate = 6.5
59 | // MaxTurnRate is the max rate considered for valid turns
60 | MaxTurnRate = 22.5
61 | // MinCirclingTime is used to decide when a switch to circling occurs.
62 | // This value is used when calculating flight phases to switch from
63 | // PossibleCircling to Circling.
64 | MinCirclingTime = 15
65 | // MinCruisingTime is used to decide when a switch to cruising occurs.
66 | // This value is used when calculating flight phases to switch from
67 | // PossibleCruising to Cruising.
68 | MinCruisingTime = 10
69 | )
70 |
71 | // Phase is a flight phase (towing, cruising, circling).
72 | type Phase struct {
73 | Type PhaseType
74 | CirclingType CirclingType
75 | Start Point
76 | StartIndex int
77 | End Point
78 | EndIndex int
79 | AvgVario float64
80 | TopVario float64
81 | AvgGndSpeed float64
82 | TopGndSpeed float64
83 | Distance float64
84 | LD float64
85 | Centroid s2.LatLng
86 | CellID s2.CellID
87 | }
88 |
89 | // Phases returns the list of flight phases for the Track.
90 | // Each phases is one of Cruising, Circling, Towing or Unknown.
91 | func (track *Track) Phases() ([]Phase, error) {
92 |
93 | if len(track.phases) > 0 {
94 | return track.phases, nil
95 | }
96 |
97 | if len(track.Points) < 2 {
98 | return []Phase{}, fmt.Errorf("track has %v points, min 2 required",
99 | len(track.Points))
100 | }
101 |
102 | var currPhase PhaseType
103 | var startIndex int
104 | var currPoint Point
105 | var turning bool
106 | //var turnRate float64
107 |
108 | currPhase = Cruising
109 | track.phases = []Phase{
110 | Phase{Type: Cruising, StartIndex: 0, Start: track.Points[0]},
111 | }
112 |
113 | // we need the bearings for each point to calculate turn rates
114 | var d float64
115 | for i := 1; i < len(track.Points); i++ {
116 | track.Points[i-1].bearing = track.Points[i-1].Bearing(track.Points[i])
117 | d = track.Points[i-1].Distance(track.Points[i])
118 | track.Points[i].distance = track.Points[i-1].distance + d
119 | track.Points[i].speed = d / track.Points[i].Time.Sub(track.Points[i-1].Time).Seconds()
120 | }
121 |
122 | for i := 0; i < len(track.Points)-1; i++ {
123 | currPoint = track.Points[i]
124 | turning, _ = track.isTurning(i)
125 |
126 | if currPhase == Cruising {
127 | // if cruising check for turning
128 | if turning {
129 | // set possible circling if turning
130 | currPhase = PossibleCircling
131 | startIndex = i
132 | } // else continue
133 | } else if currPhase == PossibleCircling {
134 | // if possible circling check for turning longer than min circling time
135 | if turning {
136 | if currPoint.Time.Sub(track.Points[startIndex].Time).Seconds() > MinCirclingTime {
137 | // if true then set circling
138 | currPhase = Circling
139 | track.wrapPhase(startIndex, Circling)
140 | }
141 | } else {
142 | // if not go back to cruising
143 | currPhase = Cruising
144 | }
145 | } else if currPhase == Circling {
146 | // if circling check for stopping to turn
147 | if !turning {
148 | // if stopping set possible cruising
149 | currPhase = PossibleCruising
150 | startIndex = i
151 | }
152 | } else if currPhase == PossibleCruising {
153 | // if possible cruising check for longer than min cruising
154 | if !turning {
155 | if currPoint.Time.Sub(track.Points[startIndex].Time).Seconds() > MinCruisingTime {
156 | // if true then set cruising
157 | currPhase = Cruising
158 | track.wrapPhase(startIndex, Cruising)
159 | }
160 | } else {
161 | // if not go back to circling
162 | currPhase = Circling
163 | }
164 | }
165 | }
166 |
167 | return track.phases, nil
168 | }
169 |
170 | func (track *Track) wrapPhase(index int, phaseType PhaseType) {
171 | p := &track.phases[len(track.phases)-1]
172 |
173 | p.EndIndex = index
174 | p.End = track.Points[index]
175 |
176 | // compute phase stats
177 | altGain := float64(p.End.GNSSAltitude - p.Start.GNSSAltitude)
178 | p.Distance = p.End.distance - p.Start.distance
179 | duration := p.Duration().Seconds()
180 | if duration != 0 {
181 | p.AvgVario = altGain / duration
182 | p.AvgGndSpeed = p.Distance / (duration / 3600)
183 | }
184 |
185 | if p.Type == Cruising && altGain != 0 {
186 | p.LD = p.Distance * 1000.0 / math.Abs(altGain)
187 | }
188 | pts := make([]s2.LatLng, p.EndIndex-p.StartIndex)
189 | for i := p.StartIndex; i < p.EndIndex; i++ {
190 | pts[i-p.StartIndex] = track.Points[i].LatLng
191 | }
192 | centroid := s2.LatLngFromPoint(s2.PolylineFromLatLngs(pts).Centroid())
193 | p.CellID = s2.CellIDFromLatLng(centroid)
194 | p.CellID = p.CellID.Parent(14)
195 | p.Centroid = centroid
196 |
197 | track.phases = append(track.phases, Phase{Type: phaseType, StartIndex: index, Start: track.Points[index]})
198 | }
199 |
200 | func (track *Track) isTurning(i int) (bool, float64) {
201 | turnRate := (track.Points[i+1].bearing - track.Points[i].bearing).Abs().Degrees() / track.Points[i+1].Time.Sub(track.Points[i].Time).Seconds()
202 | return math.Abs(turnRate) > MinTurnRate, turnRate
203 | }
204 |
205 | // Duration returns the duration of this flight phase.
206 | func (p *Phase) Duration() time.Duration {
207 | return p.End.Time.Sub(p.Start.Time)
208 | }
209 |
210 | func (track *Track) EncodePhases(format string) ([]byte, error) {
211 |
212 | phases, err := track.Phases()
213 | if err != nil {
214 | return []byte{}, err
215 | }
216 |
217 | switch format {
218 | case "json":
219 | return json.MarshalIndent(phases, "", " ")
220 | case "kml", "kmz":
221 | buf := new(bytes.Buffer)
222 | k, err := track.encodePhasesKML()
223 | if err != nil {
224 | return buf.Bytes(), err
225 | }
226 | if err := k.WriteIndent(buf, "", " "); err != nil {
227 | return buf.Bytes(), err
228 | }
229 | return buf.Bytes(), nil
230 | case "yaml":
231 | return yaml.Marshal(phases)
232 | case "csv":
233 | return track.encodePhasesCSV()
234 | default:
235 | return []byte{}, fmt.Errorf("unsupported format '%v'", format)
236 | }
237 | }
238 |
239 | func (track *Track) encodePhasesCSV() ([]byte, error) {
240 |
241 | phases, err := track.Phases()
242 | if err != nil {
243 | return []byte{}, err
244 | }
245 | records := make([][]string, len(phases))
246 | /**records[0] = []string{
247 | "TrackID", "Type", "CirclingType", "StartTime", "StartAlt",
248 | "StartIndex", "EndTime", "EndAlt", "EndIndex", "Duration",
249 | "AvgVario", "TopVario", "AvgGndSpeed", "TopGndSpeed", "Distance",
250 | "LD", "CentroidLat", "CentroidLng", "CellID"}*/
251 | var p Phase
252 | for i := 0; i < len(phases); i++ {
253 | p = phases[i]
254 | records[i] = []string{
255 | fmt.Sprintf("%d", track.Date.Year()),
256 | track.ID,
257 | fmt.Sprintf("%d", p.Type),
258 | fmt.Sprintf("%d", p.CirclingType),
259 | fmt.Sprintf("%v", p.Start.Time.Format("15:04:05")),
260 | fmt.Sprintf("%d", p.Start.GNSSAltitude),
261 | fmt.Sprintf("%d", p.StartIndex),
262 | fmt.Sprintf("%v", p.End.Time.Format("15:04:05")),
263 | fmt.Sprintf("%d", p.End.GNSSAltitude),
264 | fmt.Sprintf("%d", p.EndIndex),
265 | fmt.Sprintf("%f", p.Duration().Seconds()),
266 | fmt.Sprintf("%f", p.AvgVario), fmt.Sprintf("%f", p.TopVario),
267 | fmt.Sprintf("%f", p.AvgGndSpeed),
268 | fmt.Sprintf("%f", p.TopGndSpeed),
269 | fmt.Sprintf("%f", p.Distance), fmt.Sprintf("%f", p.LD),
270 | fmt.Sprintf("%f", p.Centroid.Lat.Degrees()),
271 | fmt.Sprintf("%f", p.Centroid.Lng.Degrees()),
272 | fmt.Sprintf("%d", p.CellID)}
273 | }
274 |
275 | buf := new(bytes.Buffer)
276 | w := csv.NewWriter(buf)
277 | err = w.WriteAll(records)
278 | if err != nil {
279 | return buf.Bytes(), err
280 | }
281 | return buf.Bytes(), nil
282 | }
283 |
284 | func (track *Track) encodePhasesKML() (*kml.CompoundElement, error) {
285 |
286 | result := kml.Document()
287 | result.Add(
288 | kml.SharedStyle(
289 | "cruising",
290 | kml.LineStyle(
291 | kml.Color(color.RGBA{R: 0, G: 0, B: 255, A: 127}),
292 | kml.Width(4),
293 | ),
294 | ),
295 | kml.SharedStyle(
296 | "circling",
297 | kml.LineStyle(
298 | kml.Color(color.RGBA{R: 0, G: 255, B: 0, A: 127}),
299 | kml.Width(4),
300 | ),
301 | ),
302 | kml.SharedStyle(
303 | "attempt",
304 | kml.LineStyle(
305 | kml.Color(color.RGBA{R: 255, G: 0, B: 0, A: 127}),
306 | kml.Width(4),
307 | ),
308 | ),
309 | )
310 |
311 | phases, err := track.Phases()
312 | if err != nil {
313 | return result, err
314 | }
315 |
316 | for i := 0; i < len(phases)-2; i++ {
317 | phase := phases[i]
318 | coords := make([]kml.Coordinate, phase.EndIndex-phase.StartIndex+1)
319 | for i := phase.StartIndex; i <= phase.EndIndex; i++ {
320 | p := track.Points[i]
321 | coords[i-phase.StartIndex].Lat = p.Lat.Degrees()
322 | coords[i-phase.StartIndex].Lon = p.Lng.Degrees()
323 | coords[i-phase.StartIndex].Alt = float64(p.GNSSAltitude)
324 | }
325 | style := "#cruising"
326 | if phase.Type == Circling && phase.End.Time.Sub(phase.Start.Time).Seconds() < 45 {
327 | style = "#attempt"
328 | } else if phase.Type == Circling {
329 | style = "#circling"
330 | }
331 | result.Add(
332 | kml.Placemark(
333 | kml.StyleURL(style),
334 | kml.LineString(
335 | kml.Extrude(false),
336 | kml.Tessellate(false),
337 | kml.AltitudeMode("absolute"),
338 | kml.Coordinates(coords...),
339 | ),
340 | ))
341 |
342 | name := fmt.Sprintf("Lat: %v Lng: %v",
343 | phase.Centroid.Lat.Degrees(), phase.Centroid.Lng.Degrees())
344 | desc := fmt.Sprintf("Alt Gain: %dm (%dm %dm)
Distance: %.2fkm
Speed: %.2fkm/h
LD: %v
Vario: %.1fm/s
Cell: %v
",
345 | phase.End.GNSSAltitude-phase.Start.GNSSAltitude,
346 | phase.Start.GNSSAltitude, phase.End.GNSSAltitude, phase.Distance,
347 | phase.AvgGndSpeed, phase.LD, phase.AvgVario, phase.CellID)
348 | result.Add(
349 | kml.Placemark(
350 | kml.Name(name),
351 | kml.Description(desc),
352 | kml.Point(
353 | kml.Coordinates(kml.Coordinate{
354 | Lon: phase.Centroid.Lng.Degrees(), Lat: phase.Centroid.Lat.Degrees(),
355 | }),
356 | ),
357 | ))
358 | }
359 | return result, nil
360 | }
361 |
--------------------------------------------------------------------------------
/pkg/igc/phase_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "bytes"
21 | "encoding/json"
22 | "fmt"
23 | "io/ioutil"
24 | "path/filepath"
25 | "testing"
26 | )
27 |
28 | type phaseTest struct {
29 | name string
30 | result string //nolint
31 | }
32 |
33 | var phaseTests = []phaseTest{
34 | {
35 | name: "phases-short-flight-1",
36 | result: "phases-short-flight-1.simple",
37 | },
38 | {
39 | name: "phases-long-flight-1",
40 | result: "phases-long-flight-1.simple",
41 | },
42 | }
43 |
44 | func TestPhases(t *testing.T) {
45 | for _, test := range phaseTests {
46 | t.Run(fmt.Sprintf("%v\n", test.name), func(t *testing.T) {
47 | f := filepath.Join("../../testdata/phases", fmt.Sprintf("%v.igc", test.name))
48 | golden := fmt.Sprintf("../../testdata/phases/%v.golden.igc", test.name)
49 | track, err := ParseLocation(f)
50 | if err != nil {
51 | t.Fatal(err)
52 | }
53 | phases, err := track.Phases()
54 | if err != nil {
55 | t.Fatal(err)
56 | }
57 |
58 | // update golden if flag is passed
59 | if *update {
60 | jsn, err := json.Marshal(phases)
61 | if err != nil {
62 | t.Fatalf("%+v :: %v\n", phases, err)
63 | }
64 | if err = ioutil.WriteFile(golden, jsn, 0644); err != nil {
65 | t.Fatal(err)
66 | }
67 | }
68 |
69 | b, _ := ioutil.ReadFile(golden)
70 | var goldenPhases []Phase
71 | _ = json.Unmarshal(b, &goldenPhases)
72 | if len(phases) != len(goldenPhases) {
73 | t.Errorf("expected %v got %v phases", len(goldenPhases), len(phases))
74 | }
75 | })
76 | }
77 | }
78 |
79 | func TestPhasesZeroOnePoints(t *testing.T) {
80 | trk := NewTrack()
81 | _, err := trk.Phases()
82 | if err == nil {
83 | t.Fatal("should get an error for track with 0 points")
84 | }
85 |
86 | trk.Points = []Point{Point{}}
87 | _, err = trk.Phases()
88 | if err == nil {
89 | t.Fatal("should get an error for track with 1 point")
90 | }
91 | }
92 |
93 | func TestEncodePhasesKML(t *testing.T) {
94 | for _, test := range phaseTests {
95 | t.Run(fmt.Sprintf("%v\n", test.name), func(t *testing.T) {
96 | f := filepath.Join("../../testdata/phases", fmt.Sprintf("%v.igc", test.name))
97 | golden := fmt.Sprintf("%v.golden.kml", f)
98 | track, err := ParseLocation(f)
99 | if err != nil {
100 | t.Fatal(err)
101 | }
102 |
103 | kml, err := track.encodePhasesKML()
104 | if err != nil {
105 | t.Fatal(err)
106 | }
107 |
108 | buf := new(bytes.Buffer)
109 | err = kml.WriteIndent(buf, "", " ")
110 | if err != nil {
111 | t.Fatal(err)
112 | }
113 | // update golden if flag is passed
114 | if *update {
115 | if err = ioutil.WriteFile(golden, buf.Bytes(), 0644); err != nil {
116 | t.Fatal(err)
117 | }
118 | }
119 |
120 | b, _ := ioutil.ReadFile(golden)
121 | if string(b) != buf.String() {
122 | t.Errorf("expected\n%v\ngot\n%v\n", string(b), buf.String())
123 | }
124 | })
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/pkg/igc/point.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "math"
21 | "strconv"
22 | "time"
23 |
24 | "github.com/golang/geo/s1"
25 | "github.com/golang/geo/s2"
26 | )
27 |
28 | const (
29 | // EarthRadius is the average earth radius.
30 | EarthRadius = 6371.0
31 | )
32 |
33 | // Point represents a GPS recording (single point in the track).
34 | //
35 | // It is based on a golang-geo s2 LatLng, adding extra metadata such as
36 | // the Time the point was recorded, pressure and GNSS altitude, number of
37 | // satellites available and extra metadata added by the recorder.
38 | //
39 | // You can use all methods available for a s2.LatLng on this struct.
40 | type Point struct {
41 | s2.LatLng
42 | Time time.Time
43 | FixValidity byte
44 | PressureAltitude int64
45 | GNSSAltitude int64
46 | IData map[string]string
47 | NumSatellites int
48 | Description string
49 | bearing s1.Angle
50 | distance float64
51 | speed float64
52 | }
53 |
54 | // NewPoint returns a new Point set to latitude and longitude 0.
55 | func NewPoint() Point {
56 | return NewPointFromLatLng(0, 0)
57 | }
58 |
59 | // NewPointFromLatLng returns a new Point with the given latitude and longitude.
60 | func NewPointFromLatLng(lat float64, lng float64) Point {
61 | return Point{
62 | LatLng: s2.LatLngFromDegrees(lat, lng),
63 | IData: make(map[string]string),
64 | }
65 | }
66 |
67 | // NewPointFromDMS returns a Point corresponding to the given string in DMS format.
68 | //
69 | // DecimalFromDMS includes more information regarding this format.
70 | func NewPointFromDMS(lat string, lng string) Point {
71 | return NewPointFromLatLng(
72 | DecimalFromDMS(lat), DecimalFromDMS(lng),
73 | )
74 | }
75 |
76 | // DecimalFromDMS returns the decimal representation (in radians) of the given DMS.
77 | //
78 | // DMS is a representation of a coordinate in Decimal,Minutes,Seconds, with an
79 | // extra character indicating north, south, east, west.
80 | //
81 | // Examples: N512646, W0064312, S342244, E0021233
82 | func DecimalFromDMS(dms string) float64 {
83 | var degrees, minutes, seconds float64
84 | if len(dms) == 7 {
85 | degrees, _ = strconv.ParseFloat(dms[1:3], 64)
86 | minutes, _ = strconv.ParseFloat(dms[3:5], 64)
87 | seconds, _ = strconv.ParseFloat(dms[5:], 64)
88 | } else if len(dms) == 8 {
89 | degrees, _ = strconv.ParseFloat(dms[1:4], 64)
90 | minutes, _ = strconv.ParseFloat(dms[4:6], 64)
91 | seconds, _ = strconv.ParseFloat(dms[6:], 64)
92 | } else {
93 | return 0
94 | }
95 | var r float64
96 | r = degrees + (minutes / 60.0) + (seconds / 3600.0)
97 | if dms[0] == 'S' || dms[0] == 'W' {
98 | r = r * -1
99 | }
100 | return r
101 | }
102 |
103 | // NewPointFromDMD returns a Point corresponding to the given string in DMD format.
104 | //
105 | // DecimalFromDMD includes more information regarding this format.
106 | func NewPointFromDMD(lat string, lng string) Point {
107 | return NewPointFromLatLng(
108 | DecimalFromDMD(lat), DecimalFromDMD(lng),
109 | )
110 | }
111 |
112 | // DecimalFromDMD returns the decimal representation (in radians) of the given DMD.
113 | //
114 | // DMD is a representation of a coordinate in Decimal,Minutes,100thMinute with an
115 | // extra character indicating north, south, east, west.
116 | //
117 | // Examples: N512688, W0064364, S342212, E0021275
118 | func DecimalFromDMD(dmd string) float64 {
119 | if len(dmd) != 8 && len(dmd) != 9 {
120 | return 0
121 | }
122 |
123 | var degrees, minutes, dminutes float64
124 | if dmd[0] == 'S' || dmd[0] == 'N' {
125 | degrees, _ = strconv.ParseFloat(dmd[1:3], 64)
126 | minutes, _ = strconv.ParseFloat(dmd[3:5], 64)
127 | dminutes, _ = strconv.ParseFloat(dmd[5:], 64)
128 | } else if dmd[len(dmd)-1] == 'S' || dmd[len(dmd)-1] == 'N' {
129 | degrees, _ = strconv.ParseFloat(dmd[0:2], 64)
130 | minutes, _ = strconv.ParseFloat(dmd[2:4], 64)
131 | dminutes, _ = strconv.ParseFloat(dmd[4:7], 64)
132 | } else if dmd[0] == 'W' || dmd[0] == 'E' {
133 | degrees, _ = strconv.ParseFloat(dmd[1:4], 64)
134 | minutes, _ = strconv.ParseFloat(dmd[4:6], 64)
135 | dminutes, _ = strconv.ParseFloat(dmd[6:], 64)
136 | } else if dmd[len(dmd)-1] == 'W' || dmd[len(dmd)-1] == 'E' {
137 | degrees, _ = strconv.ParseFloat(dmd[0:3], 64)
138 | minutes, _ = strconv.ParseFloat(dmd[3:5], 64)
139 | dminutes, _ = strconv.ParseFloat(dmd[5:8], 64)
140 | }
141 | var r float64
142 | r = degrees + ((minutes + (dminutes / 1000.0)) / 60.0)
143 | if dmd[0] == 'S' || dmd[0] == 'W' || dmd[len(dmd)-1] == 'S' || dmd[len(dmd)-1] == 'W' {
144 | r = r * -1
145 | }
146 | return r
147 | }
148 |
149 | // Distance returns the great circle distance in kms to the given point.
150 | //
151 | // Internally it uses the golang-geo s2 LatLng.Distance() method, but converts
152 | // its result (an angle) to kms considering the constant EarthRadius.
153 | func (p *Point) Distance(b Point) float64 {
154 | return float64(p.LatLng.Distance(b.LatLng) * EarthRadius)
155 | }
156 |
157 | // Speed returns the distance/time to the given point in km/h.
158 | func (p *Point) Speed(b Point) float64 {
159 | h := b.Time.Sub(p.Time).Hours()
160 | if h == 0 {
161 | return 0
162 | }
163 | return p.Distance(b) / h
164 | }
165 |
166 | // Bearing returns the bearing to the given point in degrees.
167 | func (p *Point) Bearing(b Point) s1.Angle {
168 |
169 | lat1 := p.Lat.Radians()
170 | lng1 := p.Lng.Radians()
171 | lat2 := b.Lat.Radians()
172 | lng2 := b.Lng.Radians()
173 |
174 | // https://www.movable-type.co.uk/scripts/latlong.html
175 | // ATAN2(COS(lat1)*SIN(lat2)-SIN(lat1)*COS(lat2)*COS(lon2-lon1),
176 | // SIN(lon2-lon1)*COS(lat2))
177 | bearing := math.Atan2(
178 | math.Sin(lng2-lng1)*math.Cos(lat2),
179 | math.Cos(lat1)*math.Sin(lat2)-math.Sin(lat1)*math.Cos(lat2)*math.Cos(lng2-lng1))
180 |
181 | return s1.Angle(bearing)
182 | }
183 |
--------------------------------------------------------------------------------
/pkg/igc/point_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "testing"
21 | "time"
22 | )
23 |
24 | type point struct {
25 | lat string
26 | lng string
27 | t time.Time
28 | }
29 |
30 | type PointTest struct {
31 | p1 point
32 | p2 point
33 | d float64
34 | b float64
35 | s float64
36 | }
37 |
38 | var dmdTests = []PointTest{
39 | {
40 | p1: point{lat: "", lng: ""},
41 | p2: point{lat: "", lng: ""},
42 | d: 0,
43 | b: 0,
44 | },
45 | {
46 | p1: point{lat: "5110179N", lng: "00102644W"},
47 | p2: point{lat: "5110179N", lng: "00102644W"},
48 | d: 0,
49 | b: 0,
50 | },
51 | }
52 |
53 | var tests = []PointTest{
54 | {
55 | p1: point{lat: "", lng: ""},
56 | p2: point{lat: "", lng: ""},
57 | d: 0,
58 | b: 0,
59 | },
60 | {
61 | p1: point{lat: "N500359", lng: "W0054253", t: getTime("10:00:00")},
62 | p2: point{lat: "N500359", lng: "W0054253", t: getTime("10:00:00")},
63 | d: 0,
64 | b: 0,
65 | s: 0,
66 | },
67 | {
68 | p1: point{lat: "N500359", lng: "W0054253", t: getTime("10:00:00")},
69 | p2: point{lat: "N583838", lng: "W0030412", t: getTime("10:30:00")},
70 | d: 968.8535467131387,
71 | b: 9.119818104504075,
72 | s: 968.8535467131387 / 0.5,
73 | },
74 | {
75 | p1: point{lat: "S270201", lng: "E0303722", t: getTime("12:00:00")},
76 | p2: point{lat: "N523838", lng: "W0030412", t: getTime("13:30:00")},
77 | d: 9443.596093743798,
78 | b: -19.75024484768977,
79 | s: 9443.596093743798 / 1.5,
80 | },
81 | }
82 |
83 | func TestPointDistanceDMD(t *testing.T) {
84 | for _, test := range dmdTests {
85 | p1 := NewPointFromDMD(test.p1.lat, test.p1.lng)
86 | p2 := NewPointFromDMD(test.p2.lat, test.p2.lng)
87 | result := p1.Distance(p2)
88 | if result != test.d {
89 | t.Errorf("p1: %v p2: %v :: expected distance %v got %+v", test.p1, test.p2, test.d, result)
90 | continue
91 | }
92 | }
93 | }
94 |
95 | func TestPointSpeed(t *testing.T) {
96 | for _, test := range tests {
97 | p1 := NewPointFromDMS(test.p1.lat, test.p1.lng)
98 | p1.Time = test.p1.t
99 | p2 := NewPointFromDMS(test.p2.lat, test.p2.lng)
100 | p2.Time = test.p2.t
101 | result := p1.Speed(p2)
102 | if result != test.s {
103 | t.Errorf("p1: %v p2: %v :: expected speed %v got %+v", test.p1, test.p2, test.s, result)
104 | continue
105 | }
106 | }
107 | }
108 |
109 | func TestPointDistance(t *testing.T) {
110 | for _, test := range tests {
111 | p1 := NewPointFromDMS(test.p1.lat, test.p1.lng)
112 | p2 := NewPointFromDMS(test.p2.lat, test.p2.lng)
113 | result := p1.Distance(p2)
114 | if result != test.d {
115 | t.Errorf("p1: %v p2: %v :: expected distance %v got %+v", test.p1, test.p2, test.d, result)
116 | continue
117 | }
118 | }
119 | }
120 |
121 | func TestPointBearing(t *testing.T) {
122 | for _, test := range tests {
123 | p1 := NewPointFromDMS(test.p1.lat, test.p1.lng)
124 | p2 := NewPointFromDMS(test.p2.lat, test.p2.lng)
125 | result := p1.Bearing(p2).Degrees()
126 | if result != test.b {
127 | t.Errorf("p1: %v p2: %v :: expected bearing %v got %+v", test.p1, test.p2, test.b, result)
128 | continue
129 | }
130 | }
131 | }
132 |
133 | func getTime(v string) time.Time {
134 | t, _ := time.Parse("15:04:05", v)
135 | return t
136 | }
137 |
--------------------------------------------------------------------------------
/pkg/igc/track.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "archive/zip"
21 | "bytes"
22 | "encoding/csv"
23 | "encoding/json"
24 | "fmt"
25 | "time"
26 |
27 | "github.com/golang/geo/s1"
28 | "github.com/golang/geo/s2"
29 | kml "github.com/twpayne/go-kml"
30 | "gopkg.in/yaml.v3"
31 | )
32 |
33 | // MaxSpeed is the maximum theoretical speed for a glider.
34 | //
35 | // It is used to detect bad GPS coordinates, which should be removed from the track.
36 | const MaxSpeed float64 = 500.0
37 |
38 | // Track holds all IGC flight data (header and GPS points).
39 | type Track struct {
40 | Header
41 | ID string
42 | Points []Point
43 | K []K
44 | Events []Event
45 | Satellites []Satellite
46 | Logbook []string
47 | Task Task
48 | DGPSStationID string
49 | Signature string
50 | phases []Phase
51 | }
52 |
53 | // NewTrack returns a new instance of Track, with fields initialized to zero.
54 | func NewTrack() Track {
55 | track := Track{}
56 | return track
57 | }
58 |
59 | // Header holds the meta information of a track.
60 | //
61 | // This is the H record in the IGC specification, section A3.2.
62 | type Header struct {
63 | Manufacturer string
64 | UniqueID string
65 | AdditionalData string
66 | Date time.Time
67 | Site string
68 | FixAccuracy int64
69 | Pilot string
70 | PilotBirth time.Time
71 | Crew string
72 | GliderType string
73 | GliderID string
74 | Observation string
75 | GPSDatum string
76 | FirmwareVersion string
77 | HardwareVersion string
78 | SoftwareVersion string // for non-igc flight recorders
79 | Specification string
80 | FlightRecorder string
81 | GPS string
82 | GNSSModel string
83 | PressureModel string
84 | PressureSensor string
85 | AltimeterPressure float64
86 | CompetitionID string
87 | CompetitionClass string
88 | Timezone int
89 | MOPSensor string
90 | }
91 |
92 | // K holds flight data needed less often than Points.
93 | //
94 | // This is the K record in the IGC specification, section A4.4. Fields
95 | // is a map between a given content type and its value, with the possible
96 | // content types being defined in the J record.
97 | //
98 | // Examples of content types include heading true (HDT) or magnetic (HDM),
99 | // airspeed (IAS), etc.
100 | type K struct {
101 | Time time.Time
102 | Fields map[string]string
103 | }
104 |
105 | // Satellite holds the IDs of the available satellites at a given Time.
106 | //
107 | // This is the F record in the IGC specification, section A4.3.
108 | type Satellite struct {
109 | Time time.Time
110 | Ids []string
111 | }
112 |
113 | // Event holds data records triggered at a given time.
114 | //
115 | // This is the E record in the IGC specification, section A4.2. The events
116 | // can be pilot initiated (with a PEV code), proximity alerts, etc.
117 | type Event struct {
118 | Time time.Time
119 | Type string
120 | Data string
121 | }
122 |
123 | // Task holds all the metadata put in a pre-declared task to be performed.
124 | //
125 | // This is the C record in the IGC specification, section A3.5.
126 | type Task struct {
127 | DeclarationDate time.Time
128 | Date time.Time
129 | Number int
130 | Takeoff Point
131 | Start Point
132 | Turnpoints []Point
133 | Finish Point
134 | Landing Point
135 | Description string
136 | }
137 |
138 | // Distance returns the total distance in kms between the turn points.
139 | //
140 | // It includes the Start and Finish fields as the first and last point,
141 | // respectively, with the Turnpoints in the middle. The return value is
142 | // sum of all distances between each consecutive point.
143 | func (task *Task) Distance() float64 {
144 | d := 0.0
145 | p := []Point{task.Start}
146 | p = append(p, task.Turnpoints...)
147 | p = append(p, task.Finish)
148 | for i := 0; i < len(p)-1; i++ {
149 | d += p[i].Distance(p[i+1])
150 | }
151 | return d
152 | }
153 |
154 | // Manufacturer holds manufacturer name, short ID and char identifier.
155 | //
156 | // The list of manufacturers is defined in the IGC specification,
157 | // section A2.5.6. A map Manufacturers is available in this library.
158 | type Manufacturer struct {
159 | char byte //nolint
160 | short string //nolint
161 | name string //nolint
162 | }
163 |
164 | // Manufacturers holds the list of available manufacturers.
165 | //
166 | // This list is defined in the IGC specification, section A2.5.6.
167 | var Manufacturers = map[string]Manufacturer{
168 | "GCS": {'A', "GCS", "Garrecht"},
169 | "LGS": {'B', "LGS", "Logstream"},
170 | "CAM": {'C', "CAM", "Cambridge Aero Instruments"},
171 | "DSX": {'D', "DSX", "Data Swan/DSX"},
172 | "EWA": {'E', "EWA", "EW Avionics"},
173 | "FIL": {'F', "FIL", "Filser"},
174 | "FLA": {'G', "FLA", "Flarm (Track Alarm)"},
175 | "SCH": {'H', "SCH", "Scheffel"},
176 | "ACT": {'I', "ACT", "Aircotec"},
177 | "CNI": {'K', "CNI", "ClearNav Instruments"},
178 | "NKL": {'K', "NKL", "NKL"},
179 | "LXN": {'L', "LXN", "LX Navigation"},
180 | "IMI": {'M', "IMI", "IMI Gliding Equipment"},
181 | "NTE": {'N', "NTE", "New Technologies s.r.l."},
182 | "NAV": {'O', "NAV", "Naviter"},
183 | "PES": {'P', "PES", "Peschges"},
184 | "PRT": {'R', "PRT", "Print Technik"},
185 | "SDI": {'S', "SDI", "Streamline Data Instruments"},
186 | "TRI": {'T', "TRI", "Triadis Engineering GmbH"},
187 | "LXV": {'V', "LXV", "LXNAV d.o.o."},
188 | "WES": {'W', "WES", "Westerboer"},
189 | "XCS": {' ', "XCS", "XCSoar"},
190 | "XYY": {'X', "XYY", "Other manufacturer"},
191 | "ZAN": {'Z', "ZAN", "Zander"},
192 | }
193 |
194 | func (track *Track) Cleanup() (Track, error) {
195 | clean := *track
196 |
197 | i := 1
198 | for i < len(clean.Points) {
199 | if clean.Points[i-1].Speed(clean.Points[i]) > MaxSpeed {
200 | clean.Points = append(clean.Points[:i], clean.Points[i+1:]...)
201 | }
202 | i = i + 1
203 | }
204 | return clean, nil
205 | }
206 |
207 | func (track *Track) Simplify(tolerance float64) (Track, error) {
208 | r := polylineFromPoints(track.Points).SubsampleVertices(s1.Angle(tolerance))
209 | points := make([]Point, len(r))
210 | for i, v := range r {
211 | points[i] = track.Points[v]
212 | }
213 |
214 | simplified := *track
215 | simplified.Points = points
216 | return simplified, nil
217 | }
218 |
219 | func (track *Track) Encode(format string) ([]byte, error) {
220 | switch format {
221 | case "json":
222 | return json.MarshalIndent(track, "", " ")
223 | case "kml", "kmz":
224 | return encodeKML(track, format)
225 | case "yaml":
226 | return yaml.Marshal(track)
227 | case "csv":
228 | return track.encodeCSV()
229 | default:
230 | return []byte{}, fmt.Errorf("unsupported format '%v'", format)
231 | }
232 | }
233 |
234 | func (track *Track) encodeCSV() ([]byte, error) {
235 |
236 | values := []string{
237 | fmt.Sprintf("%d", track.Date.Year()), track.ID,
238 | track.Manufacturer, track.UniqueID, track.AdditionalData,
239 | track.Date.Format("020106"), track.Site, fmt.Sprintf("%d", track.FixAccuracy), track.Pilot,
240 | track.PilotBirth.Format("020106"), track.Crew, track.GliderType, track.GliderID,
241 | track.Observation, track.GPSDatum, track.FirmwareVersion,
242 | track.HardwareVersion, track.SoftwareVersion, track.Specification,
243 | track.FlightRecorder, track.GPS, track.GNSSModel, track.PressureModel,
244 | track.PressureSensor, fmt.Sprintf("%f", track.AltimeterPressure),
245 | track.CompetitionID, track.CompetitionClass, fmt.Sprintf("%d", track.Timezone),
246 | track.MOPSensor}
247 |
248 | buff := new(bytes.Buffer)
249 | w := csv.NewWriter(buff)
250 | err := w.Write(values)
251 | w.Flush()
252 | if err != nil {
253 | return buff.Bytes(), err
254 | }
255 | return buff.Bytes(), nil
256 | }
257 |
258 | func encodeKML(track *Track, format string) ([]byte, error) {
259 |
260 | metadata := fmt.Sprintf("%v : %v : %v", track.Date, track.Pilot, track.GliderType)
261 |
262 | phasesKML, err := track.encodePhasesKML()
263 | if err != nil {
264 | return []byte{}, err
265 | }
266 |
267 | k := kml.Document(
268 | kml.Name(metadata),
269 | kml.Description(""),
270 | phasesKML,
271 | )
272 |
273 | buf := new(bytes.Buffer)
274 | if err := k.WriteIndent(buf, "", " "); err != nil {
275 | return buf.Bytes(), err
276 | }
277 |
278 | if format == "kmz" {
279 | zipbuf := new(bytes.Buffer)
280 |
281 | w := zip.NewWriter(zipbuf)
282 | f, err := w.Create("flight.kml")
283 | if err != nil {
284 | return []byte{}, err
285 | }
286 | _, err = f.Write(buf.Bytes())
287 | if err != nil {
288 | return []byte{}, err
289 | }
290 | err = w.Close()
291 | if err != nil {
292 | return []byte{}, err
293 | }
294 |
295 | return zipbuf.Bytes(), nil
296 | }
297 | return buf.Bytes(), nil
298 | }
299 |
300 | func polylineFromPoints(points []Point) *s2.Polyline {
301 | p := make(s2.Polyline, len(points))
302 | for k, v := range points {
303 | p[k] = s2.PointFromLatLng(v.LatLng)
304 | }
305 | return &p
306 | }
307 |
--------------------------------------------------------------------------------
/pkg/igc/track_test.go:
--------------------------------------------------------------------------------
1 | // Copyright The ezgliding Authors.
2 | //
3 | //
4 | // Licensed under the Apache License, Version 2.0 (the "License");
5 | // you may not use this file except in compliance with the License.
6 | // You may obtain a copy of the License at
7 | //
8 | // http://www.apache.org/licenses/LICENSE-2.0
9 | //
10 | // Unless required by applicable law or agreed to in writing, software
11 | // distributed under the License is distributed on an "AS IS" BASIS,
12 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | // See the License for the specific language governing permissions and
14 | // limitations under the License.
15 | //
16 |
17 | package igc
18 |
19 | import (
20 | "encoding/json"
21 | "fmt"
22 | "io/ioutil"
23 | "os"
24 | "path/filepath"
25 | "testing"
26 |
27 | "github.com/golang/geo/s2"
28 | )
29 |
30 | const (
31 | testDir = "/tmp/ezgliding/tests"
32 | )
33 |
34 | type simplifyTest struct {
35 | name string
36 | result string //nolint
37 | }
38 |
39 | var simplifyTests = []simplifyTest{
40 | {
41 | name: "simplify-short-flight-1",
42 | result: "simplify-short-flight-1.simple",
43 | },
44 | }
45 |
46 | func TestSimplify(t *testing.T) {
47 | for _, test := range simplifyTests {
48 | t.Run(fmt.Sprintf("%v\n", test.name), func(t *testing.T) {
49 | f := filepath.Join("../../testdata/simplify", fmt.Sprintf("%v.igc", test.name))
50 | golden := fmt.Sprintf("%v.golden", f)
51 | track, err := ParseLocation(f)
52 | if err != nil {
53 | t.Fatal(err)
54 | }
55 | simple, err := track.Simplify(0.0001)
56 | if err != nil {
57 | t.Fatal(err)
58 | }
59 |
60 | // update golden if flag is passed
61 | if *update {
62 | jsn, _ := json.Marshal(simple)
63 | if err = ioutil.WriteFile(golden, jsn, 0644); err != nil {
64 | t.Fatal(err)
65 | }
66 | }
67 |
68 | b, _ := ioutil.ReadFile(golden)
69 | var goldenTrack Track
70 | _ = json.Unmarshal(b, &goldenTrack)
71 | if len(simple.Points) != len(goldenTrack.Points) {
72 | t.Errorf("expected %v got %v simple points", len(goldenTrack.Points), len(simple.Points))
73 | }
74 | })
75 | }
76 | }
77 |
78 | func TestSimplifyStats(t *testing.T) {
79 |
80 | simplifyDB := "testdata/simplify/db"
81 | csvfile, _ := os.Create(fmt.Sprintf("%v/simplify-stats.csv", testDir))
82 |
83 | files, _ := ioutil.ReadDir(simplifyDB)
84 | fmt.Fprintf(csvfile, "id,total,clean,clean%%,simple001,simple001%%,simple0001,simple0001%%\n")
85 | for _, f := range files {
86 | track, _ := ParseLocation(fmt.Sprintf("%v/%v", simplifyDB, f.Name()))
87 | clean, err := track.Cleanup()
88 | if err != nil {
89 | t.Fatal(err)
90 | }
91 | if len(track.Points) == 0 {
92 | //t.Fatalf("track has no points :: %v", track)
93 | } else {
94 | // simplify using two different precisions
95 | simple001, err := clean.Simplify(0.001)
96 | if err != nil {
97 | t.Fatal(err)
98 | }
99 | simple0001, err := clean.Simplify(0.0001)
100 | if err != nil {
101 | t.Fatal(err)
102 | }
103 | // convert the whole lot to json for js usage
104 | jsn, _ := toLatLng(track)
105 | jsnClean, _ := toLatLng(clean)
106 | jsnSimple001, _ := toLatLng(simple001)
107 | jsnSimple0001, _ := toLatLng(simple0001)
108 | // generate the html/js content for visualization
109 | df := fmt.Sprintf("%v/js/%v.js", testDir, f.Name())
110 | d, _ := os.Create(df)
111 | _, _ = d.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", append([]byte("path ="), jsn...), append([]byte("clean ="), jsnClean...), append([]byte("simple001 ="), jsnSimple001...), append([]byte("simple0001 ="), jsnSimple0001...)))
112 | d.Close()
113 | html := fmt.Sprintf(template, f.Name(), f.Name(), f.Name())
114 | _ = ioutil.WriteFile(fmt.Sprintf("%v/%v.html", testDir, f.Name()), []byte(html), 0644)
115 | ptsClean := float64(len(clean.Points))
116 | // optimize for both simplified tracks
117 | opt := NewBruteForceOptimizer(false)
118 | //task001, err := opt.Optimize(simple001, 3, Distance)
119 | task001 := Task{}
120 | task0001, err := opt.Optimize(simple0001, 2, Distance)
121 | if err != nil {
122 | t.Fatal(err)
123 | }
124 | // fill in the csv line for this flight with simplify stats
125 | fmt.Fprintf(csvfile, "%v,%v,%v,%.1f,%v,%.1f,%v,%v,%.1f,%.1f\n",
126 | f.Name(),
127 | len(track.Points),
128 | len(clean.Points),
129 | float64(len(clean.Points))/ptsClean*100.0,
130 | len(simple001.Points),
131 | float64(len(simple001.Points))/ptsClean*100.0,
132 | task001.Distance(),
133 | len(simple0001.Points),
134 | float64(len(simple0001.Points))/ptsClean*100.0,
135 | task0001.Distance())
136 | }
137 | }
138 | csvfile.Close()
139 | }
140 |
141 | func toLatLng(track Track) ([]byte, error) {
142 |
143 | points := make([]s2.LatLng, len(track.Points))
144 | for i, v := range track.Points {
145 | points[i] = v.LatLng
146 | }
147 | return json.Marshal(points)
148 | }
149 |
150 | var template = `
151 |
152 |
153 |