Commit inicial: splat-rs — fork/port a Rust de SPLAT!

Fork de SPLAT! (John A. Magliacane, KD2BD, 2002-2014), bajo GPLv2 heredada
del original. El núcleo de cálculo de propagación (ITWOM v3.0, Sid Shumate)
se mantiene sin modificar en cpp/itwom3.0.cpp, llamado vía FFI. El resto
del pipeline (lectura de formatos SPLAT!, reportes, mapas, KML, gnuplot)
se reescribió en Rust, con paralelización nativa vía rayon reemplazando
el paralelismo por múltiples procesos del original. Validado contra el
corpus golden de SPLAT! con drift < 0.02 dB en cálculos ITWOM.
This commit is contained in:
2026-08-23 01:50:41 -05:00
commit 9d7d3bac45
46 changed files with 9883 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Rust build artifacts
/target/
**/*.rs.bk
Cargo.lock.bak
# Notas internas de desarrollo (no deben subirse)
SESION-DIALOGO.txt
# Runtime logs
nohup.out
*.log
# Editor / OS junk
.DS_Store
*.swp
*.swo
*~
.vscode/
.idea/
.claude/
Generated
+289
View File
@@ -0,0 +1,289 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "cc"
version = "1.2.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "splat-rs"
version = "0.1.0"
dependencies = [
"anyhow",
"cc",
"clap",
"rayon",
"thiserror",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "splat-rs"
version = "0.1.0"
edition = "2024"
description = "Port a Rust de SPLAT! (John A. Magliacane, KD2BD) con paralelización nativa"
license = "GPL-2.0-only"
[dependencies]
rayon = "1.10"
thiserror = "2"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
[build-dependencies]
cc = "1"
[lib]
name = "splat"
path = "src/lib.rs"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
[[bin]]
name = "splat-bench"
path = "src/bin/bench.rs"
[[bin]]
name = "splat-rs"
path = "src/bin/splat-rs.rs"
+338
View File
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+565
View File
@@ -0,0 +1,565 @@
# splat-rs — Manual del Usuario
**splat-rs** es un port en Rust de SPLAT! (Signal Propagation, Loss, And Terrain
analysis tool) con paralelización nativa, salidas en español y una nueva
funcionalidad de tabla de intensidad de campo.
Compatible con los formatos `.qth`, `.lrp`, `.az`, `.el`, `.udt`, `.cities` y
`.sdf[.bz2]` del SPLAT! 1.4.x original. Validado contra el corpus golden de
SPLAT! con drift inferior a 0.02 dB en cálculos ITWOM.
---
## 1. Inicio rápido
```bash
# Análisis de enlace TX → RX
splat-rs -t txenlace.qth -r rxenlace.qth -d sdf/
# Cobertura completa de un sitio
splat-rs -t tx_site.qth -d sdf/ -L 10 -R 80
# Tabla de intensidad de campo (alternativa al script perfiles.sh)
splat-rs -t tx_site.qth -d sdf/ --sft atenuaciones.txt --sft-max-km 150
```
Salidas en `--out-dir` (default: directorio actual):
| Archivo | Cuándo se genera |
|---|---|
| `<TX>-site_report.txt` | si **no** hay `-r` |
| `<TX>-to-<RX>.txt` | si hay `-r` (path report) |
| `coverage.ppm`/`.png`/`.kml` | si **no** está `--no-map` |
| `<archivo>.txt` + `.csv` | si está `--sft <archivo>` |
| `splat.gp` + `*.gp` | si está `-h/-H/-p/-e/-l <archivo>` |
---
## 2. Construcción
```bash
cd splat-rs
cargo build --release
# Binario: ./target/release/splat-rs
```
Requiere:
- Rust ≥ 1.80 (probado con 1.92)
- `libbz2.so` accesible (sistema o miniconda)
- `gnuplot` (opcional, para renderizar los `.gp`)
- `convert` de ImageMagick (opcional, para PPM → PNG transparente)
Para ejecutar con libbz2 desde miniconda:
```bash
LD_LIBRARY_PATH=$HOME/miniconda3/lib ./target/release/splat-rs ...
```
---
## 3. Formatos de entrada
### `.qth` — Sitio (TX o RX)
Cuatro líneas:
```
Nombre del sitio
-1 -01 -23.54 ; latitud (DMS o decimal)
79 27 31.21 ; longitud (DMS o decimal, positiva = oeste)
14 m ; altura antena AGL (metros con 'm', si no = pies)
```
### `.lrp` — Parámetros Longley-Rice
Una constante numérica por línea, comentarios con `;`:
```
15.000 ; Constante dieléctrica de la Tierra
0.005 ; Conductividad (S/m)
301.00 ; Refracción atmosférica (N-units)
426 ; Frecuencia (MHz)
5 ; Clima radioeléctrico (1-7)
1 ; Polarización (0=H, 1=V)
0.50 ; Fracción de situaciones
0.50 ; Fracción de tiempo
2000 ; ERP en watts (línea 9, opcional)
```
Climas: `1`=Ecuatorial, `2`=Subtropical Continental, `3`=Subtropical Marítimo,
`4`=Desértico, `5`=Templado Continental, `6`=Templado Marítimo (tierra),
`7`=Templado Marítimo (mar).
### `.az` — Patrón azimutal de antena (autodetectado al lado del `.qth` del TX)
```
345.0 ; rotación en grados (clockwise desde el norte verdadero)
0 0.49 ; az amplitud (0..1)
1 0.50
2 0.51
...
```
### `.el` — Patrón vertical de antena
```
8.0 345.0 ; tilt mecánico, azimuth del tilt
-10 0.04
-9.5 0.06
...
```
Usa `--no-pattern` para ignorar archivos `.az`/`.el` aun si existen.
### `.udt` — Terreno definido por usuario
CSV de obstáculos puntuales:
```
; Edificios y antenas en Quevedo
-1.0250, 79.4587, 50 m
-1.0260, 79.4590, 80 m
```
Cada feature se "pinta" en un radio de ~5 píxeles (~465 m a 1200 ppd) sobre el DEM.
El máximo entre la base SDF y el UDT prevalece.
### `.cities` o `-s <file>` — Sitios de interés
CSV `name, lat, lon`:
```
Quito, -0.18, 78.47
Guayaquil, -2 10 0, 79 53 0
```
### `.sdf[.bz2]` — Datos de elevación digital
Archivos SPLAT! estándar. Nombres `minlat:maxlat:minlon:maxlon.sdf.bz2` cubriendo
× 1° cada uno. Carpeta vía `-d sdf/`. Soporta tanto 3 arc-sec (1200 ppd) como
1 arc-sec (3600 ppd, modo HD).
---
## 4. Referencia de banderas
### Entrada
| Bandera | Descripción |
|---|---|
| `-t, --tx FILE` | TX `.qth` (obligatorio) |
| `-r, --rx FILE` | RX `.qth` (opcional; si está → path report; si no → site report) |
| `-d, --sdf DIR` | Directorio con tiles `.sdf[.bz2]` (obligatorio) |
| `--lrp FILE` | `.lrp` (default: `splat.lrp` junto al TX) |
| `--erp WATTS` | Override del ERP del `.lrp` |
| `-s, --cities FILE` | Archivo de sitios; repetible |
| `--udt FILE` | Terreno definido por usuario; repetible |
### Modelo y geometría
| Bandera | Descripción | Default |
|---|---|---|
| `--olditm` | Usa Longley-Rice clásico en vez de ITWOM v3.0 | off (ITWOM) |
| `-L, --rx-altitude-m M` | Altura RX AGL para sweep de cobertura | 10 m |
| `-R, --range-km KM` | Radio de cobertura | 40 km |
| `--metric` | Unidades métricas en reportes | true |
| `--gc M` | Altura de clutter (obstáculos urbanos) | 0 |
| `--fz F` | Clearance Fresnel (`F` ∈ [0,1]) | 0.6 (60%) |
| `--no-pattern` | Ignora `.az`/`.el` autodetectados | off |
### Salidas — gráficas (cada una toma un nombre de archivo)
| Bandera | Genera | Equivale a SPLAT |
|---|---|---|
| `-h FILE` | Gráfico de alturas (perfil + LOS + curvatura + Fresnel) | `-h` |
| `-H FILE` | Versión normalizada del anterior | `-H` |
| `-p FILE` | Perfil del terreno (elevación vs distancia) | `-p` |
| `-e FILE` | Perfil de ángulo de elevación | `-e` |
| `-l FILE` | Gráfico de pérdida del trayecto | `-l` |
| `--plot-format FMT` | Tipo de salida gnuplot (png, svg, ps...) | — |
> **Convención `-h`/`-H`**: el plot de alturas usa el marco de **tierra
> aplanada (4/3)**: el perfil del terreno se eleva por
> `d·(D-d)/(2·R')` para que la LOS sea una recta y la zona de Fresnel se
> dibuje como su geometría natural debajo. Si el terreno aparente toca la
> Fresnel en el gráfico, es una obstrucción real — convención estándar en
> ingeniería de RF (ATDI, Pathloss, etc.). Además se superpone una
> **referencia de curvatura** (parábola en el mismo eje Y, anclada al
> fondo del plot) cuyo peak coincide con el terreno más bajo y endpoints
> sit en `min(terreno)max_drop`. La magnitud del hump es D²/(8·R'):
> ~150 m para D=100 km, ~180 m para D=110 km. Si quieres ver el perfil
> geográfico crudo (sin corrección de curvatura), usa `-p`.
> **Auto-render**: si `gnuplot` está en el PATH, splat-rs lo invoca automáticamente
> tras escribir los `.gp` y produce directamente el PNG/SVG/PS. Cada flag escribe
> a su propio subdirectorio (`h-<stem>/`, `p-<stem>/`, etc.) para que no colisionen
> al pedir varios plots a la vez:
>
> ```bash
> splat-rs -t tx.qth -r rx.qth -d sdf/ --no-map \
> -h alturas.png -p terreno.png -e elevacion.png
> # → out_dir/h-alturas/alturas.png
> # → out_dir/p-terreno/terreno.png
> # → out_dir/e-elevacion/elevacion.png
> ```
>
> Si no hay `gnuplot`, splat-rs deja los `.gp` para que el usuario los renderice
> manualmente (`cd subdir && gnuplot splat.gp`).
### Salidas — mapa de cobertura (sweep 360°)
| Bandera | Descripción |
|---|---|
| `-c, --los-coverage` | Modo LOS (sin ITWOM, ~75× más rápido) |
| `--no-map` | No generar mapa (solo reporte) |
| `--no-kml` | No generar KML |
| `--no-link-line` | Omitir línea TX→RX en KML |
| `--max-loss-db DB` | Umbral de pérdida (mayor → blanco transparente). FM: 110-120, UHF: 125-135, microondas: 160+ |
| `--min-loss-db DB` | Pérdida mínima (mapea al color más fuerte) |
> **Leyenda automática**: cada vez que se genera un mapa de cobertura, splat-rs
> también escribe `legend.svg` (con texto) y `legend.ppm` (barra de colores
> sola) que muestran el mapeo color↔pérdida↔intensidad de campo del run.
### Tabla de intensidad de campo (`-sft`)
| Bandera | Descripción |
|---|---|
| `--sft FILE` | Genera tabla en `<FILE>` (texto) y `<FILE>.csv` |
| `--sft-max-km KM` | Extiende a `KM` (default 90; pasos de 10 km después de 90) |
| `--sft-azimuths LISTA` | Azimuts custom CSV (default: 0,30,60,...,330) |
| `--sft-distances LISTA` | Distancias custom CSV (override total) |
### Sistema
| Bandera | Descripción | Default |
|---|---|---|
| `--threads N` | Hilos para rayon (0 = todos los cores) | 0 |
| `--out-dir DIR` | Directorio de salida | `.` |
| `--help` | Ayuda completa | — |
| `-V, --version` | Versión | — |
---
## 5. Flujos de trabajo comunes
### 5.1 Análisis rápido de sitio (qué tan elevada está la antena vs el terreno)
```bash
splat-rs -t mi_sitio.qth -d sdf/ --no-map
```
Genera `mi_sitio-site_report.txt` con: ubicación, elevación del terreno,
altura de la antena, **HAAT** (altura sobre terreno promedio según FCC
Part 73.313(d)) y promedios de terreno en 8 azimuts cardinales.
### 5.2 Estudio de enlace punto-a-punto
```bash
splat-rs -t tx.qth -r rx.qth -d sdf/ --no-map -h alturas.png
```
Genera:
- Path report en español con pérdida ITWOM, modo de propagación, análisis de
obstrucciones y zonas de Fresnel
- `alturas.png` (vía `gnuplot splat.gp`) con perfil + LOS + curvatura 4/3 +
Fresnel del 60% y 100%
### 5.3 Mapa de cobertura ITWOM completo (lento pero preciso)
```bash
splat-rs -t tx.qth -d sdf/ -L 10 -R 80 \
--max-loss-db 120 \
-s ciudades.cities --out-dir cobertura/
```
Genera en `cobertura/`:
- `coverage.ppm` (raster PPM puro)
- `coverage.png` (con blanco → transparente)
- `coverage.kml` (Google Earth con TX, ciudades y línea de enlace)
- `<TX>-site_report.txt`
Tiempo típico: ~2-4 min en 16 cores para 80 km de radio.
### 5.4 Mapa LOS rápido (bosquejo previo)
```bash
splat-rs -t tx.qth -d sdf/ -L 10 -R 80 -c
```
Mismo formato de salidas pero **~75× más rápido** (sin llamadas a ITWOM).
Útil para identificar primero qué zonas tienen línea de vista antes de hacer
el sweep ITWOM completo. Tiempo típico: 1-3 s.
### 5.5 Tabla de intensidad de campo (alternativa a `perfiles.sh`)
**Default**: 12 azimuts × 13 distancias (540 km en pasos de 5, luego 50/60/70/80/90):
```bash
splat-rs -t tx.qth -d sdf/ --olditm --no-map --sft atenuaciones.txt
```
**Extendida hasta 150 km**:
```bash
splat-rs -t tx.qth -d sdf/ --olditm --no-map \
--sft atenuaciones.txt --sft-max-km 150
```
**Grid completamente personalizado** (8 cardinales × distancias específicas):
```bash
splat-rs -t tx.qth -d sdf/ --olditm --no-map \
--sft puntos.txt \
--sft-azimuths 0,45,90,135,180,225,270,315 \
--sft-distances 1,2,5,10,20,50,100,150,200
```
Salida en `atenuaciones.txt`:
```
# Tabla de intensidad de campo (dBµV/m) — splat-rs
# TX: Cerro Cochabamba @ (-1.6986, 79.1072), 91.5 MHz, ERP 2000 W, modelo LongleyRice
Dist/Az 0° 30° 60° 90° 120° ...
5 km 95.08 69.96 43.30 35.89 35.82 ...
10 km 24.62 47.60 30.73 33.41 13.03 ...
...
```
Y CSV `atenuaciones.csv` para Excel/LibreOffice. Si necesitas decimal coma
para `es_EC`:
```bash
sed 's/\./,/g' atenuaciones.csv > atenuaciones-es.csv
```
Tiempo típico: **<10 ms** por tabla (compute) más 6-7 s de carga SDF (una vez).
### 5.6 Estudio con patrón de antena directiva
Si junto al `.qth` del TX están `<base>.az` y `<base>.el`, splat-rs los detecta
automáticamente y aplica el patrón en la pérdida y la tabla SFT:
```bash
ls
# tx_site.qth tx_site.az tx_site.el splat.lrp
splat-rs -t tx_site.qth -d sdf/ --sft tabla.txt
# stderr: "Patrón de antena cargado (rotación 345.0°, tilt 8.0°)"
```
El reporte incluye `Patrón de antena de TX hacia RX: 0.562 (-5.00 dB)` cuando
hay `-r`.
### 5.7 Override de ERP para análisis A/B
```bash
splat-rs -t tx.qth -r rx.qth -d sdf/ --erp 2000 --no-map
splat-rs -t tx.qth -r rx.qth -d sdf/ --erp 5000 --no-map # comparar
```
### 5.8 UDT — terreno con edificios sintéticos
```bash
cat > obstaculos.udt <<EOF
; Torre de transmisión vecina
-1.0245, 79.4593, 50 m
EOF
splat-rs -t tx.qth -r rx.qth -d sdf/ --udt obstaculos.udt --no-map
```
El reporte detectará obstrucciones en cada UDT y recomendará la altura mínima
de antena RX para librar terreno y zonas de Fresnel.
---
## 6. Salidas explicadas
### Path report (`<TX>-to-<RX>.txt`)
Bloques en orden:
1. **Sitio transmisor**: nombre, ubicación DMS, elevación, altura antena, HAAT,
distancia, azimut, ángulo elevación al RX
2. **Sitio receptor**: idem desde el RX
3. **Parámetros del modelo**: dieléctrica, conductividad, refracción, frecuencia,
clima, polarización, fracciones, ERP/EIRP (si está en `.lrp`)
4. **Resumen del enlace**: pérdida en espacio libre, pérdida ITWOM (o LR),
atenuación por terreno, modo de propagación (LDV / horizonte simple /
horizonte doble + difracción/troposcatter)
5. **Análisis de obstrucciones**: lista de cada punto bloqueante y recomendaciones
de altura para librar LOS / 60% Fresnel / Fresnel completa
### Site report (`<TX>-site_report.txt`)
Cuando solo se da `-t`. Estructura:
- Sitio (DMS), elevación del terreno, altura de la antena
- HAAT (altura sobre promedio del terreno)
- Promedio del terreno en 8 azimuts (0°, 45°, 90°, ..., 315°) entre 2 y 10 millas
### Coverage map
- **`coverage.ppm`**: raster PPM/P6, dimensiones automáticas según los radiales
- **`coverage.png`**: misma imagen con `convert -transparent white` si está
ImageMagick disponible
- **`coverage.kml`**: Google Earth con `GroundOverlay` apuntando al PNG, línea
amarilla TX→RX (si hay -r), Placemarks para TX/RX/ciudades
- **`legend.svg`**: leyenda con barra de colores y dos ejes:
pérdida en dB a la izquierda, intensidad de campo (dBµV/m) a la derecha.
Calculada con la frecuencia y ERP del `.lrp`. Renderiza en cualquier navegador
o se convierte con `convert legend.svg legend.png`.
- **`legend.ppm`**: solo la barra de color (sin texto), 30×280 px. Útil para
componer con el mapa via `convert +append coverage.png legend.ppm composite.png`
o similar.
### Tabla SFT
- **`<archivo>`**: tabla de texto formateada con encabezado descriptivo
- **`<archivo>.csv`**: para importar en hojas de cálculo
### Gráficos gnuplot
Cada vez que se pide `-h/-H/-p/-e/-l`, se genera un set en el subdirectorio:
- `splat.gp`: orquestador (set title/xlabel/ylabel + plot)
- `profile.gp`: terreno
- `reference.gp`: línea de vista
- `curvature.gp`: curvatura 4/3 Tierra
- `fresnel.gp`, `fresnel_pt_6.gp`: zonas de Fresnel (cuando aplica)
Para renderizar: `cd subdir && gnuplot splat.gp` produce el archivo de imagen.
---
## 7. Rendimiento
### Hardware típico (16 cores)
| Operación | Tiempo |
|---|---|
| Carga 54 tiles SDF (Ecuador completo) | 6-7 s |
| Path report TX↔RX (1 enlace) | <1 s |
| Site report (HAAT + 8 radiales) | <1 s |
| Tabla SFT 12×13 | 1-2 ms |
| Tabla SFT 12×19 (hasta 150 km) | 2-3 ms |
| Mapa LOS coverage 80 km | 1-3 s |
| Mapa ITWOM coverage 40 km | 1-2 min |
| Mapa ITWOM coverage 80 km | 3-5 min |
Speedup típico paralelo / secuencial: 10-12× (eficiencia 65-75% de los 16 cores).
### Comparación con SPLAT! original (single-thread)
| | SPLAT! v1.4.2 | splat-rs |
|---|---|---|
| Mapa ITWOM 40 km | ~25 min | ~2 min (12.5× más rápido) |
| Tabla SFT (`perfiles.sh`) | ~30 min | ~7 s (260× más rápido) |
| Path report único | ~2 s | <1 s |
---
## 8. Diagnóstico de problemas
### "El TX (X, Y) no está cubierto por los tiles SDF de ..."
El TX cae fuera de los tiles cargados. Verifica los nombres de archivo en
`sdf/`: deben tener formato `minlat:maxlat:minlon:maxlon.sdf.bz2` y cubrir
el rango de coordenadas del TX/RX.
### Mapa de cobertura completamente blanco
`--max-loss-db` demasiado bajo para el escenario:
- Microondas en LOS limpio: pérdida 100-130 dB → `--max-loss-db 130` o más
- Microondas con obstrucciones: hasta 180 dB → `--max-loss-db 180`
### Patrón de antena no aplicado
splat-rs busca `<base>.az` y `<base>.el` donde `<base>` es el nombre del `.qth`
sin extensión. Si tu antena se llama `tx.qth`, debe haber `tx.az` / `tx.el` en
el mismo directorio. Verifica con `--no-pattern` para forzar isotrópica y
comparar.
### Aliasing radial visible en mapa de cobertura
Es inherente al método radial-sweep. Mitigaciones:
- Subir umbral con `--max-loss-db` para mostrar solo cobertura útil
- Usar HD mode (DEM 1 arc-sec, ppd=3600) — 9× más lento pero menos artifacts
- El renderer ya aplica supercover 2×2 + dilatación; resta es físicamente irreducible
### `gnuplot: not found` al pedir `-h/-H/-p/-e/-l`
splat-rs solo escribe los `.gp` data y orquestador. Para rasterizar necesitas
`gnuplot` instalado:
```bash
sudo apt install gnuplot
```
### Ejecución más lenta de lo esperado
Verifica que rayon esté usando todos los cores:
```bash
splat-rs ... 2>&1 | head -1
# "Hilos disponibles: 16 (de 16 lógicos)"
```
Si aparece menos, usa `--threads N` para forzar.
---
## 9. Diferencias con SPLAT! original
### Mejoras
- **Reentrancia thread-safe**: `static → thread_local` en ITWOM permite N hilos sin contención
- **Paralelización nativa**: rayon distribuye radiales/SFT-celdas entre todos los cores
- **Carga DEM única**: vs SPLAT que recarga en cada invocación
- **Salida en español** (homologada por Charles)
- **Sintaxis CLI compatible** + extensiones (`--sft`, `--threads`, `--max-loss-db`)
- **CSV de la tabla SFT** además del texto
- **Mensajes de error útiles** con contexto
### No implementado
- `-ano` / `-ani`: input/output alfanumérico (uso poco común)
- `-b`: archivos de fronteras cartográficas (overlay PPM)
- `-dbm`: paleta dBm en vez de dBµV/m (existe en código pero no expuesto en CLI)
- `-geo`: archivo georreferencia Xastir (formato muy específico)
- `-log`: registro del comando (uso `>&2` redirect en shell)
- `-ngs`: topografía gris como blanco (escala de grises)
- `-sc`: contornos suaves (paleta lineal vs cuantizada)
- `-db`: contornos por encima de un umbral
- `-nf`: omitir Fresnel en height plot (siempre se grafica)
### Drift numérico
- ITWOM path loss: ±0.02 dB vs SPLAT golden (floating-point con `-ffast-math` activado en C++ original)
- HAAT: ±0.1 m por sampling de los 8 radiales
- Ángulos: bit-idénticos (mismo radio terrestre 20902230.97 ft)
---
## 10. Estructura del proyecto
```
splat-rs/
├── Cargo.toml
├── MANUAL.md # este archivo
├── build.rs # compila itwom + linkea libbz2
├── cpp/
│ ├── itwom3.0.cpp # vendored, static→thread_local
│ └── itwom_ffi.cpp # C ABI wrapper (refs → pointers)
└── src/
├── lib.rs # mod tree
├── itwom.rs # FFI seguro a ITWOM/Longley-Rice
├── sdf.rs # loader .sdf[.bz2] + DemTileSet indexado
├── qth.rs # parser .qth (DMS o decimal)
├── lrp.rs # parser .lrp (tolerante a prosa)
├── pat.rs # parser .az/.el + AntennaPattern
├── udt.rs # parser .udt + DemWithUdt overlay
├── cities.rs # parser .cities
├── geo.rs # Distance, Azimuth, step_great_circle
├── radial.rs # compute_radial + plot_lr_map_parallel
├── coverage.rs # CoverageMap + rasterize_line + fill_holes
├── ppm.rs # writer PPM con paleta signal-strength
├── kml.rs # writer KML GroundOverlay + LineString
├── gnuplot.rs # writers .gp (perfil/altura/elevación)
├── report.rs # PathReport + SiteReport + ObstructionAnalysis
├── sft.rs # Strength Field Table (paralelo)
├── types.rs # Site, Config
└── bin/
├── splat-rs.rs # CLI principal
└── bench.rs # microbenchmark ser vs par
```
26 tests unitarios + 4 ignored (end-to-end con DEM real).
---
## 11. Créditos
- **SPLAT!** original: John A. Magliacane, KD2BD (20022014)
- **ITWOM v3.0**: Sid Shumate (2010)
- **Adaptación al español + variante 1.4.2-Charles**: Ing. Charles Escobar
- **Port a Rust + paralelización**: 2026
Bajo licencia GPLv2 (heredada del SPLAT! original).
+66
View File
@@ -0,0 +1,66 @@
# splat-rs
**splat-rs** es un *fork* / port a Rust de [**SPLAT!**](http://www.qsl.net/kd2bd/splat.html)
(Signal Propagation, Loss, And Terrain analysis tool), la herramienta de
predicción de cobertura de radiofrecuencia de referencia en el mundo del
radioaficionado y la radiodifusión.
## Proyecto original
**SPLAT!** fue creado por **John A. Magliacane, KD2BD** (20022014) y es
software libre bajo **GPLv2**. Este repositorio es un fork derivado de
SPLAT! 1.4.2 — no es un proyecto independiente ni pretende serlo.
El núcleo de cálculo de pérdida de propagación (**ITWOM v3.0**, de
**Sid Shumate**, a su vez basado en el modelo ITM/Longley-Rice de dominio
público del NTIA/ITS con contribuciones de J. D. McDonald y del propio
John A. Magliacane) **se mantiene sin modificar**, compilado desde
`cpp/itwom3.0.cpp` y llamado vía FFI (`cpp/itwom_ffi.cpp``src/itwom.rs`).
Ver cabecera de ese archivo para la historia completa de autoría del modelo.
## Qué cambia este fork
- **Port completo a Rust** del resto del pipeline (antes en C++): lectura de
`.qth`/`.lrp`/`.sdf`/`.az`/`.el`/`.udt`/`.cities`, generación de reportes,
mapas de cobertura, KML, gráficos vía gnuplot.
- **Paralelización nativa con [rayon](https://crates.io/crates/rayon)**: el
cálculo de cobertura, que en el SPLAT! original se paralelizaba lanzando
múltiples procesos por separado (uno por radial/azimut), acá corre en un
solo proceso multi-hilo — mismo resultado numérico (validado contra el
corpus golden de SPLAT! con drift < 0.02 dB), muchísimo más rápido.
- Salidas en español, tabla de intensidad de campo (SFT) nueva, defaults
métricos, leyenda SVG nativa sin depender de un bitmap font vendorizado.
Detalle completo de las fases de desarrollo y decisiones de diseño en el
historial de commits.
## Binarios
| Binario | Descripción |
|---|---|
| `splat-rs` | Herramienta principal — compatible en banderas con el SPLAT! original |
| `splat-bench` | Microbenchmark serial vs. paralelo del núcleo ITWOM |
## Compilar
```bash
cargo build --release
```
Requiere un compilador de C++ (se compila `cpp/itwom3.0.cpp` vía `build.rs`).
## Uso
Ver [`MANUAL.md`](./MANUAL.md) para la referencia completa de banderas y
ejemplos (análisis de enlace, cobertura, tabla SFT, exportación KML/PPM, etc.).
## Créditos y licencia
| | |
|---|---|
| SPLAT! original | John A. Magliacane, KD2BD (20022014) |
| ITWOM v3.0 | Sid Shumate (2010), sobre ITM/NTIA (dominio público) |
| Port a Rust + paralelización | Charles Escobar, 2026 |
Distribuido bajo los términos de la **GNU General Public License v2.0**
(GPLv2), heredada del SPLAT! original. Ver el archivo [`LICENSE`](./LICENSE).
+168
View File
@@ -0,0 +1,168 @@
0º dBuVm
30º dBuVm
60º dBuVm
90º dBuVm
120º dBuVm
150º dBuVm
180º dBuVm
210º dBuVm
240º dBuVm
270º dBuVm
300º dBuVm
330º dBuVm
+14
View File
@@ -0,0 +1,14 @@
0º dBuVm
+14
View File
@@ -0,0 +1,14 @@
120º dBuVm
+14
View File
@@ -0,0 +1,14 @@
150º dBuVm
+14
View File
@@ -0,0 +1,14 @@
180º dBuVm
+14
View File
@@ -0,0 +1,14 @@
210º dBuVm
+14
View File
@@ -0,0 +1,14 @@
240º dBuVm
+14
View File
@@ -0,0 +1,14 @@
270º dBuVm
+14
View File
@@ -0,0 +1,14 @@
30º dBuVm
+14
View File
@@ -0,0 +1,14 @@
300º dBuVm
+14
View File
@@ -0,0 +1,14 @@
330º dBuVm
+14
View File
@@ -0,0 +1,14 @@
60º dBuVm
+14
View File
@@ -0,0 +1,14 @@
90º dBuVm
+34
View File
@@ -0,0 +1,34 @@
fn main() {
cc::Build::new()
.cpp(true)
.file("cpp/itwom3.0.cpp")
.file("cpp/itwom_ffi.cpp")
.flag_if_supported("-std=c++11")
.flag_if_supported("-O3")
.flag_if_supported("-ffast-math")
.flag_if_supported("-fomit-frame-pointer")
.warnings(false)
.compile("itwom");
// libbz2 is used by the SDF loader (FFI shim in src/sdf.rs). On this
// machine libbz2-dev isn't installed system-wide, but miniconda ships it.
// BZ2_BZ2_LIB_DIR can override; otherwise we probe common locations.
if let Ok(dir) = std::env::var("BZ2_LIB_DIR") {
println!("cargo:rustc-link-search=native={dir}");
} else {
for candidate in ["/home/cescobar/miniconda3/lib", "/usr/lib/x86_64-linux-gnu"] {
if std::path::Path::new(candidate).join("libbz2.so").exists()
|| std::path::Path::new(candidate).join("libbz2.a").exists()
|| std::path::Path::new(candidate).join("libbz2.so.1.0").exists()
{
println!("cargo:rustc-link-search=native={candidate}");
break;
}
}
}
println!("cargo:rustc-link-lib=dylib=bz2");
println!("cargo:rerun-if-changed=cpp/itwom3.0.cpp");
println!("cargo:rerun-if-changed=cpp/itwom_ffi.cpp");
println!("cargo:rerun-if-env-changed=BZ2_LIB_DIR");
}
+2863
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
// C ABI wrapper around the C++ ITWOM entry points so Rust can FFI them
// cleanly. The C++ originals take `double &` / `int &` reference params,
// which have no C equivalent; we translate them to pointers.
#include <cstring>
extern void point_to_point(double elev[], double tht_m, double rht_m,
double eps_dielect, double sgm_conductivity, double eno_ns_surfref,
double frq_mhz, int radio_climate, int pol, double conf, double rel,
double &dbloss, char *strmode, int &errnum);
extern void point_to_point_ITM(double elev[], double tht_m, double rht_m,
double eps_dielect, double sgm_conductivity, double eno_ns_surfref,
double frq_mhz, int radio_climate, int pol, double conf, double rel,
double &dbloss, char *strmode, int &errnum);
extern double ITWOMVersion();
extern "C" {
// The C++ point_to_point / point_to_point_ITM signatures are non-const on
// `elev`, but by inspection of itwom3.0.cpp neither routine writes into
// the elev array — they only read samples (and use scratch space held in
// the thread_local statics we patched in). Passing the caller's buffer
// directly avoids a per-call heap alloc + memcpy that otherwise dominates
// a full map sweep at ~200 M invocations.
//
// If a future upstream update to ITWOM starts mutating `elev`, Rust's
// ownership guarantees still hold: the elev_buf in compute_radial gets
// cleared and refilled at the top of every ITWOM call, so mid-call
// scribbles would be overwritten before being observed.
void splat_point_to_point(double* elev, double tht_m, double rht_m,
double eps_dielect, double sgm_conductivity, double eno_ns_surfref,
double frq_mhz, int radio_climate, int pol, double conf, double rel,
double* dbloss, char* strmode, int strmode_len, int* errnum)
{
double loss = 0.0;
int err = 0;
char mode[64] = {0};
point_to_point(elev, tht_m, rht_m, eps_dielect, sgm_conductivity,
eno_ns_surfref, frq_mhz, radio_climate, pol, conf, rel,
loss, mode, err);
*dbloss = loss;
*errnum = err;
if (strmode && strmode_len > 0) {
std::strncpy(strmode, mode, (size_t)strmode_len - 1);
strmode[strmode_len - 1] = '\0';
}
}
void splat_point_to_point_itm(double* elev, double tht_m, double rht_m,
double eps_dielect, double sgm_conductivity, double eno_ns_surfref,
double frq_mhz, int radio_climate, int pol, double conf, double rel,
double* dbloss, char* strmode, int strmode_len, int* errnum)
{
double loss = 0.0;
int err = 0;
char mode[64] = {0};
point_to_point_ITM(elev, tht_m, rht_m, eps_dielect, sgm_conductivity,
eno_ns_surfref, frq_mhz, radio_climate, pol, conf, rel,
loss, mode, err);
*dbloss = loss;
*errnum = err;
if (strmode && strmode_len > 0) {
std::strncpy(strmode, mode, (size_t)strmode_len - 1);
strmode[strmode_len - 1] = '\0';
}
}
double splat_itwom_version() { return ITWOMVersion(); }
} // extern "C"
+29
View File
@@ -0,0 +1,29 @@
use splat::{geo, radial::DemLookup, sdf::DemTileSet, udt};
fn main() {
let mut base = DemTileSet::new();
base.load_dir("/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf").unwrap();
let fs = udt::load("/tmp/test-obstacle.udt").unwrap();
let wrapped = udt::DemWithUdt::new(&base, fs, 1200);
let tx = (-1.023206, 79.458669);
let rx = (-1.698611, 79.107222);
let az_from_rx = geo::azimuth(rx.0, rx.1, tx.0, tx.1);
let dist_mi = geo::distance(tx.0, tx.1, rx.0, rx.1);
println!("dist {:.2} mi, az from rx {:.2}°", dist_mi, az_from_rx);
// Sample 20 points along the path from RX to TX; print the elevation
// via the UDT-wrapped DEM at each sample point.
let mut prev_z = 0.0;
for i in 0..=20 {
let d_mi = (i as f64 / 20.0) * dist_mi;
let (lat, lon) = geo::step_great_circle(rx.0, rx.1, az_from_rx, d_mi);
let z = wrapped.elevation(lat, lon);
let mark = if z > prev_z + 1000.0 { " ← UDT HIT" } else { "" };
println!(
" d={:5.1} mi ({:.4}, {:.4}) z={:.0}m{}",
d_mi, lat, lon, z, mark,
);
prev_z = z;
}
}
+191
View File
@@ -0,0 +1,191 @@
#!/bin/bash
# Alias splat -> splat-rs (versión nueva)
alias splat='splat-rs'
shopt -s expand_aliases
# Detectar hilos disponibles del procesador
NUM_THREADS=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 2)
echo "=== SPLAT paralelo | Hilos detectados: $NUM_THREADS | Alias: splat -> splat-rs ==="
ruta="."
# Copiar archivos base del proyecto
cp "$HOME/.splat-gui/projects/$1/prediccion/tx_sitepred.qth" "$ruta/txprofile.qth"
cp "$HOME/.splat-gui/projects/$1/prediccion/splat.lrp" "$ruta/splat.lrp"
# Constantes de desplazamiento
dezp=0.53965407
dezpj=$(echo "scale=8; $dezp/12" | bc)
dezp=0
# Leer y convertir coordenadas TX a decimal
tx_sitepred=$(sed -n 1p "$ruta/txprofile.qth" | sed 's/ /_/g')
lat=$(sed -n 2p "$ruta/txprofile.qth")
lon=$(sed -n 3p "$ruta/txprofile.qth")
lat_gradopred=$(echo "$lat" | awk '{print $1}')
lat_minutopred=$(echo "$lat" | awk '{print $2}')
lat_segundopred=$(echo "$lat" | awk '{print $3}')
lattx=$(echo "scale=8; $lat_gradopred + $lat_minutopred/60 + $lat_segundopred/3600" | bc)
long_gradopred=$(echo "$lon" | awk '{print $1}')
long_minutopred=$(echo "$lon" | awk '{print $2}')
long_segundopred=$(echo "$lon" | awk '{print $3}')
longtx=$(echo "scale=8; $long_gradopred + $long_minutopred/60 + $long_segundopred/3600" | bc)
# Reescribir txprofile en formato decimal puro
echo "$tx_sitepred" > "$ruta/txprofile.qth"
echo "$lattx" >> "$ruta/txprofile.qth"
echo "$longtx" >> "$ruta/txprofile.qth"
echo "30 m" >> "$ruta/txprofile.qth"
# Constantes para direcciones diagonales
a=0.269827035
b=0.467354134
aj=$(echo "scale=8; $a/12" | bc)
bj=$(echo "scale=8; $b/12" | bc)
# === Sistema de progreso global ===
TOTAL_STEPS=168 # 12 perfiles × (13 atenuaciones + 1 imagen final)
PROGRESS_FILE=$(mktemp)
echo 0 > "$PROGRESS_FILE"
PROGRESS_LOCK="${PROGRESS_FILE}.lock"
touch "$PROGRESS_LOCK"
update_progress() {
local angulo=$1
local paso_local=$2
local total_local=14
(
flock -x 200
local current
current=$(cat "$PROGRESS_FILE")
current=$((current + 1))
echo "$current" > "$PROGRESS_FILE"
local pct_total pct_local
pct_total=$(echo "scale=1; $current * 100 / $TOTAL_STEPS" | bc)
pct_local=$(echo "scale=1; $paso_local * 100 / $total_local" | bc)
printf "\r[Total: %5s%% | Perfil %3s°: %5s%% (%2d/%2d)]" \
"$pct_total" "$angulo" "$pct_local" "$paso_local" "$total_local" >&2
) 200>"$PROGRESS_LOCK"
}
# === Función de procesamiento por perfil (ángulo) ===
procesar_perfil() {
local angulo=$1
local rxfile="${ruta}/rxprofile_${angulo}.qth"
local attnfile="${ruta}/atenuaciones${angulo}.ttxt"
local paso=0
echo "${angulo}º dBuVm" > "$attnfile"
local i=1
while [ $i -lt 19 ]; do
local latrx longrx
case $angulo in
0) latrx=$(echo "scale=8; $lattx+$dezpj*$i" | bc); longrx=$longtx ;;
180) latrx=$(echo "scale=8; $lattx-$dezpj*$i" | bc); longrx=$longtx ;;
90) latrx=$lattx; longrx=$(echo "scale=8; $longtx-$dezpj*$i" | bc) ;;
270) latrx=$lattx; longrx=$(echo "scale=8; $longtx+$dezpj*$i" | bc) ;;
30) latrx=$(echo "scale=8; $lattx+$bj*$i" | bc); longrx=$(echo "scale=8; $longtx-$aj*$i" | bc) ;;
60) latrx=$(echo "scale=8; $lattx+$aj*$i" | bc); longrx=$(echo "scale=8; $longtx-$bj*$i" | bc) ;;
120) latrx=$(echo "scale=8; $lattx-$aj*$i" | bc); longrx=$(echo "scale=8; $longtx-$bj*$i" | bc) ;;
150) latrx=$(echo "scale=8; $lattx-$bj*$i" | bc); longrx=$(echo "scale=8; $longtx-$aj*$i" | bc) ;;
210) latrx=$(echo "scale=8; $lattx-$bj*$i" | bc); longrx=$(echo "scale=8; $longtx+$aj*$i" | bc) ;;
240) latrx=$(echo "scale=8; $lattx-$aj*$i" | bc); longrx=$(echo "scale=8; $longtx+$bj*$i" | bc) ;;
300) latrx=$(echo "scale=8; $lattx+$aj*$i" | bc); longrx=$(echo "scale=8; $longtx+$bj*$i" | bc) ;;
330) latrx=$(echo "scale=8; $lattx+$bj*$i" | bc); longrx=$(echo "scale=8; $longtx+$aj*$i" | bc) ;;
esac
echo "perfil de ${angulo} grados" > "$rxfile"
echo "$latrx" >> "$rxfile"
echo "$longrx" >> "$rxfile"
echo "10 m" >> "$rxfile"
local dist=$((i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
case $angulo in
0)
timeout 10 splat -t "$ruta/txprofile.qth" -r "$rxfile" -d /opt/splat/sdf -olditm -metric -l "$ruta/attnprfl${angulo}.png"
;;
30)
timeout 10 splat -r "$rxfile" -t "$ruta/txprofile.qth" -d /opt/splat/sdf -olditm -metric -nf -l "$ruta/attnprfl${angulo}.png"
;;
*)
timeout 10 splat -r "$rxfile" -t "$ruta/txprofile.qth" -d /opt/splat/sdf -olditm -metric -l "$ruta/attnprfl${angulo}.png"
;;
esac
local attn
attn=$(cat "${tx_sitepred}-to-perfil_de_${angulo}_grados.txt" | grep "Field strength" | cut -d " " -f 8 | sed 's/\./,/g')
echo "$attn" >> "$attnfile"
paso=$((paso + 1))
update_progress "$angulo" "$paso"
fi
i=$((i + 1))
done
# Imagen de perfil final (mismas inversiones TX/RX que el original)
case $angulo in
0)
splat -r "$ruta/txprofile.qth" -t "$rxfile" -d /opt/splat/sdf -olditm -metric -n -H "$ruta/perfil${angulo}.png"
;;
*)
splat -t "$rxfile" -r "$ruta/txprofile.qth" -d /opt/splat/sdf -olditm -metric -n -H "$ruta/perfil${angulo}.png"
;;
esac
paso=$((paso + 1))
update_progress "$angulo" "$paso"
# Limpiar archivo temporal de receptor
rm -f "$rxfile"
}
# === Lanzamiento paralelo controlado ===
ANGULOS=(0 30 60 90 120 150 180 210 240 270 300 330)
PIDS=()
for angulo in "${ANGULOS[@]}"; do
# Limpiar PIDs que ya terminaron
new_pids=()
for pid in "${PIDS[@]}"; do
kill -0 "$pid" 2>/dev/null && new_pids+=("$pid")
done
PIDS=("${new_pids[@]}")
# Respetar límite de hilos
while [ ${#PIDS[@]} -ge "$NUM_THREADS" ]; do
sleep 0.3
new_pids=()
for pid in "${PIDS[@]}"; do
kill -0 "$pid" 2>/dev/null && new_pids+=("$pid")
done
PIDS=("${new_pids[@]}")
done
procesar_perfil "$angulo" &
PIDS+=($!)
done
# Esperar a que terminen todos los trabajos
for pid in "${PIDS[@]}"; do
wait "$pid" 2>/dev/null
done
echo "" >&2
# === Concatenación final en orden ===
cat "$ruta/atenuaciones0.ttxt" > "$ruta/atenuaciones.ttxt"
for angulo in 30 60 90 120 150 180 210 240 270 300 330; do
cat "$ruta/atenuaciones${angulo}.ttxt" >> "$ruta/atenuaciones.ttxt"
done
# Limpieza de temporales de progreso
rm -f "$PROGRESS_FILE" "$PROGRESS_LOCK"
echo "=== Proceso completado. Resultados en atenuaciones.ttxt y perfil*.png ==="
Executable
+303
View File
@@ -0,0 +1,303 @@
#!/bin/bash
#rm -f *.txt *.txt *.lrp *.ppm *.geo *.scf ./geo/* ./klm/* ./txt/*
#ruta=$HOME/.splat-gui/projects/"$1"/prediccion/perfiles
#if [[ ! -d "$ruta" ]]; then
# mkdir "$ruta"
#fi
ruta="."
cp $HOME/.splat-gui/projects/"$1"/prediccion/tx_sitepred.qth "$ruta"/txprofile.qth
cp $HOME/.splat-gui/projects/"$1"/prediccion/splat.lrp "$ruta"/splat.lrp
dezp=0.53965407
dezpj=`echo "scale=8; $dezp/12"|bc`
dezp=0
#"$ruta"/txprofile.qth
tx_sitepred=`sed -n 1p "$ruta"/txprofile.qth|sed s/" "/"_"/g`
lat=`sed -n 2p "$ruta"/txprofile.qth`
lon=`sed -n 3p "$ruta"/txprofile.qth`
lat_gradopred=`echo $lat|awk -F" " '{ print $1}'`
lat_minutopred=`echo $lat|awk -F" " '{ print $2}'`
lat_segundopred=`echo $lat|awk -F" " '{ print $3}'`
#echo "$lat_gradopred $lat_minutopred $lat_segundopred"
lattx=`echo "scale=8; $lat_gradopred + $lat_minutopred/60 + $lat_segundopred/3600"|bc`
#echo $lattx
long_gradopred=`echo $lon|awk -F" " '{ print $1}'`
long_minutopred=`echo $lon|awk -F" " '{ print $2}'`
long_segundopred=`echo $lon|awk -F" " '{ print $3}'`
longtx=`echo "scale=8; $long_gradopred + $long_minutopred/60 + $long_segundopred/3600"|bc`
#echo "$long_gradopred $long_minutopred $long_segundopred"
#echo $longtx
echo $tx_sitepred > "$ruta"/txprofile.qth
echo $lattx >> "$ruta"/txprofile.qth
echo $longtx >> "$ruta"/txprofile.qth
echo "30 m" >> "$ruta"/txprofile.qth
i=1
echo "0º dBuVm" > atenuaciones0.ttxt
while [ $i -lt "19" ]; do
echo perfil de 0 grados > "$ruta"/rxprofile.qth
latrx0=`echo "scale=8; $lattx+$dezpj*$i" | bc`
longrx0=$longtx
echo $latrx0 >> "$ruta"/rxprofile.qth
echo $longrx0 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -t "$ruta"/txprofile.qth -r "$ruta"/rxprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl0.png
attn=`cat "$tx_sitepred"-to-perfil_de_0_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones0.ttxt
fi
i=$(($i+1))
done
splat -r "$ruta"/txprofile.qth -t "$ruta"/rxprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil0.png
i=1
echo "180º dBuVm" > atenuaciones180.ttxt
while [ $i -lt "19" ]; do
echo perfil de 180 grados > "$ruta"/rxprofile.qth
latrx180=`echo "scale=8; $lattx-$dezpj*$i" | bc`
longrx180=$longtx
echo $latrx180 >> "$ruta"/rxprofile.qth
echo $longrx180 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl180.png
attn=`cat "$tx_sitepred"-to-perfil_de_180_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones180.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil180.png
i=1
echo "90º dBuVm" > atenuaciones90.ttxt
while [ $i -lt "19" ]; do
echo perfil de 90 grados > "$ruta"/rxprofile.qth
latrx90=$lattx
longrx90=`echo "scale=8; $longtx-$dezpj*$i" | bc`
echo $latrx90 >> "$ruta"/rxprofile.qth
echo $longrx90 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl90.png
attn=`cat "$tx_sitepred"-to-perfil_de_90_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones90.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil90.png
i=1
echo "270º dBuVm" > atenuaciones270.ttxt
while [ $i -lt "19" ]; do
echo perfil de 270 grados > "$ruta"/rxprofile.qth
latrx270=$lattx
longrx270=`echo "scale=8; $longtx+$dezpj*$i" | bc`
echo $latrx270 >> "$ruta"/rxprofile.qth
echo $longrx270 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl270.png
attn=`cat "$tx_sitepred"-to-perfil_de_270_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones270.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil270.png
a=0.269827035
b=0.467354134
aj=`echo "scale=8; $a/12"|bc`
bj=`echo "scale=8; $b/12"|bc`
i=1
echo "30º dBuVm" > atenuaciones30.ttxt
while [ $i -lt "19" ]; do
echo perfil de 30 grados > "$ruta"/rxprofile.qth
latrx30=`echo "scale=8; $lattx+$bj*$i" | bc`
longrx30=`echo "scale=8; $longtx-$aj*$i" | bc`
echo $latrx30 >> "$ruta"/rxprofile.qth
echo $longrx30 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -nf -l "$ruta"/attnprfl30.png
attn=`cat "$tx_sitepred"-to-perfil_de_30_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones30.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil30.png
i=1
echo "60º dBuVm" > atenuaciones60.ttxt
while [ $i -lt "19" ]; do
echo perfil de 60 grados > "$ruta"/rxprofile.qth
latrx60=`echo "scale=8; $lattx+$aj*$i" | bc`
longrx60=`echo "scale=8; $longtx-$bj*$i" | bc`
echo $latrx60 >> "$ruta"/rxprofile.qth
echo $longrx60 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl60.png
attn=`cat "$tx_sitepred"-to-perfil_de_60_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones60.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil60.png
i=1
echo "120º dBuVm" > atenuaciones120.ttxt
while [ $i -lt "19" ]; do
echo perfil de 120 grados > "$ruta"/rxprofile.qth
latrx120=`echo "scale=8; $lattx-$aj*$i" | bc`
longrx120=`echo "scale=8; $longtx-$bj*$i" | bc`
echo $latrx120 >> "$ruta"/rxprofile.qth
echo $longrx120 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl120.png
attn=`cat "$tx_sitepred"-to-perfil_de_120_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones120.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil120.png
i=1
echo "150º dBuVm" > atenuaciones150.ttxt
while [ $i -lt "19" ]; do
echo perfil de 150 grados > "$ruta"/rxprofile.qth
latrx150=`echo "scale=8; $lattx-$bj*$i" | bc`
longrx150=`echo "scale=8; $longtx-$aj*$i" | bc`
echo $latrx150 >> "$ruta"/rxprofile.qth
echo $longrx150 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl150.png
attn=`cat "$tx_sitepred"-to-perfil_de_150_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones150.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil150.png
i=1
echo "210º dBuVm" > atenuaciones210.ttxt
while [ $i -lt "19" ]; do
echo perfil de 210 grados > "$ruta"/rxprofile.qth
latrx210=`echo "scale=8; $lattx-$bj*$i" | bc`
longrx210=`echo "scale=8; $longtx+$aj*$i" | bc`
echo $latrx210 >> "$ruta"/rxprofile.qth
echo $longrx210 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl210.png
attn=`cat "$tx_sitepred"-to-perfil_de_210_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones210.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil210.png
i=1
echo "240º dBuVm" > atenuaciones240.ttxt
while [ $i -lt "19" ]; do
echo perfil de 240 grados > "$ruta"/rxprofile.qth
latrx240=`echo "scale=8; $lattx-$aj*$i" | bc`
longrx240=`echo "scale=8; $longtx+$bj*$i" | bc`
echo $latrx240 >> "$ruta"/rxprofile.qth
echo $longrx240 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl240.png
attn=`cat "$tx_sitepred"-to-perfil_de_240_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones240.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil240.png
i=1
echo "300º dBuVm" > atenuaciones300.ttxt
while [ $i -lt "19" ]; do
echo perfil de 300 grados > "$ruta"/rxprofile.qth
latrx300=`echo "scale=8; $lattx+$aj*$i" | bc`
longrx300=`echo "scale=8; $longtx+$bj*$i" | bc`
echo $latrx300 >> "$ruta"/rxprofile.qth
echo $longrx300 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl300.png
attn=`cat "$tx_sitepred"-to-perfil_de_300_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones300.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil300.png
i=1
echo "330º dBuVm" > atenuaciones330.ttxt
while [ $i -lt "19" ]; do
echo perfil de 330 grados > "$ruta"/rxprofile.qth
latrx330=`echo "scale=8; $lattx+$bj*$i" | bc`
longrx330=`echo "scale=8; $longtx+$aj*$i" | bc`
echo $latrx330 >> "$ruta"/rxprofile.qth
echo $longrx330 >> "$ruta"/rxprofile.qth
echo 10 m >> "$ruta"/rxprofile.qth
dist=$(($i*5))
if [[ $dist -le 40 || $dist -eq 50 || $dist -eq 60 || $dist -eq 70 || $dist -eq 80 || $dist -eq 90 ]]; then
timeout 10 splat -r "$ruta"/rxprofile.qth -t "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -l "$ruta"/attnprfl330.png
attn=`cat "$tx_sitepred"-to-perfil_de_330_grados.txt|grep "Field strength"|cut -d " " -f 8|sed s/"\."/","/g`
echo "$attn" >> atenuaciones330.ttxt
fi
i=$(($i+1))
done
splat -t "$ruta"/rxprofile.qth -r "$ruta"/txprofile.qth -d /opt/splat/sdf -olditm -metric -n -H "$ruta"/perfil330.png
cat atenuaciones0.ttxt > atenuaciones.ttxt
cat atenuaciones30.ttxt >> atenuaciones.ttxt
cat atenuaciones60.ttxt >> atenuaciones.ttxt
cat atenuaciones90.ttxt >> atenuaciones.ttxt
cat atenuaciones120.ttxt >> atenuaciones.ttxt
cat atenuaciones150.ttxt >> atenuaciones.ttxt
cat atenuaciones180.ttxt >> atenuaciones.ttxt
cat atenuaciones210.ttxt >> atenuaciones.ttxt
cat atenuaciones240.ttxt >> atenuaciones.ttxt
cat atenuaciones270.ttxt >> atenuaciones.ttxt
cat atenuaciones300.ttxt >> atenuaciones.ttxt
cat atenuaciones330.ttxt >> atenuaciones.ttxt
+75
View File
@@ -0,0 +1,75 @@
//! Microbenchmark: serial vs parallel ITWOM calls, to sanity-check the
//! thread_local patch and rayon scaling before porting the full map pipeline.
use std::time::Instant;
use rayon::prelude::*;
use splat::{LrParams, Model, path_loss};
fn main() {
let n_minus_1 = 499.0_f64;
let delta = 100.0_f64;
let mut elev = vec![n_minus_1, delta];
// Synthetic profile: gently rising then falling, to exercise the
// Longley-Rice kernel beyond the pure flat-earth case.
for i in 0..=(n_minus_1 as usize) {
let x = i as f64 / n_minus_1;
elev.push(50.0 * (std::f64::consts::PI * x).sin());
}
let p = LrParams {
eps_dielect: 15.0,
sgm_conductivity: 0.005,
eno_ns_surfref: 301.0,
frq_mhz: 426.0,
radio_climate: 5,
pol: 1,
conf: 0.5,
rel: 0.5,
};
// Match SPLAT!'s default plot resolution: 1200 ppd * 360° = 432_000 radials.
// Each radial invokes point_to_point once — this approximates a full map.
let n_calls: usize = 432_000;
let threads = rayon::current_num_threads();
println!("rayon threads: {threads}");
println!("calls per run: {n_calls}");
println!("profile samples: {} per call", elev.len() - 2);
// Benchmark both modes with a per-call clone so the two branches stay
// directly comparable: each sample gets a fresh buffer, matching the
// shape of a real radial sweep where the profile buffer is reused.
let t0 = Instant::now();
let mut seq_sum = 0.0_f64;
let mut scratch = elev.clone();
for _ in 0..n_calls {
scratch.clear();
scratch.extend_from_slice(&elev);
seq_sum += path_loss(Model::Itwom, &mut scratch, 30.0, 30.0, &p).db_loss;
}
let d_seq = t0.elapsed();
let t1 = Instant::now();
let par_sum: f64 = (0..n_calls)
.into_par_iter()
.map(|_| {
let mut s = elev.clone();
path_loss(Model::Itwom, &mut s, 30.0, 30.0, &p).db_loss
})
.sum();
let d_par = t1.elapsed();
println!();
println!("sequential: {:>8.3} s (sum={seq_sum:.3})", d_seq.as_secs_f64());
println!("parallel: {:>8.3} s (sum={par_sum:.3})", d_par.as_secs_f64());
println!("speedup: {:>8.2}x", d_seq.as_secs_f64() / d_par.as_secs_f64());
println!("efficiency: {:>8.1}% (of ideal {threads}x)",
100.0 * d_seq.as_secs_f64() / d_par.as_secs_f64() / threads as f64);
// Soundness: parallel and serial reductions of an embarrassingly parallel
// workload with identical inputs must agree on the sum (modulo fp order
// of summation, which *can* differ — so loose compare).
let delta_sum = (seq_sum - par_sum).abs();
println!("sum delta: {delta_sum:.6e}");
}
+545
View File
@@ -0,0 +1,545 @@
//! splat-rs CLI — end-to-end path analysis + coverage map.
//!
//! Flags mirror the commonly-used subset of SPLAT! 1.4's CLI:
//!
//! ```text
//! splat-rs -t tx.qth -r rx.qth -L 10 -d sdf/ -o coverage.ppm -kml
//! splat-rs -t tx.qth --lrp custom.lrp --out-dir out/
//! ```
//!
//! Outputs in `--out-dir` (defaults to `.`):
//! - `<TX>-to-<RX>.txt` path analysis report (Spanish)
//! - `coverage.ppm` raster of path loss (white = no data)
//! - `coverage.png` same raster with white → transparent (if `convert`)
//! - `coverage.kml` GroundOverlay for Google Earth
use std::path::{Path, PathBuf};
use std::time::Instant;
use clap::Parser;
use splat::{
Config, Model,
cities,
coverage::CoverageMap,
gnuplot, itwom, kml, legend, lrp, pat, ppm, qth,
radial::{
DemLookup, RadialCtx,
plot_los_coverage_parallel, plot_lr_map_parallel_radials,
},
report::{self, PathReportOpts},
sdf::DemTileSet,
sft,
types::Site,
udt,
};
#[derive(Parser, Debug)]
#[command(
name = "splat-rs",
version,
about = "RF propagation analysis (Rust port of SPLAT!)",
// SPLAT! uses `-h <file>` for the terrain height graph, so we need the
// `-h` short slot for that flag. Disable clap's auto-help shortcut and
// keep `--help` as the long form.
disable_help_flag = true,
)]
struct Cli {
/// Show help.
#[arg(long = "help", action = clap::ArgAction::Help)]
help: Option<bool>,
/// Transmitter .qth file.
#[arg(short = 't', long)]
tx: PathBuf,
/// Optional receiver .qth file. If omitted, only a site report is produced.
#[arg(short = 'r', long)]
rx: Option<PathBuf>,
/// Longley-Rice parameters (.lrp). Defaults to `splat.lrp` next to the TX file.
#[arg(long)]
lrp: Option<PathBuf>,
/// Directory containing .sdf[.bz2] terrain tiles.
#[arg(short = 'd', long)]
sdf: PathBuf,
/// RX antenna height AGL (meters) used when sweeping the coverage map.
#[arg(short = 'L', long, default_value_t = 10.0)]
rx_altitude_m: f64,
/// Coverage radius in kilometers (SPLAT's -R). Defaults to 40 km.
#[arg(short = 'R', long, alias = "range-km")]
range_km: Option<f64>,
/// Longley-Rice (classic) instead of ITWOM.
#[arg(long = "olditm")]
use_longley_rice: bool,
/// Output directory (default: current).
#[arg(long, default_value = ".")]
out_dir: PathBuf,
/// Skip coverage map generation (path report only).
#[arg(long)]
no_map: bool,
/// Skip KML generation.
#[arg(long)]
no_kml: bool,
/// Metric units in reports (default true; matches Charles' SPLAT build).
#[arg(long, default_value_t = true)]
metric: bool,
// --- Plot outputs (match SPLAT! `-p/-e/-h/-H/-l` taking filenames). ---
/// Terrain profile graph (elevation vs distance). SPLAT `-p`.
#[arg(short = 'p', long = "terrain-plot", value_name = "FILE")]
terrain_plot: Option<PathBuf>,
/// Terrain elevation-angle graph. SPLAT `-e`.
#[arg(short = 'e', long = "elevation-plot", value_name = "FILE")]
elevation_plot: Option<PathBuf>,
/// Terrain height graph (raw elevation + LOS + curvature + Fresnel). SPLAT `-h`.
#[arg(short = 'h', long = "height-plot", value_name = "FILE")]
height_plot: Option<PathBuf>,
/// Normalized terrain height graph (LOS-referenced). SPLAT `-H`.
#[arg(short = 'H', long = "height-plot-norm", value_name = "FILE")]
height_plot_norm: Option<PathBuf>,
/// Path loss graph along TX→RX trajectory. SPLAT `-l`.
#[arg(short = 'l', long = "loss-plot", value_name = "FILE")]
loss_plot: Option<PathBuf>,
/// LOS coverage mode instead of full path-loss map. Much faster:
/// only samples visibility, no ITWOM calls.
#[arg(short = 'c', long)]
los_coverage: bool,
/// Output format for gnuplot plots when invoked via `gnuplot splat.gp`.
#[arg(long, default_value = "png")]
plot_format: String,
/// Ignore any `.az`/`.el` antenna pattern files auto-discovered next to
/// the QTH. Use for a comparison run.
#[arg(long)]
no_pattern: bool,
/// User-defined terrain file(s) (.udt). Features are overlaid onto the SDF.
#[arg(long = "udt")]
udt_files: Vec<PathBuf>,
/// Ground-clutter height in meters (metric) / feet (else). SPLAT `-gc`.
#[arg(long = "gc", default_value_t = 0.0)]
clutter: f64,
/// Override ERP (watts) from the .lrp file. SPLAT `-erp`.
#[arg(long = "erp")]
erp_override: Option<f64>,
/// Fresnel-zone clearance fraction for height plot (default 0.6 = 60%). SPLAT `-fz`.
#[arg(long = "fz", default_value_t = 0.6)]
fzone_clearance: f64,
/// City/site files (`name, lat, lon`). Rendered as KML placemarks. SPLAT `-s`.
#[arg(short = 's', long = "cities")]
city_files: Vec<PathBuf>,
/// Omit the TX→RX link line in the KML output.
#[arg(long)]
no_link_line: bool,
/// Maximum path loss to render (dB). Pixels above stay white.
#[arg(long = "max-loss-db", default_value_t = 130.0)]
max_loss_db: f64,
/// Minimum path loss to render (dB).
#[arg(long = "min-loss-db", default_value_t = 80.0)]
min_loss_db: f64,
// --- Strength Field Table (new feature, no SPLAT equivalent) ------
/// Compute a field-strength table. Default grid: 12 azimuths (every 30°)
/// × 13 distances (540 km in 5-km steps, then 50/60/70/80/90 km).
/// Output is a text table and a CSV. Writes `<FILE>` (text) and
/// `<FILE>.csv`. Ignores `-r` / `-c` / `-L`. Parallel over cells.
#[arg(long = "sft", alias = "strength-field-table", value_name = "FILE")]
sft: Option<PathBuf>,
/// Extend the SFT distance grid up to this many km. Steps past 90 km
/// are added at 10-km intervals (100, 110, …). Default: 90 (no extension).
#[arg(long = "sft-max-km", default_value_t = 90.0)]
sft_max_km: f64,
/// Comma-separated list of azimuths (degrees) to use for the SFT table.
/// Overrides the default 12-radial pattern. Example: `0,45,90,135,180,225,270,315`.
#[arg(long = "sft-azimuths", value_delimiter = ',')]
sft_azimuths: Vec<f64>,
/// Comma-separated list of distances (km). Overrides the built-in grid
/// AND `--sft-max-km`. Example: `1,2,5,10,20,50,100,150`.
#[arg(long = "sft-distances", value_delimiter = ',')]
sft_distances: Vec<f64>,
/// Pool size for parallel computation. Default: all available cores.
/// Used by the coverage sweep and the SFT table.
#[arg(long, default_value_t = 0)]
threads: usize,
}
fn main() -> anyhow::Result<()> {
let args = Cli::parse();
std::fs::create_dir_all(&args.out_dir)?;
// Rayon pool config. Default (threads=0) means rayon auto-detects all
// logical cores — same as num_cpus::get(). User override via --threads.
if args.threads > 0 {
rayon::ThreadPoolBuilder::new()
.num_threads(args.threads)
.build_global()
.ok();
}
eprintln!("Hilos disponibles: {} (de {} lógicos)",
rayon::current_num_threads(),
std::thread::available_parallelism()
.map(|n| n.get()).unwrap_or(rayon::current_num_threads()),
);
// --- Load inputs ---
let t_load = Instant::now();
let mut base_dem = DemTileSet::new();
let tiles = base_dem.load_dir(&args.sdf)?;
eprintln!("Cargados {tiles} tiles SDF en {:?}", t_load.elapsed());
let tx = qth::load(&args.tx)?;
eprintln!("TX: {:?} @ ({:.4}, {:.4}) alt {:.1} m", tx.name, tx.lat, tx.lon, tx.alt);
// Fail loudly if the TX coordinate isn't inside any loaded SDF tile —
// otherwise the DEM silently returns -5000 ft sentinel and every number
// downstream (HAAT, elevation angles, path loss) is garbage.
{
let z = base_dem.elevation(tx.lat, tx.lon);
if z < -1000.0 {
anyhow::bail!(
"El TX ({:.4}, {:.4}) no está cubierto por los tiles SDF de {}. \
Verifica el rango de --sdf (los tiles están nombrados \
'minlat:maxlat:minlon:maxlon.sdf.bz2').",
tx.lat, tx.lon, args.sdf.display()
);
}
}
let lrp_path = args.lrp.clone().unwrap_or_else(|| {
args.tx.parent().unwrap_or(Path::new(".")).join("splat.lrp")
});
let mut lrp_file = lrp::load(&lrp_path)?;
if let Some(erp) = args.erp_override {
lrp_file.erp_watts = Some(erp);
eprintln!("ERP override: {erp:.1} W");
}
eprintln!("LRP: {} ({:.1} MHz)", lrp_path.display(), lrp_file.params.frq_mhz);
let model = if args.use_longley_rice { Model::LongleyRice } else { Model::Itwom };
eprintln!("Modelo: {:?} (ITWOM v{:.1})", model, itwom::itwom_version());
// --- Optional city/site overlays ---
let mut city_sites: Vec<Site> = Vec::new();
for p in &args.city_files {
let cs = cities::load(p)?;
eprintln!("Ciudades: {} ({} entradas)", p.display(), cs.len());
city_sites.extend(cs);
}
// --- User-defined terrain overlays ---
let mut udt_features = Vec::new();
for p in &args.udt_files {
let fs = udt::load(p)?;
eprintln!("UDT: {} ({} estructuras)", p.display(), fs.len());
udt_features.extend(fs);
}
// Build the effective DEM reference. When there are no UDT features we
// point straight at the tile set (zero-cost); otherwise we wrap it.
let udt_dem;
let dem: &dyn DemLookup = if udt_features.is_empty() {
&base_dem
} else {
udt_dem = udt::DemWithUdt::new(&base_dem, udt_features, Config::default().ppd());
&udt_dem
};
// --- Antenna pattern (auto-detected .az/.el next to TX QTH) ---
let pattern = if args.no_pattern {
None
} else {
let base = args.tx.with_extension("");
if base.with_extension("az").exists() || base.with_extension("el").exists() {
match pat::AntennaPattern::load_base(&base) {
Ok(p) => {
eprintln!("Patrón de antena cargado (rotación {:.1}°, tilt {:.1}°)",
p.rotation, p.tilt_deg);
Some(p)
}
Err(e) => {
eprintln!("aviso: no se pudo cargar patrón: {e}");
None
}
}
} else {
None
}
};
// --- Path report (if we have an RX) ---
if let Some(rx_path) = &args.rx {
let rx = qth::load(rx_path)?;
eprintln!("RX: {:?} @ ({:.4}, {:.4}) alt {:.1} m", rx.name, rx.lat, rx.lon, rx.alt);
let rx_z = base_dem.elevation(rx.lat, rx.lon);
if rx_z < -1000.0 {
anyhow::bail!(
"El RX ({:.4}, {:.4}) no está cubierto por los tiles SDF de {}.",
rx.lat, rx.lon, args.sdf.display()
);
}
let mut report = report::path_report(
&tx, &rx, &lrp_file.params, lrp_file.erp_watts, model, dem,
PathReportOpts { metric: args.metric, rx_alt_m: rx.alt },
);
// If a pattern is loaded, annotate the report with the directional
// gain toward the RX — matches SPLAT's `patterndB` line.
if let Some(p) = &pattern {
let az = splat::geo::azimuth(tx.lat, tx.lon, rx.lat, rx.lon);
let el = report::elevation_angle(&tx, &rx, dem);
let gain = p.gain(az, el);
let gain_db = 20.0 * (gain as f64).max(1e-9).log10();
report.push_str(&format!(
"\nPatrón de antena de {} hacia {}: {:.3} ({:+.2} dB)\n",
tx.name, rx.name, gain, gain_db
));
}
let report_name = format!("{}-to-{}.txt", sanitize(&tx.name), sanitize(&rx.name));
let report_path = args.out_dir.join(&report_name);
std::fs::write(&report_path, &report)?;
eprintln!("Reporte → {}", report_path.display());
let gp_opts = gnuplot::GpOpts {
metric: args.metric,
freq_mhz: lrp_file.params.frq_mhz,
fzone_clearance: args.fzone_clearance,
clutter: args.clutter,
};
let fmt = args.plot_format.as_str();
let gnuplot_bin = which("gnuplot");
// Each plot kind writes to its own subdirectory (out_dir / kind-stem/),
// so the data files (profile.gp, reference.gp, …) and the splat.gp
// orchestrator never collide between kinds. The user's filename
// becomes both the subdir name (without extension) and the gnuplot
// output target inside it. Gnuplot is auto-invoked if available so
// the user gets the PNG/SVG/PS without an extra manual step.
macro_rules! emit_plot {
($kind:expr, $path:expr, $writer:expr) => {{
let path: &Path = $path;
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("plot").to_owned();
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(fmt).to_owned();
let sub = args.out_dir.join(format!("{}-{}", $kind, stem));
std::fs::create_dir_all(&sub)?;
$writer(&sub, &tx, &rx, dem, &gp_opts, (&stem, &ext))?;
if let Some(gp) = &gnuplot_bin {
// Strip LD_LIBRARY_PATH so the user's potentially old
// miniconda libstdc++/libtinfo don't shadow the system
// versions that the distro gnuplot links against.
let st = std::process::Command::new(gp)
.arg("splat.gp")
.current_dir(&sub)
.env_remove("LD_LIBRARY_PATH")
.status();
let final_path = sub.join(format!("{}.{}", stem, ext));
match st {
Ok(s) if s.success() && final_path.exists() => {
eprintln!("-{}: {}", $kind, final_path.display());
}
_ => eprintln!("-{}: splat.gp en {}/ (gnuplot falló)",
$kind, sub.display()),
}
} else {
eprintln!(
"-{}: splat.gp en {}/ — instala `gnuplot` para auto-render",
$kind, sub.display()
);
}
}};
}
// `-h` / `-H`: full height plot (terrain + LOS + curvature + Fresnel).
// Both emit the same file set in our port; SPLAT's distinction is
// just normalization on the ylabel.
if let Some(p) = &args.height_plot { emit_plot!("h", p, gnuplot::write_height_plot); }
if let Some(p) = &args.height_plot_norm { emit_plot!("H", p, gnuplot::write_height_plot); }
if let Some(p) = &args.terrain_plot { emit_plot!("p", p, gnuplot::write_terrain_plot); }
if let Some(p) = &args.elevation_plot { emit_plot!("e", p, gnuplot::write_elevation_plot); }
if let Some(p) = &args.loss_plot { emit_plot!("l", p, gnuplot::write_height_plot); }
} else if args.height_plot.is_some() || args.height_plot_norm.is_some()
|| args.terrain_plot.is_some() || args.elevation_plot.is_some()
|| args.loss_plot.is_some() {
eprintln!("aviso: -h/-H/-p/-e/-l requieren -r; omitidos");
}
// --- Strength Field Table (SPLAT-rs extension, no SPLAT! equivalent) ---
if let Some(out_path) = &args.sft {
let erp = lrp_file.erp_watts.unwrap_or_else(|| {
eprintln!("aviso: .lrp sin ERP → usando 1 W para la tabla SFT");
1.0
});
// Resolve azimuth and distance grids: explicit user lists win, then
// built-in defaults possibly extended by --sft-max-km.
let azimuths: Vec<f64> = if !args.sft_azimuths.is_empty() {
args.sft_azimuths.clone()
} else {
sft::DEFAULT_AZIMUTHS.to_vec()
};
let distances: Vec<f64> = if !args.sft_distances.is_empty() {
args.sft_distances.clone()
} else {
let mut d: Vec<f64> = sft::DEFAULT_DISTANCES_KM.to_vec();
// Add 10-km rings up to --sft-max-km if it exceeds the default 90.
let mut next = 100.0_f64;
while next <= args.sft_max_km + 1e-9 {
d.push(next);
next += 10.0;
}
d
};
eprintln!(
"-sft: grid {} azimuts × {} distancias (máx {:.0} km)",
azimuths.len(), distances.len(),
distances.last().copied().unwrap_or(0.0),
);
let t_sft = Instant::now();
let tab = sft::compute(
&tx, &lrp_file.params, erp, model, dem, pattern.as_ref(),
&azimuths, &distances,
args.rx_altitude_m,
);
let resolved = if out_path.is_absolute() {
out_path.clone()
} else {
args.out_dir.join(out_path)
};
let (txt, csv) = tab.write_pair(&resolved)?;
eprintln!(
"-sft: tabla de {}×{} en {:?} ({} hilos)",
tab.azimuths.len(), tab.distances_km.len(),
t_sft.elapsed(), rayon::current_num_threads(),
);
eprintln!(" texto → {}", txt.display());
eprintln!(" csv → {}", csv.display());
}
// --- Site report when no RX was supplied ---
if args.rx.is_none() {
let report = report::site_report(&tx, dem, args.metric);
let report_name = format!("{}-site_report.txt", sanitize(&tx.name));
let report_path = args.out_dir.join(&report_name);
std::fs::write(&report_path, &report)?;
eprintln!("Reporte de sitio → {}", report_path.display());
}
// --- Coverage map ---
if !args.no_map {
let cfg = Config::default();
let range_km = args.range_km.unwrap_or(40.73);
let ctx = RadialCtx {
config: &cfg,
source: &tx,
rx_alt_m: args.rx_altitude_m,
max_range_km: range_km,
params: &lrp_file.params,
model,
dem: dem,
};
let t_sweep = Instant::now();
let mut map = if args.los_coverage {
eprintln!("Modo LOS coverage (sin ITWOM)");
let pixels = plot_los_coverage_parallel(&ctx);
eprintln!(
"Sweep: {} píxeles en {:?} ({} hilos)",
pixels.len(), t_sweep.elapsed(), rayon::current_num_threads()
);
CoverageMap::from_pixels(&pixels, cfg.ppd(), 0.05)
} else {
// Keep radial grouping so we can line-rasterize between adjacent
// samples and eliminate the diagonal-aliasing artifacts visible
// when emitting raw point samples.
let radials = plot_lr_map_parallel_radials(&ctx);
let total: usize = radials.iter().map(|r| r.len()).sum();
eprintln!(
"Sweep: {} radiales / {} muestras en {:?} ({} hilos)",
radials.len(), total, t_sweep.elapsed(), rayon::current_num_threads()
);
CoverageMap::from_radials(&radials, cfg.ppd(), 0.05)
};
// A couple of dilation passes close the staircase gaps left by
// DDA rasterization along diagonal radials (most visible on SW/NE
// sweeps). A ≥3-neighbor threshold keeps coverage from bleeding
// out into genuine white zones.
if !args.los_coverage {
map.fill_holes(3, 3);
}
eprintln!(
"Mapa: {}×{} px, {} con señal",
map.width, map.height, map.populated()
);
let ppm_path = args.out_dir.join("coverage.ppm");
let ppm_opts = ppm::PpmOpts {
min_db: args.min_loss_db,
max_db: args.max_loss_db,
palette: ppm::Palette::SignalStrength,
};
ppm::write(&ppm_path, &map, &ppm_opts)?;
eprintln!(
"PPM → {} (rango {}{} dB)",
ppm_path.display(), args.min_loss_db, args.max_loss_db,
);
// Companion legend: SVG (with axis text) plus a small PPM color
// bar (no text, for ImageMagick compositing).
let legend_opts = legend::LegendOpts {
min_db: args.min_loss_db,
max_db: args.max_loss_db,
freq_mhz: lrp_file.params.frq_mhz,
erp_watts: lrp_file.erp_watts.unwrap_or(0.0),
tick_db: 10.0,
};
let legend_svg = args.out_dir.join("legend.svg");
let legend_ppm = args.out_dir.join("legend.ppm");
legend::write_svg(&legend_svg, &legend_opts)?;
legend::write_ppm(&legend_ppm, &legend_opts)?;
eprintln!("Leyenda → {} (+ {})", legend_svg.display(), legend_ppm.display());
if let Some(magick) = which("convert") {
let png_path = args.out_dir.join("coverage.png");
let _ = std::process::Command::new(&magick)
.args([
ppm_path.to_str().unwrap(),
"-transparent", "white",
png_path.to_str().unwrap(),
])
.status();
if png_path.exists() {
eprintln!("PNG → {}", png_path.display());
}
}
if !args.no_kml {
let kml_path = args.out_dir.join("coverage.kml");
let (placemarks, title, rx_opt): (Vec<Site>, String, Option<Site>) = match &args.rx {
Some(rx_path) => {
let rx = qth::load(rx_path)?;
(vec![tx.clone(), rx.clone()],
format!("{}{}", tx.name, rx.name),
Some(rx))
}
None => (vec![tx.clone()], tx.name.clone(), None),
};
let link = if args.no_link_line {
None
} else {
rx_opt.as_ref().map(|rx| (&tx, rx))
};
kml::write_overlay_with(
&kml_path, &map, "coverage.png", &title, &placemarks,
&kml::KmlExtras { link, cities: &city_sites },
)?;
eprintln!("KML → {}", kml_path.display());
}
}
Ok(())
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() || c == '-' { c } else { '_' })
.collect()
}
fn which(bin: &str) -> Option<PathBuf> {
for dir in std::env::var_os("PATH")?.to_string_lossy().split(':') {
let p = Path::new(dir).join(bin);
if p.exists() { return Some(p); }
}
None
}
+82
View File
@@ -0,0 +1,82 @@
//! City/site file loader — same format SPLAT!'s `-s` flag consumes.
//!
//! Plain CSV, one entry per line:
//!
//! ```text
//! name, latitude, longitude
//! ```
//!
//! Where `latitude` / `longitude` accept decimal degrees or `DD MM SS.s`
//! (via the same `ReadBearing` rules as `.qth`). Comments beginning with
//! `;` are ignored.
//!
//! SPLAT uses these to paint text labels on PPM topographic maps. We don't
//! have a text renderer yet, but cities load fine as named `Site`s that
//! the KML writer emits as Placemarks, so a user-supplied cities file
//! immediately adds context to the Google Earth overlay.
use std::path::Path;
use crate::qth;
use crate::types::Site;
#[derive(Debug, thiserror::Error)]
pub enum CitiesError {
#[error("io error reading {path:?}: {source}")]
Io { path: std::path::PathBuf, #[source] source: std::io::Error },
#[error("{path:?} line {line}: expected 'name, lat, lon' row, got {got} fields")]
BadRow { path: std::path::PathBuf, line: usize, got: usize },
#[error("{path:?} line {line}: bad {field} {value:?}")]
BadValue { path: std::path::PathBuf, line: usize, field: &'static str, value: String },
}
pub fn load(path: impl AsRef<Path>) -> Result<Vec<Site>, CitiesError> {
let path = path.as_ref();
let src = std::fs::read_to_string(path)
.map_err(|e| CitiesError::Io { path: path.into(), source: e })?;
let mut out = Vec::new();
for (i, line) in src.lines().enumerate() {
let head = line.split(';').next().unwrap_or("").trim();
if head.is_empty() { continue; }
let parts: Vec<&str> = head.splitn(3, ',').map(str::trim).collect();
if parts.len() != 3 {
// Single-field lines (e.g. a title header) are tolerated; multi-
// field rows with the wrong count are flagged.
if parts.len() == 1 { continue; }
return Err(CitiesError::BadRow { path: path.into(), line: i + 1, got: parts.len() });
}
let name = parts[0].to_owned();
let lat = qth::parse_bearing(parts[1])
.ok_or_else(|| CitiesError::BadValue { path: path.into(), line: i + 1, field: "lat", value: parts[1].into() })?;
let mut lon = qth::parse_bearing(parts[2])
.ok_or_else(|| CitiesError::BadValue { path: path.into(), line: i + 1, field: "lon", value: parts[2].into() })?;
// Positive-west convention (matches .qth).
if lon < 0.0 { lon += 360.0; }
out.push(Site { name, lat, lon, alt: 0.0 });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_cities() {
let tmp = std::env::temp_dir().join("test.cities");
std::fs::write(&tmp,
"; Capital ciudades Ecuador\n\
Quito, -0.18, 78.47\n\
Guayaquil, -2 10 0, 79 53 0\n\
Cuenca, -2.9006, 79.0045 ; andean\n"
).unwrap();
let cs = load(&tmp).unwrap();
assert_eq!(cs.len(), 3);
assert_eq!(cs[0].name, "Quito");
assert!((cs[0].lat - (-0.18)).abs() < 1e-9);
assert!((cs[1].lat - (-2.166666666)).abs() < 1e-5);
assert_eq!(cs[2].name, "Cuenca");
let _ = std::fs::remove_file(&tmp);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Coverage map: aggregates `PixelWrite`s from the parallel radial sweep into
//! a 2D raster keyed by geographic coordinate. Stores the *lowest* path loss
//! seen at each pixel — matching SPLAT!'s "last write wins" semantics where
//! the strongest signal dominates overlapping radials.
use crate::radial::PixelWrite;
/// Loss-per-pixel raster covering a geographic bounding box. `loss[0][0]` is
/// the NW corner; row index grows southward (toward lower latitude), column
/// index grows eastward (matching typical PPM row-major top-to-bottom, left-
/// to-right layout).
#[derive(Debug, Clone)]
pub struct CoverageMap {
pub north: f64,
pub south: f64,
pub east: f64,
pub west: f64,
pub width: u32,
pub height: u32,
pub ppd: u32,
/// `f64::INFINITY` sentinel for "no signal". Size = width * height.
pub loss_db: Vec<f64>,
}
impl CoverageMap {
/// Build an empty map covering the given bounds at `ppd` pixels/degree.
pub fn new(north: f64, south: f64, east: f64, west: f64, ppd: u32) -> Self {
let width = (ppd as f64 * angle_span(west, east)).round() as u32;
let height = (ppd as f64 * (north - south)).round() as u32;
let n = (width as usize) * (height as usize);
Self {
north, south, east, west, width, height, ppd,
loss_db: vec![f64::INFINITY; n],
}
}
/// Build from a list of pixel writes, sizing the map to their extent.
/// `padding_deg` is added on all sides so that the source and range
/// limits are not clipped at the edges. An empty pixel list produces an
/// empty 0×0 map rather than overflowing on the degenerate bounds.
pub fn from_pixels(pixels: &[PixelWrite], ppd: u32, padding_deg: f64) -> Self {
if pixels.is_empty() {
return Self {
north: 0.0, south: 0.0, east: 0.0, west: 0.0,
width: 0, height: 0, ppd, loss_db: Vec::new(),
};
}
let (mut n, mut s, mut e, mut w) = (f64::MIN, f64::MAX, f64::MIN, f64::MAX);
for p in pixels {
if p.lat > n { n = p.lat; }
if p.lat < s { s = p.lat; }
if p.lon > e { e = p.lon; }
if p.lon < w { w = p.lon; }
}
let mut map = Self::new(n + padding_deg, s - padding_deg, e + padding_deg, w - padding_deg, ppd);
map.ingest(pixels);
map
}
/// Build from grouped radials (one `Vec<PixelWrite>` per azimuth).
/// Uses `ingest_radials`, which rasterizes segments between consecutive
/// samples on each radial — the right way to render a 360° sweep.
pub fn from_radials(radials: &[Vec<PixelWrite>], ppd: u32, padding_deg: f64) -> Self {
let (mut n, mut s, mut e, mut w) = (f64::MIN, f64::MAX, f64::MIN, f64::MAX);
let mut any = false;
for r in radials {
for p in r {
any = true;
if p.lat > n { n = p.lat; }
if p.lat < s { s = p.lat; }
if p.lon > e { e = p.lon; }
if p.lon < w { w = p.lon; }
}
}
if !any {
return Self {
north: 0.0, south: 0.0, east: 0.0, west: 0.0,
width: 0, height: 0, ppd, loss_db: Vec::new(),
};
}
let mut map = Self::new(
n + padding_deg, s - padding_deg,
e + padding_deg, w - padding_deg,
ppd,
);
map.ingest_radials(radials);
map
}
/// Write all pixels into the map, keeping the minimum loss at each cell.
pub fn ingest(&mut self, pixels: &[PixelWrite]) {
for p in pixels {
if let Some(idx) = self.index_of(p.lat, p.lon) {
let cur = self.loss_db[idx];
if p.loss_db < cur {
self.loss_db[idx] = p.loss_db;
}
}
}
}
/// Ingest a grouped radial sweep, rasterizing each consecutive pair of
/// samples as a line segment in pixel space. This fixes the classic
/// radial-sweep aliasing where diagonal radials skip pixels between
/// adjacent samples, leaving visible "streak" gaps in the PPM map.
pub fn ingest_radials(&mut self, radials: &[Vec<PixelWrite>]) {
for radial in radials {
for w in radial.windows(2) {
self.rasterize_line(
w[0].lat, w[0].lon, w[0].loss_db,
w[1].lat, w[1].lon, w[1].loss_db,
);
}
}
}
/// Line rasterization in pixel space between two geographic samples,
/// linearly interpolating the path-loss value. Writes the *minimum*
/// loss at each pixel crossed, matching SPLAT!'s "strongest signal
/// wins" overlay semantics.
///
/// Uses 2×2 supercover fill at each DDA step so perfectly-diagonal
/// moves (common on NE/SE/SW/NW radials) don't leave staircase gaps.
/// The mild thickening that 2×2 introduces is invisible at 1200 ppd
/// and is much cheaper than a full Amanatides-&-Woo traversal.
pub fn rasterize_line(
&mut self,
lat1: f64, lon1: f64, loss1: f64,
lat2: f64, lon2: f64, loss2: f64,
) {
let (x1, y1) = self.pixel_of(lat1, lon1);
let (x2, y2) = self.pixel_of(lat2, lon2);
let dx = (x2 - x1).abs();
let dy = (y2 - y1).abs();
let steps = dx.max(dy).max(1);
let inv_steps = 1.0 / steps as f64;
for k in 0..=steps {
let t = k as f64 * inv_steps;
let xf = x1 as f64 + t * (x2 - x1) as f64;
let yf = y1 as f64 + t * (y2 - y1) as f64;
let loss = loss1 + t * (loss2 - loss1);
// 2×2 block covering both floor and ceil cells in each axis.
let xl = xf.floor() as i64;
let yl = yf.floor() as i64;
self.try_update(xl, yl, loss);
self.try_update(xl + 1, yl, loss);
self.try_update(xl, yl + 1, loss);
self.try_update(xl + 1, yl + 1, loss);
}
}
/// Fill small holes in the coverage raster with the minimum of
/// neighboring finite values. One pass bridges 1-pixel gaps; more
/// passes close wider holes at the cost of some coverage "bleed"
/// near edges. The `min_neighbors` threshold prevents bleeding into
/// genuinely-empty regions: a pixel is only filled when it has at
/// least that many finite neighbors — effectively ignoring stray
/// isolated-pixel writes.
pub fn fill_holes(&mut self, passes: u32, min_neighbors: u32) {
if self.width < 3 || self.height < 3 { return; }
let w = self.width as usize;
let h = self.height as usize;
let mut next = self.loss_db.clone();
for _ in 0..passes {
for y in 1..h - 1 {
for x in 1..w - 1 {
let idx = y * w + x;
if !self.loss_db[idx].is_finite() {
let mut best = f64::INFINITY;
let mut count = 0u32;
for dy in -1_isize..=1 {
for dx in -1_isize..=1 {
if dx == 0 && dy == 0 { continue; }
let n = ((y as isize + dy) as usize) * w
+ ((x as isize + dx) as usize);
let v = self.loss_db[n];
if v.is_finite() {
if v < best { best = v; }
count += 1;
}
}
}
if count >= min_neighbors {
next[idx] = best;
}
}
}
}
self.loss_db.clone_from(&next);
}
}
#[inline]
fn try_update(&mut self, x: i64, y: i64, loss: f64) {
if x < 0 || y < 0 || x >= self.width as i64 || y >= self.height as i64 {
return;
}
let idx = (y as usize) * (self.width as usize) + (x as usize);
if loss < self.loss_db[idx] {
self.loss_db[idx] = loss;
}
}
/// Convert a geographic coordinate to signed pixel space (may return
/// values outside the raster; `rasterize_line` clips during scan).
#[inline]
fn pixel_of(&self, lat: f64, lon: f64) -> (i64, i64) {
let ppd = self.ppd as f64;
let col = ((self.east - lon) * ppd).round() as i64;
let row = ((self.north - lat) * ppd).round() as i64;
(col, row)
}
#[inline]
fn index_of(&self, lat: f64, lon: f64) -> Option<usize> {
if lat > self.north || lat < self.south || lon > self.east || lon < self.west {
return None;
}
let (col, row) = self.pixel_of(lat, lon);
if row < 0 || col < 0 || row >= self.height as i64 || col >= self.width as i64 {
return None;
}
Some(row as usize * self.width as usize + col as usize)
}
/// Count of pixels with any signal (finite loss value).
pub fn populated(&self) -> usize {
self.loss_db.iter().filter(|v| v.is_finite()).count()
}
}
#[inline]
fn angle_span(west: f64, east: f64) -> f64 {
// Both longitudes are SPLAT-style positive-west, so west > east numerically
// for a left-to-right box. If that convention is flipped, fall back to the
// absolute difference to stay robust.
let d = west - east;
if d > 0.0 { d } else { -d }
}
+122
View File
@@ -0,0 +1,122 @@
//! Geodesy helpers. Ports of SPLAT!'s `Distance`, `Azimuth`, `ElevationAngle`,
//! and the great-circle stepping used by `ReadPath`. All angles accept/return
//! degrees; internal trig runs in radians.
//!
//! SPLAT uses an Earth radius of 3959 statute miles (not the WGS-84 mean) —
//! we match that so the path-loss results stay bit-comparable.
pub const EARTH_RADIUS_MI: f64 = 3959.0;
pub const METERS_PER_MILE: f64 = 1609.344;
pub const FEET_PER_METER: f64 = 3.28084;
#[inline]
fn deg2rad(d: f64) -> f64 { d.to_radians() }
#[inline]
fn rad2deg(r: f64) -> f64 { r.to_degrees() }
/// Great-circle distance between two (lat, lon) pairs, in statute miles.
pub fn distance(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let lat1 = deg2rad(lat1);
let lat2 = deg2rad(lat2);
let lon1 = deg2rad(lon1);
let lon2 = deg2rad(lon2);
let c = (lat1.sin() * lat2.sin()) + (lat1.cos() * lat2.cos() * (lon1 - lon2).cos());
EARTH_RADIUS_MI * c.clamp(-1.0, 1.0).acos()
}
/// Initial azimuth (degrees from north, clockwise) from source to destination.
/// Faithful port of SPLAT!'s `Azimuth`, including its positive-west longitude
/// convention and reference-to-true-north correction.
pub fn azimuth(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
let src_lat = deg2rad(lat1);
let src_lon = deg2rad(lon1);
let dest_lat = deg2rad(lat2);
let dest_lon = deg2rad(lon2);
let beta = (src_lat.sin() * dest_lat.sin()
+ src_lat.cos() * dest_lat.cos() * (src_lon - dest_lon).cos())
.clamp(-1.0, 1.0)
.acos();
let num = dest_lat.sin() - src_lat.sin() * beta.cos();
let den = src_lat.cos() * beta.sin();
let az = (num / den).clamp(-1.0, 1.0).acos();
let mut diff = dest_lon - src_lon;
if diff <= -std::f64::consts::PI { diff += std::f64::consts::TAU; }
if diff >= std::f64::consts::PI { diff -= std::f64::consts::TAU; }
let az = if diff > 0.0 { std::f64::consts::TAU - az } else { az };
rad2deg(az)
}
/// Short-path longitudinal difference in `[-180, 180]`.
#[inline]
pub fn lon_diff(lon1: f64, lon2: f64) -> f64 {
let mut d = lon1 - lon2;
if d <= -180.0 { d += 360.0; }
if d >= 180.0 { d -= 360.0; }
d
}
/// Step along a great-circle path from `(lat1_deg, lon1_deg)` on initial
/// bearing `azimuth_deg` by arc distance `d_miles`, returning the new point.
///
/// Port of the core of SPLAT!'s `ReadPath` stepping.
pub fn step_great_circle(lat1_deg: f64, lon1_deg: f64, azimuth_deg: f64, d_miles: f64) -> (f64, f64) {
let lat1 = deg2rad(lat1_deg);
let lon1 = deg2rad(lon1_deg);
let az = deg2rad(azimuth_deg);
let beta = d_miles / EARTH_RADIUS_MI;
let lat2 = (lat1.sin() * beta.cos() + az.cos() * beta.sin() * lat1.cos()).asin();
let num = beta.cos() - lat1.sin() * lat2.sin();
let den = lat1.cos() * lat2.cos();
let half_pi = std::f64::consts::FRAC_PI_2;
let lon2 = if azimuth_deg == 0.0 && beta > half_pi - lat1 {
lon1 + std::f64::consts::PI
} else if (azimuth_deg - 90.0).abs() < f64::EPSILON && beta > half_pi + lat1 {
lon1 + std::f64::consts::PI
} else if (num / den).abs() > 1.0 {
lon1
} else {
let arc = (num / den).clamp(-1.0, 1.0).acos();
if std::f64::consts::PI - az >= 0.0 { lon1 - arc } else { lon1 + arc }
};
let mut lon2 = lon2;
while lon2 < 0.0 { lon2 += std::f64::consts::TAU; }
while lon2 > std::f64::consts::TAU { lon2 -= std::f64::consts::TAU; }
(rad2deg(lat2), rad2deg(lon2))
}
/// Samples-per-radian factor SPLAT uses when stepping a path. Matches
/// `ppd == 1200 → 68755` and `ppd == 3600 → 206265`.
pub fn samples_per_radian(ppd: u32) -> f64 {
match ppd {
3600 => 206265.0,
_ => 68755.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distance_matches_splat_report() {
// Estudios Quevedo → Cerro Cochabamba: SPLAT reports 84.66 km = 52.61 mi.
let d_mi = distance(-1.023206, 79.458669, -1.698611, 79.107222);
let d_km = d_mi * 1.609344;
assert!((d_km - 84.66).abs() < 0.1, "got {d_km} km");
}
#[test]
fn azimuth_matches_splat_report() {
// Same link: SPLAT reports azimuth 152.52° from TX to RX.
let az = azimuth(-1.023206, 79.458669, -1.698611, 79.107222);
assert!((az - 152.52).abs() < 0.05, "got {az}");
}
}
+408
View File
@@ -0,0 +1,408 @@
//! Gnuplot `.gp` writers for path profiles.
//!
//! Produces the standard SPLAT! output set for a TX→RX link:
//! - `profile.gp` terrain elevation vs distance
//! - `reference.gp` line-of-sight (straight from TX alt to RX alt)
//! - `curvature.gp` 4/3-Earth curvature drop below a flat reference
//! - `fresnel.gp` first Fresnel zone (lower boundary)
//! - `fresnel_pt_6.gp` 60% first Fresnel zone
//! - `height.gp` terrain profile referenced to LOS (equivalent of `-h`)
//! - `splat.gp` gnuplot script wiring the titles/labels and plotting
//!
//! Data format matches the original C++: two tab-separated columns per line
//! `distance<TAB>value`, so any downstream gnuplot setup targeting SPLAT's
//! files just works.
//!
//! All distances are in km (metric) or miles (imperial). Heights in meters
//! or feet. Units chosen via `GpOpts.metric`.
//!
//! The Fresnel math uses 4/3-Earth radius and the standard `sqrt(λ·d1·d2/d)`
//! formula, same as SPLAT!.
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use crate::geo::{self, EARTH_RADIUS_MI, METERS_PER_MILE};
use crate::radial::DemLookup;
use crate::types::Site;
const FOUR_THIRDS: f64 = 4.0 / 3.0;
const EARTH_RADIUS_M: f64 = 20_902_230.97 * 0.3048;
const FOUR_THIRDS_EARTH_M: f64 = FOUR_THIRDS * EARTH_RADIUS_M;
#[derive(Debug, Clone)]
pub struct GpOpts {
pub metric: bool,
/// Frequency (MHz) — required for Fresnel zone outputs; set 0 to skip.
pub freq_mhz: f64,
/// Fresnel zone clearance fraction (default 0.6 = 60%).
pub fzone_clearance: f64,
/// Clutter height to add to terrain (meters if metric, else feet). 0 disables.
pub clutter: f64,
}
impl Default for GpOpts {
fn default() -> Self {
Self { metric: true, freq_mhz: 0.0, fzone_clearance: 0.6, clutter: 0.0 }
}
}
/// A sampled great-circle path between two sites: `distance_mi[i]` is the
/// distance from TX along the path, `lat/lon[i]` the geographic sample, and
/// `elev_m[i]` the SDF elevation at that sample.
#[derive(Debug, Clone)]
pub struct PathProfile {
pub distance_mi: Vec<f64>,
pub lat: Vec<f64>,
pub lon: Vec<f64>,
pub elev_m: Vec<f64>,
}
impl PathProfile {
/// Sample a TX→RX great-circle path at SPLAT's default (1200 ppd) density.
pub fn sample(tx: &Site, rx: &Site, dem: &dyn DemLookup) -> Self {
let dist_mi = geo::distance(tx.lat, tx.lon, rx.lat, rx.lon);
let az = geo::azimuth(tx.lat, tx.lon, rx.lat, rx.lon);
let spr = geo::samples_per_radian(1200);
let n = ((dist_mi / EARTH_RADIUS_MI) * spr).max(2.0) as usize;
let mps = dist_mi / n as f64;
let mut p = Self {
distance_mi: Vec::with_capacity(n + 1),
lat: Vec::with_capacity(n + 1),
lon: Vec::with_capacity(n + 1),
elev_m: Vec::with_capacity(n + 1),
};
for i in 0..=n {
let d = mps * i as f64;
let (lat, lon) = geo::step_great_circle(tx.lat, tx.lon, az, d);
p.distance_mi.push(d);
p.lat.push(lat);
p.lon.push(lon);
p.elev_m.push(dem.elevation(lat, lon));
}
// Pin exact endpoints to avoid floating-point drift at the last sample.
if let Some(last) = p.elev_m.last_mut() {
*last = dem.elevation(rx.lat, rx.lon);
}
p
}
}
/// Write the full set of `.gp` data files plus `splat.gp` orchestrator into
/// `out_dir`. After this call, `gnuplot splat.gp` renders the height plot
/// (PNG by default, controlled via `basename_ext`).
///
/// Convention used: **Earth-flattened reference frame** (standard in
/// microwave/RF link engineering — Pathloss, ATDI, etc.). The terrain
/// elevation is *raised* by `d·(D-d)/(2·R')` at each distance — i.e. the
/// 4/3-Earth curvature drop is added back to the geometric altitude.
/// As a result:
///
/// - `profile.gp` shows what the terrain "looks like" if Earth were flat
/// under the link (midpoint of a real link rises by ~D²/(8R') metres
/// relative to its actual altitude — about 180 m on a 110-km link).
/// - `reference.gp` is a straight chord between the TX and RX antenna tops.
/// - Fresnel zones drop below the chord by their geometric formula and need
/// no further curvature correction.
///
/// This is the convention RF link engineers expect: if the (apparent)
/// terrain pokes into the Fresnel zone in the plot, it's a real obstacle.
/// If you want the geographic profile (no curvature correction) use `-p`
/// instead — that flag deliberately keeps the raw elevations.
pub fn write_height_plot(
out_dir: impl AsRef<Path>,
tx: &Site,
rx: &Site,
dem: &dyn DemLookup,
opts: &GpOpts,
basename_ext: (&str, &str),
) -> std::io::Result<()> {
let out_dir = out_dir.as_ref();
std::fs::create_dir_all(out_dir)?;
let p = PathProfile::sample(tx, rx, dem);
let dist_scale = if opts.metric { 1.609344 } else { 1.0 };
let height_scale = if opts.metric { 1.0 } else { 3.28084 }; // meters→feet
let d_unit = if opts.metric { "kilómetros" } else { "millas" };
let h_unit = if opts.metric { "metros" } else { "pies" };
let total_d = p.distance_mi.last().copied().unwrap_or(0.0);
let total_m = total_d * METERS_PER_MILE;
// Earth-flattened terrain: actual elevation + curvature drop at distance d.
// drop(d) = d·(D-d)/(2·R') with R' = 4/3 · Earth radius.
let curvature_at = |d_mi: f64| -> f64 {
let x_m = d_mi * METERS_PER_MILE;
x_m * (total_m - x_m) / (2.0 * FOUR_THIRDS_EARTH_M)
};
// 1. profile.gp — terrain raised by Earth-curvature so the displayed
// profile is in the LOS frame (LOS is a straight line).
let mut profile_out = Vec::with_capacity(p.distance_mi.len());
let mut clutter_out = Vec::with_capacity(p.distance_mi.len());
for i in 0..p.distance_mi.len() {
let d_scaled = p.distance_mi[i] * dist_scale;
let z_apparent = (p.elev_m[i] + curvature_at(p.distance_mi[i])) * height_scale;
profile_out.push((d_scaled, z_apparent));
if opts.clutter > 0.0 && i > 0 && i < p.distance_mi.len() - 1 {
let clut = if opts.metric { opts.clutter } else { opts.clutter / 3.28084 };
clutter_out.push((d_scaled, (p.elev_m[i] + curvature_at(p.distance_mi[i]) + clut) * height_scale));
}
}
write_two_col(out_dir.join("profile.gp"), &profile_out)?;
if !clutter_out.is_empty() {
write_two_col(out_dir.join("clutter.gp"), &clutter_out)?;
}
// 1b. curvature.gp — the Earth-curvature drop, *offset to sit at the
// bottom of the plot* so it appears as a visible reference hump
// in the same Y axis as the terrain (matches SPLAT! original).
// Baseline is set so the parabola peaks at the lowest corrected
// terrain value: at the endpoints the line sits `max_drop` below
// min(terrain), and at the midpoint it just touches min(terrain).
// That keeps the curvature curve visually anchored to the plot.
let max_drop_m = curvature_at(total_d / 2.0); // drop at midpoint = the max
let min_profile_y = profile_out.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min);
let curv_baseline = min_profile_y - max_drop_m * height_scale;
let curvature_ref: Vec<(f64, f64)> = profile_out.iter().map(|(d_scaled, _)| {
let d_mi = *d_scaled / dist_scale;
(*d_scaled, curv_baseline + curvature_at(d_mi) * height_scale)
}).collect();
write_two_col(out_dir.join("curvature.gp"), &curvature_ref)?;
// 2. reference.gp — straight LOS line between TX and RX antenna tops
// in the flattened frame.
let tx_top_m = p.elev_m.first().copied().unwrap_or(0.0) + tx.alt;
let rx_top_m = p.elev_m.last().copied().unwrap_or(0.0) + rx.alt;
let reference: Vec<(f64, f64)> = (0..=100)
.map(|k| {
let t = k as f64 / 100.0;
let d = total_d * t;
let h = tx_top_m + t * (rx_top_m - tx_top_m);
(d * dist_scale, h * height_scale)
})
.collect();
write_two_col(out_dir.join("reference.gp"), &reference)?;
// 3. fresnel.gp + fresnel_pt_6.gp — Fresnel zone lower boundary,
// computed as LOS chord minus the Fresnel-radius. Already in the
// flattened frame because LOS is straight.
if opts.freq_mhz > 0.0 {
let lambda = 299_792_458.0 / (opts.freq_mhz * 1e6);
let fresnel: Vec<(f64, f64)> = profile_out.iter().enumerate().map(|(i, (d_scaled, _))| {
let d_mi = *d_scaled / dist_scale;
let x = d_mi * METERS_PER_MILE;
let d = total_m;
let f1 = if i == 0 || i == profile_out.len() - 1 {
0.0
} else {
(lambda * x * (d - x) / d).sqrt()
};
let t = d_mi / total_d.max(1e-12);
let los_m = tx_top_m + t * (rx_top_m - tx_top_m);
(*d_scaled, (los_m - f1) * height_scale)
}).collect();
write_two_col(out_dir.join("fresnel.gp"), &fresnel)?;
let fresnel_pt6: Vec<(f64, f64)> = profile_out.iter().enumerate().map(|(i, (d_scaled, _))| {
let d_mi = *d_scaled / dist_scale;
let x = d_mi * METERS_PER_MILE;
let d = total_m;
let f1 = if i == 0 || i == profile_out.len() - 1 {
0.0
} else {
opts.fzone_clearance * (lambda * x * (d - x) / d).sqrt()
};
let t = d_mi / total_d.max(1e-12);
let los_m = tx_top_m + t * (rx_top_m - tx_top_m);
(*d_scaled, (los_m - f1) * height_scale)
}).collect();
write_two_col(out_dir.join("fresnel_pt_6.gp"), &fresnel_pt6)?;
}
// 4. splat.gp — orchestrator script (titles, labels, plot commands).
let az = geo::azimuth(tx.lat, tx.lon, rx.lat, rx.lon);
let total_scaled = total_d * dist_scale;
let mut gp = BufWriter::new(std::fs::File::create(out_dir.join("splat.gp"))?);
writeln!(gp, "set grid")?;
writeln!(gp, "set encoding utf8")?;
writeln!(gp, "set term {}", basename_ext.1)?;
writeln!(gp, "set output \"{}.{}\"", basename_ext.0, basename_ext.1)?;
if opts.freq_mhz > 0.0 {
writeln!(gp,
"set title \"splat-rs Perfil entre {} y {} (azimut: {:.2}°)\\nCurvatura 4/3 Tierra + Primera Zona de Fresnel\\nIng. Charles Escobar\"",
tx.name, rx.name, az)?;
} else {
writeln!(gp,
"set title \"splat-rs Perfil de alturas entre {} y {} ({:.2}° azimut)\\nReferencia tierra-aplanada (4/3)\"",
tx.name, rx.name, az)?;
}
writeln!(gp, "set xlabel \"Distancia entre {} y {} ({:.2} {})\"",
tx.name, rx.name, total_scaled, d_unit)?;
writeln!(gp, "set ylabel \"Altura aparente ({}) — corregida por curvatura 4/3\"", h_unit)?;
let mut plot_parts: Vec<String> = Vec::new();
plot_parts.push("\"profile.gp\" title \"Perfil del terreno (corregido 4/3 Tierra)\" with lines".into());
if !clutter_out.is_empty() {
plot_parts.push(format!(
"\"clutter.gp\" title \"Obstáculos del terreno ({:.2} {})\" with lines",
opts.clutter, if opts.metric { "metros" } else { "pies" }));
}
plot_parts.push("\"reference.gp\" title \"Línea de Vista\" with lines".into());
if opts.freq_mhz > 0.0 {
plot_parts.push(format!(
"\"fresnel.gp\" title \"Primera Zona de Fresnel ({:.3} MHz)\" with lines",
opts.freq_mhz));
plot_parts.push(format!(
"\"fresnel_pt_6.gp\" title \"{:.0}% Primera Zona de Fresnel\" with lines",
opts.fzone_clearance * 100.0));
}
plot_parts.push(
"\"curvature.gp\" title \"Contorno de Curvatura de la Tierra (4/3)\" with lines".into()
);
writeln!(gp, "plot {}", plot_parts.join(", "))?;
gp.flush()?;
Ok(())
}
/// Terrain profile plot (`-p`): elevation of the ground vs distance along
/// the TX→RX link. Writes `profile.gp` + `splat.gp`; no Fresnel or curvature.
pub fn write_terrain_plot(
out_dir: impl AsRef<Path>,
tx: &Site,
rx: &Site,
dem: &dyn DemLookup,
opts: &GpOpts,
basename_ext: (&str, &str),
) -> std::io::Result<()> {
let out_dir = out_dir.as_ref();
std::fs::create_dir_all(out_dir)?;
let p = PathProfile::sample(tx, rx, dem);
let dist_scale = if opts.metric { 1.609344 } else { 1.0 };
let height_scale = if opts.metric { 1.0 } else { 3.28084 };
let mut rows = Vec::with_capacity(p.distance_mi.len());
for i in 0..p.distance_mi.len() {
rows.push((p.distance_mi[i] * dist_scale, p.elev_m[i] * height_scale));
}
write_two_col(out_dir.join("profile.gp"), &rows)?;
let mut gp = BufWriter::new(std::fs::File::create(out_dir.join("splat.gp"))?);
let az = geo::azimuth(tx.lat, tx.lon, rx.lat, rx.lon);
let total_d = p.distance_mi.last().copied().unwrap_or(0.0) * dist_scale;
let d_unit = if opts.metric { "kilómetros" } else { "millas" };
let h_unit = if opts.metric { "metros" } else { "pies" };
writeln!(gp, "set grid")?;
writeln!(gp, "set encoding utf8")?;
writeln!(gp, "set term {}", basename_ext.1)?;
writeln!(gp, "set output \"{}.{}\"", basename_ext.0, basename_ext.1)?;
writeln!(gp, "set title \"splat-rs Perfil del terreno entre {} y {} ({:.2}° azimut)\"",
tx.name, rx.name, az)?;
writeln!(gp, "set xlabel \"Distancia entre {} y {} ({:.2} {})\"",
tx.name, rx.name, total_d, d_unit)?;
writeln!(gp, "set ylabel \"Elevación de la tierra sobre el nivel del mar ({})\"", h_unit)?;
writeln!(gp, "plot \"profile.gp\" title \"Perfil del terreno\" with lines")?;
gp.flush()?;
Ok(())
}
/// Elevation-angle plot (`-e`): for each terrain sample along the path,
/// the elevation angle from RX back to that sample. Horizontal reference
/// line is the elevation angle from RX to TX.
pub fn write_elevation_plot(
out_dir: impl AsRef<Path>,
tx: &Site,
rx: &Site,
dem: &dyn DemLookup,
opts: &GpOpts,
basename_ext: (&str, &str),
) -> std::io::Result<()> {
let out_dir = out_dir.as_ref();
std::fs::create_dir_all(out_dir)?;
let p = PathProfile::sample(tx, rx, dem);
let dist_scale = if opts.metric { 1.609344 } else { 1.0 };
// Elevation angle from RX to each sample (excluding the RX itself).
let mut angle_rows = Vec::with_capacity(p.distance_mi.len());
let mut ref_rows = Vec::with_capacity(p.distance_mi.len());
let ref_angle = crate::report::elevation_angle(rx, tx, dem);
for i in 1..p.distance_mi.len().saturating_sub(1) {
// Synthesize a throwaway Site for each sample at ground level.
let sample = Site {
name: String::new(),
lat: p.lat[i],
lon: p.lon[i],
alt: 0.0,
};
let a = crate::report::elevation_angle(rx, &sample, dem);
let d_scaled = p.distance_mi[i] * dist_scale;
angle_rows.push((d_scaled, a));
ref_rows.push((d_scaled, ref_angle));
}
write_two_col(out_dir.join("profile.gp"), &angle_rows)?;
write_two_col(out_dir.join("reference.gp"), &ref_rows)?;
let mut gp = BufWriter::new(std::fs::File::create(out_dir.join("splat.gp"))?);
let az = geo::azimuth(tx.lat, tx.lon, rx.lat, rx.lon);
let total_d = p.distance_mi.last().copied().unwrap_or(0.0) * dist_scale;
let d_unit = if opts.metric { "kilómetros" } else { "millas" };
writeln!(gp, "set grid")?;
writeln!(gp, "set encoding utf8")?;
writeln!(gp, "set term {}", basename_ext.1)?;
writeln!(gp, "set output \"{}.{}\"", basename_ext.0, basename_ext.1)?;
writeln!(gp, "set title \"splat-rs Perfil de elevación entre {} y {} ({:.2}° azimut)\"",
tx.name, rx.name, az)?;
writeln!(gp, "set xlabel \"Distancia entre {} y {} ({:.2} {})\"",
tx.name, rx.name, total_d, d_unit)?;
writeln!(gp, "set ylabel \"Ángulo de elevación LDV entre\\n{} y {} (grados)\"",
rx.name, tx.name)?;
writeln!(gp,
"plot \"profile.gp\" title \"Perfil Real de la Tierra\" with lines, \"reference.gp\" title \"Línea de Vista ({:+.2}° elevación)\" with lines",
ref_angle)?;
gp.flush()?;
Ok(())
}
fn write_two_col(path: PathBuf, rows: &[(f64, f64)]) -> std::io::Result<()> {
let mut w = BufWriter::new(std::fs::File::create(&path)?);
for (a, b) in rows {
writeln!(w, "{}\t{}", a, b)?;
}
w.flush()
}
#[cfg(test)]
mod tests {
use super::*;
struct FlatDem(f64);
impl DemLookup for FlatDem {
fn elevation(&self, _lat: f64, _lon: f64) -> f64 { self.0 }
}
#[test]
fn writes_profile_reference_fresnel() {
let tx = Site { name: "A".into(), lat: -1.0, lon: 79.0, alt: 30.0 };
let rx = Site { name: "B".into(), lat: -1.1, lon: 79.1, alt: 30.0 };
let dem = FlatDem(100.0);
let tmp = tempdir();
write_height_plot(&tmp, &tx, &rx, &dem, &GpOpts {
metric: true, freq_mhz: 426.0, fzone_clearance: 0.6, clutter: 0.0,
}, ("height", "png")).unwrap();
// curvature.gp is back as an info-only reference on the y2 axis
// (the correction is also folded into profile.gp).
for f in ["profile.gp", "reference.gp", "curvature.gp", "fresnel.gp", "fresnel_pt_6.gp", "splat.gp"] {
let p = tmp.join(f);
assert!(p.exists(), "missing {f}");
let s = std::fs::read_to_string(&p).unwrap();
assert!(!s.is_empty(), "empty {f}");
}
let _ = std::fs::remove_dir_all(&tmp);
}
fn tempdir() -> PathBuf {
let p = std::env::temp_dir().join(format!("splat-rs-gp-{}", std::process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Safe Rust wrapper around the ITWOM / Longley-Rice FFI.
//!
//! The underlying C++ ITWOM implementation has function-local `static`
//! scratch variables. Our vendored copy (`cpp/itwom3.0.cpp`) changes those
//! to `thread_local`, which gives every thread its own independent state
//! so parallel calls do not race.
use std::ffi::{c_char, c_double, c_int};
unsafe extern "C" {
fn splat_point_to_point(
elev: *mut c_double,
tht_m: c_double,
rht_m: c_double,
eps_dielect: c_double,
sgm_conductivity: c_double,
eno_ns_surfref: c_double,
frq_mhz: c_double,
radio_climate: c_int,
pol: c_int,
conf: c_double,
rel: c_double,
dbloss: *mut c_double,
strmode: *mut c_char,
strmode_len: c_int,
errnum: *mut c_int,
);
fn splat_point_to_point_itm(
elev: *mut c_double,
tht_m: c_double,
rht_m: c_double,
eps_dielect: c_double,
sgm_conductivity: c_double,
eno_ns_surfref: c_double,
frq_mhz: c_double,
radio_climate: c_int,
pol: c_int,
conf: c_double,
rel: c_double,
dbloss: *mut c_double,
strmode: *mut c_char,
strmode_len: c_int,
errnum: *mut c_int,
);
fn splat_itwom_version() -> c_double;
}
#[derive(Debug, Clone, Copy)]
pub struct LrParams {
pub eps_dielect: f64,
pub sgm_conductivity: f64,
pub eno_ns_surfref: f64,
pub frq_mhz: f64,
pub radio_climate: i32,
pub pol: i32,
pub conf: f64,
pub rel: f64,
}
#[derive(Debug, Clone)]
pub struct PathLoss {
pub db_loss: f64,
pub mode: String,
pub err: i32,
}
#[derive(Debug, Clone, Copy)]
pub enum Model {
Itwom,
LongleyRice,
}
/// Compute path loss along a terrain profile.
///
/// `elev` layout matches the Longley-Rice convention:
/// - `elev[0]`: number of samples minus one (as f64)
/// - `elev[1]`: distance between samples in meters
/// - `elev[2..]`: terrain elevations in meters
///
/// Safe to call from multiple threads concurrently (each thread has its
/// own ITWOM internal state via `thread_local`).
pub fn path_loss(
model: Model,
elev: &mut [f64],
tht_m: f64,
rht_m: f64,
params: &LrParams,
) -> PathLoss {
let mut db_loss: f64 = 0.0;
let mut err: i32 = 0;
let mut mode_buf = [0u8; 64];
invoke(model, elev, tht_m, rht_m, params, &mut db_loss, &mut mode_buf, &mut err);
let end = mode_buf.iter().position(|&b| b == 0).unwrap_or(mode_buf.len());
let mode = String::from_utf8_lossy(&mode_buf[..end]).into_owned();
PathLoss { db_loss, mode, err }
}
/// Loss-only fast path: identical math to `path_loss`, but skips the
/// UTF-8 validation + `String` allocation for the propagation-mode label.
/// Used by the parallel radial sweep where the mode is discarded anyway —
/// at ~200 M invocations per full map, keeping the allocator quiet measurably
/// reduces rayon contention.
pub fn path_loss_db(
model: Model,
elev: &mut [f64],
tht_m: f64,
rht_m: f64,
params: &LrParams,
) -> f64 {
let mut db_loss: f64 = 0.0;
let mut err: i32 = 0;
let mut mode_buf = [0u8; 1]; // unused, but FFI requires a valid pointer
invoke(model, elev, tht_m, rht_m, params, &mut db_loss, &mut mode_buf, &mut err);
db_loss
}
#[inline]
fn invoke(
model: Model,
elev: &mut [f64],
tht_m: f64,
rht_m: f64,
params: &LrParams,
db_loss: &mut f64,
mode_buf: &mut [u8],
err: &mut i32,
) {
unsafe {
let f = match model {
Model::Itwom => splat_point_to_point,
Model::LongleyRice => splat_point_to_point_itm,
};
f(
elev.as_mut_ptr(),
tht_m,
rht_m,
params.eps_dielect,
params.sgm_conductivity,
params.eno_ns_surfref,
params.frq_mhz,
params.radio_climate,
params.pol,
params.conf,
params.rel,
db_loss,
mode_buf.as_mut_ptr().cast(),
mode_buf.len() as c_int,
err,
);
}
}
pub fn itwom_version() -> f64 {
unsafe { splat_itwom_version() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_is_sane() {
let v = itwom_version();
assert!(v >= 2.0 && v < 10.0, "unexpected ITWOM version: {v}");
}
#[test]
fn flat_path_returns_finite_loss() {
// 10 km flat path at sea level, 30 m antennas, 426 MHz vertical.
let n_minus_1 = 99.0_f64;
let delta = 100.0_f64; // meters between samples
let mut elev = vec![n_minus_1, delta];
elev.extend(std::iter::repeat_n(0.0_f64, (n_minus_1 as usize) + 1));
let p = LrParams {
eps_dielect: 15.0,
sgm_conductivity: 0.005,
eno_ns_surfref: 301.0,
frq_mhz: 426.0,
radio_climate: 5,
pol: 1,
conf: 0.5,
rel: 0.5,
};
let r = path_loss(Model::Itwom, &mut elev, 30.0, 30.0, &p);
assert!(r.db_loss.is_finite(), "loss was not finite: {:?}", r);
assert!(r.db_loss > 80.0 && r.db_loss < 200.0, "implausible loss: {:?}", r);
}
#[test]
fn parallel_calls_do_not_race() {
use rayon::prelude::*;
let p = LrParams {
eps_dielect: 15.0,
sgm_conductivity: 0.005,
eno_ns_surfref: 301.0,
frq_mhz: 426.0,
radio_climate: 5,
pol: 1,
conf: 0.5,
rel: 0.5,
};
let n_minus_1 = 99.0_f64;
let delta = 100.0_f64;
let mut elev = vec![n_minus_1, delta];
elev.extend(std::iter::repeat_n(0.0_f64, (n_minus_1 as usize) + 1));
// Run 1024 identical calls across the rayon pool. If thread_local
// isolation is broken, we get data races / NaNs / differing results.
let results: Vec<f64> = (0..1024_u32)
.into_par_iter()
.map(|_| path_loss(Model::Itwom, &mut elev.clone(), 30.0, 30.0, &p).db_loss)
.collect();
let first = results[0];
assert!(first.is_finite());
for (i, r) in results.iter().enumerate() {
assert_eq!(
r.to_bits(), first.to_bits(),
"bit-diff at index {i}: {r} vs {first}"
);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
//! KML writer producing a GroundOverlay referencing the coverage PNG/PPM.
//!
//! The resulting file can be dropped into Google Earth: the overlay image
//! is geo-referenced to the map's lat/lon bounds, and the placemarks for
//! TX / RX sites render as clickable pins.
//!
//! PPM has no alpha channel; produce a PNG with `-transparent white` (e.g.
//! ImageMagick's `convert`) before pointing KML at it. Alternatively,
//! reference the PPM directly — Google Earth will render the white
//! background opaque, matching SPLAT!'s default behavior.
use std::io::{BufWriter, Write};
use std::path::Path;
use crate::coverage::CoverageMap;
use crate::types::Site;
/// Options controlling extra KML content beyond the base GroundOverlay.
#[derive(Debug, Default)]
pub struct KmlExtras<'a> {
/// Two-point path line drawn between `link.0` and `link.1` (TX → RX).
pub link: Option<(&'a Site, &'a Site)>,
/// Additional placemarks (cities / sites of interest).
pub cities: &'a [Site],
}
pub fn write_overlay(
path: impl AsRef<Path>,
map: &CoverageMap,
image_href: &str,
title: &str,
placemarks: &[Site],
) -> std::io::Result<()> {
write_overlay_with(path, map, image_href, title, placemarks, &KmlExtras::default())
}
pub fn write_overlay_with(
path: impl AsRef<Path>,
map: &CoverageMap,
image_href: &str,
title: &str,
placemarks: &[Site],
extras: &KmlExtras<'_>,
) -> std::io::Result<()> {
let path = path.as_ref();
let mut w = BufWriter::new(std::fs::File::create(path)?);
// SPLAT stores longitude positive-west; KML wants signed east-positive.
let to_east = |lon_w: f64| -lon_w;
writeln!(w, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
writeln!(w, r#"<kml xmlns="http://www.opengis.net/kml/2.2">"#)?;
writeln!(w, " <Folder>")?;
writeln!(w, " <name>{}</name>", xml_escape(title))?;
writeln!(w, " <description>Generado por splat-rs</description>")?;
// Shared style for the link line — bright yellow, semi-transparent.
writeln!(w, " <Style id=\"splat-link\">")?;
writeln!(w, " <LineStyle><color>ff00ffff</color><width>3</width></LineStyle>")?;
writeln!(w, " </Style>")?;
if map.width > 0 && map.height > 0 {
writeln!(w, " <GroundOverlay>")?;
writeln!(w, " <name>Cobertura</name>")?;
writeln!(w, " <Icon><href>{}</href></Icon>", xml_escape(image_href))?;
writeln!(w, " <LatLonBox>")?;
writeln!(w, " <north>{:.6}</north>", map.north)?;
writeln!(w, " <south>{:.6}</south>", map.south)?;
writeln!(w, " <east>{:.6}</east>", to_east(map.east))?;
writeln!(w, " <west>{:.6}</west>", to_east(map.west))?;
writeln!(w, " </LatLonBox>")?;
writeln!(w, " </GroundOverlay>")?;
}
// Link line, if requested.
if let Some((a, b)) = extras.link {
writeln!(w, " <Placemark>")?;
writeln!(w, " <name>Enlace {} → {}</name>",
xml_escape(&a.name), xml_escape(&b.name))?;
writeln!(w, " <styleUrl>#splat-link</styleUrl>")?;
writeln!(w, " <LineString>")?;
writeln!(w, " <tessellate>1</tessellate>")?;
writeln!(w, " <coordinates>")?;
writeln!(w, " {:.6},{:.6},0 {:.6},{:.6},0",
to_east(a.lon), a.lat, to_east(b.lon), b.lat)?;
writeln!(w, " </coordinates>")?;
writeln!(w, " </LineString>")?;
writeln!(w, " </Placemark>")?;
}
for s in placemarks.iter().chain(extras.cities.iter()) {
writeln!(w, " <Placemark>")?;
writeln!(w, " <name>{}</name>", xml_escape(&s.name))?;
writeln!(w, " <Point>")?;
writeln!(w, " <coordinates>{:.6},{:.6},0</coordinates>",
to_east(s.lon), s.lat)?;
writeln!(w, " </Point>")?;
writeln!(w, " </Placemark>")?;
}
writeln!(w, " </Folder>")?;
writeln!(w, "</kml>")?;
w.flush()?;
Ok(())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radial::PixelWrite;
#[test]
fn writes_valid_xml() {
let pixels = vec![PixelWrite { lat: -1.0, lon: 79.0, loss_db: 100.0 }];
let map = CoverageMap::from_pixels(&pixels, 1200, 0.1);
let tmp = std::env::temp_dir().join("splat-rs-test.kml");
let site = Site { name: "TX <test>".into(), lat: -1.0, lon: 79.0, alt: 30.0 };
write_overlay(&tmp, &map, "coverage.png", "Test Link", &[site]).unwrap();
let s = std::fs::read_to_string(&tmp).unwrap();
assert!(s.starts_with("<?xml"));
assert!(s.contains("<GroundOverlay>"));
assert!(s.contains("TX &lt;test&gt;")); // escaped
let _ = std::fs::remove_file(&tmp);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Color-bar legend generator for coverage maps.
//!
//! SPLAT! never embedded a legend in its PPM output — users either learned
//! the convention (red = stronger, blue = weaker) or kept a `.scf` file at
//! hand. We do better: for every coverage map we also emit a `legend.svg`
//! that shows the color ramp side-by-side with both the path-loss axis (dB)
//! and the field-strength axis (dBµV/m, derived from frequency and ERP).
//!
//! SVG was chosen over PPM-with-baked-text because it renders text natively
//! in any browser/viewer/KML overlay without requiring us to vendor a bitmap
//! font like SPLAT! does in `fontdata.h`. A `legend.ppm` companion is also
//! emitted (color bar only, no text) for users who want to composite it
//! into the coverage map with ImageMagick.
use std::fmt::Write as _;
use std::io::{BufWriter, Write};
use std::path::Path;
use crate::ppm;
#[derive(Debug, Clone)]
pub struct LegendOpts {
pub min_db: f64,
pub max_db: f64,
/// Frequency in MHz. Used to convert path loss → field strength.
/// Set 0 to skip the dBµV/m column.
pub freq_mhz: f64,
/// Effective Radiated Power (W). Used to convert path loss → field strength.
/// Set 0 to skip the dBµV/m column.
pub erp_watts: f64,
/// Tick spacing on the dB axis (e.g. 10 dB).
pub tick_db: f64,
}
impl Default for LegendOpts {
fn default() -> Self {
Self { min_db: 80.0, max_db: 130.0, freq_mhz: 0.0, erp_watts: 0.0, tick_db: 10.0 }
}
}
/// Write the SVG legend (the recommended output — has axis labels).
pub fn write_svg(path: impl AsRef<Path>, opts: &LegendOpts) -> std::io::Result<()> {
let path = path.as_ref();
let mut s = String::with_capacity(2048);
// Layout constants (px). Total canvas: 320 wide × 360 tall.
const W: u32 = 320;
const H: u32 = 360;
const BAR_X: u32 = 60;
const BAR_Y: u32 = 40;
const BAR_W: u32 = 36;
const BAR_H: u32 = 280;
let _ = writeln!(s, r#"<?xml version="1.0" encoding="UTF-8"?>"#);
let _ = writeln!(s,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" font-family="sans-serif" font-size="12">"#);
// White background so SVG looks right when displayed standalone.
let _ = writeln!(s, r#" <rect width="{W}" height="{H}" fill="white"/>"#);
// Title.
let _ = writeln!(s,
r#" <text x="{}" y="22" font-size="14" font-weight="bold" text-anchor="middle">Cobertura splat-rs</text>"#,
W / 2);
// Color bar: paint as a stack of 1-px rectangles. Top of bar = lowest loss
// (strongest signal). Direction matches SPLAT's PPMLR convention.
let denom = (opts.max_db - opts.min_db).max(1.0);
for row in 0..BAR_H {
let t = row as f64 / (BAR_H - 1) as f64;
let loss = opts.min_db + t * denom;
let (r, g, b) = ppm::signal_strength_rgb_for_legend(t);
let _ = writeln!(s,
r#" <rect x="{BAR_X}" y="{}" width="{BAR_W}" height="1" fill="rgb({r},{g},{b})"/>"#,
BAR_Y + row);
// Suppress unused-var warning on `loss` when ERP/freq absent.
let _ = loss;
}
// Border around the color bar so the edge is crisp.
let _ = writeln!(s,
r#" <rect x="{BAR_X}" y="{BAR_Y}" width="{BAR_W}" height="{BAR_H}" fill="none" stroke="black" stroke-width="1"/>"#);
// Tick labels — left side: dB; right side: dBµV/m if ERP is known.
let n_ticks = ((opts.max_db - opts.min_db) / opts.tick_db).round() as i32 + 1;
let have_fs = opts.freq_mhz > 0.0 && opts.erp_watts > 0.0;
for k in 0..n_ticks {
let loss = opts.min_db + (k as f64) * opts.tick_db;
if loss > opts.max_db + 1e-9 { break; }
let t = (loss - opts.min_db) / denom;
let y = BAR_Y as f64 + t * (BAR_H - 1) as f64;
// Tick mark on the bar.
let _ = writeln!(s,
r#" <line x1="{}" y1="{:.1}" x2="{}" y2="{:.1}" stroke="black" stroke-width="1"/>"#,
BAR_X - 4, y, BAR_X, y);
let _ = writeln!(s,
r#" <line x1="{}" y1="{:.1}" x2="{}" y2="{:.1}" stroke="black" stroke-width="1"/>"#,
BAR_X + BAR_W, y, BAR_X + BAR_W + 4, y);
// Left label: path loss.
let _ = writeln!(s,
r#" <text x="{}" y="{:.1}" text-anchor="end" alignment-baseline="middle">{:.0}</text>"#,
BAR_X - 6, y + 4.0, loss);
// Right label: field strength derived from path loss.
if have_fs {
// E [dBµV/m] = 139.4 + 20 log10 f - L + 10 log10 (ERP/1000)
let e = 139.4 + 20.0 * opts.freq_mhz.log10() - loss
+ 10.0 * (opts.erp_watts / 1000.0).log10();
let _ = writeln!(s,
r#" <text x="{}" y="{:.1}" alignment-baseline="middle">{:+.0}</text>"#,
BAR_X + BAR_W + 6, y + 4.0, e);
}
}
// Axis titles.
let _ = writeln!(s,
r#" <text x="{}" y="{}" text-anchor="middle">Pérdida (dB)</text>"#,
BAR_X - 22, BAR_Y - 10);
if have_fs {
let _ = writeln!(s,
r#" <text x="{}" y="{}" text-anchor="middle">E (dBµV/m)</text>"#,
BAR_X + BAR_W + 30, BAR_Y - 10);
}
// Top/bottom annotations. Top of bar = lowest path loss = strongest
// signal; bottom = highest acceptable loss before pixels go transparent.
let _ = writeln!(s,
r#" <text x="{}" y="{}" text-anchor="middle" font-style="italic">señal fuerte</text>"#,
W / 2, BAR_Y - 26);
let _ = writeln!(s,
r#" <text x="{}" y="{}" text-anchor="middle" font-style="italic">señal débil</text>"#,
W / 2, BAR_Y + BAR_H + 24);
// Footer with computation parameters.
let footer = if have_fs {
format!("{:.1} MHz, ERP {:.0} W — pérdida ≥ {:.0} dB → blanco/transparente",
opts.freq_mhz, opts.erp_watts, opts.max_db)
} else {
format!("pérdida ≥ {:.0} dB → blanco/transparente", opts.max_db)
};
let _ = writeln!(s,
r#" <text x="{}" y="{}" text-anchor="middle" font-size="10" fill="gray">{}</text>"#,
W / 2, H - 8, footer);
let _ = writeln!(s, "</svg>");
let mut f = BufWriter::new(std::fs::File::create(path)?);
f.write_all(s.as_bytes())?;
Ok(())
}
/// Companion PPM — color bar only, no text. Useful for compositing onto a
/// coverage map with `convert -append legend.ppm coverage.ppm legend-strip.ppm`
/// or similar ImageMagick recipes.
pub fn write_ppm(path: impl AsRef<Path>, opts: &LegendOpts) -> std::io::Result<()> {
const WIDTH: u32 = 30;
const HEIGHT: u32 = 280;
let mut f = BufWriter::new(std::fs::File::create(path)?);
writeln!(f, "P6")?;
writeln!(f, "# Generated by splat-rs (color bar only)")?;
writeln!(f, "{WIDTH} {HEIGHT}")?;
writeln!(f, "255")?;
let mut row = vec![0u8; (WIDTH as usize) * 3];
for y in 0..HEIGHT {
let t = y as f64 / (HEIGHT - 1) as f64;
let (r, g, b) = ppm::signal_strength_rgb_for_legend(t);
for x in 0..(WIDTH as usize) {
row[3 * x] = r;
row[3 * x + 1] = g;
row[3 * x + 2] = b;
}
f.write_all(&row)?;
let _ = opts; // keep API symmetric with SVG version
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_valid_svg() {
let tmp = std::env::temp_dir().join("test-legend.svg");
write_svg(&tmp, &LegendOpts {
min_db: 80.0, max_db: 130.0,
freq_mhz: 91.5, erp_watts: 2000.0, tick_db: 10.0,
}).unwrap();
let s = std::fs::read_to_string(&tmp).unwrap();
assert!(s.starts_with("<?xml"));
assert!(s.contains("<svg"));
assert!(s.contains("Pérdida"));
assert!(s.contains("dBµV/m"));
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn writes_valid_ppm() {
let tmp = std::env::temp_dir().join("test-legend.ppm");
write_ppm(&tmp, &LegendOpts::default()).unwrap();
let bytes = std::fs::read(&tmp).unwrap();
assert!(bytes.starts_with(b"P6\n"));
let _ = std::fs::remove_file(&tmp);
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Rust port of SPLAT! (RF propagation modeling).
//!
//! Current status: FFI to the vendored C++ ITWOM/Longley-Rice engine
//! (`point_to_point`, `point_to_point_ITM`) with per-thread state, plus a
//! skeleton for parallel radial sweeps. Terrain loading, .qth parsing, and
//! output writers are pending.
pub mod cities;
pub mod coverage;
pub mod geo;
pub mod gnuplot;
pub mod itwom;
pub mod kml;
pub mod legend;
pub mod lrp;
pub mod pat;
pub mod ppm;
pub mod qth;
pub mod radial;
pub mod report;
pub mod sdf;
pub mod sft;
pub mod types;
pub mod udt;
pub use itwom::{LrParams, Model, PathLoss, itwom_version, path_loss};
pub use radial::{DemLookup, PixelWrite, RadialCtx, compute_radial, plot_lr_map_parallel};
pub use sdf::{DemTileSet, SdfTile};
pub use types::{Config, Site};
+102
View File
@@ -0,0 +1,102 @@
//! `.lrp` (Longley-Rice parameter) file parser.
//!
//! Plaintext, one value per line, with a trailing "; comment" ignored. Order:
//! 1. Earth dielectric constant (relative permittivity)
//! 2. Earth conductivity (Siemens/meter)
//! 3. Atmospheric bending constant (N-units, surface refractivity)
//! 4. Frequency (MHz, 20 20 000)
//! 5. Radio climate (1..=7)
//! 6. Polarization (0=horizontal, 1=vertical)
//! 7. Fraction of situations (0..=1)
//! 8. Fraction of time (0..=1)
//! 9. ERP in watts (optional, 0.0 means "not set")
use std::path::Path;
use crate::itwom::LrParams;
#[derive(Debug, thiserror::Error)]
pub enum LrpError {
#[error("io error reading {path:?}: {source}")]
Io { path: std::path::PathBuf, #[source] source: std::io::Error },
#[error("{path:?}: expected {expected} numeric values, got {found}")]
TooShort { path: std::path::PathBuf, expected: usize, found: usize },
#[error("{path:?}: line {line} not a number: {value:?}")]
BadNumber { path: std::path::PathBuf, line: usize, value: String },
}
/// Parsed LRP plus the optional ERP (SPLAT stores it on line 9 when present).
#[derive(Debug, Clone)]
pub struct LrpFile {
pub params: LrParams,
pub erp_watts: Option<f64>,
}
pub fn load(path: impl AsRef<Path>) -> Result<LrpFile, LrpError> {
let path = path.as_ref();
let src = std::fs::read_to_string(path)
.map_err(|e| LrpError::Io { path: path.into(), source: e })?;
// Each numeric line is `<value>[<whitespace or ;>...]`. The leading token
// is the number; anything after is commentary (either `;`-prefixed or free
// prose). We take the first whitespace-delimited token per line, parse it
// as a float, and stop once we have 9 values — the SPLAT-ERP optional
// slot. Lines whose first token isn't numeric (pure prose) are skipped,
// matching SPLAT's own permissive `sscanf` behavior.
let mut values: Vec<(usize, f64)> = Vec::with_capacity(9);
for (i, line) in src.lines().enumerate() {
let head = line.split(';').next().unwrap_or("").trim();
if head.is_empty() { continue; }
let first_tok = head.split_whitespace().next().unwrap_or("");
if let Ok(v) = first_tok.parse::<f64>() {
values.push((i + 1, v));
if values.len() == 9 { break; }
}
}
if values.len() < 8 {
return Err(LrpError::TooShort {
path: path.into(),
expected: 8,
found: values.len(),
});
}
let params = LrParams {
eps_dielect: values[0].1,
sgm_conductivity: values[1].1,
eno_ns_surfref: values[2].1,
frq_mhz: values[3].1,
radio_climate: values[4].1 as i32,
pol: values[5].1 as i32,
conf: values[6].1,
rel: values[7].1,
};
let erp_watts = values.get(8).map(|(_, v)| *v).filter(|v| *v > 0.0);
Ok(LrpFile { params, erp_watts })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_real_lrp() {
let path = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/golden/rvt-queve-es/splat.lrp";
if !std::path::Path::new(path).exists() {
eprintln!("skipping: {path} not present");
return;
}
let f = load(path).unwrap();
assert_eq!(f.params.eps_dielect, 15.0);
assert_eq!(f.params.sgm_conductivity, 0.005);
assert_eq!(f.params.eno_ns_surfref, 301.0);
assert_eq!(f.params.frq_mhz, 426.0);
assert_eq!(f.params.radio_climate, 5);
assert_eq!(f.params.pol, 1);
assert!((f.params.conf - 0.5).abs() < 1e-9);
assert!((f.params.rel - 0.5).abs() < 1e-9);
assert_eq!(f.erp_watts, None);
}
}
+257
View File
@@ -0,0 +1,257 @@
//! Antenna pattern loader for SPLAT!-style `.az` and `.el` files.
//!
//! ## `.az` format
//! Line 1: rotation (degrees clockwise from true north; optional, 0 if missing)
//! Lines 2..N: `azimuth_deg<whitespace>amplitude` where amplitude is the
//! normalized field radiation pattern ∈ [0, 1].
//!
//! Values at missing azimuths are linearly interpolated from neighbors;
//! multiple samples at the same azimuth are averaged.
//!
//! ## `.el` format
//! Line 1: `mechanical_tilt<whitespace>tilt_azimuth_deg` (degrees; down-tilt
//! is positive in SPLAT's convention, so a +2° tilt points the beam slightly
//! below horizontal).
//! Lines 2..N: `elevation_deg<whitespace>amplitude` ∈ [0, 1] at 0.1° spacing
//! typical. Values outside the supplied range are clamped to the nearest.
//!
//! Both files support `;` comments — everything after `;` is ignored.
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum PatError {
#[error("io error reading {path:?}: {source}")]
Io { path: PathBuf, #[source] source: std::io::Error },
#[error("could not parse {field} on line {line} in {path:?}: {value:?}")]
Parse { path: PathBuf, field: &'static str, line: usize, value: String },
}
/// Full 3D antenna pattern, indexed by (azimuth_deg_int, elevation_bucket).
///
/// Follows SPLAT's internal representation: 361 azimuth bins (0..=360, with
/// index 360 duplicating index 0 for modular wrapping), and 1001 elevation
/// buckets covering -10° through +90° at 0.1° resolution. Bucket index is
/// `round(10 · (10 elevation_deg))`, so index 100 = horizontal.
#[derive(Debug, Clone)]
pub struct AntennaPattern {
/// Azimuth → relative amplitude [0..1].
pub azimuth: Vec<f32>,
/// 361 × 1001 grid of amplitudes. Row-major: `grid[az * 1001 + elev_bin]`.
pub grid: Vec<f32>,
/// Rotation from the .az header (degrees clockwise from north).
pub rotation: f64,
/// Mechanical tilt from the .el header (degrees; positive = down-tilt).
pub tilt_deg: f64,
/// Azimuth toward which the mechanical tilt is applied.
pub tilt_azimuth: f64,
}
impl AntennaPattern {
/// Load an antenna pattern given the QTH-style base path (no extension).
/// We append `.az` / `.el` ourselves. Missing files are permitted — both
/// components are optional — but at least one must exist.
pub fn load_base(base: impl AsRef<Path>) -> Result<Self, PatError> {
let base = base.as_ref();
// Strip any trailing .qth so we can take the bare stem.
let base: &Path = match base.extension().and_then(|s| s.to_str()) {
Some("qth") => base.with_extension("").leak_like(),
_ => base,
};
let az_path = base.with_extension("az");
let el_path = base.with_extension("el");
let mut pat = AntennaPattern::default_omni();
if az_path.exists() {
pat.load_az(&az_path)?;
}
if el_path.exists() {
pat.load_el(&el_path)?;
}
Ok(pat)
}
/// Isotropic default — used when no pattern files are present.
pub fn default_omni() -> Self {
Self {
azimuth: vec![1.0; 361],
grid: vec![1.0; 361 * 1001],
rotation: 0.0,
tilt_deg: 0.0,
tilt_azimuth: 0.0,
}
}
fn load_az(&mut self, path: &Path) -> Result<(), PatError> {
let src = std::fs::read_to_string(path)
.map_err(|e| PatError::Io { path: path.into(), source: e })?;
let mut lines = src.lines().enumerate();
let mut sum = vec![0.0_f64; 361];
let mut count = vec![0_u32; 361];
if let Some((n, first)) = lines.next() {
let rot = parse_first_float(first)
.ok_or_else(|| PatError::Parse {
path: path.into(), field: "rotation", line: n + 1, value: first.into(),
})?;
self.rotation = rot;
}
for (n, line) in lines {
let line = line.split(';').next().unwrap_or("").trim();
if line.is_empty() { continue; }
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 { continue; }
let az: f64 = parts[0].parse()
.map_err(|_| PatError::Parse { path: path.into(), field: "azimuth", line: n + 1, value: line.into() })?;
let amp: f64 = parts[1].parse()
.map_err(|_| PatError::Parse { path: path.into(), field: "amplitude", line: n + 1, value: line.into() })?;
let idx = az.round() as i64;
if (0..=360).contains(&idx) {
sum[idx as usize] += amp;
count[idx as usize] += 1;
}
}
for i in 0..=360 {
if count[i] > 0 {
self.azimuth[i] = (sum[i] / count[i] as f64) as f32;
} else {
self.azimuth[i] = f32::NAN;
}
}
linear_interp_gaps(&mut self.azimuth);
// Ensure index 360 == index 0 for wraparound.
self.azimuth[360] = self.azimuth[0];
Ok(())
}
fn load_el(&mut self, path: &Path) -> Result<(), PatError> {
let src = std::fs::read_to_string(path)
.map_err(|e| PatError::Io { path: path.into(), source: e })?;
let mut lines = src.lines().enumerate();
let mut el_amp = vec![f32::NAN; 1001];
if let Some((n, first)) = lines.next() {
let first = first.split(';').next().unwrap_or("").trim();
let parts: Vec<&str> = first.split_whitespace().collect();
if parts.len() >= 2 {
self.tilt_deg = parts[0].parse().map_err(|_| PatError::Parse {
path: path.into(), field: "mechanical tilt", line: n + 1, value: first.into(),
})?;
self.tilt_azimuth = parts[1].parse().map_err(|_| PatError::Parse {
path: path.into(), field: "tilt azimuth", line: n + 1, value: first.into(),
})?;
}
}
for (n, line) in lines {
let line = line.split(';').next().unwrap_or("").trim();
if line.is_empty() { continue; }
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 2 { continue; }
let el: f64 = parts[0].parse().map_err(|_| PatError::Parse {
path: path.into(), field: "elevation", line: n + 1, value: line.into(),
})?;
let amp: f64 = parts[1].parse().map_err(|_| PatError::Parse {
path: path.into(), field: "amplitude", line: n + 1, value: line.into(),
})?;
// Bucket: index 100 = horizontal (0°); -10° → 0; +90° → 1000.
let bucket = ((10.0 * (10.0 - el)).round() as i64).clamp(0, 1000) as usize;
el_amp[bucket] = amp as f32;
}
linear_interp_gaps(&mut el_amp);
// Build the 361 × 1001 grid: az[i] × el[j]. Simple separable model,
// matching the SPLAT C++ behavior in the absence of a per-azimuth
// elevation slice.
for az_i in 0..361 {
let az_amp = self.azimuth[az_i];
for el_j in 0..1001 {
self.grid[az_i * 1001 + el_j] = az_amp * el_amp[el_j];
}
}
Ok(())
}
/// Look up the relative field amplitude at a given (azimuth, elevation).
pub fn gain(&self, az_deg: f64, el_deg: f64) -> f32 {
let az = normalize_az(az_deg - self.rotation);
let az_i = (az.round() as i64).rem_euclid(361) as usize;
let bucket = ((10.0 * (10.0 - el_deg)).round() as i64).clamp(0, 1000) as usize;
self.grid[az_i * 1001 + bucket]
}
}
fn parse_first_float(line: &str) -> Option<f64> {
let line = line.split(';').next().unwrap_or("").trim();
let tok = line.split_whitespace().next()?;
tok.parse().ok()
}
fn linear_interp_gaps(v: &mut [f32]) {
let n = v.len();
let mut i = 0;
while i < n {
if v[i].is_nan() {
let left = (0..i).rev().find(|&k| !v[k].is_nan());
let right = (i + 1..n).find(|&k| !v[k].is_nan());
let filled = match (left, right) {
(Some(l), Some(r)) => {
let t = (i as f32 - l as f32) / (r as f32 - l as f32);
v[l] + t * (v[r] - v[l])
}
(Some(l), None) => v[l],
(None, Some(r)) => v[r],
(None, None) => 0.0,
};
v[i] = filled;
}
i += 1;
}
}
fn normalize_az(a: f64) -> f64 {
let mut x = a % 360.0;
if x < 0.0 { x += 360.0; }
x
}
// Small extension trait so we can get a `&Path` from a `PathBuf` owned locally
// in `load_base` without cloning at every call site. Uses Box::leak which is
// fine for a one-shot CLI invocation; avoid in hot loops.
trait PathExt {
fn leak_like(self) -> &'static Path;
}
impl PathExt for PathBuf {
fn leak_like(self) -> &'static Path {
let boxed: Box<Path> = self.into_boxed_path();
Box::leak(boxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_real_az_el() {
let base = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sample_data/wnju-dt";
if !std::path::Path::new(&format!("{base}.az")).exists() {
eprintln!("skipping: sample .az missing");
return;
}
let pat = AntennaPattern::load_base(base).unwrap();
// Sample pattern: az 0° amplitude 0.49 in the file.
let g = pat.gain(0.0, 0.0);
assert!(g > 0.0 && g.is_finite(), "bad gain at 0°/0°: {g}");
// Pattern should vary with azimuth (non-constant).
let g90 = pat.gain(90.0, 0.0);
assert!((g - g90).abs() > 1e-4, "pattern is constant: {g} vs {g90}");
}
#[test]
fn omni_default() {
let pat = AntennaPattern::default_omni();
assert_eq!(pat.gain(0.0, 0.0), 1.0);
assert_eq!(pat.gain(180.0, 45.0), 1.0);
}
}
+129
View File
@@ -0,0 +1,129 @@
//! PPM P6 writer for coverage maps.
//!
//! PPM has no alpha channel, so "transparency" in SPLAT! is done by painting
//! the background pure white (#FFFFFF) and post-processing with ImageMagick's
//! `-transparent white` to produce a PNG. We write with the same convention
//! so the existing toolchain (KML overlays, web viewers) keeps working.
//!
//! The color ramp maps a path-loss band `[min_db, max_db]` through a standard
//! "signal strength" palette: red (weakest signal / highest loss) → yellow →
//! green → cyan → blue (strongest). Pixels with no data stay white.
use std::io::{BufWriter, Write};
use std::path::Path;
use crate::coverage::CoverageMap;
#[derive(Debug, Clone)]
pub struct PpmOpts {
/// Loss values ≤ this dB are mapped to the brightest-signal color.
pub min_db: f64,
/// Loss values ≥ this dB (but finite) are mapped to the weakest-signal color.
pub max_db: f64,
/// Color scheme; only `SignalStrength` is implemented right now.
pub palette: Palette,
}
#[derive(Debug, Clone, Copy)]
pub enum Palette {
/// Red → yellow → green → cyan → blue, binned to 5 levels — matches the
/// SPLAT! default `-L` Longley-Rice signal-level color ramp.
SignalStrength,
}
impl Default for PpmOpts {
fn default() -> Self {
// Typical VHF/UHF broadcast reception: <130 dB path loss is usable at
// nominal antenna gain; beyond that the pixel is painted white so the
// `-transparent white` convert step leaves it empty. Users can widen
// the band with --max-loss-db.
Self { min_db: 80.0, max_db: 130.0, palette: Palette::SignalStrength }
}
}
pub fn write(path: impl AsRef<Path>, map: &CoverageMap, opts: &PpmOpts) -> std::io::Result<()> {
let path = path.as_ref();
let f = std::fs::File::create(path)?;
let mut w = BufWriter::new(f);
// PPM header
writeln!(w, "P6")?;
writeln!(w, "# Generated by splat-rs")?;
writeln!(w, "{} {}", map.width, map.height)?;
writeln!(w, "255")?;
let denom = (opts.max_db - opts.min_db).max(1.0);
let mut row = Vec::<u8>::with_capacity(map.width as usize * 3);
for y in 0..map.height {
row.clear();
for x in 0..map.width {
let idx = (y as usize) * (map.width as usize) + (x as usize);
let loss = map.loss_db[idx];
let (r, g, b) = if !loss.is_finite() || loss > opts.max_db {
// White = no signal / loss too high to be usable. This is
// what later steps flip to transparent via `convert -transparent white`
// and what SPLAT!'s conventional PPMLR output does.
(255u8, 255u8, 255u8)
} else {
let t = ((loss - opts.min_db) / denom).clamp(0.0, 1.0);
signal_strength_rgb(t)
};
row.push(r); row.push(g); row.push(b);
}
w.write_all(&row)?;
}
w.flush()?;
Ok(())
}
/// Map `t ∈ [0, 1]` (0 = lowest loss, 1 = highest) to a 5-level signal
/// palette: red → yellow → green → cyan → blue. Matches SPLAT!'s default
/// -L contour colors closely enough that a user switching tools won't be
/// disoriented; bit-exact palette match is a non-goal.
/// Public re-export of the palette so the legend module can match exactly
/// what the coverage PPM uses. Internal callers use the `signal_strength_rgb`
/// alias below.
pub fn signal_strength_rgb_for_legend(t: f64) -> (u8, u8, u8) {
signal_strength_rgb(t)
}
fn signal_strength_rgb(t: f64) -> (u8, u8, u8) {
// Match the "magenta→red→yellow→green→cyan" palette SPLAT! uses by
// default for signal strength: strongest signal at the low-loss end
// (t=0) is magenta/pink; weakest usable signal (t=1) is cyan.
let segments: [(u8, u8, u8); 6] = [
(255, 0, 255), // 0: strongest — magenta
(255, 0, 0), // 1: red
(255, 128, 0), // 2: orange
(255, 255, 0), // 3: yellow
( 0, 255, 0), // 4: green
( 0, 255, 255), // 5: weakest usable — cyan
];
let scaled = t * (segments.len() as f64 - 1.0);
let i = scaled.floor() as usize;
let frac = scaled - i as f64;
let (r1, g1, b1) = segments[i];
let (r2, g2, b2) = segments[(i + 1).min(segments.len() - 1)];
let lerp = |a: u8, b: u8| ((a as f64) * (1.0 - frac) + (b as f64) * frac).round() as u8;
(lerp(r1, r2), lerp(g1, g2), lerp(b1, b2))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radial::PixelWrite;
#[test]
fn writes_header_and_pixels() {
let pixels = vec![
PixelWrite { lat: -1.0, lon: 79.0, loss_db: 100.0 },
PixelWrite { lat: -1.1, lon: 79.1, loss_db: 150.0 },
];
let map = CoverageMap::from_pixels(&pixels, 1200, 0.05);
let tmp = std::env::temp_dir().join("splat-rs-test.ppm");
write(&tmp, &map, &PpmOpts::default()).unwrap();
let bytes = std::fs::read(&tmp).unwrap();
// Header sanity: starts with "P6\n"
assert!(bytes.starts_with(b"P6\n"));
let _ = std::fs::remove_file(&tmp);
}
}
+147
View File
@@ -0,0 +1,147 @@
//! `.qth` (site/station location) file parser.
//!
//! A QTH file is 4 plaintext lines:
//! 1. Station name
//! 2. Latitude (decimal degrees *or* "DD MM SS.s"). Positive = north.
//! 3. Longitude (decimal degrees *or* "DD MM SS.s"). Positive = *west*
//! (SPLAT's convention, not ISO 6709).
//! 4. Antenna height AGL — a number optionally followed by "m"/"meters"
//! for metric, otherwise feet.
//!
//! We normalize to `Site { lat, lon, alt_meters }`. Note the longitude sign
//! convention stays positive-west (matches the rest of the engine).
use std::path::Path;
use crate::types::Site;
#[derive(Debug, thiserror::Error)]
pub enum QthError {
#[error("io error reading {path:?}: {source}")]
Io { path: std::path::PathBuf, #[source] source: std::io::Error },
#[error("file {path:?} is missing required line {line}")]
MissingLine { path: std::path::PathBuf, line: &'static str },
#[error("could not parse bearing {value:?} in {path:?}")]
BadBearing { path: std::path::PathBuf, value: String },
#[error("could not parse antenna height {value:?} in {path:?}")]
BadHeight { path: std::path::PathBuf, value: String },
}
pub fn load(path: impl AsRef<Path>) -> Result<Site, QthError> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)
.map_err(|e| QthError::Io { path: path.into(), source: e })?;
parse(&contents).map_err(|kind| match kind {
ParseKind::Missing(which) => QthError::MissingLine { path: path.into(), line: which },
ParseKind::Bearing(v) => QthError::BadBearing { path: path.into(), value: v },
ParseKind::Height(v) => QthError::BadHeight { path: path.into(), value: v },
})
}
enum ParseKind {
Missing(&'static str),
Bearing(String),
Height(String),
}
fn parse(src: &str) -> Result<Site, ParseKind> {
let mut lines = src.lines();
let name = lines.next().ok_or(ParseKind::Missing("name"))?.trim().to_owned();
let lat_s = lines.next().ok_or(ParseKind::Missing("latitude"))?;
let lon_s = lines.next().ok_or(ParseKind::Missing("longitude"))?;
let alt_s = lines.next().ok_or(ParseKind::Missing("antenna height"))?;
let lat = parse_bearing(lat_s).ok_or_else(|| ParseKind::Bearing(lat_s.into()))?;
let lon = parse_bearing(lon_s).ok_or_else(|| ParseKind::Bearing(lon_s.into()))?;
let alt_meters = parse_height_meters(alt_s).ok_or_else(|| ParseKind::Height(alt_s.into()))?;
Ok(Site { name, lat, lon, alt: alt_meters })
}
/// Parse a bearing: either "40.139722" or "40 08 23.5". Port of SPLAT!'s
/// `ReadBearing`, including the sign-propagation rule ("if any component
/// is negative, the whole bearing is negative").
pub fn parse_bearing(input: &str) -> Option<f64> {
// Collapse internal whitespace runs to single spaces and trim.
let cleaned: String = input.split_whitespace().collect::<Vec<_>>().join(" ");
let fields: Vec<&str> = cleaned.split(' ').filter(|s| !s.is_empty()).collect();
let bearing = match fields.len() {
1 => fields[0].parse::<f64>().ok()?,
3 => {
let d: i64 = fields[0].parse().ok()?;
let m: i64 = fields[1].parse().ok()?;
let s: f64 = fields[2].parse().ok()?;
let mag = (d.abs() as f64) + (m.abs() as f64) / 60.0 + s.abs() / 3600.0;
if d < 0 || m < 0 || s < 0.0 { -mag } else { mag }
}
_ => return None,
};
if !(-360.0..=360.0).contains(&bearing) {
return Some(0.0); // match C++ behavior of returning 0 for out-of-range
}
Some(bearing)
}
/// Parse antenna height: "14 m" / "14 meters" / "14M" → meters, bare number
/// "100" → feet (converted to meters).
pub fn parse_height_meters(input: &str) -> Option<f64> {
let s = input.trim();
// Strip trailing CR if any.
let s = s.trim_end_matches('\r');
// Detect "m" / "M" / "meters" anywhere after the number.
let metric = s.chars().any(|c| c == 'm' || c == 'M');
// Take the leading numeric part.
let num_end = s
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E'))
.unwrap_or(s.len());
let num_str = s[..num_end].trim();
let n: f64 = num_str.parse().ok()?;
Some(if metric { n } else { n * 0.3048 })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dms_with_negative_sign() {
// SPLAT rule: any negative component makes the whole bearing negative.
assert!((parse_bearing("-1 01 23.54").unwrap() + 1.0232055555).abs() < 1e-9);
}
#[test]
fn decimal_bearing() {
assert!((parse_bearing("79.458669").unwrap() - 79.458669).abs() < 1e-9);
}
#[test]
fn height_feet_default() {
// Bare number → feet, converted to meters (100 ft ≈ 30.48 m).
let h = parse_height_meters("100").unwrap();
assert!((h - 30.48).abs() < 1e-6);
}
#[test]
fn height_metric_suffix() {
assert!((parse_height_meters("14 m").unwrap() - 14.0).abs() < 1e-9);
assert!((parse_height_meters("14 meters").unwrap() - 14.0).abs() < 1e-9);
assert!((parse_height_meters("14M").unwrap() - 14.0).abs() < 1e-9);
}
#[test]
fn loads_real_qth() {
let path = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/golden/rvt-queve-es/txenlace.qth";
if !std::path::Path::new(path).exists() {
eprintln!("skipping: {path} not present");
return;
}
let s = super::load(path).unwrap();
assert_eq!(s.name, "Estudios Quevedo");
// Lat "-1 -01 -23.54" → -1.02321°. SPLAT ReadBearing applies the
// negative-propagation rule because of the leading '-1'.
assert!((s.lat - (-1.023205555)).abs() < 1e-5, "got lat {}", s.lat);
assert!((s.lon - 79.458669444).abs() < 1e-5, "got lon {}", s.lon);
assert!((s.alt - 14.0).abs() < 1e-9, "got alt {}", s.alt);
}
}
+417
View File
@@ -0,0 +1,417 @@
//! Parallel azimuthal (radial) propagation sweep.
//!
//! The expensive step of a path-loss map is computing the signal attenuation
//! along every radial from the transmitter out to the coverage radius. SPLAT!'s
//! original C++ does this with a single-threaded sweep from 0° to 360°; the
//! work is embarrassingly parallel (each radial is independent of the others
//! once the DEM is loaded). Rayon + thread-local ITWOM state let us scale
//! nearly linearly with CPU count.
//!
//! Output strategy: each radial returns a `Vec<PixelWrite>` and the driver
//! merges them after the parallel sweep. Writes never contend. When two
//! radials hit the same pixel (near the TX), the merge keeps the *lower* loss
//! (strongest signal) — matching SPLAT!'s sequential overwrite semantics with
//! `PutSignal`.
use rayon::prelude::*;
use crate::geo::{self, EARTH_RADIUS_MI, FEET_PER_METER, METERS_PER_MILE};
use crate::itwom::{self, LrParams, Model};
use crate::types::{Config, Site};
/// A single pixel write produced by a radial: geographic coordinate plus
/// path loss in dB. Aggregated by the map builder.
#[derive(Debug, Clone, Copy)]
pub struct PixelWrite {
pub lat: f64,
pub lon: f64,
pub loss_db: f64,
}
/// Per-radial context passed to the compute function. Holds *read-only*
/// references so Rayon can share it freely across threads.
pub struct RadialCtx<'a> {
pub config: &'a Config,
pub source: &'a Site,
/// RX antenna height in meters AGL — the "probe" used to compute loss
/// at every radial sample point, matching SPLAT!'s `-L altitude` flag.
pub rx_alt_m: f64,
pub max_range_km: f64,
pub params: &'a LrParams,
pub model: Model,
pub dem: &'a dyn DemLookup,
}
/// Minimal interface the radial sweep needs from the DEM. Concrete impl
/// will back this with an in-memory SDF tile set. Defining the trait here
/// keeps `compute_radial` testable with synthetic terrain.
pub trait DemLookup: Sync {
/// Returns elevation in meters, or some large negative sentinel for
/// no-data. Callers should treat z < -1000 as "outside DEM coverage".
fn elevation(&self, lat: f64, lon: f64) -> f64;
}
/// Compute one radial from the transmitter outward along `azimuth` degrees.
///
/// Pure function — no shared mutable state, safe to call concurrently. This
/// is the unit of work `plot_lr_map_parallel` distributes across cores.
///
/// Algorithm (port of SPLAT!'s `PlotLRPath` driver):
/// 1. Walk outward along the great-circle bearing in steps of
/// `1 / samples_per_radian` radians, sampling the DEM at each step.
/// 2. For every sample past the 3rd, call ITWOM with the *prefix* profile
/// (samples 0..=i). That gives path loss from TX to the point at step `i`.
/// 3. Emit a `PixelWrite` per outer point with the loss in dB.
///
/// Complexity per radial is O(N²) in ITWOM calls (one per step, with the
/// call size proportional to the step index). Matches SPLAT!'s cost model.
pub fn compute_radial(ctx: &RadialCtx<'_>, azimuth_deg: f64) -> Vec<PixelWrite> {
let max_d_mi = ctx.max_range_km / 1.609344;
let spr = geo::samples_per_radian(ctx.config.ppd());
let path_length = (max_d_mi / EARTH_RADIUS_MI) * spr;
let n = path_length.max(2.0) as usize;
let miles_per_sample = max_d_mi / path_length;
let delta_m = miles_per_sample * METERS_PER_MILE;
// Sample the terrain profile once along the full radial.
let mut lats = Vec::with_capacity(n + 1);
let mut lons = Vec::with_capacity(n + 1);
let mut elev_m = Vec::with_capacity(n + 1);
for i in 0..=n {
let d_mi = miles_per_sample * i as f64;
let (lat, lon) =
geo::step_great_circle(ctx.source.lat, ctx.source.lon, azimuth_deg, d_mi);
let z = ctx.dem.elevation(lat, lon);
if z < -1000.0 { break; }
lats.push(lat);
lons.push(lon);
elev_m.push(z);
}
if elev_m.len() < 4 {
return Vec::new(); // too short to do any meaningful path loss
}
let source_alt_m = ctx.source.alt;
// Reused elev buffer for ITWOM — first two slots are header (npts-1, delta).
let mut elev_buf = Vec::with_capacity(elev_m.len() + 2);
let mut out = Vec::with_capacity(elev_m.len().saturating_sub(2));
for i in 2..elev_m.len() {
elev_buf.clear();
elev_buf.push((i - 1) as f64); // number_of_points - 1
elev_buf.push(delta_m);
elev_buf.extend_from_slice(&elev_m[0..=i]);
// Hot path: we only need the dB number, not the mode label.
let loss_db = itwom::path_loss_db(
ctx.model,
&mut elev_buf,
source_alt_m,
ctx.rx_alt_m,
ctx.params,
);
out.push(PixelWrite {
lat: lats[i],
lon: lons[i],
loss_db,
});
}
let _ = FEET_PER_METER; // silence unused import warning if feet conv unused
out
}
/// LOS-only radial — no ITWOM calls. Checks whether each point along the
/// radial has line of sight to the TX, taking Earth curvature (4/3 R) and
/// antenna heights into account. Emits `loss_db = 0.0` for visible points
/// and `loss_db = f64::INFINITY` for obstructed ones, so downstream
/// `CoverageMap::ingest` (which keeps the *min* loss per pixel) produces
/// a binary visibility raster.
///
/// Dramatically cheaper than the full path-loss sweep — use it as a quick
/// "where would this transmitter even be seen from" pass before paying
/// for the ITWOM computation.
pub fn compute_los_radial(ctx: &RadialCtx<'_>, azimuth_deg: f64) -> Vec<PixelWrite> {
let max_d_mi = ctx.max_range_km / 1.609344;
let spr = geo::samples_per_radian(ctx.config.ppd());
let path_length = (max_d_mi / EARTH_RADIUS_MI) * spr;
let n = path_length.max(2.0) as usize;
let miles_per_sample = max_d_mi / path_length;
// 4/3-Earth effective radius in meters, for curvature correction.
let four_thirds_earth_m: f64 = (4.0 / 3.0) * 20_902_230.97 * 0.3048;
let tx_ground_m = ctx.dem.elevation(ctx.source.lat, ctx.source.lon);
let tx_top_m = tx_ground_m + ctx.source.alt;
// Compare *slopes* (rise/run) rather than cosines — slopes at different
// distances compose correctly. The curvature-corrected apparent height
// of a point at distance d is `z - d²/(2R)`, so the slope from the TX
// antenna top is `(z - tx_top)/d - d/(2R)`.
let slope = |z: f64, d_m: f64| (z - tx_top_m) / d_m - d_m / (2.0 * four_thirds_earth_m);
let mut out = Vec::with_capacity(n);
let mut max_terrain_slope = f64::NEG_INFINITY;
for i in 1..n {
let d_mi = miles_per_sample * i as f64;
let (lat, lon) =
geo::step_great_circle(ctx.source.lat, ctx.source.lon, azimuth_deg, d_mi);
let z = ctx.dem.elevation(lat, lon);
if z < -1000.0 { break; }
let d_m = d_mi * METERS_PER_MILE;
let s_terrain = slope(z, d_m);
if s_terrain > max_terrain_slope {
max_terrain_slope = s_terrain;
}
let s_rx = slope(z + ctx.rx_alt_m, d_m);
let visible = s_rx >= max_terrain_slope;
out.push(PixelWrite {
lat,
lon,
loss_db: if visible { 0.0 } else { f64::INFINITY },
});
}
out
}
/// Parallel 360° LOS coverage sweep. Same shape as `plot_lr_map_parallel`
/// but uses `compute_los_radial` — typically 100-500× faster because no
/// ITWOM invocations are needed.
pub fn plot_los_coverage_parallel(ctx: &RadialCtx<'_>) -> Vec<PixelWrite> {
let ppd = ctx.config.ppd() as f64;
let n_steps = (360.0 * ppd) as u32;
(0..n_steps)
.into_par_iter()
.map(|i| i as f64 / ppd)
.flat_map_iter(|az| compute_los_radial(ctx, az))
.filter(|p| p.loss_db.is_finite())
.collect()
}
/// Sweep the full 360° azimuth range in parallel, using one radial per
/// `1.0 / ppd` degree step (matching SPLAT!'s native resolution).
///
/// The call blocks until all radials complete. Returns flat pixel writes
/// that the caller folds into a coverage map.
pub fn plot_lr_map_parallel(ctx: &RadialCtx<'_>) -> Vec<PixelWrite> {
let ppd = ctx.config.ppd() as f64;
let n_steps = (360.0 * ppd) as u32;
(0..n_steps)
.into_par_iter()
.map(|i| i as f64 / ppd)
.flat_map_iter(|az| compute_radial(ctx, az))
.collect()
}
/// Same sweep as `plot_lr_map_parallel`, but keeps the per-radial grouping
/// so the caller can rasterize consecutive samples as line segments
/// (eliminating diagonal-radial aliasing artifacts in the output map).
///
/// Use `CoverageMap::from_radials(&result, ppd, pad)` to render without
/// streak gaps.
pub fn plot_lr_map_parallel_radials(ctx: &RadialCtx<'_>) -> Vec<Vec<PixelWrite>> {
let ppd = ctx.config.ppd() as f64;
let n_steps = (360.0 * ppd) as u32;
(0..n_steps)
.into_par_iter()
.map(|i| i as f64 / ppd)
.map(|az| compute_radial(ctx, az))
.collect()
}
/// Sequential baseline of the same sweep, used for correctness diffing and
/// benchmark comparison against the parallel version.
pub fn plot_lr_map_sequential(ctx: &RadialCtx<'_>) -> Vec<PixelWrite> {
let ppd = ctx.config.ppd() as f64;
let n_steps = (360.0 * ppd) as u32;
(0..n_steps)
.map(|i| i as f64 / ppd)
.flat_map(|az| compute_radial(ctx, az))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdf::DemTileSet;
struct FlatDem(f64);
impl DemLookup for FlatDem {
fn elevation(&self, _lat: f64, _lon: f64) -> f64 { self.0 }
}
#[test]
fn single_radial_returns_monotonic_growing_loss() {
// Flat Earth: loss should increase (mostly monotonically) with distance.
let cfg = Config::default();
let src = Site { name: "X".into(), lat: -1.0, lon: 79.0, alt: 30.0 };
let p = LrParams {
eps_dielect: 15.0,
sgm_conductivity: 0.005,
eno_ns_surfref: 301.0,
frq_mhz: 426.0,
radio_climate: 5,
pol: 1,
conf: 0.5,
rel: 0.5,
};
let dem = FlatDem(50.0);
let ctx = RadialCtx {
config: &cfg,
source: &src,
rx_alt_m: 10.0,
max_range_km: 20.0,
params: &p,
model: Model::Itwom,
dem: &dem,
};
let r = compute_radial(&ctx, 0.0);
assert!(!r.is_empty());
// Spot-check: the last point should have more loss than the first.
let first = r.first().unwrap().loss_db;
let last = r.last().unwrap().loss_db;
assert!(last > first, "expected loss to grow: first={first} last={last}");
for p in &r {
assert!(p.loss_db.is_finite(), "non-finite: {:?}", p);
}
}
/// Sweeps a small fraction of the 360° range with real Ecuador DEM,
/// comparing sequential vs parallel. Small radius so both variants
/// finish quickly — the full map is proved out by
/// `full_rvt_queve_parallel_only` below.
#[test]
#[ignore = "expensive; run with --ignored"]
fn rvt_queve_quadrant_ser_vs_par() {
let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf";
if !std::path::Path::new(tiles_dir).exists() {
eprintln!("skipping: {tiles_dir} not present");
return;
}
let mut dem = DemTileSet::new();
let loaded = dem.load_dir(tiles_dir).unwrap();
println!("loaded {loaded} tiles");
let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 };
let p = LrParams {
eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0,
frq_mhz: 426.0, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5,
};
let cfg = Config::default();
// 10 km radius, first 90° only — ~108 k radials × ~118 samples each.
let ppd = cfg.ppd() as f64;
let n = (90.0 * ppd) as u32;
let build_ctx = |range| RadialCtx {
config: &cfg, source: &src, rx_alt_m: 10.0, max_range_km: range,
params: &p, model: Model::Itwom, dem: &dem,
};
let ctx = build_ctx(10.0);
let quadrant_par = |ctx: &RadialCtx<'_>| -> Vec<PixelWrite> {
use rayon::prelude::*;
(0..n).into_par_iter()
.map(|i| i as f64 / ppd)
.flat_map_iter(|az| compute_radial(ctx, az))
.collect()
};
let quadrant_seq = |ctx: &RadialCtx<'_>| -> Vec<PixelWrite> {
(0..n)
.map(|i| i as f64 / ppd)
.flat_map(|az| compute_radial(ctx, az))
.collect()
};
let t0 = std::time::Instant::now();
let px_seq = quadrant_seq(&ctx);
let d_seq = t0.elapsed();
let t1 = std::time::Instant::now();
let px_par = quadrant_par(&ctx);
let d_par = t1.elapsed();
println!("pixels: seq={} par={}", px_seq.len(), px_par.len());
println!("sequential: {:?}", d_seq);
println!("parallel: {:?}", d_par);
println!("speedup: {:.2}x", d_seq.as_secs_f64() / d_par.as_secs_f64());
assert_eq!(px_seq.len(), px_par.len(),
"quadrant sweep produced different pixel counts");
}
/// Compute a single radial toward Cerro Cochabamba and check the path
/// loss at that distance matches SPLAT!'s reported 145.5 dB. This is our
/// primary correctness gate for the pipeline.
#[test]
#[ignore = "needs real DEM"]
fn rvt_queve_cerro_cochabamba_matches_splat() {
let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf";
if !std::path::Path::new(tiles_dir).exists() { return; }
let mut dem = DemTileSet::new();
dem.load_dir(tiles_dir).unwrap();
let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 };
let p = LrParams {
eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0,
frq_mhz: 426.0, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5,
};
let cfg = Config::default();
let ctx = RadialCtx {
config: &cfg, source: &src, rx_alt_m: 20.0, max_range_km: 90.0,
params: &p, model: Model::Itwom, dem: &dem,
};
let radial = compute_radial(&ctx, 152.52);
// SPLAT reports 84.66 km to Cerro Cochabamba. Find the sample closest
// to that distance along this radial.
let d = |p: &PixelWrite| crate::geo::distance(
src.lat, src.lon, p.lat, p.lon) * 1.609344;
let target_km = 84.66;
let best = radial.iter()
.min_by(|a, b| (d(a) - target_km).abs().total_cmp(&(d(b) - target_km).abs()))
.expect("non-empty radial");
println!(
"Cerro Cochabamba sample: {:.2} km, loss {:.2} dB (SPLAT golden: 145.50 dB)",
d(best), best.loss_db
);
// The SPLAT C++ report uses RX AGL=20 m at Cerro Cochabamba; our
// radial uses a uniform rx_alt_m=20. Matching within 5 dB is a
// generous tolerance that still catches gross errors — bit-for-bit
// equivalence would need same SDF no-data handling and interpolation.
assert!(
(best.loss_db - 145.5).abs() < 5.0,
"loss drifted too far from SPLAT golden: {:.2} dB", best.loss_db
);
}
/// Full 360°/40 km sweep, parallel only — validates the end-to-end
/// pipeline under realistic workload. Aim: <3 min on a 16-core box.
#[test]
#[ignore = "expensive; run with --ignored"]
fn full_rvt_queve_parallel_only() {
let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf";
if !std::path::Path::new(tiles_dir).exists() {
eprintln!("skipping: {tiles_dir} not present");
return;
}
let mut dem = DemTileSet::new();
dem.load_dir(tiles_dir).unwrap();
let src = Site { name: "Q".into(), lat: -1.023206, lon: 79.458669, alt: 14.0 };
let p = LrParams {
eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0,
frq_mhz: 426.0, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5,
};
let cfg = Config::default();
let ctx = RadialCtx {
config: &cfg, source: &src, rx_alt_m: 10.0, max_range_km: 40.73,
params: &p, model: Model::Itwom, dem: &dem,
};
let t = std::time::Instant::now();
let px = plot_lr_map_parallel(&ctx);
println!("full sweep: {} pixels in {:?}", px.len(), t.elapsed());
assert!(px.len() > 100_000_000);
}
}
+673
View File
@@ -0,0 +1,673 @@
//! Path and site reports. Spanish-localized text output matching the
//! homologated SPLAT!-Charles binary. Byte-for-byte equivalence is not a goal
//! (the original has mixed English/Spanish that we cleaned up); what we *do*
//! preserve is the numeric content — distances, azimuths, angles, losses —
//! so any downstream tool that parses values out of reports keeps working.
use std::fmt::Write as _;
use crate::geo::{self, EARTH_RADIUS_MI, METERS_PER_MILE};
use crate::itwom::{self, LrParams, Model};
use crate::radial::DemLookup;
use crate::types::Site;
pub const DASHES: &str = "---------------------------------------------------------------------------";
/// SPLAT's Earth radius constant (20_902_230.97 ft, used with no 4/3
/// adjustment inside `ElevationAngle`). Converted to meters here.
const EARTH_RADIUS_M: f64 = 20_902_230.97 * 0.3048;
/// `ElevationAngle` — port of SPLAT!'s. Returns elevation angle in degrees
/// from source to destination, apparent over-the-earth (positive = uptilt).
pub fn elevation_angle(
src: &Site, dst: &Site, dem: &dyn DemLookup,
) -> f64 {
let a = dem.elevation(dst.lat, dst.lon) + dst.alt + EARTH_RADIUS_M;
let b = dem.elevation(src.lat, src.lon) + src.alt + EARTH_RADIUS_M;
let dx = geo::distance(src.lat, src.lon, dst.lat, dst.lon) * METERS_PER_MILE;
let cos_v = ((b * b + dx * dx - a * a) / (2.0 * b * dx)).clamp(-1.0, 1.0);
cos_v.acos().to_degrees() - 90.0
}
/// Sample a path from `src` toward `(azimuth_deg, distance_mi)` and return
/// lat/lon of the endpoint plus the elevation at that endpoint.
fn endpoint_and_elev(
src: &Site, az_deg: f64, dist_mi: f64, dem: &dyn DemLookup,
) -> (f64, f64, f64) {
let (lat, lon) = geo::step_great_circle(src.lat, src.lon, az_deg, dist_mi);
(lat, lon, dem.elevation(lat, lon))
}
/// Average terrain elevation (meters) along a radial from `src` toward
/// `az_deg`, between `start_mi` and `end_mi`. Returns `None` if SDF
/// coverage is insufficient, matching SPLAT!'s -9999 sentinel semantics.
pub fn average_terrain(
src: &Site, az_deg: f64, start_mi: f64, end_mi: f64, dem: &dyn DemLookup,
) -> Option<f64> {
let (end_lat, end_lon, end_z) = endpoint_and_elev(src, az_deg, end_mi, dem);
if end_z < -1000.0 { return None; }
// Re-sample along the path with the ppd-derived step count, matching
// ReadPath. We use 1200 ppd here as a reasonable default — HAAT is
// coarse-grained anyway.
let spr = geo::samples_per_radian(1200);
let path_len = ((end_mi / EARTH_RADIUS_MI) * spr) as usize;
let miles_per_sample = end_mi / path_len as f64;
let mut sum = 0.0;
let mut samples = 0usize;
for i in 0..path_len {
let d = miles_per_sample * i as f64;
let (lat, lon) = geo::step_great_circle(src.lat, src.lon, az_deg, d);
let z = dem.elevation(lat, lon);
if d >= start_mi && z > -1000.0 {
sum += z;
samples += 1;
}
}
let _ = (end_lat, end_lon);
if samples == 0 { None } else { Some(sum / samples as f64) }
}
/// Height above average terrain (FCC Part 73.313(d)): compares antenna
/// elevation to the average ground elevation over eight radials (45° spacing)
/// between 2 and 10 miles from the antenna.
pub fn haat(ant: &Site, dem: &dyn DemLookup) -> Option<f64> {
let mut sum = 0.0;
let mut c = 0;
for k in 0..8 {
let az = (k * 45) as f64;
let t = average_terrain(ant, az, 2.0, 10.0, dem)?;
sum += t;
c += 1;
}
let avg = sum / c as f64;
let ant_z = dem.elevation(ant.lat, ant.lon);
Some((ant.alt + ant_z) - avg)
}
/// Format a decimal bearing as `D° M' S"` — port of `dec2dms`. SPLAT
/// truncates the fractional seconds (C `(int)` cast), so we do too, keeping
/// the formatted output byte-identical with the original reports.
pub fn dec2dms(bearing: f64) -> String {
let mag = bearing.abs();
let d_f = mag.floor();
let m = 60.0 * (mag - d_f);
let m_f = m.floor();
let s = 60.0 * (m - m_f);
let s_i = (s as i32).clamp(0, 59); // truncate, not round
let sign = if bearing < 0.0 { -1 } else { 1 };
format!("{}° {}' {}\"", d_f as i32 * sign, m_f as i32, s_i)
}
#[derive(Debug, Clone, Copy)]
pub struct PathReportOpts {
pub metric: bool,
pub rx_alt_m: f64, // used only if the .qth rx alt is zero
}
/// Produce a Spanish path-analysis report matching the homologated SPLAT!
/// output. Takes the already-computed ITWOM result so the caller can reuse
/// a cached computation (e.g. from the radial sweep).
#[allow(clippy::too_many_arguments)]
pub fn path_report(
source: &Site,
destination: &Site,
params: &LrParams,
erp_watts: Option<f64>,
model: Model,
dem: &dyn DemLookup,
opts: PathReportOpts,
) -> String {
let mut out = String::with_capacity(2048);
let _ = opts.rx_alt_m;
let metric = opts.metric;
let _ = writeln!(out, "\n\t\t--==[ SPLAT!-rs v0.1 Análisis de trayecto ]==--\n");
let _ = writeln!(out, "{DASHES}\n");
// --- TX side ---
write_site_block(&mut out, source, destination, params, dem, metric, true);
// --- RX side ---
let _ = writeln!(out, "\n{DASHES}\n");
write_site_block(&mut out, destination, source, params, dem, metric, false);
// --- Parameters used ---
let _ = writeln!(out, "\n{DASHES}\n");
match model {
Model::Itwom => {
let _ = writeln!(out,
"Parámetros ITWOM Versión {:.1} usados en este análisis:\n",
itwom::itwom_version());
}
Model::LongleyRice => {
let _ = writeln!(out, "Parámetros Longley-Rice usados en este análisis:\n");
}
}
let _ = writeln!(out, "Constante dieléctrica de la Tierra: {:.3}", params.eps_dielect);
let _ = writeln!(out, "Conductividad de la Tierra: {:.3} Siemens/metro", params.sgm_conductivity);
let _ = writeln!(out, "Constante de refracción atmosférica (N-units): {:.3} ppm", params.eno_ns_surfref);
let _ = writeln!(out, "Frecuencia: {:.3} MHz", params.frq_mhz);
let _ = writeln!(out, "Clima radioeléctrico: {} ({})",
params.radio_climate, climate_name(params.radio_climate));
let _ = writeln!(out, "Polarización: {} ({})",
params.pol, if params.pol == 1 { "Vertical" } else { "Horizontal" });
let _ = writeln!(out, "Fracción de situaciones: {:.1}%", params.conf * 100.0);
let _ = writeln!(out, "Fracción de tiempo: {:.1}%", params.rel * 100.0);
if let Some(erp) = erp_watts {
let _ = write!(out, "ERP del transmisor: ");
write_power(&mut out, erp);
let dbm = 10.0 * (erp * 1000.0).log10();
let _ = writeln!(out, " ({:+.2} dBm)", dbm);
let eirp = erp * 1.636_816_521;
let _ = write!(out, "EIRP del transmisor: ");
write_power(&mut out, eirp);
let dbm_eirp = 10.0 * (eirp * 1000.0).log10();
let _ = writeln!(out, " ({:+.2} dBm)", dbm_eirp);
}
// --- Summary + propagation ---
let _ = writeln!(out, "\n{DASHES}\n");
let _ = writeln!(out, "Resumen para el enlace entre {} y {}:\n",
source.name, destination.name);
let d_mi = geo::distance(source.lat, source.lon, destination.lat, destination.lon);
let fsl = 36.6 + 20.0 * params.frq_mhz.log10() + 20.0 * d_mi.log10();
// Compute end-to-end ITWOM loss across the full path between TX and RX.
let r = compute_end_to_end_loss(source, destination, params, model, dem);
let _ = writeln!(out, "Pérdida en espacio libre: {:.2} dB", fsl);
match model {
Model::Itwom => {
let _ = writeln!(out,
"Pérdida ITWOM Versión {:.1}: {:.2} dB", itwom::itwom_version(), r.db_loss);
}
Model::LongleyRice => {
let _ = writeln!(out, "Pérdida Longley-Rice: {:.2} dB", r.db_loss);
}
}
let _ = writeln!(out, "Atenuación por apantallamiento del terreno: {:.2} dB", r.db_loss - fsl);
let mode_es = translate_mode(&r.mode);
let _ = writeln!(out, "Modo de propagación: {mode_es}");
let err_desc = match r.err {
0 => "(Sin error)",
1 => "(algunos parámetros están cerca del límite de rango)",
2 => "(se sustituyeron parámetros imposibles por valores por defecto)",
3 => "(una combinación de parámetros está fuera de rango)",
_ => "(algunos parámetros están fuera de rango)",
};
let err_label = match model {
Model::Itwom => "Número de error ITWOM",
Model::LongleyRice => "Número de error Longley-Rice",
};
let _ = writeln!(out, "{err_label}: {} {}", r.err, err_desc);
let _ = writeln!(out, "\n{DASHES}\n");
// --- Obstruction and Fresnel analysis -----------------------------
let obstr = obstruction_analysis(source, destination, params.frq_mhz, dem, metric);
out.push_str(&obstr);
out
}
fn write_site_block(
out: &mut String,
s: &Site,
other: &Site,
_params: &LrParams,
dem: &dyn DemLookup,
metric: bool,
is_tx: bool,
) {
let label = if is_tx { "transmisor" } else { "receptor" };
let _ = writeln!(out, "Sitio {label}: {}", s.name);
if s.lat >= 0.0 {
let _ = write!(out,
"Locación del sitio: {:.4} Norte / {:.4} Oeste ({} N / ",
s.lat, s.lon, dec2dms(s.lat));
} else {
let _ = write!(out,
"Locación del sitio: {:.4} Sur / {:.4} Oeste ({} S / ",
-s.lat, s.lon, dec2dms(s.lat));
}
let _ = writeln!(out, "{} W)", dec2dms(s.lon));
let ground_m = dem.elevation(s.lat, s.lon);
if metric {
let _ = writeln!(out, "Elevación de la tierra: {:.2} metros SNM", ground_m);
let _ = writeln!(out, "Altura de la antena: {:.2} metros SNT / {:.2} metros SNM",
s.alt, s.alt + ground_m);
} else {
let _ = writeln!(out, "Elevación de la tierra: {:.2} pies SNM", ground_m * 3.28084);
let _ = writeln!(out, "Altura de la antena: {:.2} pies SNT / {:.2} pies SNM",
s.alt * 3.28084, (s.alt + ground_m) * 3.28084);
}
// HAAT, if we can compute it.
if is_tx {
if let Some(h) = haat(s, dem) {
if metric {
let _ = writeln!(out,
"Altura de la antena sobre el promedio del terreno: {:.2} metros", h);
} else {
let _ = writeln!(out,
"Altura de la antena sobre el promedio del terreno: {:.2} pies", h * 3.28084);
}
}
}
let dist_mi = geo::distance(s.lat, s.lon, other.lat, other.lon);
if metric {
let _ = writeln!(out, "Distancia a {}: {:.2} kilómetros", other.name, dist_mi * 1.609344);
} else {
let _ = writeln!(out, "Distancia a {}: {:.2} millas", other.name, dist_mi);
}
let az = geo::azimuth(s.lat, s.lon, other.lat, other.lon);
let _ = writeln!(out, "Azimut hacia {}: {:.2} grados", other.name, az);
let angle = elevation_angle(s, other, dem);
if angle >= 0.0 {
let _ = writeln!(out, "Ángulo de elevación hacia {}: {:+.4} grados", other.name, angle);
} else {
let _ = writeln!(out, "Ángulo de depresión hacia {}: {:+.4} grados", other.name, angle);
}
}
fn write_power(out: &mut String, w: f64) {
if w < 1.0 {
let _ = write!(out, "{:.1} milivatios", 1000.0 * w);
} else if w < 10.0 {
let _ = write!(out, "{:.1} vatios", w);
} else if w < 10_000.0 {
let _ = write!(out, "{:.0} vatios", w);
} else {
let _ = write!(out, "{:.3} kilovatios", w / 1000.0);
}
}
fn climate_name(c: i32) -> &'static str {
match c {
1 => "Ecuatorial",
2 => "Subtropical Continental",
3 => "Subtropical Marítimo",
4 => "Desértico",
5 => "Templado Continental",
6 => "Templado Marítimo, sobre tierra",
7 => "Templado Marítimo, sobre mar",
_ => "desconocido",
}
}
fn translate_mode(mode: &str) -> String {
// ITWOM returns compact strings like "L-o-S", "1_Hrzn_Diff",
// "2_Hrzn_Tropo". Classic Longley-Rice (ITM) returns verbose variants
// such as "Line-Of-Sight Mode", "Single Horizon Diffraction Dominant".
let m = mode.trim();
if m.is_empty() { return String::new(); }
// ITWOM compact forms ----------------------------------------------
if m == "L-o-S" { return "Línea de vista".into(); }
if m.starts_with("1_Hrzn") || m.starts_with("2_Hrzn") {
let hrzn = if m.starts_with("1_Hrzn") { "Horizonte simple " } else { "Horizonte doble " };
let suffix = if m.contains("_Diff") {
"con difracción dominante"
} else if m.contains("_Tropo") {
"con dispersión troposférica dominante"
} else if m.contains("_Peak") {
"RX en el pico del terreno"
} else { "" };
return format!("{hrzn}{suffix}");
}
// Classic ITM verbose forms ----------------------------------------
let lowered = m.to_ascii_lowercase();
if lowered.contains("line-of-sight") || lowered.contains("line of sight") {
return "Línea de vista".into();
}
if lowered.contains("single horizon") || lowered.contains("double horizon") {
let hrzn = if lowered.contains("single") { "Horizonte simple " } else { "Horizonte doble " };
let suffix = if lowered.contains("diffraction") {
"con difracción dominante"
} else if lowered.contains("troposcatter") || lowered.contains("tropo") {
"con dispersión troposférica dominante"
} else { "" };
return format!("{hrzn}{suffix}");
}
// Unknown — pass through verbatim so the user can see the raw mode string.
m.to_string()
}
/// Port of SPLAT!'s `SiteReport`. Used when the user runs `splat-rs -t tx`
/// without an RX — summarizes a single site's position, ground elevation,
/// HAAT, and mean terrain in the 8 cardinal/inter-cardinal directions
/// between 2 and 10 miles from the antenna (FCC Part 73.313(d)).
pub fn site_report(site: &Site, dem: &dyn DemLookup, metric: bool) -> String {
let mut out = String::with_capacity(1024);
let _ = writeln!(out, "\n\t--==[ SPLAT!-rs v0.1 Reporte de sitio para: {} ]==--\n",
site.name);
let _ = writeln!(out, "{DASHES}\n");
if site.lat >= 0.0 {
let _ = write!(out, "Sitio: {:.4} Norte / {:.4} Oeste ({} N / ",
site.lat, site.lon, dec2dms(site.lat));
} else {
let _ = write!(out, "Sitio: {:.4} Sur / {:.4} Oeste ({} S / ",
-site.lat, site.lon, dec2dms(site.lat));
}
let _ = writeln!(out, "{} W)", dec2dms(site.lon));
let ground = dem.elevation(site.lat, site.lon);
if metric {
let _ = writeln!(out, "Elevación de la tierra: {:.2} metros SNM", ground);
let _ = writeln!(out, "Altura de la antena: {:.2} metros SNT / {:.2} metros SNM",
site.alt, site.alt + ground);
} else {
let _ = writeln!(out, "Elevación de la tierra: {:.2} pies SNM", ground * 3.28084);
let _ = writeln!(out, "Altura de la antena: {:.2} pies SNT / {:.2} pies SNM",
site.alt * 3.28084, (site.alt + ground) * 3.28084);
}
if let Some(h) = haat(site, dem) {
if metric {
let _ = writeln!(out, "Altura de la antena sobre el promedio del terreno: {:.2} metros\n", h);
} else {
let _ = writeln!(out, "Altura de la antena sobre el promedio del terreno: {:.2} pies\n", h * 3.28084);
}
// Per-radial mean terrain at the 8 standard azimuths.
for az in (0..=315).step_by(45) {
let t = average_terrain(site, az as f64, 2.0, 10.0, dem);
let _ = write!(out, "Promedio del terreno a {:3} grados de azimut: ", az);
match t {
Some(mean) => {
if metric {
let _ = writeln!(out, "{:.2} metros SNM", mean);
} else {
let _ = writeln!(out, "{:.2} pies SNM", mean * 3.28084);
}
}
None => { let _ = writeln!(out, "Sin terreno"); }
}
}
} else {
let _ = writeln!(out, "(HAAT no disponible — falta cobertura SDF para el anillo 210 mi)");
}
let _ = writeln!(out, "\n{DASHES}\n");
out
}
/// Port of SPLAT!'s `ObstructionAnalysis`. Walks the path from RX back toward
/// TX; at each sample it checks whether terrain (plus clutter, if set) blocks
/// LOS, then whether the first Fresnel zone and 60% Fresnel zone are clear.
/// Produces the final "no obstructions / first Fresnel is clear" block that
/// rounds out a SPLAT path report.
///
/// Simplified vs the C++ original:
/// - We don't print per-obstruction lat/lon/elevation rows (the C++ does;
/// we keep the summary lines, which are the actionable part).
/// - `clutter` is assumed zero — the RVT Queve study doesn't set it. Adding
/// it is a matter of threading the value through; left for a follow-up.
pub fn obstruction_analysis(
tx: &Site, rx: &Site, freq_mhz: f64, dem: &dyn DemLookup, metric: bool,
) -> String {
let mut out = String::new();
let mut obstruction_list = String::new();
let mut header_emitted = false;
// Sample the path TX → RX at SPLAT's default resolution.
let az = geo::azimuth(rx.lat, rx.lon, tx.lat, tx.lon); // from RX toward TX
let dist_mi = geo::distance(rx.lat, rx.lon, tx.lat, tx.lon);
let spr = geo::samples_per_radian(1200);
let path_len = ((dist_mi / EARTH_RADIUS_MI) * spr).max(2.0) as usize;
let miles_per_sample = dist_mi / path_len as f64;
let rx_ground = dem.elevation(rx.lat, rx.lon);
let tx_ground = dem.elevation(tx.lat, tx.lon);
// All heights in meters. h_r = RX above Earth center, h_t = TX above center.
let clutter_m = 0.0;
let mut h_r = rx_ground + rx.alt + EARTH_RADIUS_M;
let h_r_orig = h_r;
let mut h_r_f1 = h_r;
let mut h_r_fpt6 = h_r;
let h_t = tx_ground + tx.alt + EARTH_RADIUS_M;
let d_tx_m = dist_mi * METERS_PER_MILE;
let cos_tx_angle =
((h_r * h_r) + (d_tx_m * d_tx_m) - (h_t * h_t)) / (2.0 * h_r * d_tx_m);
let mut cos_tx_angle = cos_tx_angle;
let lambda = if freq_mhz > 0.0 {
// SPLAT's formula: c / f. 9.8425e8 ft/s ÷ (f MHz × 1e6) = wavelength in ft.
// In meters: 2.998e8 / (f × 1e6).
299_792_458.0 / (freq_mhz * 1e6)
} else {
0.0
};
let fzone_clearance = 0.60_f64;
// Walk from RX back toward TX.
for i in (1..path_len).rev() {
let d_from_rx_mi = miles_per_sample * i as f64;
let (lat, lon) = geo::step_great_circle(rx.lat, rx.lon, az, d_from_rx_mi);
let z = dem.elevation(lat, lon);
if z < -1000.0 { continue; }
let h_x = z + EARTH_RADIUS_M + clutter_m;
let d_x = d_from_rx_mi * METERS_PER_MILE;
let cos_test_angle =
((h_r * h_r) + (d_x * d_x) - (h_x * h_x)) / (2.0 * h_r * d_x);
// If this sample obstructs, list its lat/lon/distance/elevation.
if cos_tx_angle > cos_test_angle {
if !header_emitted {
let _ = writeln!(
obstruction_list,
"Entre {} y {}, splat-rs detectó obstrucciones en:\n",
rx.name, tx.name
);
header_emitted = true;
}
let (ns_lat, ns_tag) = if lat >= 0.0 { (lat, 'N') } else { (-lat, 'S') };
if metric {
let _ = writeln!(
obstruction_list,
" {:>8.4} {},{:>9.4} W, {:>5.2} kilómetros, {:>6.2} metros SNM",
ns_lat, ns_tag, lon, d_from_rx_mi * 1.609344, z
);
} else {
let _ = writeln!(
obstruction_list,
" {:>8.4} {},{:>9.4} W, {:>5.2} millas, {:>6.2} pies SNM",
ns_lat, ns_tag, lon, d_from_rx_mi, z * 3.28084
);
}
}
// LOS check — raise RX until path is clear.
let mut ct = cos_test_angle;
while cos_tx_angle > ct {
h_r += 1.0;
ct = ((h_r * h_r) + (d_x * d_x) - (h_x * h_x)) / (2.0 * h_r * d_x);
cos_tx_angle =
((h_r * h_r) + (d_tx_m * d_tx_m) - (h_t * h_t)) / (2.0 * h_r * d_tx_m);
}
if lambda > 0.0 {
// First Fresnel zone clearance.
let mut cos_f1 =
((h_r_f1 * h_r_f1) + (d_tx_m * d_tx_m) - (h_t * h_t)) / (2.0 * h_r_f1 * d_tx_m);
let mut h_los = (h_r_f1 * h_r_f1 + d_x * d_x - 2.0 * h_r_f1 * d_x * cos_f1).sqrt();
let mut h_f = h_los - (lambda * d_x * (d_tx_m - d_x) / d_tx_m).sqrt();
while h_f < h_x {
h_r_f1 += 1.0;
cos_f1 = ((h_r_f1 * h_r_f1) + (d_tx_m * d_tx_m) - (h_t * h_t))
/ (2.0 * h_r_f1 * d_tx_m);
h_los =
(h_r_f1 * h_r_f1 + d_x * d_x - 2.0 * h_r_f1 * d_x * cos_f1).sqrt();
h_f = h_los - (lambda * d_x * (d_tx_m - d_x) / d_tx_m).sqrt();
}
// 60% Fresnel clearance.
let mut cos_pt6 = ((h_r_fpt6 * h_r_fpt6) + (d_tx_m * d_tx_m) - (h_t * h_t))
/ (2.0 * h_r_fpt6 * d_tx_m);
let mut h_los = (h_r_fpt6 * h_r_fpt6 + d_x * d_x - 2.0 * h_r_fpt6 * d_x * cos_pt6).sqrt();
let mut h_f = h_los - fzone_clearance * (lambda * d_x * (d_tx_m - d_x) / d_tx_m).sqrt();
while h_f < h_x {
h_r_fpt6 += 1.0;
cos_pt6 = ((h_r_fpt6 * h_r_fpt6) + (d_tx_m * d_tx_m) - (h_t * h_t))
/ (2.0 * h_r_fpt6 * d_tx_m);
h_los =
(h_r_fpt6 * h_r_fpt6 + d_x * d_x - 2.0 * h_r_fpt6 * d_x * cos_pt6).sqrt();
h_f = h_los
- fzone_clearance * (lambda * d_x * (d_tx_m - d_x) / d_tx_m).sqrt();
}
}
}
// Prepend the per-obstruction listing (if any) before the summary text
// so the report reads top-to-bottom: "here are the obstructions, now
// here is how to clear them".
if !obstruction_list.is_empty() {
out.push('\n');
out.push_str(&obstruction_list);
}
// LOS summary.
if h_r > h_r_orig {
let extra_m = h_r - rx_ground - EARTH_RADIUS_M;
let _ = writeln!(out);
if metric {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} metros SNT\npara librar todas las obstrucciones detectadas por SPLAT!-rs.",
rx.name, extra_m);
} else {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} pies SNT\npara librar todas las obstrucciones detectadas por SPLAT!-rs.",
rx.name, extra_m * 3.28084);
}
} else {
let _ = writeln!(out,
"\nSPLAT!-rs no detectó obstrucciones por terreno en el trayecto LDV.");
}
// Fresnel summary.
if lambda > 0.0 {
if h_r_f1 > h_r_orig {
let extra_m = h_r_f1 - rx_ground - EARTH_RADIUS_M;
let _ = writeln!(out);
if metric {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} metros SNT\npara librar la primera zona de Fresnel.",
rx.name, extra_m);
} else {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} pies SNT\npara librar la primera zona de Fresnel.",
rx.name, extra_m * 3.28084);
}
} else {
let _ = writeln!(out, "\nLa primera zona de Fresnel está libre.");
}
if h_r_fpt6 > h_r_orig {
let extra_m = h_r_fpt6 - rx_ground - EARTH_RADIUS_M;
let _ = writeln!(out);
if metric {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} metros SNT\npara librar el {:.0}% de la primera zona de Fresnel.",
rx.name, extra_m, fzone_clearance * 100.0);
} else {
let _ = writeln!(out,
"La antena en {} debe elevarse al menos a {:.2} pies SNT\npara librar el {:.0}% de la primera zona de Fresnel.",
rx.name, extra_m * 3.28084, fzone_clearance * 100.0);
}
} else {
let _ = writeln!(out,
"\nEl {:.0}% de la primera zona de Fresnel está libre.",
fzone_clearance * 100.0);
}
}
out
}
fn compute_end_to_end_loss(
source: &Site,
destination: &Site,
params: &LrParams,
model: Model,
dem: &dyn DemLookup,
) -> itwom::PathLoss {
// Build the TX→RX profile at SPLAT's default resolution, then call ITWOM.
let az = geo::azimuth(source.lat, source.lon, destination.lat, destination.lon);
let dist_mi = geo::distance(source.lat, source.lon, destination.lat, destination.lon);
let spr = geo::samples_per_radian(1200);
let path_len = ((dist_mi / EARTH_RADIUS_MI) * spr) as usize;
let miles_per_sample = dist_mi / path_len as f64;
let delta_m = miles_per_sample * METERS_PER_MILE;
let mut elev_m = Vec::with_capacity(path_len + 2);
for i in 0..=path_len {
let d = miles_per_sample * i as f64;
let (lat, lon) = geo::step_great_circle(source.lat, source.lon, az, d);
elev_m.push(dem.elevation(lat, lon));
}
// Force the last point to be exactly at the destination — matches SPLAT's
// "exact destination at path.length-1".
if let Some(last) = elev_m.last_mut() {
*last = dem.elevation(destination.lat, destination.lon);
}
let mut elev_buf = Vec::with_capacity(elev_m.len() + 2);
elev_buf.push((elev_m.len() - 1) as f64);
elev_buf.push(delta_m);
elev_buf.extend(elev_m.iter().copied());
itwom::path_loss(model, &mut elev_buf, source.alt, destination.alt, params)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sdf::DemTileSet;
#[test]
fn dec2dms_roundtrip() {
assert_eq!(dec2dms(1.023_205_555), "1° 1' 23\"");
assert_eq!(dec2dms(-1.023_205_555), "-1° 1' 23\"");
assert_eq!(dec2dms(79.458669), "79° 27' 31\"");
}
#[test]
#[ignore = "needs real DEM"]
fn full_rvt_queve_report() {
let tiles_dir = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf";
if !std::path::Path::new(tiles_dir).exists() { return; }
let mut dem = DemTileSet::new();
dem.load_dir(tiles_dir).unwrap();
let tx = crate::qth::load(
"/home/cescobar/Laboratorio/splat-1.4.2-Charles/golden/rvt-queve-es/txenlace.qth"
).unwrap();
let rx = crate::qth::load(
"/home/cescobar/Laboratorio/splat-1.4.2-Charles/golden/rvt-queve-es/rxenlace.qth"
).unwrap();
let lrp = crate::lrp::load(
"/home/cescobar/Laboratorio/splat-1.4.2-Charles/golden/rvt-queve-es/splat.lrp"
).unwrap();
let txt = path_report(
&tx, &rx, &lrp.params, lrp.erp_watts, Model::Itwom, &dem,
PathReportOpts { metric: true, rx_alt_m: 10.0 },
);
println!("{txt}");
// Sanity: must contain the key Spanish strings and a loss value near 145 dB.
assert!(txt.contains("Sitio transmisor: Estudios Quevedo"));
assert!(txt.contains("Sitio receptor: Cerro Cochabamba"));
assert!(txt.contains("Línea de vista") || txt.contains("Horizonte"));
assert!(txt.contains("kilómetros"));
}
}
+400
View File
@@ -0,0 +1,400 @@
//! SDF tile loader: SPLAT!'s Digital Elevation Model format.
//!
//! An SDF tile covers a 1°×1° quadrangle at either 3-arcsec (1200×1200) or
//! 1-arcsec (3600×3600) resolution. On disk, each tile is an ASCII stream
//! (optionally bz2-compressed) with:
//!
//! - 4 header lines: `max_west`, `min_north`, `min_west`, `max_north` (integer degrees)
//! - `ppd × ppd` integer elevation samples in meters, one per line, stored
//! in row-major order (`x` outer = lat index, `y` inner = lon index)
//!
//! Filenames encode the covered quadrangle as `minlat:maxlat:minlon:maxlon.sdf[.bz2]`.
//!
//! This loader mmaps/reads the tile into a flat `Vec<i16>` (one value per
//! pixel) — elevations fit easily in i16 (Everest ≈ 8849 m), halving memory
//! vs the original `int[1200][1200]`.
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use crate::radial::DemLookup;
#[derive(Debug, thiserror::Error)]
pub enum SdfError {
#[error("io error reading {path}: {source}")]
Io { path: PathBuf, #[source] source: std::io::Error },
#[error("tile {path}: malformed header (expected 4 integer lines)")]
BadHeader { path: PathBuf },
#[error("tile {path}: expected {expected} elevation samples, found {found}")]
ShortData { path: PathBuf, expected: usize, found: usize },
#[error("tile {path}: could not parse integer on line {line}")]
ParseInt { path: PathBuf, line: usize },
#[error("filename {0:?} does not match 'minlat:maxlat:minlon:maxlon' pattern")]
BadFilename(PathBuf),
}
/// A single 1°×1° elevation tile.
#[derive(Debug, Clone)]
pub struct SdfTile {
pub min_north: i32,
pub max_north: i32,
pub min_west: i32,
pub max_west: i32,
pub ppd: u32,
/// Elevations in meters, row-major: `data[x * ppd + y]` where
/// `x` increases with latitude, `y` decreases as longitude moves east
/// from `max_west` (matching SPLAT!'s indexing).
pub data: Vec<i16>,
pub min_el: i16,
pub max_el: i16,
}
impl SdfTile {
/// Load a tile from a path, transparently decompressing `.bz2` if present.
pub fn load(path: impl AsRef<Path>) -> Result<Self, SdfError> {
let path = path.as_ref();
let (min_north, max_north, min_west, max_west) = parse_filename(path)?;
let file = std::fs::File::open(path)
.map_err(|e| SdfError::Io { path: path.into(), source: e })?;
let reader: Box<dyn Read> = if path.extension().and_then(|s| s.to_str()) == Some("bz2") {
Box::new(bz2_decoder(file))
} else {
Box::new(file)
};
Self::read(path, reader, min_north, max_north, min_west, max_west)
}
fn read(
path: &Path,
reader: impl Read,
fn_min_north: i32,
fn_max_north: i32,
fn_min_west: i32,
fn_max_west: i32,
) -> Result<Self, SdfError> {
let mut rdr = BufReader::new(reader);
let mut line = String::new();
let read_int = |rdr: &mut BufReader<_>, line: &mut String, n: usize| -> Result<i32, SdfError> {
line.clear();
rdr.read_line(line).map_err(|e| SdfError::Io { path: path.into(), source: e })?;
line.trim().parse::<i32>().map_err(|_| SdfError::ParseInt { path: path.into(), line: n })
};
let hdr_max_west = read_int(&mut rdr, &mut line, 1)?;
let hdr_min_north = read_int(&mut rdr, &mut line, 2)?;
let hdr_min_west = read_int(&mut rdr, &mut line, 3)?;
let hdr_max_north = read_int(&mut rdr, &mut line, 4)?;
// Cross-check header against filename; mismatches usually mean a
// corrupt or renamed tile. Warn via Result instead of silently trusting.
if (hdr_min_north, hdr_max_north, hdr_min_west, hdr_max_west)
!= (fn_min_north, fn_max_north, fn_min_west, fn_max_west)
{
return Err(SdfError::BadHeader { path: path.into() });
}
// Autodetect resolution by counting remaining lines; tiles are either
// 1200×1200 or 3600×3600.
let mut buf = Vec::new();
rdr.read_to_end(&mut buf).map_err(|e| SdfError::Io { path: path.into(), source: e })?;
let n_samples = buf.iter().filter(|&&b| b == b'\n').count()
+ if buf.last() != Some(&b'\n') && !buf.is_empty() { 1 } else { 0 };
let ppd: u32 = match n_samples {
1_440_000 => 1200,
12_960_000 => 3600,
other => {
return Err(SdfError::ShortData {
path: path.into(),
expected: 1_440_000,
found: other,
});
}
};
let expected = (ppd * ppd) as usize;
let mut data = Vec::with_capacity(expected);
let mut min_el = i16::MAX;
let mut max_el = i16::MIN;
let mut line_no = 5usize;
for chunk in buf.split(|&b| b == b'\n') {
if chunk.is_empty() {
continue;
}
let s = std::str::from_utf8(chunk)
.map_err(|_| SdfError::ParseInt { path: path.into(), line: line_no })?;
let v: i32 = s.trim().parse()
.map_err(|_| SdfError::ParseInt { path: path.into(), line: line_no })?;
// Clamp to i16 range; any real SRTM value fits, guards against garbage.
let v16 = v.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
data.push(v16);
if v16 < min_el { min_el = v16; }
if v16 > max_el { max_el = v16; }
line_no += 1;
}
if data.len() != expected {
return Err(SdfError::ShortData {
path: path.into(),
expected,
found: data.len(),
});
}
Ok(Self {
min_north: hdr_min_north,
max_north: hdr_max_north,
min_west: hdr_min_west,
max_west: hdr_max_west,
ppd,
data,
min_el,
max_el,
})
}
/// Look up elevation in meters at (lat, lon). Returns `None` if the
/// point falls outside this tile's quadrangle.
#[inline]
pub fn elevation(&self, lat: f64, lon: f64) -> Option<f64> {
let mpi = (self.ppd - 1) as i32;
let x = (self.ppd as f64 * (lat - self.min_north as f64)).round() as i32;
let y = mpi - (self.ppd as f64 * lon_diff(self.max_west as f64, lon)).round() as i32;
if x < 0 || x > mpi || y < 0 || y > mpi {
return None;
}
let idx = (x as u32 * self.ppd + y as u32) as usize;
Some(self.data[idx] as f64)
}
}
/// Short-path longitudinal difference in `[-180, 180]`. Port of SPLAT!'s `LonDiff`.
#[inline]
pub fn lon_diff(lon1: f64, lon2: f64) -> f64 {
let mut d = lon1 - lon2;
if d <= -180.0 { d += 360.0; }
if d >= 180.0 { d -= 360.0; }
d
}
fn parse_filename(path: &Path) -> Result<(i32, i32, i32, i32), SdfError> {
let stem = path
.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| SdfError::BadFilename(path.into()))?;
// Strip .bz2 then .sdf
let stem = stem.strip_suffix(".bz2").unwrap_or(stem);
let stem = stem.strip_suffix(".sdf").unwrap_or(stem);
let parts: Vec<&str> = stem.split(':').collect();
if parts.len() != 4 {
return Err(SdfError::BadFilename(path.into()));
}
let parse = |s: &str| s.parse::<i32>().map_err(|_| SdfError::BadFilename(path.into()));
Ok((parse(parts[0])?, parse(parts[1])?, parse(parts[2])?, parse(parts[3])?))
}
/// Very small bz2 decoder shim. We have `libbz2` linked via `itwom`'s
/// build; we call it through a minimal unsafe wrapper to avoid pulling
/// another crate just for this.
fn bz2_decoder(file: std::fs::File) -> impl Read {
BzReader::new(file)
}
// ---- Minimal libbz2 FFI ----
// We don't depend on the `bzip2` crate to keep the dep tree lean; libbz2
// is already linked via the cc build of the C++ ITWOM code's deps, plus
// it's a system library. Only `BZ2_bzRead*` is needed.
#[allow(non_camel_case_types)]
type BZFILE = std::ffi::c_void;
unsafe extern "C" {
fn BZ2_bzReadOpen(
bzerror: *mut std::ffi::c_int,
f: *mut std::ffi::c_void,
verbosity: std::ffi::c_int,
small: std::ffi::c_int,
unused: *mut std::ffi::c_void,
nunused: std::ffi::c_int,
) -> *mut BZFILE;
fn BZ2_bzRead(
bzerror: *mut std::ffi::c_int,
b: *mut BZFILE,
buf: *mut std::ffi::c_void,
len: std::ffi::c_int,
) -> std::ffi::c_int;
fn BZ2_bzReadClose(bzerror: *mut std::ffi::c_int, b: *mut BZFILE);
fn fdopen(fd: std::ffi::c_int, mode: *const std::ffi::c_char) -> *mut std::ffi::c_void;
fn fclose(f: *mut std::ffi::c_void) -> std::ffi::c_int;
}
struct BzReader {
_file: std::fs::File, // keep the fd alive
cfile: *mut std::ffi::c_void,
bz: *mut BZFILE,
eof: bool,
}
impl BzReader {
fn new(file: std::fs::File) -> Self {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let mode = c"rb";
let cfile = unsafe { fdopen(libc_dup(fd), mode.as_ptr()) };
assert!(!cfile.is_null(), "fdopen failed");
let mut bzerror = 0i32;
let bz = unsafe { BZ2_bzReadOpen(&mut bzerror, cfile, 0, 0, std::ptr::null_mut(), 0) };
assert_eq!(bzerror, 0, "BZ2_bzReadOpen failed: {bzerror}");
Self { _file: file, cfile, bz, eof: false }
}
}
fn libc_dup(fd: i32) -> i32 {
unsafe extern "C" {
fn dup(fd: std::ffi::c_int) -> std::ffi::c_int;
}
unsafe { dup(fd) }
}
impl Read for BzReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.eof { return Ok(0); }
let mut bzerror = 0i32;
let n = unsafe {
BZ2_bzRead(
&mut bzerror,
self.bz,
buf.as_mut_ptr().cast(),
buf.len() as std::ffi::c_int,
)
};
// 0 = BZ_OK, 4 = BZ_STREAM_END
if bzerror == 4 {
self.eof = true;
return Ok(n as usize);
}
if bzerror != 0 {
return Err(std::io::Error::other(format!("bz2 read error {bzerror}")));
}
Ok(n as usize)
}
}
impl Drop for BzReader {
fn drop(&mut self) {
let mut bzerror = 0i32;
unsafe {
BZ2_bzReadClose(&mut bzerror, self.bz);
fclose(self.cfile);
}
}
}
/// A collection of SDF tiles indexed by their 1°×1° quadrangle. Implements
/// `DemLookup` so it can plug into the parallel radial sweep.
///
/// A linear scan over tiles would dominate the cost of a full map sweep
/// (millions of lookups × dozens of tiles); we hash on the integer-degree
/// quadrangle instead, turning lookup into O(1).
#[derive(Debug, Default)]
pub struct DemTileSet {
tiles: Vec<SdfTile>,
/// Map from `(min_north, min_west)` (integer degrees) to `tiles` index.
index: std::collections::HashMap<(i32, i32), usize>,
}
impl DemTileSet {
pub fn new() -> Self { Self::default() }
pub fn insert(&mut self, tile: SdfTile) {
let key = (tile.min_north, tile.min_west);
let idx = self.tiles.len();
self.tiles.push(tile);
self.index.insert(key, idx);
}
/// Load every `*.sdf.bz2` and `*.sdf` tile in a directory.
pub fn load_dir(&mut self, dir: impl AsRef<Path>) -> Result<usize, SdfError> {
let dir = dir.as_ref();
let rd = std::fs::read_dir(dir)
.map_err(|e| SdfError::Io { path: dir.into(), source: e })?;
let mut n = 0;
for ent in rd {
let ent = ent.map_err(|e| SdfError::Io { path: dir.into(), source: e })?;
let p = ent.path();
let name = match p.file_name().and_then(|s| s.to_str()) { Some(s) => s, None => continue };
if name.ends_with(".sdf") || name.ends_with(".sdf.bz2") {
let t = SdfTile::load(&p)?;
self.insert(t);
n += 1;
}
}
Ok(n)
}
pub fn len(&self) -> usize { self.tiles.len() }
pub fn is_empty(&self) -> bool { self.tiles.is_empty() }
}
impl DemLookup for DemTileSet {
#[inline]
fn elevation(&self, lat: f64, lon: f64) -> f64 {
// Each tile covers [min_north, min_north+1) × [min_west, min_west+1).
// Floor gives the tile key for any point inside it.
let key = (lat.floor() as i32, lon.floor() as i32);
if let Some(&i) = self.index.get(&key) {
if let Some(v) = self.tiles[i].elevation(lat, lon) {
return v;
}
}
// Fallback: try neighbors (rounding edge cases). Rare.
for (dlat, dlon) in [(0, 0), (-1, 0), (0, -1), (-1, -1)] {
let k = (key.0 + dlat, key.1 + dlon);
if let Some(&i) = self.index.get(&k) {
if let Some(v) = self.tiles[i].elevation(lat, lon) {
return v;
}
}
}
-5000.0 / 3.28084
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lon_diff_wraps_at_180() {
assert!((lon_diff(10.0, 20.0) - (-10.0)).abs() < 1e-9);
assert!((lon_diff(170.0, -170.0) - (-20.0)).abs() < 1e-9);
assert!((lon_diff(-170.0, 170.0) - 20.0).abs() < 1e-9);
}
#[test]
fn parses_filename() {
let r = parse_filename(Path::new("-2:-1:79:80.sdf.bz2")).unwrap();
assert_eq!(r, (-2, -1, 79, 80));
}
#[test]
fn loads_real_tile() {
let path = "/home/cescobar/Laboratorio/splat-1.4.2-Charles/sdf/-2:-1:79:80.sdf.bz2";
if !std::path::Path::new(path).exists() {
eprintln!("skipping: {path} not present");
return;
}
let t = SdfTile::load(path).unwrap();
assert_eq!(t.ppd, 1200);
assert_eq!(t.data.len(), 1200 * 1200);
assert_eq!(t.min_north, -2);
assert_eq!(t.max_north, -1);
assert_eq!(t.min_west, 79);
assert_eq!(t.max_west, 80);
// Cerro Cochabamba: lat -1.6986°, lon 79.1072°. Expected ~2976 m.
let z = t.elevation(-1.6986, 79.1072).expect("point inside tile");
assert!(
(z - 2976.0).abs() < 100.0,
"elevation mismatch: got {z}, expected ~2976"
);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Strength Field Table: for each of 12 cardinal/inter-cardinal azimuths
//! (0°, 30°, …, 330°) and 13 range steps (5, 10, …, 40 km, 50, 60, 70, 80,
//! 90 km), compute the field strength in dBµV/m at the RX probe point.
//!
//! This ports Charles' `perfiles.sh` workflow into splat-rs. The bash
//! version forks SPLAT! 216 times — 216 × tile loads (~7 s each),
//! 216 × temp-file coordination, all strictly serial. Here we compute
//! the 156 live cells (12 × 13) in parallel through rayon, sharing one
//! read-only DEM, one `thread_local` ITWOM state per worker, and emit
//! both a human-readable text table and a CSV for spreadsheet import.
//!
//! Field-strength formula (matches SPLAT's `PathReport`):
//!
//! ```text
//! E [dBµV/m] = 139.4 + 20·log10(f_MHz) - total_loss [dB] + 10·log10(ERP/1000 [W])
//! total_loss = itwom_loss - antenna_gain_dB
//! ```
//!
//! If no antenna pattern is supplied the gain is 0 dB (isotropic). If ERP
//! is missing from the `.lrp` a sentinel of 1 W is assumed and a warning
//! is printed — most broadcasting `.lrp` files do carry the ERP line.
use std::fmt::Write as _;
use std::path::Path;
use rayon::prelude::*;
use crate::geo::{self, EARTH_RADIUS_MI, METERS_PER_MILE};
use crate::itwom::{self, LrParams, Model};
use crate::pat::AntennaPattern;
use crate::radial::DemLookup;
use crate::report;
use crate::types::Site;
/// Azimuth spacing used by Charles' workflow (12 rays around the compass).
pub const DEFAULT_AZIMUTHS: [f64; 12] = [
0.0, 30.0, 60.0, 90.0, 120.0, 150.0,
180.0, 210.0, 240.0, 270.0, 300.0, 330.0,
];
/// Distance rings in km — matches the filter in `perfiles.sh`
/// (`dist <= 40 OR 50 OR 60 OR 70 OR 80 OR 90`).
pub const DEFAULT_DISTANCES_KM: [f64; 13] = [
5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0,
50.0, 60.0, 70.0, 80.0, 90.0,
];
/// One cell of the table: azimuth/distance plus the computed field strength.
#[derive(Debug, Clone, Copy)]
pub struct SftCell {
pub azimuth_deg: f64,
pub distance_km: f64,
/// dBµV/m at the RX probe at this radial/distance, or `None` when the
/// DEM doesn't cover the endpoint (so we can't build a valid profile).
pub field_strength_dbuvm: Option<f64>,
}
/// Complete field-strength table plus the inputs it was computed from.
#[derive(Debug, Clone)]
pub struct SftTable {
pub tx_name: String,
pub tx_lat: f64,
pub tx_lon: f64,
pub freq_mhz: f64,
pub erp_watts: f64,
pub model: Model,
pub azimuths: Vec<f64>,
pub distances_km: Vec<f64>,
/// `values[i][j]` = cell for azimuth `i`, distance `j`.
pub values: Vec<Vec<SftCell>>,
}
/// Compute the full SFT in parallel. Each (az, dist) pair maps to one
/// ITWOM end-to-end computation; 156 cells total at default grid.
#[allow(clippy::too_many_arguments)]
pub fn compute(
tx: &Site,
params: &LrParams,
erp_watts: f64,
model: Model,
dem: &(dyn DemLookup + Sync),
antenna: Option<&AntennaPattern>,
azimuths: &[f64],
distances_km: &[f64],
rx_alt_m: f64,
) -> SftTable {
// Flatten to cell list so rayon can parallelize over the full 156-way
// work set — using only 12-way azimuth parallelism would leave 4 of
// our 16 cores idle.
let pairs: Vec<(f64, f64)> = azimuths.iter()
.flat_map(|&a| distances_km.iter().map(move |&d| (a, d)))
.collect();
let flat: Vec<SftCell> = pairs
.par_iter()
.map(|&(az, d_km)| compute_cell(tx, params, erp_watts, model, dem, antenna, az, d_km, rx_alt_m))
.collect();
// Reshape to values[az_idx][dist_idx].
let n_dist = distances_km.len();
let mut values: Vec<Vec<SftCell>> = Vec::with_capacity(azimuths.len());
for (ai, _) in azimuths.iter().enumerate() {
let row = flat[ai * n_dist..(ai + 1) * n_dist].to_vec();
values.push(row);
}
SftTable {
tx_name: tx.name.clone(),
tx_lat: tx.lat,
tx_lon: tx.lon,
freq_mhz: params.frq_mhz,
erp_watts,
model,
azimuths: azimuths.to_vec(),
distances_km: distances_km.to_vec(),
values,
}
}
#[allow(clippy::too_many_arguments)]
fn compute_cell(
tx: &Site,
params: &LrParams,
erp_watts: f64,
model: Model,
dem: &(dyn DemLookup + Sync),
antenna: Option<&AntennaPattern>,
azimuth_deg: f64,
distance_km: f64,
rx_alt_m: f64,
) -> SftCell {
let d_mi = distance_km / 1.609344;
let (rx_lat, rx_lon) = geo::step_great_circle(tx.lat, tx.lon, azimuth_deg, d_mi);
// Validate DEM coverage at endpoint — without it the rest is garbage.
if dem.elevation(rx_lat, rx_lon) < -1000.0 {
return SftCell { azimuth_deg, distance_km, field_strength_dbuvm: None };
}
let rx = Site {
name: String::new(),
lat: rx_lat,
lon: rx_lon,
alt: rx_alt_m,
};
// Build elevation profile TX → RX at the default 1200-ppd resolution.
let spr = geo::samples_per_radian(1200);
let path_len = ((d_mi / EARTH_RADIUS_MI) * spr).max(2.0) as usize;
let mps = d_mi / path_len as f64;
let delta_m = mps * METERS_PER_MILE;
let mut elev_m: Vec<f64> = (0..=path_len)
.map(|i| {
let d = mps * i as f64;
let (lat, lon) = geo::step_great_circle(tx.lat, tx.lon, azimuth_deg, d);
dem.elevation(lat, lon)
})
.collect();
if let Some(last) = elev_m.last_mut() {
*last = dem.elevation(rx.lat, rx.lon);
}
// Pack for ITWOM: [npts-1, delta_m, samples...].
let mut elev_buf: Vec<f64> = Vec::with_capacity(elev_m.len() + 2);
elev_buf.push((elev_m.len() - 1) as f64);
elev_buf.push(delta_m);
elev_buf.extend_from_slice(&elev_m);
let loss_db = itwom::path_loss_db(model, &mut elev_buf, tx.alt, rx_alt_m, params);
if !loss_db.is_finite() {
return SftCell { azimuth_deg, distance_km, field_strength_dbuvm: None };
}
// Pattern gain toward this RX. `elevation_angle` returns degrees.
let pattern_db = match antenna {
Some(ant) => {
let el = report::elevation_angle(tx, &rx, dem);
let g = ant.gain(azimuth_deg, el) as f64;
20.0 * g.max(1e-9).log10()
}
None => 0.0,
};
let total_loss = loss_db - pattern_db;
// SPLAT!'s field strength formula (W reference, dBµV/m output).
let erp_w = erp_watts.max(1e-9);
let e = 139.4 + 20.0 * params.frq_mhz.log10() - total_loss
+ 10.0 * (erp_w / 1000.0).log10();
SftCell {
azimuth_deg,
distance_km,
field_strength_dbuvm: Some(e),
}
}
impl SftTable {
/// Human-readable text table. One column per azimuth, one row per
/// distance. Missing cells render as `----`.
pub fn to_text(&self) -> String {
let mut out = String::with_capacity(2048);
let _ = writeln!(out,
"# Tabla de intensidad de campo (dBµV/m) — splat-rs");
let _ = writeln!(out,
"# TX: {} @ ({:.4}, {:.4}), {:.1} MHz, ERP {:.0} W, modelo {:?}",
self.tx_name, self.tx_lat, self.tx_lon, self.freq_mhz, self.erp_watts, self.model);
let _ = writeln!(out);
// Header row.
let _ = write!(out, "{:>10}", "Dist/Az");
for &az in &self.azimuths {
let _ = write!(out, " {:>6.0}°", az);
}
let _ = writeln!(out);
// Data rows.
for (j, &d_km) in self.distances_km.iter().enumerate() {
let _ = write!(out, "{:>7.0} km", d_km);
for i in 0..self.azimuths.len() {
match self.values[i][j].field_strength_dbuvm {
Some(v) => { let _ = write!(out, " {:>7.2}", v); }
None => { let _ = write!(out, " {:>7}", "----"); }
}
}
let _ = writeln!(out);
}
out
}
/// CSV export for spreadsheets. Decimal separator is `.` — users with
/// `es_EC` locale can swap with `sed "s/\./,/g"` if their Excel is
/// configured for comma.
pub fn to_csv(&self) -> String {
let mut out = String::with_capacity(2048);
// Header
out.push_str("distancia_km");
for &az in &self.azimuths {
let _ = write!(out, ",{:.0}", az);
}
out.push('\n');
// Rows
for (j, &d_km) in self.distances_km.iter().enumerate() {
let _ = write!(out, "{:.0}", d_km);
for i in 0..self.azimuths.len() {
match self.values[i][j].field_strength_dbuvm {
Some(v) => { let _ = write!(out, ",{:.2}", v); }
None => out.push_str(","),
}
}
out.push('\n');
}
out
}
/// Convenience: write both a `.txt` (human) and `.csv` (machine) file.
/// The base path is taken as-is for the text version, and has `.csv`
/// appended (or substituted if already ending in `.txt`) for the CSV.
pub fn write_pair(&self, base: &Path) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
let txt_path = base.to_path_buf();
let csv_path = match base.extension().and_then(|s| s.to_str()) {
Some("txt") => base.with_extension("csv"),
_ => {
let mut p = base.to_path_buf();
let current = p.to_string_lossy().into_owned();
p = std::path::PathBuf::from(format!("{current}.csv"));
p
}
};
std::fs::write(&txt_path, self.to_text())?;
std::fs::write(&csv_path, self.to_csv())?;
Ok((txt_path, csv_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FlatDem(f64);
impl DemLookup for FlatDem {
fn elevation(&self, _lat: f64, _lon: f64) -> f64 { self.0 }
}
#[test]
fn computes_table_over_flat_dem() {
let tx = Site { name: "T".into(), lat: -1.7, lon: 79.1, alt: 30.0 };
let dem = FlatDem(100.0);
let p = LrParams {
eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0,
frq_mhz: 91.5, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5,
};
let t = compute(
&tx, &p, 2000.0, Model::Itwom, &dem, None,
&DEFAULT_AZIMUTHS, &DEFAULT_DISTANCES_KM, 10.0,
);
assert_eq!(t.values.len(), 12);
assert_eq!(t.values[0].len(), 13);
// On a flat DEM, field strength should be roughly equal at equal
// distances regardless of azimuth — modulo small FP jitter.
for j in 0..13 {
let at_0 = t.values[0][j].field_strength_dbuvm.unwrap();
for i in 0..12 {
let v = t.values[i][j].field_strength_dbuvm.unwrap();
assert!((v - at_0).abs() < 0.5,
"az {} dist {}: {} vs {}", t.azimuths[i], t.distances_km[j], v, at_0);
}
}
// Field strength should decrease with distance.
let v_5 = t.values[0][0].field_strength_dbuvm.unwrap();
let v_90 = t.values[0][12].field_strength_dbuvm.unwrap();
assert!(v_5 > v_90, "FS should drop with range: {} vs {}", v_5, v_90);
}
#[test]
fn text_and_csv_formatting() {
let tx = Site { name: "T".into(), lat: -1.7, lon: 79.1, alt: 30.0 };
let dem = FlatDem(100.0);
let p = LrParams {
eps_dielect: 15.0, sgm_conductivity: 0.005, eno_ns_surfref: 301.0,
frq_mhz: 91.5, radio_climate: 5, pol: 1, conf: 0.5, rel: 0.5,
};
let t = compute(
&tx, &p, 2000.0, Model::Itwom, &dem, None,
&[0.0, 90.0], &[5.0, 10.0], 10.0,
);
let txt = t.to_text();
assert!(txt.contains(""));
assert!(txt.contains("5 km"));
let csv = t.to_csv();
assert!(csv.starts_with("distancia_km,0,90\n"));
assert!(csv.lines().count() == 3); // header + 2 distance rows
}
}
+49
View File
@@ -0,0 +1,49 @@
//! Core domain types. Ports of SPLAT!'s `struct site`, `struct LR`, etc.
#[derive(Debug, Clone)]
pub struct Site {
pub name: String,
/// Latitude in decimal degrees. SPLAT uses positive-north convention
/// but the on-disk .qth format stores positive-south; conversion happens
/// at parse time.
pub lat: f64,
/// Longitude in decimal degrees, positive-west (SPLAT convention).
pub lon: f64,
/// Antenna height above ground, meters (when `Config.metric`), else feet.
pub alt: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct Config {
/// HD_MODE off (3-arcsec, 1200 ppd) or on (1-arcsec, 3600 ppd).
pub hd_mode: bool,
/// 1, 4, 9, 16, 25, 36, 49, 64. Number of SDF tiles to hold in memory.
pub max_pages: u32,
/// Use metric units (meters/km) in user-facing output.
pub metric: bool,
/// 4/3 Earth radius multiplier (default 4/3 for standard atmosphere).
pub earth_radius_mult: f64,
}
impl Config {
/// Pixels per degree of terrain resolution.
pub fn ppd(&self) -> u32 {
if self.hd_mode { 3600 } else { 1200 }
}
/// Total pixels along one edge of the full coverage square (sqrt(max_pages) * ppd).
pub fn edge_pixels(&self) -> u32 {
(self.max_pages as f64).sqrt() as u32 * self.ppd()
}
}
impl Default for Config {
fn default() -> Self {
Self {
hd_mode: false,
max_pages: 9,
metric: true,
earth_radius_mult: 4.0 / 3.0,
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! `.udt` (User-Defined Terrain) loader.
//!
//! A UDT file lists extra terrain features — buildings, towers, custom
//! obstacles — added on top of the SDF-derived elevation model. Format is
//! one feature per line:
//!
//! ```text
//! latitude, longitude, height
//! ```
//!
//! Where latitude/longitude accept the same formats as `.qth` (decimal
//! degrees or `DD MM SS`). Height is feet unless followed by `m`/`M`/
//! `meters`.
//!
//! Lines may have trailing `; comment`. The first line of the file may be
//! a comment as well — SPLAT ignores it. Multiple features at the same
//! coordinate take the *tallest* height (consistent with "tallest obstacle
//! wins" semantics).
use std::path::Path;
use crate::qth;
#[derive(Debug, thiserror::Error)]
pub enum UdtError {
#[error("io error reading {path:?}: {source}")]
Io { path: std::path::PathBuf, #[source] source: std::io::Error },
#[error("{path:?} line {line}: expected 3 comma-separated fields, got {got}")]
BadRow { path: std::path::PathBuf, line: usize, got: usize },
#[error("{path:?} line {line}: could not parse {field}: {value:?}")]
BadValue { path: std::path::PathBuf, line: usize, field: &'static str, value: String },
}
/// A single user-defined terrain feature in meters AMSL.
#[derive(Debug, Clone, Copy)]
pub struct UdtFeature {
pub lat: f64,
pub lon: f64,
/// Feature elevation in meters above mean sea level.
pub height_m: f64,
}
pub fn load(path: impl AsRef<Path>) -> Result<Vec<UdtFeature>, UdtError> {
let path = path.as_ref();
let src = std::fs::read_to_string(path)
.map_err(|e| UdtError::Io { path: path.into(), source: e })?;
let mut out = Vec::new();
for (i, line) in src.lines().enumerate() {
let head = line.split(';').next().unwrap_or("").trim();
if head.is_empty() { continue; }
let parts: Vec<&str> = head.split(',').map(str::trim).collect();
if parts.len() != 3 {
// Skip single-field prose lines (some UDT files start with a
// plain title line). Only reject lines that look like they
// tried to be a row but have the wrong field count.
if parts.len() == 1 { continue; }
return Err(UdtError::BadRow { path: path.into(), line: i + 1, got: parts.len() });
}
let lat = qth::parse_bearing(parts[0])
.ok_or_else(|| UdtError::BadValue { path: path.into(), line: i + 1, field: "lat", value: parts[0].into() })?;
let lon = qth::parse_bearing(parts[1])
.ok_or_else(|| UdtError::BadValue { path: path.into(), line: i + 1, field: "lon", value: parts[1].into() })?;
// Positive-west convention (matches .qth).
let lon = if lon < 0.0 { lon + 360.0 } else { lon };
let height_m = qth::parse_height_meters(parts[2])
.ok_or_else(|| UdtError::BadValue { path: path.into(), line: i + 1, field: "height", value: parts[2].into() })?;
out.push(UdtFeature { lat, lon, height_m });
}
Ok(out)
}
/// A `DemLookup` that layers UDT features over a base DEM by quantizing
/// both the feature coordinates and the query point to the DEM's pixel
/// grid. Whichever of base-DEM and UDT gives the higher elevation wins —
/// matching SPLAT!'s semantics of "the tallest thing inside this cell".
///
/// Lookup is O(1) via a `HashMap` keyed by the integer-pixel cell. Multiple
/// UDT features landing in the same cell are merged to the maximum height.
pub struct DemWithUdt<'a> {
pub base: &'a dyn crate::radial::DemLookup,
pub ppd: u32,
grid: std::collections::HashMap<(i64, i64), f64>,
}
impl<'a> DemWithUdt<'a> {
/// Paint-radius (in pixels) around each UDT feature. Controls how
/// tolerant the grid is to path-sampling that lands near — but not
/// exactly on — the user-specified coordinate. At 1200 ppd one pixel
/// is ≈ 93 m, so a 5-pixel radius ≈ 465 m, which comfortably
/// accommodates typical great-circle stepping drift over a link.
pub const PAINT_RADIUS: i64 = 5;
pub fn new(base: &'a dyn crate::radial::DemLookup, features: Vec<UdtFeature>, ppd: u32) -> Self {
let mut grid = std::collections::HashMap::new();
let ppd_f = ppd as f64;
for f in features {
let ki = (f.lat * ppd_f).round() as i64;
let kj = (f.lon * ppd_f).round() as i64;
// Paint the whole neighborhood at construction so lookup stays O(1).
for dlat in -Self::PAINT_RADIUS..=Self::PAINT_RADIUS {
for dlon in -Self::PAINT_RADIUS..=Self::PAINT_RADIUS {
let key = (ki + dlat, kj + dlon);
let cur = grid.entry(key).or_insert(f64::MIN);
if f.height_m > *cur { *cur = f.height_m; }
}
}
}
Self { base, ppd, grid }
}
}
impl<'a> crate::radial::DemLookup for DemWithUdt<'a> {
fn elevation(&self, lat: f64, lon: f64) -> f64 {
let base_z = self.base.elevation(lat, lon);
if self.grid.is_empty() { return base_z; }
let ppd_f = self.ppd as f64;
let key = (
(lat * ppd_f).round() as i64,
(lon * ppd_f).round() as i64,
);
if let Some(&h) = self.grid.get(&key) {
if h > base_z { return h; }
}
base_z
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::radial::DemLookup;
#[test]
fn parses_basic_udt() {
let tmp = std::env::temp_dir().join("test.udt");
std::fs::write(&tmp,
"; Header comment\n\
-1 1 23.54, 79 27 31, 50 m\n\
-1.7, 79.1, 100 ; tower\n\
-1.7, 79.1, 80 m ; lower tower same spot\n"
).unwrap();
let fs = load(&tmp).unwrap();
assert_eq!(fs.len(), 3);
assert!((fs[0].lat - (-1.023205555)).abs() < 1e-5);
assert!((fs[0].height_m - 50.0).abs() < 1e-9);
// Second row: bare number = feet → meters
assert!((fs[1].height_m - 30.48).abs() < 1e-6, "got {}", fs[1].height_m);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn dem_with_udt_overrides_at_same_pixel() {
struct Flat(f64);
impl crate::radial::DemLookup for Flat {
fn elevation(&self, _lat: f64, _lon: f64) -> f64 { self.0 }
}
let base = Flat(50.0);
let udt = vec![UdtFeature { lat: -1.0, lon: 79.0, height_m: 200.0 }];
let d = DemWithUdt::new(&base, udt, 1200);
assert_eq!(d.elevation(-1.0, 79.0), 200.0); // within half-pixel
assert_eq!(d.elevation(-1.5, 79.5), 50.0); // far away → base
}
}
+4
View File
@@ -0,0 +1,4 @@
30 m