├── .github
├── FUNDING.yml
└── workflows
│ ├── ci.yaml
│ └── docs.yaml
├── .gitignore
├── .vscode
└── settings.json
├── AUTHORS
├── CONTRIBUTING
├── LICENSE
├── README.md
├── docs
├── Makefile
├── make.bat
└── source
│ ├── conf.py
│ ├── generated
│ ├── weatherrouting.grib.rst
│ ├── weatherrouting.polar.rst
│ ├── weatherrouting.routers.linearbestisorouter.rst
│ ├── weatherrouting.routers.router.rst
│ ├── weatherrouting.routers.rst
│ ├── weatherrouting.routers.shortestpathrouter.rst
│ ├── weatherrouting.routing.rst
│ ├── weatherrouting.rst
│ └── weatherrouting.utils.rst
│ └── index.rst
├── requirements.txt
├── scripts
└── replace_latlon_tests.py
├── setup.cfg
├── setup.py
├── tests
├── __init__.py
├── data
│ └── bavaria38.pol
├── linearbestisorouter_test.py
├── main.py.ex
├── mock_grib.py
├── mock_point_validity.py
├── polar_test.py
├── shortestpathrouter_test.py
├── tests_requirements.txt
└── utils_test.py
├── tox.ini
└── weatherrouting
├── __init__.py
├── grib.py
├── polar.py
├── routers
├── __init__.py
├── linearbestisorouter.py
├── router.py
└── shortestpathrouter.py
├── routing.py
└── utils.py
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: dakk # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | unit-tests:
7 | runs-on: ubuntu-latest
8 |
9 | strategy:
10 | matrix:
11 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
12 |
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v2
16 |
17 | - name: Set up Python ${{ matrix.python-version }}
18 | uses: actions/setup-python@v2
19 | with:
20 | python-version: ${{ matrix.python-version }}
21 |
22 | - name: Install dependencies
23 | run: pip install tox
24 |
25 | - name: Run tests
26 | run: tox -e unit-tests
27 |
28 | linters:
29 | runs-on: ubuntu-latest
30 | container:
31 | image: cimg/python:3.8
32 |
33 | steps:
34 | - name: Checkout
35 | uses: actions/checkout@v2
36 |
37 | - name: Set up Python 3.8
38 | uses: actions/setup-python@v2
39 | with:
40 | python-version: 3.8
41 |
42 | - name: Install dependencies
43 | run: pip install tox
44 |
45 | - name: Run linters
46 | run: tox -e linters
47 |
48 | # coverage:
49 | # runs-on: ubuntu-latest
50 | # container:
51 | # image: cimg/python:3.8
52 |
53 | # steps:
54 | # - name: Checkout
55 | # uses: actions/checkout@v2
56 |
57 | # - name: Set up Python 3.8
58 | # uses: actions/setup-python@v2
59 | # with:
60 | # python-version: 3.8
61 |
62 | # - name: Install dependencies
63 | # run: pip install tox
64 |
65 | # - name: Run coverage
66 | # run: tox -e coverage
67 |
68 | typecheck:
69 | runs-on: ubuntu-latest
70 | container:
71 | image: cimg/python:3.8
72 |
73 | steps:
74 | - name: Checkout
75 | uses: actions/checkout@v2
76 |
77 | - name: Set up Python 3.8
78 | uses: actions/setup-python@v2
79 | with:
80 | python-version: 3.8
81 |
82 | - name: Install dependencies
83 | run: pip install tox
84 |
85 | - name: Run typecheck
86 | run: tox -e typecheck
--------------------------------------------------------------------------------
/.github/workflows/docs.yaml:
--------------------------------------------------------------------------------
1 | name: docs
2 |
3 | on: [push, pull_request, workflow_dispatch]
4 |
5 | permissions:
6 | contents: write
7 |
8 | jobs:
9 | docs:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: actions/setup-python@v3
14 | with:
15 | python-version: '3.10'
16 | - name: Install dependencies
17 | run: |
18 | pip install sphinx sphinx_rtd_theme sphinx_rtd_dark_mode myst_nb
19 | pip install latlon3
20 | python setup.py install
21 | - name: Sphinx build
22 | run: |
23 | sphinx-build docs/source _build
24 | - name: Deploy to GitHub Pages
25 | uses: peaceiris/actions-gh-pages@v3
26 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
27 | with:
28 | publish_branch: gh-pages
29 | github_token: ${{ secrets.GITHUB_TOKEN }}
30 | publish_dir: _build/
31 | force_orphan: true
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | dist
3 | *.egg-info
4 | *.pyc
5 | .tox
6 | coverage.xml
7 | .coverage*
8 | *_cache
9 | venv/
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.testing.pytestArgs": [
3 | "tests"
4 | ],
5 | "python.testing.unittestEnabled": false,
6 | "python.testing.pytestEnabled": true
7 | }
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Davide Gessa
2 | Riccardo Apolloni
3 | Enrico Ferreguti
4 | Paolo Cavallini
--------------------------------------------------------------------------------
/CONTRIBUTING:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Prepare the env
4 |
5 | ```
6 | pyenv virtualenv weatherrouting-env
7 | pip install tox
8 | ```
9 |
10 |
11 | ## Pre-commit checks
12 |
13 | ```
14 | tox
15 | ```
16 |
17 |
18 | ## Make docs
19 |
20 | ```
21 | pip install sphinx sphinx_rtd_theme sphinx_rtd_dark_mode myst_nb
22 | cd docs
23 | make html
24 | ```
25 |
26 |
27 | ## Publish
28 |
29 | ```
30 | rm dist/*
31 | python setup.py sdist bdist_wheel
32 | python -m twine upload dist/*
33 | ```
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # libweatherrouting
2 |
3 | 
4 | [](https://www.gnu.org/licenses/gpl-3.0)
5 | 
6 |
7 | A 100% python weather routing library for sailing.
8 |
9 | ## Reference
10 |
11 | An introductory explanation (english, french, spanish and italian translations) of weather routing tools and methods can be find in: https://globalsolochallenge.com/weather-routing/
12 |
13 | ## Install
14 |
15 | `pip install weatherrouting`
16 |
17 | ## Usage
18 |
19 | For a comprehensive usage reference example pleace refer to [wind_forecast_routing QGIS plugin](https://github.com/enricofer/wind_forecast_routing/blob/master/wind_forecast_routing_algorithm.py) or [GWeatherRouting standalone application](https://github.com/dakk/gweatherrouting)
20 |
21 | Almost one external function has to be implemented as a preliminary requirement for library usage:
22 |
23 | ### Wind direction and speed for a given location at specified time
24 | A function that accept a datetime item, a float latitude and float longitude as parameters,
25 | performs a wind forecast analysis for the specified time and location (usually sampling a grib file)
26 | and returns a tuple with true wind direction (`twd`) expressed in degrees and true wind speed (`tws`) expressed in meters per second or `None` if running out of temporal/geographic grib scope.
27 |
28 | ```python
29 | def get_wind_at(t, lat, lon)
30 | # wind forecast analysys implementation
31 | # speed is in m/s, direction in degrees
32 | ...
33 | return (twd, tws)
34 | ```
35 |
36 | ### Point validity (Optional)
37 | A function that accept a float latitude and float longitude as parameters,
38 | performs a test to check if the specified location is eligible as waypoint (i.e. lay or not on sea)
39 | and returns a boolean (True if valid, False if invalid)
40 |
41 | ```python
42 | def point_validity(lat, lon)
43 | #
44 | ...
45 | return True/False
46 | ```
47 |
48 | ### Line validity (Optional)
49 | A function that accept a vector defined as four float parameters (latitude1, longitude1, latitude2, longitude2)
50 | performs a test to check whether the specified line between two waypoints is valid (i.e. lays completely or not on sea, or in other words is in line of sight)
51 | and returns a boolean (True if valid, False if invalid)
52 |
53 | ```python
54 | def line_validity(lat1, lon1, lat1, lon1)
55 | #
56 | ...
57 | return True/False
58 | ```
59 |
60 | ### Import weatherrouting module
61 |
62 | ```python
63 | from weatherrouting import Routing, Polar
64 | from weatherrouting.routers.linearbestisorouter import LinearBestIsoRouter
65 | from datetime import datetime
66 | ```
67 |
68 | ### Define a track points list
69 | Define a list of trackpoints as lat,long tuples (almost 2) that have to be reached by the route
70 |
71 | ```python
72 | track = ((38.1, 5.1), (38.4, 5.2), (38.2, 5.7))
73 | ```
74 |
75 | ### Define a polar wrapper
76 | Define the polar object from a [polar file]( https://www.seapilot.com/features/polars/ ) describing the performance of the boat at different wind speeds (`tws`) and different angles (`twd`)
77 |
78 | ```python
79 | polar_obj = Polar("polar_files/bavaria38.pol")
80 | ```
81 |
82 | ### Define the start datetime
83 | Define the polar object from a [polar file]( https://www.seapilot.com/features/polars/ ) describing the performance of the boat at different wind speeds (`tws`) and different angles (`twd~)
84 |
85 | ```python
86 | start = datetime.fromisoformat('2021-04-02T12:00:00')
87 | ```
88 |
89 | ### Define routing object
90 |
91 | ```python
92 | routing_obj = Routing(
93 | LinearBestIsoRouter, # specify a router type
94 | polar_obj, # the polar object for a specific sail boat
95 | track, # the list of track points (lat,lon)
96 | get_wind_at, # the function that returns (twd,tws) for a specified (datetime, lat, lon)
97 | start, # the start datetime
98 | start_position = (37.8, 4.8) # the start location (lat lon, optional, the first track point if undefined)
99 | point_validity = point_validity # the point validity function (optional)
100 | line_validity = line_validity # the line validity function (optional)
101 | )
102 | ```
103 |
104 | ### Perform route calculation
105 | Calculate subsequent steps until the end track point is reached
106 |
107 | ```python
108 | while not self.routing_obj.end:
109 | res = self.routing_obj.step() # default step duration is set to 1 hour
110 |
111 | # you can call a step with custom timedelta (in hour) at anytime
112 | while not self.routing_obj.end:
113 | res = self.routing_obj.step(timedelta=0.25) # 15min time delta
114 | ```
115 | the step method returns a RoutingResult object with the following informations during routing calculation:
116 | ```python
117 | res.time # the datetime of step
118 | res.isochrones # all points reached at a specified datetime
119 | res.progress # the calculation progress
120 | ```
121 | and after the end of the routing calculation contains a list of tuple containing the waypoints informations (lat,lon,datetime, twd, tws, speed, heading)
122 | ```python
123 | res.path # the list of route waypoints
124 | ```
125 |
126 | ### Export path as geojson
127 | The path could be exported as a geojson object for cartographic representation
128 | ```python
129 | from weatherrouting.utils import path_as_geojson
130 | import json
131 |
132 | json.dumps(path_as_geojson(res.path))
133 | ```
134 |
135 |
136 |
137 | ## License
138 |
139 | Read the LICENSE file.
140 |
141 | ## Credits
142 |
143 | This work is partially based and inspired by Riccardo Apolloni
144 | [Virtual Sailing Simulator](https://web.archive.org/web/20180324153950/https://riccardoapolloni.altervista.org/).
145 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = source
9 | BUILDDIR = build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=source
11 | set BUILDDIR=build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/docs/source/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # For the full list of built-in configuration values, see the documentation:
4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
5 | import os
6 | import sys
7 |
8 | sys.path.append(os.path.abspath(os.path.join("..", "..")))
9 |
10 | # -- Project information -----------------------------------------------------
11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
12 | project = "weatherrouting"
13 | copyright = "2017-2025, Davide Gessa (dakk)"
14 | author = "Davide Gessa (dakk)"
15 |
16 | # -- General configuration ---------------------------------------------------
17 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
18 |
19 | extensions = [
20 | "sphinx.ext.autodoc",
21 | "sphinx.ext.autosummary",
22 | "sphinx.ext.coverage",
23 | "sphinx.ext.napoleon",
24 | "sphinx_rtd_dark_mode",
25 | "sphinx_rtd_theme",
26 | "myst_nb",
27 | ]
28 |
29 | templates_path = ["_templates"]
30 | exclude_patterns = []
31 | autodoc_source_dir = [
32 | "../weatherrouting",
33 | ]
34 | pygments_style = "lightbulb"
35 | default_dark_mode = False
36 |
37 | # -- Options for HTML output -------------------------------------------------
38 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
39 |
40 | html_theme = "sphinx_rtd_theme"
41 | html_static_path = ["_static"]
42 |
43 |
44 | # autosummary_imported_members = True
45 | autosummary_generate = True
46 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.grib.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.grib
2 | ===================
3 |
4 | .. automodule:: weatherrouting.grib
5 |
6 |
7 | .. rubric:: Classes
8 |
9 | .. autosummary::
10 |
11 | Grib
12 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.polar.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.polar
2 | ====================
3 |
4 | .. automodule:: weatherrouting.polar
5 |
6 |
7 | .. rubric:: Classes
8 |
9 | .. autosummary::
10 |
11 | Polar
12 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.routers.linearbestisorouter.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.routers.linearbestisorouter
2 | ==========================================
3 |
4 | .. automodule:: weatherrouting.routers.linearbestisorouter
5 |
6 |
7 | .. rubric:: Classes
8 |
9 | .. autosummary::
10 |
11 | LinearBestIsoRouter
12 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.routers.router.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.routers.router
2 | =============================
3 |
4 | .. automodule:: weatherrouting.routers.router
5 |
6 |
7 | .. rubric:: Classes
8 |
9 | .. autosummary::
10 |
11 | IsoPoint
12 | Router
13 | RouterParam
14 | RoutingResult
15 |
16 | .. rubric:: Exceptions
17 |
18 | .. autosummary::
19 |
20 | RoutingNoWindError
21 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.routers.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.routers
2 | ======================
3 |
4 | .. automodule:: weatherrouting.routers
5 |
6 |
7 | .. rubric:: Modules
8 |
9 | .. autosummary::
10 | :toctree:
11 | :recursive:
12 |
13 | linearbestisorouter
14 | router
15 | shortestpathrouter
16 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.routers.shortestpathrouter.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.routers.shortestpathrouter
2 | =========================================
3 |
4 | .. automodule:: weatherrouting.routers.shortestpathrouter
5 |
6 |
7 | .. rubric:: Classes
8 |
9 | .. autosummary::
10 |
11 | ShortestPathRouter
12 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.routing.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.routing
2 | ======================
3 |
4 | .. automodule:: weatherrouting.routing
5 |
6 |
7 | .. rubric:: Functions
8 |
9 | .. autosummary::
10 |
11 | list_routing_algorithms
12 |
13 | .. rubric:: Classes
14 |
15 | .. autosummary::
16 |
17 | Routing
18 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.rst:
--------------------------------------------------------------------------------
1 | weatherrouting
2 | ==============
3 |
4 | .. automodule:: weatherrouting
5 |
6 |
7 | .. rubric:: Modules
8 |
9 | .. autosummary::
10 | :toctree:
11 | :recursive:
12 |
13 | grib
14 | polar
15 | routers
16 | routing
17 | utils
18 |
--------------------------------------------------------------------------------
/docs/source/generated/weatherrouting.utils.rst:
--------------------------------------------------------------------------------
1 | weatherrouting.utils
2 | ====================
3 |
4 | .. automodule:: weatherrouting.utils
5 |
6 |
7 | .. rubric:: Functions
8 |
9 | .. autosummary::
10 |
11 | cfbinomiale
12 | km2nm
13 | lossodromic
14 | max_reach_distance
15 | ms_to_knots
16 | nm2km
17 | ortodromic
18 | ortodromic2
19 | path_as_geojson
20 | point_distance
21 | reduce180
22 | reduce360
23 | routage_point_distance
24 |
--------------------------------------------------------------------------------
/docs/source/index.rst:
--------------------------------------------------------------------------------
1 | libWeatherRouting
2 | ===================
3 |
4 | A 100% python weather routing library for sailing.
5 |
6 | .. toctree::
7 | :maxdepth: 2
8 | :caption: WeatherRouting
9 |
10 | .. autosummary::
11 | :toctree: generated
12 | :recursive:
13 |
14 | weatherrouting
15 | weatherrouting.routers
16 |
17 | Indices and tables
18 | ==================
19 |
20 | * :ref:`genindex`
21 | * :ref:`modindex`
22 | * :ref:`search`
23 |
24 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | .
--------------------------------------------------------------------------------
/scripts/replace_latlon_tests.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 |
16 | # import numpy
17 | import math
18 | import time
19 |
20 | import latlon
21 | from geographiclib.geodesic import Geodesic
22 |
23 | # def get_bearing(lat1, long1, lat2, long2):
24 | # dLon = (long2 - long1)
25 | # x = math.cos(math.radians(lat2)) * math.sin(math.radians(dLon))
26 | # y = math.cos(math.radians(lat1)) * math.sin(math.radians(lat2)) - math.sin(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.cos(math.radians(dLon))
27 | # brng = numpy.arctan2(x,y)
28 | # brng = numpy.degrees(brng)
29 |
30 | # return brng
31 |
32 | # from turfpy import measurement
33 | # from geojson import Point, Feature
34 | # start = Feature(geometry=Point((-75.343, 39.984)))
35 | # end = Feature(geometry=Point((-75.534, 39.123)))
36 | # measurement.bearing(start,end)
37 |
38 |
39 | def routage_point_distance(lat_a: float, lon_a: float, distance: float, hdg: float):
40 | """Returns the point from (lat_a, lon_a) to the given (distance, hdg)"""
41 | d = distance
42 |
43 | p = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
44 | of = p.offset(math.degrees(hdg), d).to_string("D")
45 | return (float(of[0]), float(of[1]))
46 |
47 |
48 | def ortodromic(lat_a: float, lon_a: float, lat_b: float, lon_b: float):
49 | p1 = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
50 | p2 = latlon.LatLon(latlon.Latitude(lat_b), latlon.Longitude(lon_b))
51 |
52 | return (p1.distance(p2), math.radians(p1.heading_initial(p2)))
53 |
54 |
55 | def lossodromic(lat_a: float, lon_a: float, lat_b: float, lon_b: float):
56 | p1 = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
57 | p2 = latlon.LatLon(latlon.Latitude(lat_b), latlon.Longitude(lon_b))
58 |
59 | return (p1.distance(p2, ellipse="sphere"), math.radians(p1.heading_initial(p2)))
60 |
61 |
62 | T = [-32.06, 115.74, 32.11195529143165, -63.95925278363717]
63 |
64 | geod = Geodesic.WGS84
65 |
66 | t = time.time()
67 | g = geod.Inverse(T[0], T[1], T[2], T[3])
68 | print(g, g["s12"] * 1e-3, math.radians(g["azi1"]))
69 | print(time.time() - t, "\n\n")
70 |
71 | t = time.time()
72 | gg = lossodromic(T[0], T[1], T[2], T[3])
73 | print(gg, math.degrees(gg[1]))
74 | print(time.time() - t, "\n\n")
75 |
76 |
77 | gg = ortodromic(T[0], T[1], T[2], T[3])
78 | print(gg, math.degrees(gg[1]))
79 |
80 |
81 | g = geod.Direct(-32.06, 115.74, 225, 20000e3)
82 | print(g)
83 |
84 | g = routage_point_distance(-32.06, 115.74, 20000, math.radians(225))
85 | print(g)
86 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore =
3 | # allow bare exception
4 | E722,
5 | # not pep8, black adds whitespace before ':'
6 | E203,
7 | # not pep8, https://www.python.org/dev/peps/pep-0008/#pet-peeves
8 | E231,
9 | # not pep8, black adds line break before binary operator
10 | W503,
11 | # Google Python style is not RST until after processed by Napoleon
12 | # See https://github.com/peterjc/flake8-rst-docstrings/issues/17
13 | RST201,RST203,RST301,
14 | max_line_length = 100
15 | max-complexity = 10
16 | exclude =
17 | __pycache__
18 | .tox
19 | .git
20 | bin
21 | build
22 | venv
23 | rst-roles =
24 | # Python programming language:
25 | py:func,py:mod,mod
26 |
27 | [isort]
28 | line_length = 100
29 | multi_line_output = 3
30 | include_trailing_comma = true
31 |
32 | [black]
33 | line-length = 100
34 |
35 | [mypy]
36 | ignore_missing_imports = True
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | from setuptools import setup
16 |
17 | setup(
18 | name="weatherrouting",
19 | version="0.2.3",
20 | description="Weather routing library for sailing",
21 | author="Davide Gessa",
22 | setup_requires="setuptools",
23 | author_email="gessadavide@gmail.com",
24 | packages=["weatherrouting", "weatherrouting.routers"],
25 | install_requires=["latlon3"], # ['geographiclib'],
26 | test_suite="tests",
27 | )
28 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 |
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 |
15 | # For detail about GNU see .
16 |
--------------------------------------------------------------------------------
/tests/data/bavaria38.pol:
--------------------------------------------------------------------------------
1 | TWA\TWS 0 4 6 8 10 12 14 16 20 25 30 35 40 45 50 55 60
2 | 0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
3 | 5 0.0 0.3 0.5 0.6 0.7 0.8 0.8 0.9 0.9 0.9 0.8 0.7 0.1 0.0 0.0 0.0 0.0
4 | 10 0.0 0.6 0.9 1.2 1.4 1.6 1.7 1.7 1.8 1.7 1.6 1.3 0.3 0.1 0.1 0.0 0.0
5 | 15 0.0 0.9 1.4 1.8 2.2 2.4 2.6 2.6 2.6 2.6 2.5 2.0 0.7 0.3 0.2 0.0 0.0
6 | 20 0.0 1.0 1.6 2.0 2.5 2.8 2.9 3.0 3.0 3.0 2.8 2.3 1.1 0.5 0.2 0.0 0.0
7 | 25 0.0 1.2 1.9 2.4 2.9 3.3 3.5 3.5 3.6 3.5 3.4 2.7 1.6 0.7 0.3 0.0 0.0
8 | 32 0.0 2.0 3.1 4.0 4.9 5.5 5.8 5.9 6.0 5.9 5.6 4.5 3.4 1.4 0.5 0.0 0.0
9 | 36 0.0 2.4 3.5 4.5 5.4 6.0 6.3 6.4 6.5 6.5 6.4 6.1 5.0 2.0 0.6 0.0 0.0
10 | 40 0.0 2.7 3.9 4.9 5.8 6.3 6.6 6.7 6.8 6.8 6.8 6.6 5.9 2.3 1.0 0.0 0.0
11 | 45 0.0 3.0 4.3 5.3 6.2 6.6 6.9 7.0 7.0 7.1 7.1 7.0 6.7 2.5 1.1 0.0 0.0
12 | 52 0.0 3.4 4.8 5.7 6.5 6.9 7.1 7.2 7.3 7.4 7.4 7.4 7.3 2.6 1.1 0.0 0.0
13 | 60 0.0 3.7 5.1 6.1 6.8 7.2 7.4 7.5 7.5 7.7 7.7 7.7 7.7 3.1 1.5 0.0 0.0
14 | 70 0.0 3.9 5.3 6.3 6.9 7.3 7.5 7.7 7.9 8.0 8.1 8.2 8.2 3.3 1.6 0.0 0.0
15 | 80 0.0 4.0 5.3 6.4 7.0 7.3 7.6 7.8 8.1 8.3 8.4 8.5 8.5 3.4 1.7 0.0 0.0
16 | 90 0.0 3.9 5.3 6.4 7.1 7.4 7.5 7.8 8.3 8.5 8.6 8.7 8.7 3.9 2.2 0.4 0.4
17 | 100 0.0 3.9 5.3 6.4 7.1 7.4 7.7 8.0 8.4 8.6 8.8 9.0 9.0 4.5 2.7 0.5 0.5
18 | 110 0.0 3.9 5.3 6.4 7.1 7.5 7.9 8.2 8.6 9.0 9.3 9.7 9.7 5.3 3.4 1.0 1.0
19 | 120 0.0 3.7 5.1 6.2 7.0 7.4 7.7 8.1 8.8 9.4 9.9 10.5 10.5 6.3 4.2 1.1 1.1
20 | 130 0.0 3.3 4.7 5.8 6.6 7.2 7.5 7.9 8.7 9.5 10.3 11.1 11.1 7.2 5.0 1.7 1.7
21 | 140 0.0 2.9 4.3 5.3 6.2 6.9 7.3 7.6 8.4 9.6 10.9 12.1 12.1 9.1 6.7 2.4 1.8
22 | 150 0.0 2.5 3.7 4.8 5.7 6.5 7.1 7.4 8.1 9.7 11.7 13.6 13.6 10.9 8.2 2.7 2.7
23 | 160 0.0 2.2 3.3 4.3 5.2 6.1 6.8 7.1 7.8 9.1 11.2 13.8 13.8 11.7 9.0 3.5 2.8
24 | 170 0.0 2.0 3.0 4.0 4.8 5.8 6.5 6.9 7.5 8.6 10.4 12.7 12.7 12.1 9.5 3.8 3.2
25 | 180 0.0 1.8 2.8 3.7 4.5 5.5 6.2 6.7 7.3 8.2 9.7 11.6 11.6 11.6 9.3 3.5 2.9
26 |
--------------------------------------------------------------------------------
/tests/linearbestisorouter_test.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 |
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 |
15 | # For detail about GNU see .
16 | import datetime
17 | import json
18 | import math
19 | import os
20 | import time
21 | import unittest
22 |
23 | from parameterized import parameterized
24 |
25 | import weatherrouting
26 | from weatherrouting.routers.linearbestisorouter import LinearBestIsoRouter
27 |
28 | from .mock_grib import MockGrib
29 | from .mock_point_validity import MockpointValidity
30 |
31 | polar_bavaria38 = weatherrouting.Polar(
32 | os.path.join(os.path.dirname(__file__), "data/bavaria38.pol")
33 | )
34 |
35 |
36 | def heading(y, x):
37 | a = math.degrees(math.atan2(y, x))
38 | if a < 0:
39 | a = 360 + a
40 | return (90 - a + 360) % 360
41 |
42 |
43 | class TestRoutingStraigthUpwind(unittest.TestCase):
44 | @parameterized.expand(
45 | [
46 | [1, 0],
47 | [1, 1],
48 | [0, 1],
49 | [-1, 1],
50 | [-1, 0],
51 | [-1, -1],
52 | [0, -1],
53 | [1, -1],
54 | ]
55 | )
56 | def test_step(self, s0, s1):
57 | base_start = [34, 17]
58 | base_gjs = {}
59 |
60 | base_end = [base_start[0] + s0, base_start[1] + s1]
61 | head = heading(s0, s1)
62 | # print("TEST UPWIND TWD", head, "step", s0, s1)
63 | pvmodel = MockpointValidity([base_start, base_end])
64 | routing_obj = weatherrouting.Routing(
65 | LinearBestIsoRouter,
66 | polar_bavaria38,
67 | [base_start, base_end],
68 | MockGrib(10, head, 0),
69 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
70 | line_validity=pvmodel.line_validity,
71 | )
72 | routing_obj.algorithm.set_param_value("subdiv", 2)
73 | res = None
74 | i = 0
75 |
76 | ptime = time.time()
77 | while not routing_obj.end:
78 | res = routing_obj.step()
79 | i += 1
80 | ntime = time.time()
81 | # print(i, ntime - ptime, "\n")
82 | # print(routing_obj.get_current_best_path(), "\n")
83 | ptime = ntime # noqa: F841
84 |
85 | path_to_end = res.path
86 | if not base_gjs:
87 | base_gjs = weatherrouting.utils.path_as_geojson(path_to_end)
88 | else:
89 | base_gjs["features"] += weatherrouting.utils.path_as_geojson(path_to_end)[
90 | "features"
91 | ]
92 | gjs = json.dumps(base_gjs) # noqa: F841
93 |
94 | # print(gjs)
95 |
96 |
97 | class TestRoutingLowWindNoIsland(unittest.TestCase):
98 | def setUp(self):
99 | grib = MockGrib(2, 180, 0.1)
100 | self.track = [(5, 38), (5.2, 38.2)]
101 | island_route = MockpointValidity(self.track)
102 | self.routing_obj = weatherrouting.Routing(
103 | LinearBestIsoRouter,
104 | polar_bavaria38,
105 | self.track,
106 | grib,
107 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
108 | point_validity=island_route.point_validity,
109 | )
110 |
111 | def test_step(self):
112 | res = None
113 | i = 0
114 |
115 | while not self.routing_obj.end:
116 | res = self.routing_obj.step()
117 | i += 1
118 |
119 | self.assertEqual(i, 7)
120 | self.assertEqual(not res.path, False)
121 |
122 | path_to_end = res.path
123 | self.assertEqual(
124 | res.time, datetime.datetime.fromisoformat("2021-04-02 18:00:00")
125 | )
126 |
127 | gj = weatherrouting.utils.path_as_geojson(path_to_end)
128 |
129 | self.assertEqual(len(gj["features"]), 8)
130 | self.assertEqual(
131 | gj["features"][-1]["properties"]["end-timestamp"], "2021-04-02 18:00:00"
132 | )
133 |
134 |
135 | class TestRoutingLowWindMockIsland5(unittest.TestCase):
136 | def setUp(self):
137 | grib = MockGrib(2, 180, 0.1)
138 | self.track = [(5, 38), (5.2, 38.2)]
139 | island_route = MockpointValidity(self.track, factor=5)
140 | self.routing_obj = weatherrouting.Routing(
141 | LinearBestIsoRouter,
142 | polar_bavaria38,
143 | self.track,
144 | grib,
145 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
146 | point_validity=island_route.point_validity,
147 | )
148 |
149 | def test_step(self):
150 | res = None
151 | i = 0
152 |
153 | while not self.routing_obj.end:
154 | res = self.routing_obj.step()
155 | i += 1
156 |
157 | self.assertEqual(i, 7)
158 | self.assertEqual(not res.path, False)
159 |
160 |
161 | class CheckRouteMediumWindMockIsland8(unittest.TestCase):
162 | def setUp(self):
163 | grib = MockGrib(5, 45, 0.5)
164 | self.track = [(5, 38), (4.6, 37.6)]
165 | island_route = MockpointValidity(self.track, factor=8)
166 | self.routing_obj = weatherrouting.Routing(
167 | LinearBestIsoRouter,
168 | polar_bavaria38,
169 | self.track,
170 | grib,
171 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
172 | line_validity=island_route.line_validity,
173 | )
174 |
175 | def test_step(self):
176 | res = None
177 | i = 0
178 |
179 | while not self.routing_obj.end:
180 | res = self.routing_obj.step()
181 | i += 1
182 |
183 | self.assertEqual(i, 7)
184 | self.assertEqual(not res.path, False)
185 |
186 |
187 | class CheckRouteHighWindMockIsland3(unittest.TestCase):
188 | def setUp(self):
189 | grib = MockGrib(10, 270, 0.5)
190 | self.track = [(5, 38), (5.5, 38.5)]
191 | island_route = MockpointValidity(self.track, factor=3)
192 | self.routing_obj = weatherrouting.Routing(
193 | LinearBestIsoRouter,
194 | polar_bavaria38,
195 | self.track,
196 | grib,
197 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
198 | line_validity=island_route.line_validity,
199 | )
200 |
201 | def test_step(self):
202 | res = None
203 | i = 0
204 |
205 | while not self.routing_obj.end:
206 | res = self.routing_obj.step()
207 | i += 1
208 |
209 | self.assertEqual(i, 7)
210 | self.assertEqual(not res.path, False)
211 |
212 |
213 | class CheckRouteOutOfScope(unittest.TestCase):
214 | def setUp(self):
215 | grib = MockGrib(
216 | 10,
217 | 270,
218 | 0.5,
219 | out_of_scope=datetime.datetime.fromisoformat("2021-04-02T15:00:00"),
220 | )
221 | self.track = [(5, 38), (5.5, 38.5)]
222 | island_route = MockpointValidity(self.track, factor=3)
223 | self.routing_obj = weatherrouting.Routing(
224 | LinearBestIsoRouter,
225 | polar_bavaria38,
226 | self.track,
227 | grib,
228 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
229 | line_validity=island_route.line_validity,
230 | )
231 |
232 | def test_step(self):
233 | res = None
234 | i = 0
235 |
236 | while not self.routing_obj.end:
237 | res = self.routing_obj.step()
238 | i += 1
239 |
240 | self.assertEqual(i, 4)
241 | self.assertEqual(not res.path, False)
242 |
243 |
244 | class CheckRouteMultipoint(unittest.TestCase):
245 | def setUp(self):
246 | grib = MockGrib(10, 270, 0.5)
247 | self.track = [(5, 38), (5.3, 38.3), (5.6, 38.6)]
248 | # island_route = MockpointValidity(self.track, factor=3)
249 | self.routing_obj = weatherrouting.Routing(
250 | LinearBestIsoRouter,
251 | polar_bavaria38,
252 | self.track,
253 | grib,
254 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
255 | )
256 |
257 | def test_step(self):
258 | res = None
259 | i = 0
260 |
261 | while not self.routing_obj.end:
262 | res = self.routing_obj.step()
263 | i += 1
264 |
265 | self.assertEqual(i, 6)
266 | self.assertEqual(not res.path, False)
267 |
268 |
269 | class TestRoutingCustomStep(unittest.TestCase):
270 | def setUp(self):
271 | grib = MockGrib(2, 180, 0.1)
272 | self.track = [(5, 38), (5.2, 38.2)]
273 | island_route = MockpointValidity(self.track, factor=5)
274 | self.routing_obj = weatherrouting.Routing(
275 | LinearBestIsoRouter,
276 | polar_bavaria38,
277 | self.track,
278 | grib,
279 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
280 | point_validity=island_route.point_validity,
281 | )
282 |
283 | def test_step(self):
284 | res = None
285 | i = 0
286 |
287 | while not self.routing_obj.end:
288 | res = self.routing_obj.step(timedelta=0.5)
289 | i += 1
290 |
291 | self.assertEqual(i, 12)
292 | self.assertEqual(not res.path, False)
293 |
--------------------------------------------------------------------------------
/tests/main.py.ex:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | '''
5 | This program is free software: you can redistribute it and/or modify
6 | it under the terms of the GNU General Public License as published by
7 | the Free Software Foundation, either version 3 of the License, or
8 | (at your option) any later version.
9 |
10 | This program is distributed in the hope that it will be useful,
11 | but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | GNU General Public License for more details.
14 |
15 | For detail about GNU see .
16 | '''
17 |
18 | import unittest
19 |
20 | from utils import *
21 | from polar import *
22 | from shortestpathrouter import *
23 | from linearbestisorouter import *
24 |
25 | if __name__ == '__main__':
26 | unittest.main()
--------------------------------------------------------------------------------
/tests/mock_grib.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | import datetime
16 | import random
17 | from typing import Optional, Tuple
18 |
19 |
20 | class MockGrib:
21 | def __init__(self, starttws, starttwd, fuzziness, out_of_scope=None):
22 | """
23 | Params:
24 | starttws: start wind speed in m/s
25 | starttwd: start wind direction in degree
26 | fuzziness: randomness factor
27 | """
28 | self.starttws = starttws
29 | self.starttwd = starttwd
30 | self.fuzziness = fuzziness
31 | self.out_of_scope = out_of_scope
32 | self.seedSource = datetime.datetime.fromisoformat("2000-01-01T00:00:00")
33 |
34 | def tws_var(self, t=None):
35 | if t:
36 | delta = t - self.seedSource
37 | random.seed(delta.total_seconds())
38 | return self.starttws + self.starttws * (
39 | random.random() * self.fuzziness - self.fuzziness / 2
40 | )
41 |
42 | def twd_var(self, t=None):
43 | if t:
44 | delta = t - self.seedSource
45 | random.seed(delta.total_seconds())
46 | return self.starttwd + self.starttwd * (
47 | random.random() * self.fuzziness - self.fuzziness / 2
48 | )
49 |
50 | def get_wind_at(self, t, lat, lon) -> Optional[Tuple[float, float]]:
51 | """
52 | Returns a tuple containing direction in degree and speed in m/s
53 | """
54 | if not self.out_of_scope or t < self.out_of_scope:
55 | return (
56 | self.twd_var(t),
57 | self.tws_var(t),
58 | )
59 | else:
60 | return None
61 |
--------------------------------------------------------------------------------
/tests/mock_point_validity.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | import weatherrouting
16 |
17 |
18 | class MockpointValidity:
19 | def __init__(self, track, factor=4):
20 | self.mean_point = (
21 | (track[0][1] + track[1][1]) / 2,
22 | (track[0][0] + track[1][0]) / 2,
23 | )
24 | self.mean_island = (
25 | weatherrouting.utils.point_distance(*(track[0] + track[1])) / factor
26 | )
27 |
28 | def point_validity(self, y, x):
29 | if (
30 | weatherrouting.utils.point_distance(x, y, *(self.mean_point))
31 | < self.mean_island
32 | ):
33 | return False
34 | else:
35 | return True
36 |
37 | def line_validity(self, y1, x1, y2, x2):
38 | if (
39 | weatherrouting.utils.point_distance(x1, y2, *(self.mean_point))
40 | < self.mean_island
41 | ):
42 | return False
43 | else:
44 | return True
45 |
--------------------------------------------------------------------------------
/tests/polar_test.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 |
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 |
15 | # For detail about GNU see .
16 | import math
17 | import os
18 | import tempfile
19 | import unittest
20 |
21 | import weatherrouting
22 |
23 |
24 | def create_temp_file(content: str, test_instance=None) -> str:
25 | temp_file = tempfile.NamedTemporaryFile(
26 | mode="w", delete=False, suffix=".pol", encoding="utf-8"
27 | )
28 | temp_file.write(content)
29 | temp_file.close()
30 | if test_instance:
31 | test_instance.addCleanup(os.remove, temp_file.name)
32 | return temp_file.name
33 |
34 |
35 | class TestPolar(unittest.TestCase):
36 | def setUp(self):
37 | self.polar_obj = weatherrouting.Polar(
38 | os.path.join(os.path.dirname(__file__), "data/bavaria38.pol")
39 | )
40 | self.valid_file_path = os.path.join(
41 | os.path.dirname(__file__), "data/bavaria38.pol"
42 | )
43 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
44 | self.valid_polar_content_lines = f.readlines()
45 |
46 | def test_to_string(self):
47 | f = open(os.path.join(os.path.dirname(__file__), "data/bavaria38.pol"), "r")
48 | d = f.read()
49 | f.close()
50 | self.assertEqual(self.polar_obj.to_string(), d)
51 |
52 | def test_get_speed(self):
53 | self.assertAlmostEqual(
54 | self.polar_obj.get_speed(8, math.radians(60)), 6.1, delta=0.001
55 | )
56 | self.assertAlmostEqual(
57 | self.polar_obj.get_speed(8.3, math.radians(60)), 6.205, delta=0.001
58 | )
59 | self.assertAlmostEqual(
60 | self.polar_obj.get_speed(8.3, math.radians(64)), 6.279, delta=0.001
61 | )
62 | self.assertAlmostEqual(
63 | self.polar_obj.get_speed(2.2, math.radians(170)), 1.1, delta=0.001
64 | )
65 |
66 | def test_routage(self):
67 | self.assertAlmostEqual(
68 | self.polar_obj.get_routage_speed(2.2, math.radians(170)),
69 | 1.2406897519211786,
70 | delta=0.001,
71 | )
72 | self.assertAlmostEqual(
73 | self.polar_obj.get_twa_routage(2.2, math.radians(170)),
74 | 2.4434609527920568,
75 | delta=0.001,
76 | )
77 |
78 | def test_reaching(self):
79 | self.assertAlmostEqual(
80 | self.polar_obj.get_reaching(6.1)[0], 5.3549999999999995, delta=0.001
81 | )
82 | self.assertAlmostEqual(
83 | self.polar_obj.get_reaching(6.1)[1], 1.3962634015954636, delta=0.001
84 | )
85 |
86 | # --- Tests for Polar.validate_file ---
87 | def test_validate_valid_file(self):
88 | # This should not raise an error for a known valid file
89 | try:
90 | weatherrouting.Polar.validate_file(self.valid_file_path)
91 | except weatherrouting.PolarError as e:
92 | self.fail(
93 | f"validate_polar_file raised PolarError unexpectedly for a valid file: {e}"
94 | )
95 |
96 | def test_validate_empty_file(self):
97 | temp_file_path = create_temp_file("", self)
98 | with self.assertRaisesRegex(weatherrouting.PolarError, "EMPTY_FILE"):
99 | weatherrouting.Polar.validate_file(temp_file_path)
100 |
101 | def test_validate_wind_numeric(self):
102 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
103 | valid_content = f.read()
104 | corrupt_content = valid_content.replace("30", "a")
105 | temp_file_path = create_temp_file(corrupt_content, self)
106 | with self.assertRaisesRegex(
107 | weatherrouting.PolarError, "WIND_SPEED_NOT_NUMERIC"
108 | ):
109 | weatherrouting.Polar.validate_file(temp_file_path)
110 |
111 | def test_validate_wind_incresing(self):
112 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
113 | valid_content = f.read()
114 | corrupt_content = valid_content.replace("50", "100")
115 | temp_file_path = create_temp_file(corrupt_content, self)
116 | with self.assertRaisesRegex(
117 | weatherrouting.PolarError, "WIND_SPEEDS_NOT_INCREASING"
118 | ):
119 | weatherrouting.Polar.validate_file(temp_file_path)
120 |
121 | def test_validate_empty_line(self):
122 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
123 | valid_content = f.read()
124 | corrupt_content = valid_content.replace("\n", "\n\n")
125 | temp_file_path = create_temp_file(corrupt_content, self)
126 | with self.assertRaisesRegex(weatherrouting.PolarError, "EMPTY_LINE"):
127 | weatherrouting.Polar.validate_file(temp_file_path)
128 |
129 | def test_validate_column_count_mismatch(self):
130 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
131 | valid_content = f.read()
132 | corrupt_content = valid_content.replace("60", "60 70")
133 | temp_file_path = create_temp_file(corrupt_content, self)
134 | with self.assertRaisesRegex(weatherrouting.PolarError, "COLUMN_COUNT_MISMATCH"):
135 | weatherrouting.Polar.validate_file(temp_file_path)
136 |
137 | def test_validate_twa_out_of_range(self):
138 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
139 | valid_content = f.read()
140 | corrupt_content = valid_content.replace("100", "181")
141 | temp_file_path = create_temp_file(corrupt_content, self)
142 | with self.assertRaisesRegex(weatherrouting.PolarError, "TWA_OUT_OF_RANGE"):
143 | weatherrouting.Polar.validate_file(temp_file_path)
144 |
145 | def test_validate_twa_not_numeric(self):
146 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
147 | valid_content = f.read()
148 | corrupt_content = valid_content.replace("100", "a")
149 | temp_file_path = create_temp_file(corrupt_content, self)
150 | with self.assertRaisesRegex(weatherrouting.PolarError, "TWA_NOT_NUMERIC"):
151 | weatherrouting.Polar.validate_file(temp_file_path)
152 |
153 | def test_validate_empty_value(self):
154 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
155 | valid_content = f.read()
156 | corrupt_content = valid_content.replace("7.7", "-")
157 | temp_file_path = create_temp_file(corrupt_content, self)
158 | with self.assertRaisesRegex(weatherrouting.PolarError, "EMPTY_VALUE"):
159 | weatherrouting.Polar.validate_file(temp_file_path)
160 |
161 | def test_validate_negative_speed(self):
162 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
163 | valid_content = f.read()
164 | corrupt_content = valid_content.replace("7.7", "-1")
165 | temp_file_path = create_temp_file(corrupt_content)
166 | with self.assertRaisesRegex(weatherrouting.PolarError, "NEGATIVE_SPEED"):
167 | weatherrouting.Polar.validate_file(temp_file_path)
168 |
169 | def test_validate_speed_not_numeric(self):
170 | with open(self.valid_file_path, "r", encoding="utf-8") as f:
171 | valid_content = f.read()
172 | corrupt_content = valid_content.replace("7.7", "a")
173 | temp_file_path = create_temp_file(corrupt_content, self)
174 | with self.assertRaisesRegex(weatherrouting.PolarError, "SPEED_NOT_NUMERIC"):
175 | weatherrouting.Polar.validate_file(temp_file_path)
176 |
--------------------------------------------------------------------------------
/tests/shortestpathrouter_test.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 |
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 |
15 | # For detail about GNU see .
16 | import datetime
17 | import json
18 | import unittest
19 |
20 | import weatherrouting
21 | from weatherrouting.routers.router import IsoPoint
22 | from weatherrouting.routers.shortestpathrouter import ShortestPathRouter
23 |
24 | from .mock_grib import MockGrib
25 | from .mock_point_validity import MockpointValidity
26 |
27 |
28 | class TestRoutingNoIsland(unittest.TestCase):
29 | def setUp(self):
30 | grib = MockGrib(2, 180, 0.1)
31 | self.track = [(5, 38), (5.2, 38.2)]
32 | island_route = MockpointValidity(self.track)
33 | self.routing_obj = weatherrouting.Routing(
34 | ShortestPathRouter,
35 | None,
36 | self.track,
37 | grib,
38 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
39 | point_validity=island_route.point_validity,
40 | )
41 |
42 | def test_step(self):
43 | res = None
44 | i = 0
45 |
46 | while not self.routing_obj.end:
47 | res = self.routing_obj.step()
48 | i += 1
49 |
50 | self.assertEqual(i, 3)
51 | self.assertEqual(not res.path, False)
52 |
53 | path_to_end = res.path + [IsoPoint(self.track[-1])]
54 | self.assertEqual(
55 | res.time, datetime.datetime.fromisoformat("2021-04-02 14:00:00")
56 | )
57 | self.assertEqual(
58 | len(json.dumps(weatherrouting.utils.path_as_geojson(path_to_end))), 1201
59 | )
60 |
61 |
62 | class TestRoutingMockIsland5(unittest.TestCase):
63 | def setUp(self):
64 | grib = MockGrib(2, 180, 0.1)
65 | self.track = [(5, 38), (5.2, 38.2)]
66 | island_route = MockpointValidity(self.track, factor=5)
67 | self.routing_obj = weatherrouting.Routing(
68 | ShortestPathRouter,
69 | None,
70 | self.track,
71 | grib,
72 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
73 | point_validity=island_route.point_validity,
74 | )
75 |
76 | def test_step(self):
77 | res = None
78 | i = 0
79 |
80 | while not self.routing_obj.end:
81 | res = self.routing_obj.step()
82 | i += 1
83 |
84 | self.assertEqual(i, 3)
85 | self.assertEqual(not res.path, False)
86 |
87 |
88 | class CheckRouteOutOfScope(unittest.TestCase):
89 | def setUp(self):
90 | grib = MockGrib(
91 | 10,
92 | 270,
93 | 0.5,
94 | out_of_scope=datetime.datetime.fromisoformat("2021-04-02T15:00:00"),
95 | )
96 | self.track = [(5, 38), (5.5, 38.5)]
97 | island_route = MockpointValidity(self.track, factor=3)
98 | self.routing_obj = weatherrouting.Routing(
99 | ShortestPathRouter,
100 | None,
101 | self.track,
102 | grib,
103 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
104 | line_validity=island_route.line_validity,
105 | )
106 |
107 | def test_step(self):
108 | res = None
109 | i = 0
110 |
111 | while not self.routing_obj.end:
112 | res = self.routing_obj.step()
113 | i += 1
114 |
115 | self.assertEqual(i, 4)
116 | self.assertEqual(not res.path, False)
117 |
118 |
119 | class TestRoutingCustomStep(unittest.TestCase):
120 | def setUp(self):
121 | grib = MockGrib(2, 180, 0.1)
122 | self.track = [(5, 38), (5.2, 38.2)]
123 | island_route = MockpointValidity(self.track)
124 | self.routing_obj = weatherrouting.Routing(
125 | ShortestPathRouter,
126 | None,
127 | self.track,
128 | grib,
129 | datetime.datetime.fromisoformat("2021-04-02T12:00:00"),
130 | point_validity=island_route.point_validity,
131 | )
132 |
133 | def test_step(self):
134 | res = None
135 | i = 0
136 |
137 | while not self.routing_obj.end:
138 | res = self.routing_obj.step(timedelta=0.5)
139 | i += 1
140 |
141 | self.assertEqual(i, 5)
142 | self.assertEqual(not res.path, False)
143 |
144 | path_to_end = res.path + [IsoPoint(self.track[-1])]
145 | self.assertEqual(
146 | res.time, datetime.datetime.fromisoformat("2021-04-02 14:00:00")
147 | )
148 | self.assertEqual(
149 | len(json.dumps(weatherrouting.utils.path_as_geojson(path_to_end))), 1813
150 | )
151 |
--------------------------------------------------------------------------------
/tests/tests_requirements.txt:
--------------------------------------------------------------------------------
1 | geographiclib
--------------------------------------------------------------------------------
/tests/utils_test.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | #
5 | # This program is free software: you can redistribute it and/or modify
6 | # it under the terms of the GNU General Public License as published by
7 | # the Free Software Foundation, either version 3 of the License, or
8 | # (at your option) any later version.
9 |
10 | # This program is distributed in the hope that it will be useful,
11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 | # GNU General Public License for more details.
14 |
15 | # For detail about GNU see .
16 | import unittest
17 |
18 | import weatherrouting
19 |
20 |
21 | class TestUtils(unittest.TestCase):
22 | def test_point_distance(self):
23 | self.assertEqual(
24 | round(weatherrouting.utils.point_distance(0.0, 0.0, 1 / 60, 0.0)), 1
25 | )
26 |
27 | def test_max_dist_reaching(self):
28 | p1 = (5, 38)
29 | maxd = weatherrouting.utils.max_reach_distance(p1, 5)
30 | self.assertAlmostEqual(maxd, 5.000000000000199, delta=0.001)
31 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = linters,typecheck,unit-tests,coverage
3 | requires =
4 | tox>=4
5 | skipsdist=True
6 |
7 | [testenv]
8 | deps =
9 | ; geographiclib
10 | pytest
11 | latlon3
12 |
13 | commands =
14 | python -I -m build --wheel -C=--build-option=-- -C=--build-option=-- -C=--build-option=-j4
15 |
16 | [testenv:coverage]
17 | deps =
18 | {[testenv]deps}
19 | pytest
20 | pytest-cov
21 | parameterized
22 | commands =
23 | pytest --cov-report term-missing --cov-report html --cov-report xml --cov=weatherrouting
24 |
25 | [testenv:unit-tests]
26 | deps =
27 | {[testenv]deps}
28 | pytest
29 | parameterized
30 | commands =
31 | pytest --durations=0 #-rP
32 |
33 | [testenv:flake8]
34 | deps =
35 | ; {[testenv]deps}
36 | flake8
37 | pep8-naming
38 | commands =
39 | flake8 ./weatherrouting
40 | flake8 ./tests
41 |
42 | [testenv:isort]
43 | deps =
44 | ; {[testenv]deps}
45 | isort
46 | commands =
47 | isort .
48 |
49 | [testenv:black]
50 | deps =
51 | ; {[testenv]deps}
52 | black[jupyter]
53 | commands =
54 | black .
55 |
56 | [testenv:typecheck]
57 | deps =
58 | ; {[testenv]deps}
59 | mypy
60 | types-setuptools
61 | commands =
62 | mypy weatherrouting
63 |
64 | [testenv:linters]
65 | deps =
66 | {[testenv:isort]deps}
67 | {[testenv:black]deps}
68 | {[testenv:flake8]deps}
69 | commands =
70 | {[testenv:isort]commands}
71 | {[testenv:black]commands}
72 | {[testenv:flake8]commands}
73 |
74 |
--------------------------------------------------------------------------------
/weatherrouting/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | from .grib import Grib # noqa: F401
16 | from .polar import Polar, PolarError # noqa: F401
17 | from .routers import * # noqa: F401, F403
18 | from .routing import Routing, list_routing_algorithms # noqa: F401
19 | from .utils import * # noqa: F401, F403
20 |
--------------------------------------------------------------------------------
/weatherrouting/grib.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | from abc import ABC, abstractmethod
15 |
16 | # For detail about GNU see .
17 | from typing import Tuple
18 |
19 |
20 | class Grib(ABC):
21 | """
22 | Grib class is an abstract class that should be implement for providing grib data to routers
23 | """
24 |
25 | @abstractmethod
26 | def get_wind_at(self, t: float, lat: float, lon: float) -> Tuple[float, float]:
27 | """
28 | Returns (twd: degree, tws: m/s) for the given point (lat, lon) at time t
29 | or None if running out of temporal/geographic grib scope
30 | """
31 | raise Exception("Not implemented")
32 |
--------------------------------------------------------------------------------
/weatherrouting/polar.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | # Copyright (C) 2012 Riccardo Apolloni
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 |
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 |
16 | # For detail about GNU see .
17 | import math
18 | import re
19 | from io import TextIOWrapper
20 | from typing import Dict, Optional, Tuple
21 |
22 |
23 | class PolarError(Exception):
24 | pass
25 |
26 |
27 | class Polar:
28 | def __init__(self, polar_path: str, f: Optional[TextIOWrapper] = None):
29 | """
30 | Parameters
31 | ----------
32 | polar_path : string
33 | Path of the polar file
34 | f : File
35 | File object for passing an opened file
36 | """
37 | self.validate_file(polar_path)
38 |
39 | self.tws = []
40 | self.twa = []
41 | self.vmgdict: Dict[Tuple[float, float], Tuple[float, float]] = {}
42 | self.speed_table = []
43 |
44 | if f is None:
45 | f = open(polar_path, "r")
46 |
47 | tws = f.readline().split()
48 | for i in range(1, len(tws)):
49 | self.tws.append(float(tws[i].replace("\x02", "")))
50 |
51 | line = f.readline()
52 | while line != "":
53 | data = line.split()
54 | twa = float(data[0])
55 | self.twa.append(math.radians(twa))
56 | speedline = []
57 | for i in range(1, len(data)):
58 | speed = float(data[i])
59 | speedline.append(speed)
60 | self.speed_table.append(speedline)
61 | line = f.readline()
62 | f.close()
63 |
64 | def to_string(self) -> str:
65 | s = "TWA\\TWS"
66 | for x in self.tws:
67 | s += f"\t{x:.0f}"
68 | s += "\n"
69 |
70 | l_idx = 0
71 | for y in self.twa:
72 | s += f"{round(math.degrees(y))}"
73 | sl = self.speed_table[l_idx]
74 |
75 | for x in sl:
76 | s += f"\t{x:.1f}"
77 | s += "\n"
78 |
79 | l_idx += 1
80 |
81 | return s
82 |
83 | def get_speed(self, tws: float, twa: float) -> float: # noqa: C901
84 | """Returns the speed (in knots) given tws (in knots) and twa (in radians)"""
85 |
86 | tws1 = 0
87 | tws2 = 0
88 |
89 | for k in range(0, len(self.tws)):
90 | if tws >= self.tws[k]:
91 | tws1 = k
92 | for k in range(len(self.tws) - 1, 0, -1):
93 | if tws <= self.tws[k]:
94 | tws2 = k
95 | if tws1 > tws2: # TWS over table limits
96 | tws2 = len(self.tws) - 1
97 | twa1 = 0
98 | twa2 = 0
99 | for k in range(0, len(self.twa)):
100 | if twa >= self.twa[k]:
101 | twa1 = k
102 | for k in range(len(self.twa) - 1, 0, -1):
103 | if twa <= self.twa[k]:
104 | twa2 = k
105 |
106 | speed1 = self.speed_table[twa1][tws1]
107 | speed2 = self.speed_table[twa2][tws1]
108 | speed3 = self.speed_table[twa1][tws2]
109 | speed4 = self.speed_table[twa2][tws2]
110 |
111 | if twa1 != twa2:
112 | speed12 = speed1 + (speed2 - speed1) * (twa - self.twa[twa1]) / (
113 | self.twa[twa2] - self.twa[twa1]
114 | ) # interpolazione su twa
115 | speed34 = speed3 + (speed4 - speed3) * (twa - self.twa[twa1]) / (
116 | self.twa[twa2] - self.twa[twa1]
117 | ) # interpolazione su twa
118 | else:
119 | speed12 = speed1
120 | speed34 = speed3
121 | if tws1 != tws2:
122 | speed = speed12 + (speed34 - speed12) * (tws - self.tws[tws1]) / (
123 | self.tws[tws2] - self.tws[tws1]
124 | )
125 | else:
126 | speed = speed12
127 | return speed
128 |
129 | def get_reaching(self, tws: float) -> Tuple[float, float]:
130 | maxspeed = 0.0
131 | twamaxspeed = 0.0
132 | for twa_ in range(0, 181):
133 | twa = math.radians(twa_)
134 | speed = self.get_speed(tws, twa)
135 | if speed > maxspeed:
136 | maxspeed = speed
137 | twamaxspeed = twa
138 | return (maxspeed, twamaxspeed)
139 |
140 | def get_max_vmgtwa(self, tws: float, twa: float) -> Tuple[float, float]:
141 | if (tws, twa) not in self.vmgdict:
142 | twamin = max(0, twa - math.pi / 2)
143 | twamax = min(math.pi, twa + math.pi / 2)
144 | alfa = twamin
145 | maxvmg = -1.0
146 | while alfa < twamax:
147 | v = self.get_speed(tws, alfa)
148 | vmg = v * math.cos(alfa - twa)
149 | if vmg - maxvmg > 10**-3: # 10**-3 errore tollerato
150 | maxvmg = vmg
151 | twamaxvmg = alfa
152 | alfa = alfa + math.radians(1)
153 | self.vmgdict[tws, twa] = (maxvmg, twamaxvmg)
154 | return self.vmgdict[(tws, twa)]
155 |
156 | def get_max_vmg_up(self, tws: float) -> Tuple[float, float]:
157 | vmguptupla = self.get_max_vmgtwa(tws, 0)
158 | return (vmguptupla[0], vmguptupla[1])
159 |
160 | def get_max_vmg_down(self, tws: float) -> Tuple[float, float]:
161 | vmgdowntupla = self.get_max_vmgtwa(tws, math.pi)
162 | return (-vmgdowntupla[0], vmgdowntupla[1])
163 |
164 | def get_routage_speed(self, tws, twa) -> float:
165 | up = self.get_max_vmg_up(tws)
166 | vmgup = up[0]
167 | twaup = up[1]
168 | down = self.get_max_vmg_down(tws)
169 | vmgdown = down[0]
170 | twadown = down[1]
171 | v = 0.0
172 |
173 | if twa >= twaup and twa <= twadown:
174 | v = self.get_speed(tws, twa)
175 | else:
176 | if twa < twaup:
177 | v = vmgup / math.cos(twa)
178 | if twa > twadown:
179 | v = vmgdown / math.cos(twa)
180 | return v
181 |
182 | def get_twa_routage(self, tws: float, twa: float) -> float:
183 | up = self.get_max_vmg_up(tws)
184 | # vmgup = up[0]
185 | twaup = up[1]
186 | down = self.get_max_vmg_down(tws)
187 | # vmgdown = down[0]
188 | twadown = down[1]
189 | if twa >= twaup and twa <= twadown:
190 | pass
191 | # twa = twa
192 | else:
193 | if twa < twaup:
194 | twa = twaup
195 | if twa > twadown:
196 | twa = twadown
197 | return twa
198 |
199 | # ---- Start validate function ----
200 | @staticmethod
201 | def validate_file(filepath):
202 | """Validates the structure and content of a polar file.
203 |
204 | Returns True if valid, raises PolarError with specific message if invalid.
205 | """
206 | with open(filepath, "r") as f:
207 | content = f.read()
208 | lines = content.strip().split("\n")
209 |
210 | # Check for empty file
211 | if len(lines) == 1 and not lines[0] or not lines[0]:
212 | raise PolarError("EMPTY_FILE")
213 |
214 | # Process header (wind speeds)
215 | Polar._validate_header(lines[0])
216 |
217 | # Check data rows
218 | header_parts = re.split(r"\s+", lines[0].strip())
219 | expected_columns = len(header_parts)
220 |
221 | for line in lines[1:]:
222 | Polar._validate_data_row(line, expected_columns)
223 |
224 | return True
225 |
226 | @staticmethod
227 | def _validate_header(header_line):
228 | """Validates the header line containing wind speeds."""
229 | header_parts = re.split(r"\s+", header_line.strip())
230 |
231 | # Try to parse wind speeds (should be numeric)
232 | try:
233 | tws = [float(ws) for ws in header_parts[1:]]
234 | except ValueError:
235 | raise PolarError("WIND_SPEED_NOT_NUMERIC")
236 |
237 | # Check for increasing wind speeds
238 | if not all(tws[i] <= tws[i + 1] for i in range(len(tws) - 1)):
239 | raise PolarError("WIND_SPEEDS_NOT_INCREASING")
240 |
241 | return True
242 |
243 | @staticmethod
244 | def _validate_data_row(line, expected_columns):
245 | """Validates a single data row in the polar file."""
246 | parts = re.split(r"\s+", line.strip())
247 |
248 | # Skip empty lines
249 | if not parts or (len(parts) == 1 and not parts[0]):
250 | raise PolarError("EMPTY_LINE")
251 |
252 | # Check number of columns
253 | if len(parts) != expected_columns:
254 | raise PolarError("COLUMN_COUNT_MISMATCH")
255 |
256 | # Validate TWA
257 | Polar._validate_twa(parts[0])
258 |
259 | # Validate boat speeds
260 | for speed in parts[1:]:
261 | Polar._validate_boat_speed(speed)
262 |
263 | return True
264 |
265 | @staticmethod
266 | def _validate_twa(twa_str):
267 | """Validates a TWA (True Wind Angle) value."""
268 | try:
269 | twa = float(twa_str)
270 | if twa < 0 or twa > 180:
271 | raise PolarError("TWA_OUT_OF_RANGE")
272 | except ValueError:
273 | raise PolarError("TWA_NOT_NUMERIC")
274 |
275 | return True
276 |
277 | @staticmethod
278 | def _validate_boat_speed(speed_str):
279 | """Validates a boat speed value."""
280 | if speed_str in ["", "-", "NaN", "NULL"]:
281 | raise PolarError("EMPTY_VALUE")
282 |
283 | try:
284 | boat_speed = float(speed_str)
285 | if boat_speed < 0:
286 | raise PolarError("NEGATIVE_SPEED")
287 | except ValueError:
288 | raise PolarError("SPEED_NOT_NUMERIC")
289 |
290 | return True
291 |
292 | # ---- End validate function ----
293 |
--------------------------------------------------------------------------------
/weatherrouting/routers/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | from .router import IsoPoint, RoutingNoWindError, RoutingResult # noqa: F401
16 |
--------------------------------------------------------------------------------
/weatherrouting/routers/linearbestisorouter.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | # Copyright (C) 2012 Riccardo Apolloni
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 |
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 |
16 | # For detail about GNU see .
17 |
18 | import datetime
19 | from typing import List
20 |
21 | from .. import utils
22 | from .router import IsoPoint, Router, RouterParam, RoutingResult
23 |
24 |
25 | class LinearBestIsoRouter(Router):
26 | PARAMS = {
27 | **Router.PARAMS,
28 | "min_increase": RouterParam(
29 | "min_increase",
30 | "Minimum increase (nm)",
31 | "float",
32 | "Set the minimum value for selecting a new valid point",
33 | default=10.0,
34 | lower=1.0,
35 | upper=100.0,
36 | step=0.1,
37 | digits=1,
38 | ),
39 | }
40 |
41 | def _route(self, lastlog, time, timedelta, start, end, iso_f): # noqa: C901
42 | position = start
43 | path = []
44 |
45 | def generate_path(p):
46 | nonlocal path
47 | nonlocal isoc # noqa: F824
48 | nonlocal position
49 | path.append(p)
50 | for iso in isoc[::-1][1::]:
51 | path.append(iso[path[-1].prev_idx])
52 | path = path[::-1]
53 | position = path[-1].pos
54 |
55 | if self.grib.get_wind_at(
56 | time + datetime.timedelta(hours=timedelta), end[0], end[1]
57 | ):
58 | if lastlog is not None and len(lastlog.isochrones) > 0:
59 | isoc = iso_f(
60 | time + datetime.timedelta(hours=timedelta),
61 | timedelta,
62 | lastlog.isochrones,
63 | end,
64 | )
65 | else:
66 | nwdist = utils.point_distance(end[0], end[1], start[0], start[1])
67 | isoc = iso_f(
68 | time + datetime.timedelta(hours=timedelta),
69 | timedelta,
70 | [[IsoPoint((start[0], start[1]), time=time, next_wp_dist=nwdist)]],
71 | end,
72 | )
73 |
74 | nearest_dist = self.get_param_value("min_increase")
75 | nearest_solution = None
76 | for p in isoc[-1]:
77 | distance_to_end_point = p.point_distance(end)
78 | if distance_to_end_point < self.get_param_value("min_increase"):
79 | # (twd,tws) = self.grib.get_wind_at (time + datetime.timedelta(hours=timedelta),
80 | # p.pos[0], p.pos[1])
81 | max_reach_distance = utils.max_reach_distance(p.pos, p.speed)
82 | if distance_to_end_point < abs(max_reach_distance * 1.1):
83 | if (
84 | not self.point_validity
85 | or self.point_validity(end[0], end[1])
86 | ) and (
87 | not self.line_validity
88 | or self.line_validity(end[0], end[1], p.pos[0], p.pos[1])
89 | ):
90 | if distance_to_end_point < nearest_dist:
91 | nearest_dist = distance_to_end_point
92 | nearest_solution = p
93 | if nearest_solution:
94 | generate_path(nearest_solution)
95 |
96 | # out of grib scope
97 | else:
98 | min_dist = 1000000
99 | isoc = lastlog.isochrones
100 | for p in isoc[-1]:
101 | check_dist = p.point_distance(end)
102 | if check_dist < min_dist:
103 | min_dist = check_dist
104 | min_p = p
105 | generate_path(min_p)
106 |
107 | return RoutingResult(
108 | time=time + datetime.timedelta(hours=timedelta),
109 | path=path,
110 | position=position,
111 | isochrones=isoc,
112 | )
113 |
114 | def get_current_best_path(self, lastlog, end) -> List: # noqa: C901
115 | path = []
116 |
117 | def generate_path(p):
118 | nonlocal path
119 | nonlocal isoc # noqa: F824
120 | path.append(p)
121 | for iso in isoc[::-1][1::]:
122 | path.append(iso[path[-1].prev_idx])
123 | path = path[::-1]
124 |
125 | min_dist = 1000000
126 | isoc = lastlog.isochrones
127 | for p in isoc[-1]:
128 | check_dist = p.point_distance(end)
129 | if check_dist < min_dist:
130 | min_dist = check_dist
131 | min_p = p
132 | generate_path(min_p)
133 |
134 | return path
135 |
136 | def route(self, lastlog, t, timedelta, start, end) -> RoutingResult:
137 | return self._route(lastlog, t, timedelta, start, end, self.calculate_isochrones)
138 |
--------------------------------------------------------------------------------
/weatherrouting/routers/router.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | # Copyright (C) 2012 Riccardo Apolloni
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 |
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 |
16 | # For detail about GNU see .
17 |
18 | import math
19 | from concurrent.futures import ThreadPoolExecutor
20 | from dataclasses import dataclass
21 | from typing import Any, Dict, Optional, Tuple
22 |
23 | from .. import utils
24 |
25 | # http://www.tecepe.com.br/nav/vrtool/routing.htm
26 |
27 | # Le isocrone saranno un albero; deve essere semplice:
28 | # - accedere alla lista delle isocrone dell'ultimo albero
29 | # - aggiungere un layer per il nuovo t
30 | # - fare pruning di foglie
31 |
32 | # [[level1], [level2,level2], [level3,level3,level3,level3]]
33 |
34 |
35 | class RouterParam:
36 | def __init__(
37 | self,
38 | code,
39 | name,
40 | ttype,
41 | tooltip,
42 | default,
43 | lower=None,
44 | upper=None,
45 | step=None,
46 | digits=None,
47 | ):
48 | self.code = code
49 | self.name = name
50 | self.ttype = ttype
51 | self.tooltip = tooltip
52 | self.default = default
53 | self.value = default
54 |
55 | self.lower = lower
56 | self.upper = upper
57 | self.digits = digits
58 | self.step = step
59 |
60 |
61 | class RoutingNoWindError(Exception):
62 | pass
63 |
64 |
65 | class RoutingResult:
66 | def __init__(self, time, path=[], isochrones=[], position=None, progress=0):
67 | self.time = time
68 | self.path = path
69 | self.isochrones = isochrones
70 | self.position = position
71 | self.progress = progress
72 |
73 | def __str__(self):
74 | sp = list(map(lambda x: x.to_list(True), self.path))
75 | return f"RoutingResult(time={self.time}, path={sp}, progress={self.progress})"
76 | # position=%s, self.position,
77 |
78 |
79 | @dataclass
80 | class IsoPoint:
81 | pos: Tuple[float, float]
82 | prev_idx: int = -1
83 | time: Optional[float] = None
84 | twd: float = 0
85 | tws: float = 0
86 | speed: float = 0
87 | brg: float = 0
88 | next_wp_dist: float = 0
89 | start_wp_los: Tuple[float, float] = (0, 0)
90 |
91 | def to_list(self, only_pos=False):
92 | if only_pos:
93 | return [self.pos[0], self.pos[1]]
94 | return [
95 | self.pos[0],
96 | self.pos[1],
97 | self.prev_idx,
98 | self.time,
99 | self.twd,
100 | self.tws,
101 | self.speed,
102 | self.brg,
103 | self.next_wp_dist,
104 | self.start_wp_los,
105 | ]
106 |
107 | @staticmethod
108 | def from_list(lst):
109 | return IsoPoint(
110 | (lst[0], lst[1]),
111 | lst[2],
112 | lst[3],
113 | lst[4],
114 | lst[5],
115 | lst[6],
116 | lst[7],
117 | lst[8],
118 | lst[9],
119 | )
120 |
121 | def lossodromic(self, to):
122 | return utils.lossodromic(self.pos[0], self.pos[1], to[0], to[1])
123 |
124 | def point_distance(self, to):
125 | return utils.point_distance(to[0], to[1], self.pos[0], self.pos[1])
126 |
127 |
128 | class Router:
129 | PARAMS: Dict[str, Any] = {
130 | "subdiv": RouterParam(
131 | "subdiv",
132 | "Filtering subdivision of isopoint resolution",
133 | "int",
134 | "Set the filtering subdivision of isopoint resolution",
135 | default=1,
136 | lower=1,
137 | upper=30,
138 | step=1,
139 | digits=0,
140 | ),
141 | "concurrent": RouterParam(
142 | "concurrent",
143 | "Calculation concurrency",
144 | "bool",
145 | "Enable isochrones calculation concurrency",
146 | default=False,
147 | lower=False,
148 | upper=True,
149 | ),
150 | }
151 |
152 | def __init__(
153 | self,
154 | polar,
155 | grib,
156 | point_validity=None,
157 | line_validity=None,
158 | points_validity=None,
159 | lines_validity=None,
160 | ):
161 | self.polar = polar
162 | self.grib = grib
163 | self.point_validity = point_validity
164 | self.line_validity = line_validity
165 | self.points_validity = points_validity
166 | self.lines_validity = lines_validity
167 |
168 | if self.points_validity:
169 | self.point_validity = None
170 | if self.lines_validity:
171 | self.line_validity = None
172 |
173 | def set_param_value(self, code, value):
174 | if code not in self.PARAMS:
175 | raise Exception(f"Invalid param: {code}")
176 | self.PARAMS[code].value = value
177 |
178 | def get_param_value(self, code):
179 | if code not in self.PARAMS:
180 | raise Exception(f"Invalid param: {code}")
181 | return self.PARAMS[code].value
182 |
183 | def calculate_shortest_path_isochrones(self, fixed_speed, t, dt, isocrone, nextwp):
184 | """Calculates isochrones based on shortest path at fixed speed in knots (motoring);
185 | the speed considers reductions / increases derived from leeway"""
186 |
187 | def point_f(p, tws, twa, dt, brg):
188 | # TODO: add current factor
189 | speed = fixed_speed
190 | return (
191 | utils.routage_point_distance(
192 | p[0], p[1], speed * dt * utils.NAUTICAL_MILE_IN_KM, brg
193 | ),
194 | speed,
195 | )
196 |
197 | return self._calculate_isochrones(
198 | t, dt, isocrone, nextwp, point_f, self.get_param_value("subdiv")
199 | )
200 |
201 | def calculate_isochrones(self, t, dt, isocrone, nextwp):
202 | """Calculate isochrones depending on routageSpeed from polar"""
203 |
204 | def point_f(p, tws, twa, dt, brg):
205 | speed = self.polar.get_speed(tws, math.copysign(twa, 1))
206 | # Issue 19 : for routage_point_distance defaut distance unit is nm
207 | # speed*dt is nm (don't convert in km)
208 | rpd = (
209 | utils.routage_point_distance(p[0], p[1], speed * dt, brg),
210 | speed,
211 | )
212 | # print ('tws', tws, 'sog', speed, 'twa', math.degrees(twa), 'brg',
213 | # math.degrees(brg), 'rpd', rpd)tox
214 |
215 | return rpd
216 |
217 | return self._calculate_isochrones(
218 | t, dt, isocrone, nextwp, point_f, self.get_param_value("subdiv")
219 | )
220 |
221 | def _filter_validity(self, isonew, last): # noqa: C901
222 | def valid_point(a):
223 | if not self.point_validity(a.pos[0], a.pos[1]):
224 | return False
225 | return True
226 |
227 | def valid_line(a):
228 | if not self.line_validity(
229 | a.pos[0], a.pos[1], last[a.prev_idx].pos[0], last[a.prev_idx].pos[1]
230 | ):
231 | return False
232 | return True
233 |
234 | if self.point_validity:
235 | isonew = list(filter(valid_point, isonew))
236 | if self.line_validity:
237 | isonew = list(filter(valid_line, isonew))
238 | if self.points_validity:
239 | pp = list(map(lambda a: a.pos, isonew))
240 | pv = self.points_validity(pp)
241 |
242 | for x in range(len(isonew)):
243 | if not pv[x]:
244 | isonew[x] = None
245 | isonew = list(filter(lambda a: a is not None, isonew))
246 | if self.lines_validity:
247 | pp = list(
248 | map(
249 | lambda a: [
250 | a.pos[0],
251 | a.pos[1],
252 | last[a.prev_idx].pos[0],
253 | last[a.prev_idx].pos[1],
254 | ],
255 | isonew,
256 | )
257 | )
258 | pv = self.lines_validity(pp)
259 |
260 | for x in range(len(isonew)):
261 | if not pv[x]:
262 | isonew[x] = None
263 | isonew = list(filter(lambda a: a is not None, isonew))
264 |
265 | return isonew
266 |
267 | def _calculate_isochrones( # noqa: C901
268 | self, t, dt, isocrone, nextwp, point_f, subdiv
269 | ):
270 | """Calcuates isochrones based on pointF next point calculation"""
271 | last = isocrone[-1]
272 |
273 | newisopoints = []
274 |
275 | def _calculate_iso_points(i):
276 | last = isocrone[-1]
277 | cisos = []
278 | p = last[i]
279 |
280 | try:
281 | (twd, tws) = self.grib.get_wind_at(t, p.pos[0], p.pos[1])
282 | except Exception as e:
283 | raise RoutingNoWindError() from e
284 |
285 | twd = math.radians(twd)
286 | tws = utils.ms_to_knots(tws)
287 |
288 | for twa in range(-180, 180, 5):
289 | twa = math.radians(twa)
290 | brg = utils.reduce360(twd + twa)
291 |
292 | # Calculate next point
293 | ptoiso, speed = point_f(p.pos, tws, twa, dt, brg)
294 |
295 | nextwpdist = utils.point_distance(
296 | ptoiso[0], ptoiso[1], nextwp[0], nextwp[1]
297 | )
298 | startwplos = isocrone[0][0].lossodromic((ptoiso[0], ptoiso[1]))
299 |
300 | if nextwpdist > p.next_wp_dist:
301 | continue
302 |
303 | # if self.point_validity:
304 | # if not self.point_validity (ptoiso[0], ptoiso[1]):
305 | # continue
306 | # if self.line_validity:
307 | # if not self.line_validity (ptoiso[0], ptoiso[1], p.pos[0], p.pos[1]):
308 | # continue
309 |
310 | cisos.append(
311 | IsoPoint(
312 | (ptoiso[0], ptoiso[1]),
313 | i,
314 | t,
315 | twd,
316 | tws,
317 | speed,
318 | math.degrees(brg),
319 | nextwpdist,
320 | startwplos,
321 | )
322 | )
323 |
324 | return cisos
325 |
326 | # foreach point of the iso
327 |
328 | if self.get_param_value("concurrent"):
329 | executor = ThreadPoolExecutor()
330 | for x in executor.map(_calculate_iso_points, range(0, len(last))):
331 | newisopoints.extend(x)
332 |
333 | executor.shutdown()
334 | else:
335 | for i in range(0, len(last)):
336 | newisopoints += _calculate_iso_points(i)
337 |
338 | newisopoints = sorted(newisopoints, key=(lambda a: a.start_wp_los[1]))
339 |
340 | # Remove slow isopoints inside
341 | bearing = {}
342 | for x in newisopoints:
343 | k = str(int(math.degrees(x.start_wp_los[1]) / subdiv))
344 |
345 | if k in bearing:
346 | if x.next_wp_dist < bearing[k].next_wp_dist:
347 | bearing[k] = x
348 | else:
349 | bearing[k] = x
350 |
351 | isonew = self._filter_validity(list(bearing.values()), last)
352 | isonew = sorted(isonew, key=(lambda a: a.start_wp_los[1]))
353 | isocrone.append(isonew)
354 |
355 | # print(f"Before filtre: {len(newisopoints)}\tAfter filter: {len(isonew)}")
356 |
357 | return isocrone
358 |
359 | def calculate_vmg(self, speed, angle, start, end) -> float:
360 | """Calculates the Velocity-Made-Good of a boat sailing from start to end
361 | at current speed / angle"""
362 | return speed * math.cos(angle)
363 |
364 | def route(self, lastlog, t, timedelta, start, end) -> RoutingResult:
365 | raise Exception("Not implemented")
366 |
--------------------------------------------------------------------------------
/weatherrouting/routers/shortestpathrouter.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | # Copyright (C) 2012 Riccardo Apolloni
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 |
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 |
16 | # For detail about GNU see .
17 | from .linearbestisorouter import LinearBestIsoRouter, RouterParam, RoutingResult
18 |
19 |
20 | class ShortestPathRouter(LinearBestIsoRouter):
21 | PARAMS = {
22 | **LinearBestIsoRouter.PARAMS,
23 | "min_increase": RouterParam(
24 | "min_increase",
25 | "Minimum increase (nm)",
26 | "float",
27 | "Set the minimum value for selecting a new valid point",
28 | default=10.0,
29 | lower=1.0,
30 | upper=100.0,
31 | step=0.1,
32 | digits=1,
33 | ),
34 | "fixed_speed": RouterParam(
35 | "fixed_speed",
36 | "Fixed speed (kn)",
37 | "float",
38 | "Set the fixed speed",
39 | default=5.0,
40 | lower=1.0,
41 | upper=60.0,
42 | step=0.1,
43 | digits=1,
44 | ),
45 | }
46 |
47 | def route(self, lastlog, t, timedelta, start, end) -> RoutingResult:
48 | return self._route(
49 | lastlog,
50 | t,
51 | timedelta,
52 | start,
53 | end,
54 | lambda t, dt, isoc, end: self.calculate_shortest_path_isochrones(
55 | self.get_param_value("fixed_speed"), t, timedelta, isoc, end
56 | ),
57 | )
58 |
--------------------------------------------------------------------------------
/weatherrouting/routing.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | #
4 | # This program is free software: you can redistribute it and/or modify
5 | # it under the terms of the GNU General Public License as published by
6 | # the Free Software Foundation, either version 3 of the License, or
7 | # (at your option) any later version.
8 |
9 | # This program is distributed in the hope that it will be useful,
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | # GNU General Public License for more details.
13 |
14 | # For detail about GNU see .
15 | from typing import List
16 |
17 | from .routers import RoutingResult, linearbestisorouter
18 |
19 |
20 | def list_routing_algorithms():
21 | """Return a list of routing algorithms along with their names"""
22 |
23 | return [
24 | {
25 | "name": "LinearBestIsoRouter",
26 | "class": linearbestisorouter.LinearBestIsoRouter,
27 | }
28 | ]
29 |
30 |
31 | class Routing:
32 | """
33 | Routing class
34 | """
35 |
36 | def __init__(
37 | self,
38 | algorithm,
39 | polar,
40 | track,
41 | grib,
42 | start_datetime,
43 | start_position=None,
44 | point_validity=None,
45 | line_validity=None,
46 | points_validity=None,
47 | lines_validity=None,
48 | ):
49 | """
50 | Parameters
51 | ----------
52 | algorithm : Router
53 | The routing algorithm class
54 | polar : Polar
55 | Polar object of the boat we want to route
56 | track : list
57 | A list of track points (lat, lon)
58 | grib : Grib
59 | Grib object that abstract our wind / wave / wathever queries
60 | start_datetime : datetime
61 | Start time
62 | start_position : (float, float)
63 | Optional, default to None
64 | A pair containing initial position (or None if we want to start from the
65 | first track point)
66 | point_validity : function(lat, lon)
67 | Optional, default to None
68 | A functions that receives lat and lon and returns True if the point is valid
69 | (ie: in the sea)
70 | line_validity : function(lat1, lon1, lat2, lon2)
71 | Optional, default to None
72 | A functions that receives a vector defined by lat1, lon1, lat2, lon2 and
73 | returns True if the line is valid (ie: completely in the sea)
74 | points_validity : function (latlons)
75 | Optional, default to None
76 | A functions that receives a list of latlon and returns a list of boolean with
77 | True if the point at i is valid (ie: in the sea)
78 | lines_validity : function(latlons)
79 | Optional, default to None
80 | A functions that receives a list of vectors defined by lat1, lon1, lat2, lon2
81 | and returns a list of boolean with True if the line at i is valid (ie:
82 | completely in the sea)
83 |
84 | """
85 |
86 | self.end = False
87 | self.algorithm = algorithm(
88 | polar, grib, point_validity, line_validity, points_validity, lines_validity
89 | )
90 | self.track = track
91 | self.steps = 0
92 | self.path = []
93 | self.time = start_datetime
94 | self.grib = grib
95 | self.log = []
96 | self._startingNewPoint = True
97 |
98 | if start_position:
99 | self.wp = 0
100 | self.position = start_position
101 | else:
102 | self.wp = 1
103 | self.position = self.track[0]
104 |
105 | def get_current_best_path(self) -> List:
106 | last_wp = (self.wp - 1) if self.wp >= len(self.track) else self.wp
107 | return self.algorithm.get_current_best_path(self.log[-1], self.track[last_wp])
108 |
109 | def step(self, timedelta=1) -> RoutingResult:
110 | """Execute a single routing step"""
111 | self.steps += 1
112 |
113 | if self.wp >= len(self.track):
114 | self.end = True
115 | res = self.log[-1]
116 | return self.log[-1]
117 |
118 | # Next waypoint
119 | nextwp = self.track[self.wp]
120 |
121 | if self._startingNewPoint or len(self.log) == 0:
122 | res = self.algorithm.route(
123 | None, self.time, timedelta, self.position, nextwp
124 | )
125 | self._startingNewPoint = False
126 | else:
127 | res = self.algorithm.route(
128 | self.log[-1], self.time, timedelta, self.position, nextwp
129 | )
130 |
131 | # self.time += 0.2
132 | ff = 100 / len(self.track)
133 | progress = ff * self.wp + len(self.log) % ff
134 |
135 | if len(res.path) != 0:
136 | self.position = res.position
137 | self.path = self.path + res.path
138 | self.wp += 1
139 | self._startingNewPoint = True
140 |
141 | np = []
142 | ptime = None
143 | for x in self.path:
144 | nt = x.time
145 |
146 | if ptime:
147 | if ptime < nt:
148 | np.append(x)
149 | ptime = nt
150 | else:
151 | np.append(x)
152 | ptime = nt
153 |
154 | self.path = np
155 | self.time = res.time
156 | nlog = RoutingResult(
157 | progress=progress, time=res.time, path=self.path, isochrones=res.isochrones
158 | )
159 |
160 | self.log.append(nlog)
161 | return nlog
162 |
--------------------------------------------------------------------------------
/weatherrouting/utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright (C) 2017-2025 Davide Gessa
3 | # Copyright (C) 2021 Enrico Ferreguti
4 | # Copyright (C) 2012 Riccardo Apolloni
5 | #
6 | # This program is free software: you can redistribute it and/or modify
7 | # it under the terms of the GNU General Public License as published by
8 | # the Free Software Foundation, either version 3 of the License, or
9 | # (at your option) any later version.
10 |
11 | # This program is distributed in the hope that it will be useful,
12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 | # GNU General Public License for more details.
15 |
16 | # For detail about GNU see .
17 | import math
18 | from typing import Tuple
19 |
20 | import latlon
21 |
22 | # from geographiclib.geodesic import Geodesic
23 | # geod = Geodesic.WGS84
24 |
25 | EARTH_RADIUS = 60.0 * 360 / (2 * math.pi) # nm
26 | NAUTICAL_MILE_IN_KM = 1.852
27 | # Speed conversion m/s to kt
28 | MS2KT = 1.94384
29 |
30 |
31 | def ms_to_knots(v: float) -> float:
32 | return v * MS2KT
33 |
34 |
35 | def cfbinomiale(n: int, i: int) -> float:
36 | # TODO: remove
37 | return math.factorial(n) / (math.factorial(n - i) * math.factorial(i))
38 |
39 |
40 | def ortodromic2(
41 | lat1: float, lon1: float, lat2: float, lon2: float
42 | ) -> Tuple[float, float]:
43 | # TODO: remove
44 | p1 = math.radians(lat1)
45 | p2 = math.radians(lat2)
46 | dp = math.radians(lat2 - lat1)
47 | dp2 = math.radians(lon2 - lon1)
48 |
49 | a = math.sin(dp / 2) * math.sin(dp2 / 2) + math.cos(p1) * math.cos(p2) * math.sin(
50 | dp2 / 2
51 | ) * math.sin(dp2 / 2)
52 | c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
53 | return (EARTH_RADIUS * c, a)
54 |
55 |
56 | def ortodromic(
57 | lat_a: float, lon_a: float, lat_b: float, lon_b: float
58 | ) -> Tuple[float, float]:
59 | """Returns the ortodromic distance in km between A and B"""
60 | # g = geod.Inverse(lat_a, lon_a, lat_b, lon_b)
61 | # return (g['s12'] * 1e-3, math.radians (g['azi1']))
62 |
63 | p1 = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
64 | p2 = latlon.LatLon(latlon.Latitude(lat_b), latlon.Longitude(lon_b))
65 | return (p1.distance(p2), math.radians(p1.heading_initial(p2)))
66 |
67 |
68 | def lossodromic(
69 | lat_a: float, lon_a: float, lat_b: float, lon_b: float
70 | ) -> Tuple[float, float]:
71 | """Returns the lossodromic distance in km between A and B"""
72 | # g = geod.Inverse(lat_a, lon_a, lat_b, lon_b)
73 | # return (g['s12'] * 1e-3, math.radians (g['azi1']))
74 |
75 | p1 = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
76 | p2 = latlon.LatLon(latlon.Latitude(lat_b), latlon.Longitude(lon_b))
77 | return (p1.distance(p2, ellipse="sphere"), math.radians(p1.heading_initial(p2)))
78 |
79 |
80 | def km2nm(d: float) -> float:
81 | return d * 0.539957
82 |
83 |
84 | def nm2km(d: float) -> float:
85 | return d / 0.539957
86 |
87 |
88 | def point_distance(
89 | lat_a: float, lon_a: float, lat_b: float, lon_b: float, unit: str = "nm"
90 | ) -> float:
91 | """Returns the distance between two geo points"""
92 | p1 = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
93 | p2 = latlon.LatLon(latlon.Latitude(lat_b), latlon.Longitude(lon_b))
94 | d = p1.distance(p2)
95 |
96 | # d = ortodromic(lat_a, lon_a, lat_b, lon_b)[0]
97 |
98 | if unit == "nm":
99 | return km2nm(d)
100 | else: # if unit == "km":
101 | return d
102 |
103 |
104 | def routage_point_distance(
105 | lat_a: float, lon_a: float, distance: float, hdg: float, unit: str = "nm"
106 | ) -> Tuple[float, float]:
107 | """Returns the point from (lat_a, lon_a) to the given (distance, hdg)"""
108 | if unit == "nm":
109 | d = nm2km(distance)
110 | elif unit == "km":
111 | d = distance
112 |
113 | # g = geod.Direct(lat_a, lon_a, math.degrees(hdg), d * 1e3)
114 | # return (g['lat2'], g['lon2'])
115 |
116 | p = latlon.LatLon(latlon.Latitude(lat_a), latlon.Longitude(lon_a))
117 | of = p.offset(math.degrees(hdg), d).to_string("D")
118 | return (float(of[0]), float(of[1]))
119 |
120 |
121 | def max_reach_distance(p, speed: float, dt: float = (1.0 / 60.0 * 60.0)) -> float:
122 | maxp = routage_point_distance(p[0], p[1], speed * dt, 1)
123 | return point_distance(p[0], p[1], maxp[0], maxp[1])
124 |
125 |
126 | def reduce360(alfa: float) -> float:
127 | if math.isnan(alfa):
128 | return 0.0
129 |
130 | n_ = int(alfa * 0.5 / math.pi)
131 | n = math.copysign(n_, 1)
132 | if alfa > 2.0 * math.pi:
133 | alfa = alfa - n * 2.0 * math.pi
134 | if alfa < 0:
135 | alfa = (n + 1) * 2.0 * math.pi + alfa
136 | if alfa > 2.0 * math.pi or alfa < 0:
137 | return 0.0
138 | return alfa
139 |
140 |
141 | def reduce180(alfa: float) -> float:
142 | if alfa > math.pi:
143 | alfa = alfa - 2 * math.pi
144 | if alfa < -math.pi:
145 | alfa = 2 * math.pi + alfa
146 | if alfa > math.pi or alfa < -math.pi:
147 | return 0.0
148 | return alfa
149 |
150 |
151 | def path_as_geojson(path) -> object:
152 | feats = []
153 | route = []
154 |
155 | for order, wayp in enumerate(path):
156 | feat = {
157 | "type": "Feature",
158 | "id": order,
159 | "geometry": {
160 | "type": "Point",
161 | "coordinates": [wayp.pos[1], wayp.pos[0]], # longitude, latitude
162 | },
163 | "properties": {
164 | "timestamp": str(wayp.time),
165 | "twd": math.degrees(wayp.twd),
166 | "tws": wayp.tws,
167 | "knots": wayp.speed,
168 | "heading": wayp.brg,
169 | },
170 | }
171 | feats.append(feat)
172 | route.append([wayp.pos[1], wayp.pos[0]]) # longitude, latitude
173 |
174 | feats.append(
175 | {
176 | "type": "Feature",
177 | "id": 999,
178 | "geometry": {"type": "LineString", "coordinates": route},
179 | "properties": {
180 | "start-timestamp": str(path[0].time),
181 | "end-timestamp": str(path[-1].time),
182 | },
183 | }
184 | )
185 |
186 | return {"type": "FeatureCollection", "features": feats}
187 |
--------------------------------------------------------------------------------