Compare commits
10 commits
bbd3d7b20d
...
47b49fba99
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b49fba99 | |||
| f2ee6a68dd | |||
| 478d255ea9 | |||
| 7de7e153b7 | |||
| f65d0a1a9a | |||
| 005f5d5559 | |||
| 81a526cfad | |||
| 1a9f846719 | |||
| 3b7f7b39c9 | |||
| ee71ba5b7f |
8 changed files with 1835 additions and 5 deletions
|
|
@ -8,8 +8,8 @@ Static website for W5ISP (amateur radio callsign) built with [Zola](https://www.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `zola serve` - Local dev server with live reload (default: http://127.0.0.1:1111)
|
- `zola serve --drafts` - Local dev server with live reload, including drafts (default: http://127.0.0.1:1111). Always pass `--drafts` in dev so future-dated and draft posts are visible while working on them.
|
||||||
- `zola build` - Build site to `public/` directory
|
- `zola build` - Build site to `public/` directory (drafts excluded, which is what deploys need)
|
||||||
- `zola check` - Validate links and templates
|
- `zola check` - Validate links and templates
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
|
||||||
66
content/blog/ft8-second-by-second.md
Normal file
66
content/blog/ft8-second-by-second.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
+++
|
||||||
|
title = "An FT8 transmission, second by second"
|
||||||
|
date = 2026-04-26
|
||||||
|
+++
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/css/scene.css">
|
||||||
|
|
||||||
|
<div class="scene-host" data-scene-host>
|
||||||
|
|
||||||
|
FT8 sounds like a chorus of out-of-tune piccolos having a brief panic attack. Twelve and a bit seconds of warbling, repeating every fifteen, somehow containing an entire contact between two stations on opposite sides of the planet. Dial-up modems at least had the decency to be loud about what they were doing.
|
||||||
|
|
||||||
|
Here's what those tones are doing, second by second, from the moment someone clicks "Enable TX" to the moment a near-identical string lights up on a screen 8000 km away.
|
||||||
|
|
||||||
|
<div id="scene-ft8-spectrum"></div>
|
||||||
|
|
||||||
|
The whole transmission as a receiver sees it. 12.64 seconds wide, eight 8-FSK tones tall, spaced 6.25 Hz apart. The 79 tones aren't pseudo-random fill, they're the literal output of an FT8 encoder running in your browser on "CQ W5ISP EM13". Drag the cursor or flip auto-play to step through. The amber staircase (3, 1, 4, 0, 6, 5, 2) is the [Costas array](https://en.wikipedia.org/wiki/Costas_array), repeated three times. Everything else hangs off it.
|
||||||
|
|
||||||
|
## Before t = 0: the computer does the hard part
|
||||||
|
|
||||||
|
The radio's job in FT8 is mostly to stay out of the way. The actual work happens in software, where your message gets compressed into 77 bits and not one bit more. "CQ W5ISP EM13" fits comfortably. A heartfelt paragraph about your antenna does not. The protocol is constitutionally incapable of small talk, which depending on your view of amateur radio is either a tragedy or its single greatest feature.
|
||||||
|
|
||||||
|
A [source encoder](https://en.wikipedia.org/wiki/Source_coding) that knows the shape of a QSO does the squeezing: callsigns hash to integer indices, grids pack into 15 bits, signal reports are small offsets from a base, and three bits up front declare which of the message-type schemas applies. From there:
|
||||||
|
|
||||||
|
1. [CRC-14](https://en.wikipedia.org/wiki/Cyclic_redundancy_check) over the 77 bits, appended. 91 bits.
|
||||||
|
2. [LDPC(174,91)](https://en.wikipedia.org/wiki/Low-density_parity-check_code) doubles that to 174 bits. The extra 83 are parity, smeared across the codeword so noise has to chew through a lot of it before anything is unrecoverable. This is the part that buys you decodes at -24 dB SNR.
|
||||||
|
3. Group the 174 bits three at a time, [Gray-code](https://en.wikipedia.org/wiki/Gray_code) the result, and you have 58 symbols numbered 0 through 7.
|
||||||
|
|
||||||
|
Three [Costas arrays](https://en.wikipedia.org/wiki/Costas_array) get spliced in (start, middle, end), the sequence [3, 1, 4, 0, 6, 5, 2] each time. 7 + 29 + 7 + 29 + 7 = 79 symbols, 160 ms each, 12.64 seconds total. The symbol rate and tone spacing are both 6.25, which is not an accident: it makes the eight tones orthogonal at symbol boundaries, which is the cleanest possible situation for the demodulator on the other end.
|
||||||
|
|
||||||
|
<div id="scene-ft8-pipeline"></div>
|
||||||
|
|
||||||
|
Same pipeline, drawn so the bit counts tell the story. 77 in, 91 after CRC, 174 after LDPC, 58 octal symbols, 79 on-air after Costas insertion. The miniaturized spectrogram at the bottom is what actually goes out.
|
||||||
|
|
||||||
|
## t = 0 ms: the carrier appears
|
||||||
|
|
||||||
|
The transmission starts exactly 500 ms into a 15-second UTC slot. PA drive ramps from zero over the first few ms of symbol one, because the alternative is splattering keyclicks across the band and earning the eternal contempt of the operator on the next 25 Hz bin over.
|
||||||
|
|
||||||
|
## t = 0 ms to 1.12 s: first Costas array
|
||||||
|
|
||||||
|
Seven symbols of [3, 1, 4, 0, 6, 5, 2], chosen for the property that no time-or-frequency shift of the pattern looks like itself. The receiver, which does not know your transmit frequency to within 6.25 Hz or your start time to within 160 ms, slides this template across the spectrogram in both axes. There is exactly one place where the score spikes, and that place is your signal. Sync done.
|
||||||
|
|
||||||
|
## t = 1.12 to 5.76 s: first data block
|
||||||
|
|
||||||
|
29 symbols carrying 87 bits of LDPC codeword, hopping between the eight tone slots in a pattern that looks random because, post-LDPC, it is. The receiver runs eight matched filters in parallel and keeps soft confidence values for every bit, because giving the decoder hard yes/no decisions throws away 2-3 dB of margin and FT8 has zero dB to spare.
|
||||||
|
|
||||||
|
## t = 5.76 to 6.88 s: middle Costas array
|
||||||
|
|
||||||
|
Same seven symbols again. The receiver uses this for two things: a fresh fix on timing and frequency in case the ionosphere is being twitchy, and a second shot at initial sync if the first Costas got eaten by a noise burst.
|
||||||
|
|
||||||
|
## t = 6.88 to 11.52 s: second data block
|
||||||
|
|
||||||
|
The other 87 bits. Once the second block lands, the receiver has 174 soft-valued bit estimates and starts running [belief propagation](https://en.wikipedia.org/wiki/Belief_propagation) on the LDPC graph. After 20 to 50 iterations it either converges on a valid codeword or it doesn't. If it does, you have 91 candidate bits.
|
||||||
|
|
||||||
|
## t = 11.52 to 12.64 s: final Costas array, then silence
|
||||||
|
|
||||||
|
Seven sync symbols, PA ramps to zero, transmission ends. By now sync is long since locked, but the third Costas is there for symmetry and so the decoder can still recover the message if either of the other two Costas blocks got obliterated.
|
||||||
|
|
||||||
|
## t > 12.64 s: the receiver does the rest
|
||||||
|
|
||||||
|
CRC check on the 91 bits. If it passes, the source decoder reads the i3 message-type bits, unpacks the matching schema, looks up callsign hashes against its recently-heard table, expands the grid, and "CQ W5ISP EM13" pops up in WSJT-X with a frequency offset and an SNR. If the CRC fails, the decoder's apparent success was a false alarm (the noise happened to look like some other valid codeword), and the result gets dropped on the floor.
|
||||||
|
|
||||||
|
WSJT-X does this for every 6.25 Hz bin in a 2500 Hz passband, in parallel, every 15 seconds. On a good 20m evening, one slot can produce 30 or 40 decodes from stations scattered across the planet. The dial-up modem had it easy: 4 kHz of clean phone line and 30 dB SNR. FT8 has 50 Hz of HF and sometimes -24 dB. Same idea, harder problem, weirder noises.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/ft8.js" defer></script>
|
||||||
185
content/blog/gps-ntp-pi.md
Normal file
185
content/blog/gps-ntp-pi.md
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
+++
|
||||||
|
title = "A stratum-1 NTP server on a Raspberry Pi with GPS and 1PPS"
|
||||||
|
date = 2026-04-27
|
||||||
|
draft = false
|
||||||
|
+++
|
||||||
|
|
||||||
|
Internet NTP is fine, in the same way that supermarket sushi is fine. Polling pool.ntp.org over a residential link gets you within a few milliseconds of UTC, which is enough to keep TLS certificates from looking like they're from the future and enough to make `tail -f` across two machines look mostly sensible. It is not enough to do anything interesting with timestamps. Below the millisecond floor, the network jitter eats everything: the kernel queueing the packet, the NIC waking up, the path through your ISP's gear, the upstream NTP server's own scheduling latency. You can stack more peers, you can pick lower-latency ones, you cannot make the network honest. The network does not want to be honest. The network is, broadly speaking, a series of nested apologies wearing a TCP/IP costume.
|
||||||
|
|
||||||
|
The fix is to skip the network entirely. A GPS receiver with a 1PPS output can pin the local clock to within a few hundred nanoseconds of UTC for the cost of a $50 hat, an antenna with a view of the sky, and an afternoon of fighting Raspberry Pi UART config, which costs nothing in money and a great deal in dignity. That puts you a few orders of magnitude better than you can ever get over the internet, and lets you serve time to the rest of your network at stratum 1, which is a thing nobody in your house will care about and you will mention at every available opportunity anyway.
|
||||||
|
|
||||||
|
## The two halves of GPS time
|
||||||
|
|
||||||
|
Every GPS receiver does two related but separate things, and pretends they are one thing. The first is decode the navigation message and produce a stream of NMEA sentences over a serial port: `$GPRMC` once a second with a UTC timestamp, position, speed, the works. The second is emit a hardware pulse, exactly once a second, on a dedicated pin, whose rising edge is aligned with the start of each UTC second to within roughly 10 nanoseconds.
|
||||||
|
|
||||||
|
Those are very different signals dressed up to look like one, like a labrador in a bowtie. NMEA tells you what second it is. The pulse tells you when that second started. NMEA arrives over a UART running at 9600 baud, which means the timestamp characters dribble in over tens of milliseconds and the OS hands them to userspace whenever it feels like it, which is roughly the temporal precision of a postcard. The pulse is a clean voltage edge that the kernel can timestamp inside an interrupt handler. Use the NMEA stream for the integer second, use the pulse for the fractional part, and the result is a clock with the slow accuracy of GPS and the fast precision of a hardware edge. The labrador, somehow, becomes a Swiss watch.
|
||||||
|
|
||||||
|
That split is exactly what chrony's `refclock` configuration is designed around: a slow source for coarse time, a `PPS` source locked to it for fine time, and chrony's filter combining the two and pretending it was never any other way.
|
||||||
|
|
||||||
|
## The Uputronics hat
|
||||||
|
|
||||||
|
The [Uputronics GPS Expansion Board](https://store.uputronics.com/index.php?route=product/product&product_id=81) is a GPIO header hat built around a u-blox MAX-M8Q. It exposes the receiver's UART on the Pi's `/dev/ttyAMA0` (the "real" PL011 UART, not the software-emulated `mini-uart`, which is real in the sense that a stage prop is real) and routes the 1PPS line to GPIO 18. There's an SMA jack for an external active antenna, a coin-cell holder for the backup battery so the receiver doesn't have to cold-start every reboot, and an LED that blinks once per second when it has a fix, which is the most satisfying status indicator in the entire stack and the one piece of debugging gear that has never lied to me.
|
||||||
|
|
||||||
|
A few alternatives exist (Adafruit's Ultimate GPS HAT, the Sparkfun XA1110 breakout with a wired pulse) and the rest of the writeup applies to any of them with minor pin changes. The Uputronics is the one I have, and the MAX-M8Q is a nice receiver: 72 channel, GPS + GLONASS + Galileo + BeiDou, fix in under 30 seconds from cold and a couple of seconds warm. It will also, if you put the antenna anywhere near a window, find more satellites than you knew were up there, which is one of those quietly humbling reminders that the sky is full of expensive American, Russian, European, and Chinese hardware all screaming the time at you for free.
|
||||||
|
|
||||||
|
## Wiring up the Pi
|
||||||
|
|
||||||
|
Three packages cover everything below. `pps-tools` brings in `ppstest` for sanity-checking the pulse before chrony gets involved, `gpsd` parses the NMEA stream from the receiver and publishes it where chrony can read it, and `chrony` is the NTP daemon itself:
|
||||||
|
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install pps-tools gpsd chrony
|
||||||
|
|
||||||
|
chrony's built-in refclock drivers are `PHC`, `PPS`, `SHM`, and `SOCK`. There's no NMEA parser inside chrony itself, presumably because the chrony authors got one good look at NMEA 0183 and decided life was too short. gpsd does the parsing and hands the result to chrony through shared memory or a Unix socket.
|
||||||
|
|
||||||
|
On a Pi 4 or 5, the kernel default is to give `/dev/ttyAMA0` to the Bluetooth radio and shove the serial console out the mini-UART, which is the wrong choice for absolutely everything you might want to do, but is the right choice if you are a Raspberry Pi engineer who has never had to talk to a GPS at four in the morning. Three lines in `/boot/firmware/config.txt` fix it:
|
||||||
|
|
||||||
|
enable_uart=1
|
||||||
|
dtoverlay=disable-bt
|
||||||
|
dtoverlay=pps-gpio,gpiopin=18
|
||||||
|
|
||||||
|
The first turns on the PL011 and points it at the GPIO pins the hat uses. The second tells the kernel to stop fighting you for the good UART by parking the Bluetooth modem on the mini-UART, or you can leave Bluetooth disabled entirely, which is what I did because I have never once wanted a Raspberry Pi to do Bluetooth. The third loads the [PPS-GPIO kernel driver](https://www.kernel.org/doc/html/latest/driver-api/pps.html) and tells it to watch GPIO 18 for the pulse and timestamp every rising edge, which is the actual technically interesting bit and gets one line of config because Linux is occasionally generous.
|
||||||
|
|
||||||
|
You also want to evict the serial console from `/dev/ttyAMA0`, because if `getty` is sitting on the port reading login banners at the receiver, the GPS will simply file all of that under "not NMEA, do not care" and the receiver will go on muttering its time-of-day to nobody in particular. There are two pieces to disable: the kernel console handoff in `cmdline.txt` and the `serial-getty@ttyAMA0` systemd service that gets enabled on the fly when the kernel passes that console argument, because systemd believes in defense in depth, where the depth is mostly directed at you. Remove the token from `/boot/firmware/cmdline.txt`:
|
||||||
|
|
||||||
|
sudo sed -i 's/console=serial0,115200 //' /boot/firmware/cmdline.txt
|
||||||
|
|
||||||
|
Then disable the getty service so it doesn't come back and grab the UART before gpsd does:
|
||||||
|
|
||||||
|
sudo systemctl disable --now serial-getty@ttyAMA0
|
||||||
|
|
||||||
|
Skipping the second step is the most common reason gpsd shows up in the journal complaining `SER: /dev/ttyAMA0 already opened by another process`, which is the polite Unix way of saying "you have two daemons in love with the same UART and one of them needs to go." The cmdline change alone only takes effect on the next boot, and even then `systemd-getty-generator` may have already wired up a unit that survives the cmdline edit, because systemd holds opinions and is willing to die on hills you didn't know existed.
|
||||||
|
|
||||||
|
You also need to give chrony's `_chrony` user and gpsd's `gpsd` user access to the PPS device. The kernel creates `/dev/pps0` owned by root with no group access, so a one-line udev rule plus group memberships handle it:
|
||||||
|
|
||||||
|
sudo tee /etc/udev/rules.d/99-pps.rules <<'EOF'
|
||||||
|
KERNEL=="pps[0-9]*", GROUP="dialout", MODE="0660"
|
||||||
|
EOF
|
||||||
|
sudo usermod -aG dialout gpsd _chrony
|
||||||
|
sudo udevadm control --reload && sudo udevadm trigger
|
||||||
|
|
||||||
|
After a reboot, `/dev/pps0` shows up and `sudo ppstest /dev/pps0` should print one timestamp per second with sub-microsecond jitter:
|
||||||
|
|
||||||
|
trying PPS source "/dev/pps0"
|
||||||
|
found PPS source "/dev/pps0"
|
||||||
|
ok, found 1 source(s), now start fetching data...
|
||||||
|
source 0 - assert 1769472001.000000123, sequence: 1
|
||||||
|
source 0 - assert 1769472002.000000118, sequence: 2
|
||||||
|
|
||||||
|
If the assert times don't tick once per second, your wiring is wrong, your antenna doesn't have sky, or the receiver hasn't acquired yet, in roughly that order of likelihood. The hat's LED is the fastest way to tell which, and is why I have very strong feelings about hardware that includes a status indicator and very tepid feelings about hardware that does not.
|
||||||
|
|
||||||
|
## Pointing gpsd at the receiver
|
||||||
|
|
||||||
|
gpsd's job is to read NMEA off the UART and publish two things into the kernel's POSIX shared memory: the timestamp from the latest `$GPRMC` (segment 0, the coarse source) and, if the kernel PPS device is present and gpsd was built with PPS support, a timestamp for each pulse (segment 1, the fine source).
|
||||||
|
|
||||||
|
Edit `/etc/default/gpsd`:
|
||||||
|
|
||||||
|
START_DAEMON="true"
|
||||||
|
USBAUTO="false"
|
||||||
|
DEVICES="/dev/ttyAMA0 /dev/pps0"
|
||||||
|
GPSD_OPTIONS="-n"
|
||||||
|
|
||||||
|
`-n` tells gpsd to start polling the receiver immediately instead of waiting for a client to connect, which matters because chrony talks to gpsd through SHM rather than the gpsd socket protocol, so without `-n` gpsd will sit idle, chrony will see nothing, and you will spend a happy hour reading manpages before remembering that gpsd is, at heart, a daemon with abandonment issues. Listing `/dev/pps0` alongside the UART tells gpsd to pair the kernel PPS source with the receiver and publish PPS timestamps into SHM segment 1.
|
||||||
|
|
||||||
|
sudo systemctl enable --now gpsd
|
||||||
|
|
||||||
|
Before starting gpsd you can sanity-check the raw NMEA by reading the UART directly for a few seconds:
|
||||||
|
|
||||||
|
sudo stty -F /dev/ttyAMA0 9600 raw -echo
|
||||||
|
sudo timeout 3 cat /dev/ttyAMA0
|
||||||
|
|
||||||
|
You should see a stream of `$GPRMC`, `$GPGGA`, `$GPGSV` sentences, which look like CSV designed by someone who had heard CSV described once at a party. If the timestamp field in `$GPRMC` is empty and the status flag reads `V` (void), the receiver doesn't have a fix yet. Wait for the hat's PPS LED to start blinking and try again. Once gpsd is running it owns `/dev/ttyAMA0`, so don't leave a `cat` running against it unless you enjoy the sound of two processes fighting over a serial port.
|
||||||
|
|
||||||
|
After gpsd is up, the verification chrony cares about happens through chrony itself. Once the chrony config below is in place, `chronyc sources -v` will show the `NMEA` and `PPS` refclocks transitioning out of the `?` state and Reach climbing off zero within a couple of polling intervals. That, plus `ppstest /dev/pps0` for the pulse and a brief `cat /dev/ttyAMA0` for the NMEA stream, is all the diagnostic surface the setup needs, which is a refreshing change from anything else in modern Linux.
|
||||||
|
|
||||||
|
## The chrony config
|
||||||
|
|
||||||
|
The interesting part of `/etc/chrony/chrony.conf`:
|
||||||
|
|
||||||
|
# Coarse time from NMEA via gpsd's SHM segment 0. ~100 ms accuracy,
|
||||||
|
# used to resolve which second the PPS pulse belongs to.
|
||||||
|
refclock SHM 0 refid NMEA precision 1e-1 offset 0.0 delay 0.2 noselect
|
||||||
|
|
||||||
|
# Fine time from 1PPS via gpsd's SHM segment 1. Sub-microsecond
|
||||||
|
# precision, but ambiguous about which second it is, so it has to be
|
||||||
|
# locked to the coarse source.
|
||||||
|
refclock SHM 1 refid PPS precision 1e-7 lock NMEA prefer
|
||||||
|
|
||||||
|
# Internet peers as a sanity check, not a time source.
|
||||||
|
pool 2.pool.ntp.org iburst maxsources 4
|
||||||
|
|
||||||
|
# Serve time to the LAN.
|
||||||
|
allow 10.0.0.0/24
|
||||||
|
allow 192.168.0.0/16
|
||||||
|
|
||||||
|
# Don't let the kernel step the clock more than 100 ms after startup.
|
||||||
|
makestep 1.0 3
|
||||||
|
|
||||||
|
The `noselect` on the NMEA source is deliberate. Its job is not to discipline the clock, only to tag each PPS pulse with the right second, like the supporting actor whose entire role is making the lead's punchlines land. The `lock NMEA` on the PPS line is what actually wires the two halves together: chrony refuses to use a PPS pulse unless a coarse source has independently agreed on which second it represents, which keeps a stuck or rolled-over receiver from quietly skewing your clock by a full second and ruining everyone's evening.
|
||||||
|
|
||||||
|
The `prefer` on PPS tells chrony that when it's available and locked, nothing else gets a vote, which is the only sane policy in a household where someone has gone to the trouble of mounting a GPS antenna. The internet pool is there so that if the antenna falls over or the receiver loses its mind, you degrade gracefully to ordinary NTP instead of free-running on the Pi's crystal, which is a quartz oscillator with the long-term stability of a wet noodle and the temperature sensitivity of a small mammal.
|
||||||
|
|
||||||
|
## Verifying it actually works
|
||||||
|
|
||||||
|
`chronyc tracking` is where the truth lives:
|
||||||
|
|
||||||
|
Reference ID : 50505300 (PPS)
|
||||||
|
Stratum : 1
|
||||||
|
Ref time (UTC) : Sat Aug 16 04:13:22 2026
|
||||||
|
System time : 0.000000091 seconds slow of NTP time
|
||||||
|
Last offset : -0.000000037 seconds
|
||||||
|
RMS offset : 0.000000412 seconds
|
||||||
|
Frequency : 12.831 ppm slow
|
||||||
|
Residual freq : +0.001 ppm
|
||||||
|
Skew : 0.014 ppm
|
||||||
|
Root delay : 0.000000001 seconds
|
||||||
|
Root dispersion : 0.000004273 seconds
|
||||||
|
Update interval : 16.0 sec
|
||||||
|
Leap status : Normal
|
||||||
|
|
||||||
|
The numbers to look at are RMS offset (sub-microsecond is great, single-digit microseconds is fine, anything bigger means you have something interesting to investigate and a free evening you weren't planning on) and root dispersion (chrony's own estimate of how far off it might be, which is chrony being unusually candid for an NTP daemon). Stratum 1 with reference ID `PPS` means chrony thinks the local clock is being disciplined by a hardware reference, which is exactly the claim being made and the bit you can mention at parties.
|
||||||
|
|
||||||
|
`chronyc sources -v` shows the individual refclocks and any internet peers, with the `*` marker on whichever source is currently being used to discipline the clock. If `PPS` isn't starred after a few minutes of runtime, something's wrong: either the PPS pulse isn't reaching the kernel (`sudo ppstest /dev/pps0` will tell you), or gpsd isn't publishing into SHM segment 1 (check `/dev/pps0` permissions and that gpsd's `DEVICES` line includes it), or the NMEA segment hasn't locked and PPS is refusing to run on principle. The principle is correct. Side with chrony.
|
||||||
|
|
||||||
|
## Tightening it down
|
||||||
|
|
||||||
|
The setup above lands somewhere around 300 ns RMS on a stock Pi 4 or 5, which is already absurd by any practical standard and would have astonished an entire generation of horologists. Two cheap changes pull it under 150 ns, because of course they do, and because if you have come this far you are going to do them.
|
||||||
|
|
||||||
|
The first is to take gpsd out of the PPS path. Right now the pulse travels from the kernel `/dev/pps0` device into gpsd's PPS thread, into SHM segment 1, into chrony, which is three trips through userspace for what was supposed to be a single voltage edge. Each handoff is a context switch with scheduler jitter attached, like passing a wine glass between increasingly drunk dinner guests. chrony has its own `PPS` refclock driver that opens `/dev/pps0` directly and reads the kernel's interrupt-timestamped pulses without anything in between. gpsd is still useful for parsing NMEA, so it stays in the picture for the coarse source. Replace the SHM 1 line with a direct PPS line:
|
||||||
|
|
||||||
|
refclock SHM 0 refid NMEA precision 1e-1 offset 0.0 delay 0.2 noselect
|
||||||
|
refclock PPS /dev/pps0 refid PPS lock NMEA prefer
|
||||||
|
|
||||||
|
`sudo systemctl restart chrony` and watch `chronyc sources -v`. The estimated error on the PPS line drops by roughly half on its own.
|
||||||
|
|
||||||
|
The second is the CPU governor. Raspberry Pi OS defaults to `ondemand`, which lets the cores idle at 600 MHz and ramp up only when something needs them, which is excellent for laptops and rubbish for time servers. Interrupts arriving during a low-clock idle take longer to enter the handler, and the first few microseconds of every pulse are timestamped at the slow clock before the core wakes up and remembers it has a job. Pinning to `performance` flattens that:
|
||||||
|
|
||||||
|
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||||
|
|
||||||
|
To make it stick across reboots without dragging in `cpufrequtils` (which isn't packaged on current Debian), a tiny systemd unit does the same job:
|
||||||
|
|
||||||
|
sudo tee /etc/systemd/system/cpu-performance.service <<'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Set CPU governor to performance
|
||||||
|
After=multi-user.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/sh -c 'echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor'
|
||||||
|
RemainAfterExit=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
sudo systemctl enable cpu-performance.service
|
||||||
|
|
||||||
|
The cost is roughly 1 watt of additional idle power and a Pi that runs a few degrees warmer, which you will not notice and your electric bill will not notice and nobody anywhere will ever notice.
|
||||||
|
|
||||||
|
After both changes, `chronyc sources -v` on my own box reports the PPS source at +/- 107 ns estimated error and individual samples scattered around -100 to +200 ns of the locked-on clock. The remaining noise is the Pi's interrupt latency itself: with a stock kernel, occasionally something high-priority sits on a core long enough to delay the PPS interrupt by a microsecond or two, and that long tail dominates everything. Killing it requires a `PREEMPT_RT` kernel (real but small additional gain, significant complexity, and an entirely new set of things that can go wrong at four in the morning) or hardware timestamping (a different project entirely, involving NICs with PTP support or dedicated timecards, and a credit card you are willing to be unkind to). For a closet stratum-1, 100 ns is the floor where the law of diminishing returns stops being a curve and becomes a wall, against which you are politely invited to bash your head.
|
||||||
|
|
||||||
|
## What you actually get
|
||||||
|
|
||||||
|
Once it's running, `ntpdate -q` from another machine on the LAN reports offsets in the tens-of-microseconds range, dominated by the LAN switch and the receiving machine's own clock-handling rather than anything on the Pi. The Pi itself is within a microsecond of UTC on the long-term average, which is a thousand times tighter than what the internet pool can give you and several thousand times more than anything on your network can usefully consume.
|
||||||
|
|
||||||
|
It is also wildly more time accuracy than any reasonable amateur radio application needs. FT8 wants you within a couple hundred milliseconds of the slot boundary, and meteor scatter modes want maybe 10 ms. The Pi will deliver you nanoseconds. Pulse-per-second timestamps on SDR captures, distributed measurement runs, and logging across multiple boxes are where the precision actually pays off, along with the simple, dignified, slightly unhinged satisfaction of running a stratum-1 server in a closet for the price of a nice dinner.
|
||||||
|
|
||||||
|
The closet part matters too. The Pi pulls about 3 watts running chrony and the GPS receiver, which is less than the wall wart that powers it on standby and dramatically less than your router. It has been running on my network continuously since I built it, which is the highest praise I can give a piece of infrastructure: I forget it exists, and the clocks are right.
|
||||||
77
content/blog/hf-skip-muf.md
Normal file
77
content/blog/hf-skip-muf.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
+++
|
||||||
|
title = "HF skip and the MUF: why the sky reflects some frequencies and shrugs at others"
|
||||||
|
date = 2026-05-20
|
||||||
|
draft = true
|
||||||
|
+++
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/css/scene.css">
|
||||||
|
|
||||||
|
<div class="scene-host" data-scene-host>
|
||||||
|
|
||||||
|
The microwave bands treat the atmosphere as a faintly annoying medium that occasionally bends signals around hills by a few degrees and otherwise stays out of the way. On HF, the atmosphere is the point. A 14 MHz signal launched at the right angle from Texas hits an invisible ceiling a few hundred kilometers up, bounces off, and lands in Germany. It does so without the transmitter doing anything clever, on equipment that would not look out of place in 1950, and using a physical mechanism that nobody designed and that we get to use for free.
|
||||||
|
|
||||||
|
The ceiling is the ionosphere, the bounce is refraction rather than reflection (but everyone says reflection anyway because it's shorter), and the rules that decide whether any given signal gets bounced or ignored come down to two numbers: the frequency of the signal and the density of free electrons in the layer it's trying to punch through. Get the ratio right and the sky throws your signal around the planet. Get it wrong and your signal sails into space, never to be heard from again except possibly by some very confused extraterrestrials wondering why Earth has so much to say about DX.
|
||||||
|
|
||||||
|
## The layers, briefly
|
||||||
|
|
||||||
|
Sunlight knocks electrons loose from atoms and molecules in the upper atmosphere. The density of those electrons, by altitude, is not uniform. It clumps into layers, each with its own chemistry, its own diurnal habits, and its own opinions about which amateur bands it likes.
|
||||||
|
|
||||||
|
- **D layer**, 60 to 90 km up. Present only during the day because its electrons recombine the moment the sun stops topping them up. The D layer doesn't reflect HF signals; it absorbs them, with losses that scale roughly as 1/f<sup>2</sup>. This is why 80 meters is useless at noon and excellent at midnight.
|
||||||
|
- **E layer**, 90 to 150 km. Also mostly a daytime feature, supports short-skip propagation on the lower HF bands and occasionally hosts [sporadic E](https://en.wikipedia.org/wiki/Sporadic_E_propagation), which is a separate phenomenon involving patchy ionization clouds that refract 6 meters and 10 meters in ways that seem to violate basic physics but don't.
|
||||||
|
- **F layer**, splitting into F1 and F2 during the day and merging into a single F layer at night. F2 sits around 250 to 500 km up, survives the night because the recombination rate at that altitude is slow, and does essentially all of the long-haul HF work. When somebody works Japan from Texas on 20 meters, they are using F2.
|
||||||
|
|
||||||
|
<div id="scene-layers"></div>
|
||||||
|
|
||||||
|
Drag the time slider and watch the ionosphere breathe. Each layer's color intensity tracks its electron density at that hour, and the critical frequency fc printed on each band is what it can reflect at straight-up incidence. Flip the auto-advance on and let the whole thing run; the layers do the same thing they've been doing for 4.5 billion years, which is to say ignore radio entirely and respond only to where the sun is pointing.
|
||||||
|
|
||||||
|
## Critical frequency and the MUF
|
||||||
|
|
||||||
|
Point a signal straight up at the ionosphere. At low frequencies, every photon bounces back. Raise the frequency. At some point, called the critical frequency *f*<sub>c</sub>, the signal starts punching through and disappearing into space. The critical frequency depends on the electron density of the layer, and the relationship is *f*<sub>c</sub> (MHz) ~ 9 * sqrt(*N*), with *N* in electrons per cubic centimeter * 10<sup>-6</sup>. For a quiet nighttime F2 layer, *f*<sub>c</sub> might be 3 or 4 MHz. For the same F2 under a solar maximum noon sun, it can top 15.
|
||||||
|
|
||||||
|
That's straight-up incidence. Signals launched at an angle don't have to overcome as much ionization to be bent back down, because the geometry gives them more path length inside the layer. The [Maximum Usable Frequency](https://en.wikipedia.org/wiki/Maximum_usable_frequency), or MUF, is the highest frequency that still refracts back to Earth for a given path angle, and it's related to the critical frequency by *MUF* = *f*<sub>c</sub> / cos(θ), where θ is the angle of incidence at the layer. For a low takeoff angle (long-distance path), the MUF can be three or four times the critical frequency. This is the single most important equation in HF propagation, and it has been quietly running the amateur bands since before any of us were born.
|
||||||
|
|
||||||
|
Below the MUF, signals refract. Above it, signals escape. Right at the MUF, signals do both, poorly. Operators care because every HF band has exactly one answer to "is today a good day to work DX on this frequency?" and the answer is "is the MUF for your path higher than the band?"
|
||||||
|
|
||||||
|
## The skip zone
|
||||||
|
|
||||||
|
A signal leaving an antenna at a high takeoff angle reaches the F2 layer at a steep angle of incidence and needs a high *f*<sub>c</sub> to bounce. A signal leaving at a low takeoff angle reaches the layer at a shallow angle and bounces at a much lower *f*<sub>c</sub>. For any given operating frequency, some takeoff angles refract back to Earth and some punch through.
|
||||||
|
|
||||||
|
The consequence is the [skip zone](https://en.wikipedia.org/wiki/Skip_zone), an annular dead region around the transmitter. The ground wave dies off within a few tens of kilometers. Anything closer than the shortest path that still bounces (the skip distance) hears nothing. Past the skip distance, signals start coming down, and the band is usable from there out to the limit of a single hop, which is around 4000 km for F2. Beyond that, signals either make multiple hops (losing a few dB each time they touch the ground) or ride the layer for long stretches in [chordal propagation](https://en.wikipedia.org/wiki/Skywave) that nobody fully predicts.
|
||||||
|
|
||||||
|
<div id="scene-rays"></div>
|
||||||
|
|
||||||
|
A fan of rays leaves the transmitter at every takeoff angle from 5° to 85°. Solid rays bend back down at the lowest layer that can hold them; dashed rays punch through every layer and keep going into space (sorry). Crank the frequency past the F2 MUF and the dashed rays take over, which is what band closure actually looks like. The hatched region is the skip zone, where nobody hears you no matter how much power you run.
|
||||||
|
|
||||||
|
The skip zone is why, on 20 meters, the guy 200 km away is impossible to hear and the guy 3000 km away is loud. His signal found an angle that worked. Yours, for him, did the same.
|
||||||
|
|
||||||
|
## The daily cycle
|
||||||
|
|
||||||
|
Sunrise: D layer starts absorbing the low bands, F2 rises and its electron density climbs. 160, 80, and 40 meters get progressively louder in absorption and quieter in usable signal. 20, 15, 10 meters start to open. By noon, the low bands are closed to anything but local contacts and the high bands are carrying most of the traffic.
|
||||||
|
|
||||||
|
Sunset: D layer recombines within a couple of hours, because chemistry. Absorption on the low bands evaporates. 80 and 40 meters light up, and 160 meters (which has effectively no daytime to speak of) becomes usable for the first time since yesterday. F2 starts to decay but slowly enough that 20 meters often holds through the evening. The highest bands (10, 12 meters) close first, then 15, then 17, then 20, in a cascade that tracks the F2 electron density as it coasts downward through the night. Around 2 AM, 40 meters is doing long-haul DX work and 10 meters is as dead as it's going to get.
|
||||||
|
|
||||||
|
Come dawn, it all starts over. Every day. For as long as there is a sun.
|
||||||
|
|
||||||
|
<div id="scene-bandclock"></div>
|
||||||
|
|
||||||
|
Every amateur band, every hour of the day, colored by how useful it is at the current solar flux. Move the sunspot slider and watch the high bands bloom green at solar max and go dark at solar min. Move the hour marker and see the shift from the low bands at night to the high bands by day. This is, to a first approximation, the amateur's worldview compressed onto one circle.
|
||||||
|
|
||||||
|
## The solar cycle
|
||||||
|
|
||||||
|
Superimposed on the daily cycle is an 11-year wobble in how active the sun is, measured by [sunspot number](https://en.wikipedia.org/wiki/Wolf_number). More sunspots, more extreme ultraviolet, more ionization, higher F2 electron density, higher MUF. At solar maximum, 10 meters is open worldwide for much of the day and MUFs routinely clear 30 MHz, which opens 6 meters (technically VHF) to F2 propagation. At solar minimum, 20 meters is the highest band anyone trusts for DX and 10 meters is mostly silence punctuated by beacons hoping for an ear.
|
||||||
|
|
||||||
|
The current cycle (Cycle 25, peaking somewhere around now) has been unexpectedly strong. The previous cycle (24) was a dud. The cycle before that (23) was fine. There is no good model of which cycles will be strong and which won't, and people who claim otherwise are selling something. What we have is the data, which goes back to the 1750s and shows a noisy periodic signal that averages 11 years and occasionally produces decades-long lulls ([Maunder minimum](https://en.wikipedia.org/wiki/Maunder_Minimum), which coincided with HF being not yet invented, so it didn't much matter at the time).
|
||||||
|
|
||||||
|
The practical consequence is that the same HF station does wildly different things depending on where you are in the cycle. A 100-watt rig and a dipole that worked Europe routinely on 20 meters at solar max will struggle to work the next state over at solar min. The equipment hasn't changed. The sky has.
|
||||||
|
|
||||||
|
## Why this is the opposite of microwave NLOS
|
||||||
|
|
||||||
|
Microwave NLOS contacts happen when tropospheric physics (ducting, diffraction, rain scatter) briefly makes an atmosphere that otherwise doesn't cooperate do something unusual. They're rare, they require specific weather, and the operator hunts for the conditions rather than the other way around.
|
||||||
|
|
||||||
|
HF skip is the opposite deal. The ionosphere cooperates every day. There is no exotic mechanism. A dipole, a hundred watts, and the right frequency for the time of day will work the world routinely, and has been doing so since Marconi. The challenge isn't finding a mechanism that works. The challenge is picking the frequency whose MUF currently crosses the path you want to use, which is a forecasting problem and a propagation problem and a "look at who's active on the band right now" problem, and which is the whole game.
|
||||||
|
|
||||||
|
A microwave operator stares at weather maps. An HF operator stares at solar flux numbers and sunspot counts and [ionospheric sounder](https://en.wikipedia.org/wiki/Ionosonde) data, and is watching the same planet do a completely different trick with radio waves two hundred kilometers higher up. Both are working. Neither is straightforward. Both get called, by outsiders, "just radio," which undersells them a bit.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/hf-skip.js" defer></script>
|
||||||
|
|
@ -125,6 +125,48 @@
|
||||||
}
|
}
|
||||||
.scene-control input[type="range"]:focus { outline: none; }
|
.scene-control input[type="range"]:focus { outline: none; }
|
||||||
|
|
||||||
|
/* Toggle button: short, pill-shaped on/off control. */
|
||||||
|
.scene-toggle {
|
||||||
|
align-self: end;
|
||||||
|
justify-self: start;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: var(--s-panel);
|
||||||
|
color: var(--s-fg);
|
||||||
|
border: 1px solid var(--s-grid-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.32rem 0.85rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||||
|
user-select: none;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-toggle:hover {
|
||||||
|
border-color: var(--s-cyan);
|
||||||
|
color: var(--s-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-toggle[data-on="1"] {
|
||||||
|
background: var(--s-cyan);
|
||||||
|
border-color: var(--s-cyan);
|
||||||
|
color: var(--s-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-toggle[data-on="1"]:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
color: var(--s-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--s-cyan);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.scene-box .scene-caption {
|
.scene-box .scene-caption {
|
||||||
margin-top: 0.65rem;
|
margin-top: 0.65rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
|
||||||
755
static/js/ft8.js
Normal file
755
static/js/ft8.js
Normal file
|
|
@ -0,0 +1,755 @@
|
||||||
|
// FT8 transmission anatomy scenes. Vanilla JS + 2D canvas. Scoped to one post.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
bg: '#1a1d24',
|
||||||
|
panel: '#242932',
|
||||||
|
fg: '#d8dce4',
|
||||||
|
dim: '#8189a0',
|
||||||
|
grid: '#2d323d',
|
||||||
|
gridStrong: '#3a414e',
|
||||||
|
blue: '#78b5f3',
|
||||||
|
cyan: '#6dc5d3',
|
||||||
|
green: '#9dca83',
|
||||||
|
amber: '#d6a86a',
|
||||||
|
red: '#e58089',
|
||||||
|
yellow: '#e2c37d',
|
||||||
|
magenta: '#c78de0',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- framework ----
|
||||||
|
function setupCanvas(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
const fit = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width * dpr);
|
||||||
|
canvas.height = Math.round(rect.height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
new ResizeObserver(fit).observe(canvas);
|
||||||
|
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
|
||||||
|
const root = document.getElementById(id);
|
||||||
|
if (!root) return null;
|
||||||
|
root.classList.add('scene-box');
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
root.appendChild(canvas);
|
||||||
|
|
||||||
|
const readoutEl = document.createElement('div');
|
||||||
|
readoutEl.className = 'scene-readout';
|
||||||
|
const readoutSpans = {};
|
||||||
|
readout.forEach(r => {
|
||||||
|
const item = document.createElement('span');
|
||||||
|
item.className = 'scene-readout-item';
|
||||||
|
const label = document.createElement('span'); label.textContent = r.label + ':';
|
||||||
|
const val = document.createElement('span'); val.textContent = r.init ?? '';
|
||||||
|
item.appendChild(label); item.appendChild(val);
|
||||||
|
readoutEl.appendChild(item);
|
||||||
|
readoutSpans[r.key] = val;
|
||||||
|
});
|
||||||
|
if (readout.length) root.appendChild(readoutEl);
|
||||||
|
|
||||||
|
const controlsEl = document.createElement('div');
|
||||||
|
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
|
||||||
|
const values = {};
|
||||||
|
const inputs = {};
|
||||||
|
controls.forEach(c => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'scene-control';
|
||||||
|
|
||||||
|
if (c.type === 'toggle') {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'scene-toggle';
|
||||||
|
let on = !!c.value;
|
||||||
|
values[c.key] = on ? 1 : 0;
|
||||||
|
const render = () => {
|
||||||
|
btn.textContent = `${c.label}: ${on ? (c.onLabel || 'on') : (c.offLabel || 'off')}`;
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
btn.dataset.on = on ? '1' : '0';
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
on = !on;
|
||||||
|
values[c.key] = on ? 1 : 0;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
inputs[c.key] = {
|
||||||
|
set: v => { on = !!v; values[c.key] = on ? 1 : 0; render(); },
|
||||||
|
};
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
controlsEl.appendChild(wrap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const name = document.createElement('span'); name.textContent = c.label;
|
||||||
|
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
|
||||||
|
label.appendChild(name); label.appendChild(valSpan);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'range';
|
||||||
|
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
|
||||||
|
input.value = c.value;
|
||||||
|
values[c.key] = parseFloat(input.value);
|
||||||
|
const fmt = c.format || (v => v.toFixed(2));
|
||||||
|
valSpan.textContent = fmt(values[c.key]);
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
values[c.key] = parseFloat(input.value);
|
||||||
|
valSpan.textContent = fmt(values[c.key]);
|
||||||
|
});
|
||||||
|
inputs[c.key] = { input, valSpan, fmt };
|
||||||
|
wrap.appendChild(label); wrap.appendChild(input);
|
||||||
|
controlsEl.appendChild(wrap);
|
||||||
|
});
|
||||||
|
if (controls.length) root.appendChild(controlsEl);
|
||||||
|
|
||||||
|
if (caption) {
|
||||||
|
const cap = document.createElement('div');
|
||||||
|
cap.className = 'scene-caption';
|
||||||
|
cap.textContent = caption;
|
||||||
|
root.appendChild(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ctx, getSize } = setupCanvas(canvas);
|
||||||
|
const setReadout = (key, txt) => {
|
||||||
|
if (readoutSpans[key]) readoutSpans[key].textContent = txt;
|
||||||
|
};
|
||||||
|
const setControl = (key, v) => {
|
||||||
|
const c = inputs[key];
|
||||||
|
if (!c) return;
|
||||||
|
if (c.set) { c.set(v); return; }
|
||||||
|
c.input.value = v;
|
||||||
|
values[key] = parseFloat(c.input.value);
|
||||||
|
c.valSpan.textContent = c.fmt(values[key]);
|
||||||
|
};
|
||||||
|
return { canvas, ctx, getSize, values, setReadout, setControl, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
|
||||||
|
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12, family = 'system-ui, sans-serif') {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `${size}px ${family}`;
|
||||||
|
ctx.textAlign = align;
|
||||||
|
ctx.textBaseline = baseline;
|
||||||
|
ctx.fillText(str, x, y);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
|
||||||
|
else ctx.rect(x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- FT8 protocol constants ----
|
||||||
|
const COSTAS = [3, 1, 4, 0, 6, 5, 2];
|
||||||
|
const SYM_DUR = 0.16; // seconds per symbol
|
||||||
|
const TONE_HZ = 6.25; // tone spacing
|
||||||
|
const N_SYM = 79;
|
||||||
|
const TOTAL = N_SYM * SYM_DUR; // 12.64 s
|
||||||
|
|
||||||
|
// ---- Real FT8 encoder for "CQ W5ISP EM13" ----
|
||||||
|
//
|
||||||
|
// Direct port of kgoba/ft8_lib's encoder. Source-encodes a standard i3=1
|
||||||
|
// message ("call_to call_de grid"), appends CRC-14, runs LDPC(174,91), and
|
||||||
|
// maps to 79 8-FSK tones with Costas sync arrays at start/middle/end.
|
||||||
|
// Output is the literal sequence of tones a real WSJT-X transmit produces.
|
||||||
|
|
||||||
|
const FT8_GRAY_MAP = [0, 1, 3, 2, 5, 6, 4, 7];
|
||||||
|
const FT8_CRC_POLY = 0x2757;
|
||||||
|
const NTOKENS = 2063592;
|
||||||
|
const MAX22 = 4194304;
|
||||||
|
|
||||||
|
// 83x12-byte LDPC parity generator matrix (lifted from kFTX_LDPC_generator).
|
||||||
|
// Each row is one parity row's bitmask over the 91-bit message (MSB first).
|
||||||
|
const FT8_LDPC_GEN = [
|
||||||
|
[0x83,0x29,0xce,0x11,0xbf,0x31,0xea,0xf5,0x09,0xf2,0x7f,0xc0],
|
||||||
|
[0x76,0x1c,0x26,0x4e,0x25,0xc2,0x59,0x33,0x54,0x93,0x13,0x20],
|
||||||
|
[0xdc,0x26,0x59,0x02,0xfb,0x27,0x7c,0x64,0x10,0xa1,0xbd,0xc0],
|
||||||
|
[0x1b,0x3f,0x41,0x78,0x58,0xcd,0x2d,0xd3,0x3e,0xc7,0xf6,0x20],
|
||||||
|
[0x09,0xfd,0xa4,0xfe,0xe0,0x41,0x95,0xfd,0x03,0x47,0x83,0xa0],
|
||||||
|
[0x07,0x7c,0xcc,0xc1,0x1b,0x88,0x73,0xed,0x5c,0x3d,0x48,0xa0],
|
||||||
|
[0x29,0xb6,0x2a,0xfe,0x3c,0xa0,0x36,0xf4,0xfe,0x1a,0x9d,0xa0],
|
||||||
|
[0x60,0x54,0xfa,0xf5,0xf3,0x5d,0x96,0xd3,0xb0,0xc8,0xc3,0xe0],
|
||||||
|
[0xe2,0x07,0x98,0xe4,0x31,0x0e,0xed,0x27,0x88,0x4a,0xe9,0x00],
|
||||||
|
[0x77,0x5c,0x9c,0x08,0xe8,0x0e,0x26,0xdd,0xae,0x56,0x31,0x80],
|
||||||
|
[0xb0,0xb8,0x11,0x02,0x8c,0x2b,0xf9,0x97,0x21,0x34,0x87,0xc0],
|
||||||
|
[0x18,0xa0,0xc9,0x23,0x1f,0xc6,0x0a,0xdf,0x5c,0x5e,0xa3,0x20],
|
||||||
|
[0x76,0x47,0x1e,0x83,0x02,0xa0,0x72,0x1e,0x01,0xb1,0x2b,0x80],
|
||||||
|
[0xff,0xbc,0xcb,0x80,0xca,0x83,0x41,0xfa,0xfb,0x47,0xb2,0xe0],
|
||||||
|
[0x66,0xa7,0x2a,0x15,0x8f,0x93,0x25,0xa2,0xbf,0x67,0x17,0x00],
|
||||||
|
[0xc4,0x24,0x36,0x89,0xfe,0x85,0xb1,0xc5,0x13,0x63,0xa1,0x80],
|
||||||
|
[0x0d,0xff,0x73,0x94,0x14,0xd1,0xa1,0xb3,0x4b,0x1c,0x27,0x00],
|
||||||
|
[0x15,0xb4,0x88,0x30,0x63,0x6c,0x8b,0x99,0x89,0x49,0x72,0xe0],
|
||||||
|
[0x29,0xa8,0x9c,0x0d,0x3d,0xe8,0x1d,0x66,0x54,0x89,0xb0,0xe0],
|
||||||
|
[0x4f,0x12,0x6f,0x37,0xfa,0x51,0xcb,0xe6,0x1b,0xd6,0xb9,0x40],
|
||||||
|
[0x99,0xc4,0x72,0x39,0xd0,0xd9,0x7d,0x3c,0x84,0xe0,0x94,0x00],
|
||||||
|
[0x19,0x19,0xb7,0x51,0x19,0x76,0x56,0x21,0xbb,0x4f,0x1e,0x80],
|
||||||
|
[0x09,0xdb,0x12,0xd7,0x31,0xfa,0xee,0x0b,0x86,0xdf,0x6b,0x80],
|
||||||
|
[0x48,0x8f,0xc3,0x3d,0xf4,0x3f,0xbd,0xee,0xa4,0xea,0xfb,0x40],
|
||||||
|
[0x82,0x74,0x23,0xee,0x40,0xb6,0x75,0xf7,0x56,0xeb,0x5f,0xe0],
|
||||||
|
[0xab,0xe1,0x97,0xc4,0x84,0xcb,0x74,0x75,0x71,0x44,0xa9,0xa0],
|
||||||
|
[0x2b,0x50,0x0e,0x4b,0xc0,0xec,0x5a,0x6d,0x2b,0xdb,0xdd,0x00],
|
||||||
|
[0xc4,0x74,0xaa,0x53,0xd7,0x02,0x18,0x76,0x16,0x69,0x36,0x00],
|
||||||
|
[0x8e,0xba,0x1a,0x13,0xdb,0x33,0x90,0xbd,0x67,0x18,0xce,0xc0],
|
||||||
|
[0x75,0x38,0x44,0x67,0x3a,0x27,0x78,0x2c,0xc4,0x20,0x12,0xe0],
|
||||||
|
[0x06,0xff,0x83,0xa1,0x45,0xc3,0x70,0x35,0xa5,0xc1,0x26,0x80],
|
||||||
|
[0x3b,0x37,0x41,0x78,0x58,0xcc,0x2d,0xd3,0x3e,0xc3,0xf6,0x20],
|
||||||
|
[0x9a,0x4a,0x5a,0x28,0xee,0x17,0xca,0x9c,0x32,0x48,0x42,0xc0],
|
||||||
|
[0xbc,0x29,0xf4,0x65,0x30,0x9c,0x97,0x7e,0x89,0x61,0x0a,0x40],
|
||||||
|
[0x26,0x63,0xae,0x6d,0xdf,0x8b,0x5c,0xe2,0xbb,0x29,0x48,0x80],
|
||||||
|
[0x46,0xf2,0x31,0xef,0xe4,0x57,0x03,0x4c,0x18,0x14,0x41,0x80],
|
||||||
|
[0x3f,0xb2,0xce,0x85,0xab,0xe9,0xb0,0xc7,0x2e,0x06,0xfb,0xe0],
|
||||||
|
[0xde,0x87,0x48,0x1f,0x28,0x2c,0x15,0x39,0x71,0xa0,0xa2,0xe0],
|
||||||
|
[0xfc,0xd7,0xcc,0xf2,0x3c,0x69,0xfa,0x99,0xbb,0xa1,0x41,0x20],
|
||||||
|
[0xf0,0x26,0x14,0x47,0xe9,0x49,0x0c,0xa8,0xe4,0x74,0xce,0xc0],
|
||||||
|
[0x44,0x10,0x11,0x58,0x18,0x19,0x6f,0x95,0xcd,0xd7,0x01,0x20],
|
||||||
|
[0x08,0x8f,0xc3,0x1d,0xf4,0xbf,0xbd,0xe2,0xa4,0xea,0xfb,0x40],
|
||||||
|
[0xb8,0xfe,0xf1,0xb6,0x30,0x77,0x29,0xfb,0x0a,0x07,0x8c,0x00],
|
||||||
|
[0x5a,0xfe,0xa7,0xac,0xcc,0xb7,0x7b,0xbc,0x9d,0x99,0xa9,0x00],
|
||||||
|
[0x49,0xa7,0x01,0x6a,0xc6,0x53,0xf6,0x5e,0xcd,0xc9,0x07,0x60],
|
||||||
|
[0x19,0x44,0xd0,0x85,0xbe,0x4e,0x7d,0xa8,0xd6,0xcc,0x7d,0x00],
|
||||||
|
[0x25,0x1f,0x62,0xad,0xc4,0x03,0x2f,0x0e,0xe7,0x14,0x00,0x20],
|
||||||
|
[0x56,0x47,0x1f,0x87,0x02,0xa0,0x72,0x1e,0x00,0xb1,0x2b,0x80],
|
||||||
|
[0x2b,0x8e,0x49,0x23,0xf2,0xdd,0x51,0xe2,0xd5,0x37,0xfa,0x00],
|
||||||
|
[0x6b,0x55,0x0a,0x40,0xa6,0x6f,0x47,0x55,0xde,0x95,0xc2,0x60],
|
||||||
|
[0xa1,0x8a,0xd2,0x8d,0x4e,0x27,0xfe,0x92,0xa4,0xf6,0xc8,0x40],
|
||||||
|
[0x10,0xc2,0xe5,0x86,0x38,0x8c,0xb8,0x2a,0x3d,0x80,0x75,0x80],
|
||||||
|
[0xef,0x34,0xa4,0x18,0x17,0xee,0x02,0x13,0x3d,0xb2,0xeb,0x00],
|
||||||
|
[0x7e,0x9c,0x0c,0x54,0x32,0x5a,0x9c,0x15,0x83,0x6e,0x00,0x00],
|
||||||
|
[0x36,0x93,0xe5,0x72,0xd1,0xfd,0xe4,0xcd,0xf0,0x79,0xe8,0x60],
|
||||||
|
[0xbf,0xb2,0xce,0xc5,0xab,0xe1,0xb0,0xc7,0x2e,0x07,0xfb,0xe0],
|
||||||
|
[0x7e,0xe1,0x82,0x30,0xc5,0x83,0xcc,0xcc,0x57,0xd4,0xb0,0x80],
|
||||||
|
[0xa0,0x66,0xcb,0x2f,0xed,0xaf,0xc9,0xf5,0x26,0x64,0x12,0x60],
|
||||||
|
[0xbb,0x23,0x72,0x5a,0xbc,0x47,0xcc,0x5f,0x4c,0xc4,0xcd,0x20],
|
||||||
|
[0xde,0xd9,0xdb,0xa3,0xbe,0xe4,0x0c,0x59,0xb5,0x60,0x9b,0x40],
|
||||||
|
[0xd9,0xa7,0x01,0x6a,0xc6,0x53,0xe6,0xde,0xcd,0xc9,0x03,0x60],
|
||||||
|
[0x9a,0xd4,0x6a,0xed,0x5f,0x70,0x7f,0x28,0x0a,0xb5,0xfc,0x40],
|
||||||
|
[0xe5,0x92,0x1c,0x77,0x82,0x25,0x87,0x31,0x6d,0x7d,0x3c,0x20],
|
||||||
|
[0x4f,0x14,0xda,0x82,0x42,0xa8,0xb8,0x6d,0xca,0x73,0x35,0x20],
|
||||||
|
[0x8b,0x8b,0x50,0x7a,0xd4,0x67,0xd4,0x44,0x1d,0xf7,0x70,0xe0],
|
||||||
|
[0x22,0x83,0x1c,0x9c,0xf1,0x16,0x94,0x67,0xad,0x04,0xb6,0x80],
|
||||||
|
[0x21,0x3b,0x83,0x8f,0xe2,0xae,0x54,0xc3,0x8e,0xe7,0x18,0x00],
|
||||||
|
[0x5d,0x92,0x6b,0x6d,0xd7,0x1f,0x08,0x51,0x81,0xa4,0xe1,0x20],
|
||||||
|
[0x66,0xab,0x79,0xd4,0xb2,0x9e,0xe6,0xe6,0x95,0x09,0xe5,0x60],
|
||||||
|
[0x95,0x81,0x48,0x68,0x2d,0x74,0x8a,0x38,0xdd,0x68,0xba,0xa0],
|
||||||
|
[0xb8,0xce,0x02,0x0c,0xf0,0x69,0xc3,0x2a,0x72,0x3a,0xb1,0x40],
|
||||||
|
[0xf4,0x33,0x1d,0x6d,0x46,0x16,0x07,0xe9,0x57,0x52,0x74,0x60],
|
||||||
|
[0x6d,0xa2,0x3b,0xa4,0x24,0xb9,0x59,0x61,0x33,0xcf,0x9c,0x80],
|
||||||
|
[0xa6,0x36,0xbc,0xbc,0x7b,0x30,0xc5,0xfb,0xea,0xe6,0x7f,0xe0],
|
||||||
|
[0x5c,0xb0,0xd8,0x6a,0x07,0xdf,0x65,0x4a,0x90,0x89,0xa2,0x00],
|
||||||
|
[0xf1,0x1f,0x10,0x68,0x48,0x78,0x0f,0xc9,0xec,0xdd,0x80,0xa0],
|
||||||
|
[0x1f,0xbb,0x53,0x64,0xfb,0x8d,0x2c,0x9d,0x73,0x0d,0x5b,0xa0],
|
||||||
|
[0xfc,0xb8,0x6b,0xc7,0x0a,0x50,0xc9,0xd0,0x2a,0x5d,0x03,0x40],
|
||||||
|
[0xa5,0x34,0x43,0x30,0x29,0xea,0xc1,0x5f,0x32,0x2e,0x34,0xc0],
|
||||||
|
[0xc9,0x89,0xd9,0xc7,0xc3,0xd3,0xb8,0xc5,0x5d,0x75,0x13,0x00],
|
||||||
|
[0x7b,0xb3,0x8b,0x2f,0x01,0x86,0xd4,0x66,0x43,0xae,0x96,0x20],
|
||||||
|
[0x26,0x44,0xeb,0xad,0xeb,0x44,0xb9,0x46,0x7d,0x1f,0x42,0xc0],
|
||||||
|
[0x60,0x8c,0xc8,0x57,0x59,0x4b,0xfb,0xb5,0x5d,0x69,0x60,0x00],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALPHANUM_SPACE = ' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const ALPHANUM = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
const NUMERIC = '0123456789';
|
||||||
|
const LETTERS_SPACE = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
|
||||||
|
function isDigit(c) { return c >= '0' && c <= '9'; }
|
||||||
|
|
||||||
|
function packBasecall(call) {
|
||||||
|
const length = call.length;
|
||||||
|
if (length <= 2) return -1;
|
||||||
|
const c6 = [' ', ' ', ' ', ' ', ' ', ' '];
|
||||||
|
if (isDigit(call[2]) && length <= 6) {
|
||||||
|
// AB0XYZ form: left-aligned
|
||||||
|
for (let i = 0; i < length; i++) c6[i] = call[i];
|
||||||
|
} else if (isDigit(call[1]) && length <= 5) {
|
||||||
|
// A0XYZ form: shift right by 1
|
||||||
|
for (let i = 0; i < length; i++) c6[i + 1] = call[i];
|
||||||
|
} else {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
const i0 = ALPHANUM_SPACE.indexOf(c6[0]);
|
||||||
|
const i1 = ALPHANUM.indexOf(c6[1]);
|
||||||
|
const i2 = NUMERIC.indexOf(c6[2]);
|
||||||
|
const i3 = LETTERS_SPACE.indexOf(c6[3]);
|
||||||
|
const i4 = LETTERS_SPACE.indexOf(c6[4]);
|
||||||
|
const i5 = LETTERS_SPACE.indexOf(c6[5]);
|
||||||
|
if (i0 < 0 || i1 < 0 || i2 < 0 || i3 < 0 || i4 < 0 || i5 < 0) return -1;
|
||||||
|
let n = i0;
|
||||||
|
n = n * 36 + i1;
|
||||||
|
n = n * 10 + i2;
|
||||||
|
n = n * 27 + i3;
|
||||||
|
n = n * 27 + i4;
|
||||||
|
n = n * 27 + i5;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pack28(call) {
|
||||||
|
if (call === 'DE') return 0;
|
||||||
|
if (call === 'QRZ') return 1;
|
||||||
|
if (call === 'CQ') return 2;
|
||||||
|
const n = packBasecall(call);
|
||||||
|
if (n < 0) return -1;
|
||||||
|
return NTOKENS + MAX22 + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packGrid4(grid) {
|
||||||
|
const a = grid.charCodeAt(0) - 65;
|
||||||
|
const b = grid.charCodeAt(1) - 65;
|
||||||
|
const c = grid.charCodeAt(2) - 48;
|
||||||
|
const d = grid.charCodeAt(3) - 48;
|
||||||
|
return ((a * 18 + b) * 10 + c) * 10 + d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pack a standard i3=1 message into a 10-byte (77-bit) payload.
|
||||||
|
// Last 3 bits of byte 9 are zero, ready for CRC injection.
|
||||||
|
function packStdMessage(callTo, callDe, grid) {
|
||||||
|
const n28a = pack28(callTo);
|
||||||
|
const n28b = pack28(callDe);
|
||||||
|
const igrid4 = packGrid4(grid);
|
||||||
|
const i3 = 1;
|
||||||
|
const n29a = n28a * 2;
|
||||||
|
const n29b = n28b * 2;
|
||||||
|
const p = new Uint8Array(10);
|
||||||
|
p[0] = Math.floor(n29a / (1 << 21)) & 0xff;
|
||||||
|
p[1] = Math.floor(n29a / (1 << 13)) & 0xff;
|
||||||
|
p[2] = Math.floor(n29a / (1 << 5)) & 0xff;
|
||||||
|
p[3] = ((n29a * 8) & 0xff) | (Math.floor(n29b / (1 << 26)) & 0x07);
|
||||||
|
p[4] = Math.floor(n29b / (1 << 18)) & 0xff;
|
||||||
|
p[5] = Math.floor(n29b / (1 << 10)) & 0xff;
|
||||||
|
p[6] = Math.floor(n29b / (1 << 2)) & 0xff;
|
||||||
|
p[7] = ((n29b * 64) & 0xff) | ((igrid4 >>> 10) & 0x3f);
|
||||||
|
p[8] = (igrid4 >>> 2) & 0xff;
|
||||||
|
p[9] = ((igrid4 * 64) & 0xff) | ((i3 * 8) & 0x38);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC-14 over numBits of msg (MSB first), poly 0x2757, no init/xor.
|
||||||
|
function computeCRC14(msg, numBits) {
|
||||||
|
let r = 0;
|
||||||
|
let i = 0;
|
||||||
|
for (let b = 0; b < numBits; b++) {
|
||||||
|
if (b % 8 === 0) {
|
||||||
|
r ^= (msg[i] << 6);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (r & 0x2000) r = (r << 1) ^ FT8_CRC_POLY;
|
||||||
|
else r = (r << 1);
|
||||||
|
}
|
||||||
|
return r & 0x3fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append CRC-14 to a 77-bit payload, producing 91 bits in 12 bytes.
|
||||||
|
function addCRC(payload) {
|
||||||
|
const a = new Uint8Array(12);
|
||||||
|
for (let i = 0; i < 10; i++) a[i] = payload[i];
|
||||||
|
a[9] &= 0xf8;
|
||||||
|
a[10] = 0;
|
||||||
|
a[11] = 0;
|
||||||
|
const crc = computeCRC14(a, 96 - 14);
|
||||||
|
a[9] |= (crc >>> 11) & 0x07;
|
||||||
|
a[10] = (crc >>> 3) & 0xff;
|
||||||
|
a[11] = (crc << 5) & 0xff;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parity8(x) {
|
||||||
|
x ^= x >>> 4;
|
||||||
|
x ^= x >>> 2;
|
||||||
|
x ^= x >>> 1;
|
||||||
|
return x & 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDPC(174, 91) systematic encoder. First 91 bits of cw = a91; last 83 bits
|
||||||
|
// are parity computed via FT8_LDPC_GEN.
|
||||||
|
function encodeLDPC(a91) {
|
||||||
|
const cw = new Uint8Array(22);
|
||||||
|
for (let j = 0; j < 22; j++) cw[j] = j < 12 ? a91[j] : 0;
|
||||||
|
let mask = 0x80 >>> (91 % 8);
|
||||||
|
let idx = 11;
|
||||||
|
for (let i = 0; i < 83; i++) {
|
||||||
|
let s = 0;
|
||||||
|
for (let j = 0; j < 12; j++) s ^= parity8(a91[j] & FT8_LDPC_GEN[i][j]);
|
||||||
|
if (s & 1) cw[idx] |= mask;
|
||||||
|
mask >>>= 1;
|
||||||
|
if (mask === 0) { mask = 0x80; idx++; }
|
||||||
|
}
|
||||||
|
return cw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the 174-bit codeword to 79 8-FSK tones, splicing in three Costas
|
||||||
|
// arrays at positions 0..6, 36..42, 72..78. Each data tone is the Gray-coded
|
||||||
|
// value of three consecutive codeword bits (MSB first).
|
||||||
|
function tonesFromCodeword(cw) {
|
||||||
|
const tones = new Array(N_SYM);
|
||||||
|
let mask = 0x80, b = 0;
|
||||||
|
for (let t = 0; t < N_SYM; t++) {
|
||||||
|
if (t < 7) tones[t] = COSTAS[t];
|
||||||
|
else if (t >= 36 && t < 43) tones[t] = COSTAS[t - 36];
|
||||||
|
else if (t >= 72) tones[t] = COSTAS[t - 72];
|
||||||
|
else {
|
||||||
|
let v = 0;
|
||||||
|
if (cw[b] & mask) v |= 4;
|
||||||
|
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||||
|
if (cw[b] & mask) v |= 2;
|
||||||
|
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||||
|
if (cw[b] & mask) v |= 1;
|
||||||
|
mask >>>= 1; if (mask === 0) { mask = 0x80; b++; }
|
||||||
|
tones[t] = FT8_GRAY_MAP[v];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tones;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSymbols() {
|
||||||
|
const payload = packStdMessage('CQ', 'W5ISP', 'EM13');
|
||||||
|
const a91 = addCRC(payload);
|
||||||
|
const cw = encodeLDPC(a91);
|
||||||
|
return tonesFromCodeword(cw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseOf(idx) {
|
||||||
|
if (idx < 7) return 'costas1';
|
||||||
|
if (idx < 36) return 'data1';
|
||||||
|
if (idx < 43) return 'costas2';
|
||||||
|
if (idx < 72) return 'data2';
|
||||||
|
return 'costas3';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASES = [
|
||||||
|
{ from: 0, to: 7, kind: 'costas', label: 'Costas 1', sub: 'initial sync (7 syms)' },
|
||||||
|
{ from: 7, to: 36, kind: 'data', label: 'Data block 1', sub: '29 syms = 87 LDPC bits' },
|
||||||
|
{ from: 36, to: 43, kind: 'costas', label: 'Costas 2', sub: 're-sync, drift fix' },
|
||||||
|
{ from: 43, to: 72, kind: 'data', label: 'Data block 2', sub: '29 syms = 87 LDPC bits' },
|
||||||
|
{ from: 72, to: 79, kind: 'costas', label: 'Costas 3', sub: 'final sync' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scene 1: Spectrogram timeline
|
||||||
|
// ============================================================
|
||||||
|
function sceneSpectrum() {
|
||||||
|
const symbols = buildSymbols();
|
||||||
|
|
||||||
|
const s = scene('scene-ft8-spectrum', {
|
||||||
|
height: 440,
|
||||||
|
controls: [
|
||||||
|
{ key: 'cursor', label: 'time cursor', min: 0, max: TOTAL, step: 0.01, value: 0,
|
||||||
|
format: v => v < 1 ? `${Math.round(v * 1000)} ms` : `${v.toFixed(2)} s` },
|
||||||
|
{ key: 'play', type: 'toggle', label: 'auto-play', value: 0 },
|
||||||
|
],
|
||||||
|
readout: [
|
||||||
|
{ key: 'sym', label: 'symbol', init: '1 / 79' },
|
||||||
|
{ key: 'phase', label: 'phase', init: 'Costas 1' },
|
||||||
|
{ key: 'tone', label: 'tone', init: '3 (+18.75 Hz)' },
|
||||||
|
{ key: 'mean', label: 'meaning', init: 'sync 1/7 (Costas[0]=3)' },
|
||||||
|
],
|
||||||
|
caption: 'The literal 79 tones a real WSJT-X transmit produces for "CQ W5ISP EM13", computed in the browser by the actual FT8 source coder + CRC-14 + LDPC(174,91) encoder + Gray map. Each cell is one 160 ms symbol; eight rows are the eight FT8 tones at 6.25 Hz spacing. Costas sync symbols are amber, data symbols cyan. The block of zeros at the start of data block 1 is real: "CQ" packs to n28a = 2, so the high bits of the codeword are essentially all zero.',
|
||||||
|
});
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const now = performance.now();
|
||||||
|
const dt = (now - last) / 1000;
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
if (s.values.play > 0.5) {
|
||||||
|
let t = s.values.cursor + dt * 1.5;
|
||||||
|
if (t > TOTAL) t = 0;
|
||||||
|
s.setControl('cursor', t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { w, h } = s.getSize();
|
||||||
|
clear(s.ctx, w, h);
|
||||||
|
const ctx = s.ctx;
|
||||||
|
|
||||||
|
const cursor = s.values.cursor;
|
||||||
|
const cursorSym = Math.min(N_SYM - 1, Math.floor(cursor / SYM_DUR));
|
||||||
|
|
||||||
|
const padL = 56, padR = 64, padT = 78, padB = 50;
|
||||||
|
const plotW = w - padL - padR;
|
||||||
|
const plotH = h - padT - padB;
|
||||||
|
const cellW = plotW / N_SYM;
|
||||||
|
const cellH = plotH / 8;
|
||||||
|
|
||||||
|
// Plot panel
|
||||||
|
ctx.fillStyle = '#171b22';
|
||||||
|
ctx.fillRect(padL, padT, plotW, plotH);
|
||||||
|
|
||||||
|
// Phase background bands and brackets
|
||||||
|
PHASES.forEach(p => {
|
||||||
|
const x1 = padL + p.from * cellW;
|
||||||
|
const x2 = padL + p.to * cellW;
|
||||||
|
ctx.fillStyle = p.kind === 'costas' ? 'rgba(214, 168, 106, 0.07)' : 'rgba(109, 197, 211, 0.05)';
|
||||||
|
ctx.fillRect(x1, padT, x2 - x1, plotH);
|
||||||
|
|
||||||
|
const color = p.kind === 'costas' ? C.amber : C.cyan;
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1 + 1, padT - 22);
|
||||||
|
ctx.lineTo(x1 + 1, padT - 14);
|
||||||
|
ctx.lineTo(x2 - 1, padT - 14);
|
||||||
|
ctx.lineTo(x2 - 1, padT - 22);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const cx = (x1 + x2) / 2;
|
||||||
|
text(ctx, p.label, cx, padT - 42, color, 'center', 'alphabetic', 11, 'system-ui, sans-serif');
|
||||||
|
text(ctx, p.sub, cx, padT - 28, C.dim, 'center', 'alphabetic', 9, 'system-ui, sans-serif');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Faint cell grid
|
||||||
|
ctx.strokeStyle = 'rgba(45, 50, 61, 0.35)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (let i = 0; i <= N_SYM; i += 1) {
|
||||||
|
const x = padL + i * cellW;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padT);
|
||||||
|
ctx.lineTo(x, padT + plotH);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let t = 0; t <= 8; t++) {
|
||||||
|
const y = padT + t * cellH;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(padL, y);
|
||||||
|
ctx.lineTo(padL + plotW, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tone-row labels (left: index, right: Hz offset)
|
||||||
|
for (let t = 0; t < 8; t++) {
|
||||||
|
const y = padT + (7 - t) * cellH + cellH / 2;
|
||||||
|
text(ctx, `t${t}`, padL - 8, y, C.dim, 'right', 'middle', 10, 'ui-monospace, monospace');
|
||||||
|
text(ctx, `+${(t * TONE_HZ).toFixed(2)} Hz`, padL + plotW + 6, y, '#5d6470', 'left', 'middle', 9, 'ui-monospace, monospace');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbol cells
|
||||||
|
symbols.forEach((tone, i) => {
|
||||||
|
const isCostas = phaseOf(i).startsWith('costas');
|
||||||
|
const x = padL + i * cellW + 0.5;
|
||||||
|
const y = padT + (7 - tone) * cellH + 0.5;
|
||||||
|
const sw = cellW - 1;
|
||||||
|
const sh = cellH - 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = isCostas ? C.amber : C.cyan;
|
||||||
|
ctx.fillRect(x, y, sw, sh);
|
||||||
|
|
||||||
|
if (i === cursorSym) {
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeRect(x - 0.5, y - 0.5, sw + 1, sh + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cursor line
|
||||||
|
const cx = padL + (cursor / TOTAL) * plotW;
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, padT - 6);
|
||||||
|
ctx.lineTo(cx, padT + plotH + 6);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Time axis ticks. Sub-second values render in ms.
|
||||||
|
const ticks = [0, 1.12, 5.76, 6.88, 11.52, 12.64];
|
||||||
|
ticks.forEach(t => {
|
||||||
|
const x = padL + (t / TOTAL) * plotW;
|
||||||
|
ctx.strokeStyle = C.gridStrong;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, padT + plotH);
|
||||||
|
ctx.lineTo(x, padT + plotH + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
const label = t < 1 ? `${Math.round(t * 1000)} ms` : `${t.toFixed(2)} s`;
|
||||||
|
text(ctx, label, x, padT + plotH + 8, C.dim, 'center', 'top', 9, 'ui-monospace, monospace');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Y-axis label
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(14, padT + plotH / 2);
|
||||||
|
ctx.rotate(-Math.PI / 2);
|
||||||
|
text(ctx, 'tone (8-FSK)', 0, 0, C.dim, 'center', 'middle', 11);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// X-axis label
|
||||||
|
text(ctx, 'time within transmission (s)', padL + plotW / 2, padT + plotH + 30, C.dim, 'center', 'top', 11);
|
||||||
|
|
||||||
|
// Readouts
|
||||||
|
s.setReadout('sym', `${cursorSym + 1} / ${N_SYM}`);
|
||||||
|
const ph = phaseOf(cursorSym);
|
||||||
|
const phLabel = {
|
||||||
|
costas1: 'Costas 1 (initial sync)',
|
||||||
|
data1: 'Data block 1',
|
||||||
|
costas2: 'Costas 2 (re-sync)',
|
||||||
|
data2: 'Data block 2',
|
||||||
|
costas3: 'Costas 3 (final sync)',
|
||||||
|
}[ph];
|
||||||
|
s.setReadout('phase', phLabel);
|
||||||
|
const tone = symbols[cursorSym];
|
||||||
|
s.setReadout('tone', `${tone} (+${(tone * TONE_HZ).toFixed(2)} Hz)`);
|
||||||
|
|
||||||
|
let mean;
|
||||||
|
if (ph.startsWith('costas')) {
|
||||||
|
const ci = ph === 'costas1' ? cursorSym : ph === 'costas2' ? cursorSym - 36 : cursorSym - 72;
|
||||||
|
mean = `sync ${ci + 1}/7 (Costas[${ci}] = ${COSTAS[ci]})`;
|
||||||
|
} else {
|
||||||
|
const di = ph === 'data1' ? cursorSym - 7 : cursorSym - 14;
|
||||||
|
const b0 = di * 3;
|
||||||
|
mean = `LDPC bits ${b0}-${b0 + 2} (3 of 174)`;
|
||||||
|
}
|
||||||
|
s.setReadout('mean', mean);
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scene 2: Encoding pipeline
|
||||||
|
// ============================================================
|
||||||
|
function scenePipeline() {
|
||||||
|
const symbols = buildSymbols();
|
||||||
|
|
||||||
|
const s = scene('scene-ft8-pipeline', {
|
||||||
|
height: 480,
|
||||||
|
caption: 'The actual encoder pipeline that produced the spectrogram above, run on "CQ W5ISP EM13". Each row is a stage; bar widths are proportional to bit (or symbol) count. The LDPC step nearly doubles the stream, and the Costas step splices 21 sync symbols on top of 58 data symbols. The bottom row is the same 79 tones as the spectrogram, miniaturized.',
|
||||||
|
});
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
const stages = [
|
||||||
|
{ kind: 'msg', label: 'Message', sub: 'human-readable, variable length' },
|
||||||
|
{ kind: 'bar', label: 'Source-coded', sub: '77 bits (i3.n3 type + packed fields)',
|
||||||
|
segments: [{ count: 77, color: C.green, tag: '77' }], total: 77, of: 174 },
|
||||||
|
{ kind: 'bar', label: '+ CRC-14', sub: '91 bits = 77 message + 14 CRC',
|
||||||
|
segments: [
|
||||||
|
{ count: 77, color: C.green, tag: '77 msg' },
|
||||||
|
{ count: 14, color: C.yellow, tag: '14 CRC' },
|
||||||
|
], total: 91, of: 174 },
|
||||||
|
{ kind: 'bar', label: 'LDPC(174,91)', sub: '174 bits = 91 + 83 sparse parity',
|
||||||
|
segments: [
|
||||||
|
{ count: 91, color: C.green, tag: '91 msg+CRC' },
|
||||||
|
{ count: 83, color: C.magenta, tag: '83 parity' },
|
||||||
|
], total: 174, of: 174 },
|
||||||
|
{ kind: 'bar', label: 'Symbol-mapped', sub: '58 octal symbols (3 bits each, Gray-coded 8-FSK)',
|
||||||
|
segments: [{ count: 58, color: C.cyan, tag: '58 sym' }], total: 58, of: 79, units: 'sym' },
|
||||||
|
{ kind: 'bar', label: '+ 3 Costas arrays', sub: '79 symbols = 7 + 29 + 7 + 29 + 7',
|
||||||
|
segments: [
|
||||||
|
{ count: 7, color: C.amber, tag: '7' },
|
||||||
|
{ count: 29, color: C.cyan, tag: '29' },
|
||||||
|
{ count: 7, color: C.amber, tag: '7' },
|
||||||
|
{ count: 29, color: C.cyan, tag: '29' },
|
||||||
|
{ count: 7, color: C.amber, tag: '7' },
|
||||||
|
], total: 79, of: 79, units: 'sym' },
|
||||||
|
{ kind: 'air', label: 'On the air', sub: '12.64 s, 8-FSK, 6.25 baud, 50 Hz wide' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const { w, h } = s.getSize();
|
||||||
|
clear(s.ctx, w, h);
|
||||||
|
const ctx = s.ctx;
|
||||||
|
|
||||||
|
const padL = 14, padR = 14, padT = 12, padB = 8;
|
||||||
|
const labelW = 170;
|
||||||
|
const barL = padL + labelW + 8;
|
||||||
|
const barW = w - barL - padR;
|
||||||
|
const rowH = (h - padT - padB) / stages.length;
|
||||||
|
|
||||||
|
stages.forEach((st, idx) => {
|
||||||
|
const cy = padT + idx * rowH + rowH / 2;
|
||||||
|
|
||||||
|
// Labels (left column)
|
||||||
|
text(ctx, st.label, padL, cy - 6, C.fg, 'left', 'alphabetic', 12, 'system-ui, sans-serif');
|
||||||
|
text(ctx, st.sub, padL, cy + 9, C.dim, 'left', 'alphabetic', 10, 'system-ui, sans-serif');
|
||||||
|
|
||||||
|
if (st.kind === 'msg') {
|
||||||
|
const bh = 30;
|
||||||
|
const by = cy - bh / 2;
|
||||||
|
ctx.fillStyle = C.panel;
|
||||||
|
roundRect(ctx, barL, by, barW, bh, 6);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = C.gridStrong;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
text(ctx, 'CQ W5ISP EM13', barL + barW / 2, cy, C.fg, 'center', 'middle', 14, 'ui-monospace, monospace');
|
||||||
|
} else if (st.kind === 'bar') {
|
||||||
|
const bh = 26;
|
||||||
|
const by = cy - bh / 2;
|
||||||
|
// Faint full-width track
|
||||||
|
ctx.fillStyle = 'rgba(45, 50, 61, 0.45)';
|
||||||
|
ctx.fillRect(barL, by, barW, bh);
|
||||||
|
// Segments scaled by st.of
|
||||||
|
let cx = barL;
|
||||||
|
const px = barW / st.of;
|
||||||
|
st.segments.forEach(seg => {
|
||||||
|
const segW = seg.count * px;
|
||||||
|
ctx.fillStyle = seg.color;
|
||||||
|
ctx.fillRect(cx, by, segW, bh);
|
||||||
|
if (segW > 32) {
|
||||||
|
text(ctx, seg.tag, cx + segW / 2, cy, '#1a1d24', 'center', 'middle', 10, 'ui-monospace, monospace');
|
||||||
|
}
|
||||||
|
cx += segW;
|
||||||
|
});
|
||||||
|
// Right-edge total
|
||||||
|
const totalLabel = st.units ? `${st.total} ${st.units}` : `${st.total} bits`;
|
||||||
|
text(ctx, totalLabel, barL + barW + 0, cy, C.dim, 'right', 'middle', 10, 'ui-monospace, monospace');
|
||||||
|
} else if (st.kind === 'air') {
|
||||||
|
const bh = 36;
|
||||||
|
const by = cy - bh / 2;
|
||||||
|
ctx.fillStyle = '#171b22';
|
||||||
|
ctx.fillRect(barL, by, barW, bh);
|
||||||
|
// Mini spectrogram: 79 columns, 8 rows compressed
|
||||||
|
const cellW = barW / N_SYM;
|
||||||
|
const rowsH = bh - 4;
|
||||||
|
for (let i = 0; i < N_SYM; i++) {
|
||||||
|
const isCostas = phaseOf(i).startsWith('costas');
|
||||||
|
ctx.fillStyle = isCostas ? C.amber : C.cyan;
|
||||||
|
const tone = symbols[i];
|
||||||
|
const cx2 = barL + i * cellW;
|
||||||
|
const cy2 = by + 2 + (7 - tone) * rowsH / 7;
|
||||||
|
ctx.fillRect(cx2, cy2, Math.max(cellW - 0.6, 1), 3.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector arrow between stages
|
||||||
|
if (idx < stages.length - 1) {
|
||||||
|
const ay = cy + rowH / 2;
|
||||||
|
const ax = padL + 60;
|
||||||
|
ctx.strokeStyle = C.gridStrong;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax, ay - 5);
|
||||||
|
ctx.lineTo(ax, ay + 5);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax - 3, ay + 1);
|
||||||
|
ctx.lineTo(ax, ay + 6);
|
||||||
|
ctx.lineTo(ax + 3, ay + 1);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loop() { draw(); requestAnimationFrame(loop); }
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- init ----
|
||||||
|
function init() {
|
||||||
|
sceneSpectrum();
|
||||||
|
scenePipeline();
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
|
else init();
|
||||||
|
})();
|
||||||
705
static/js/hf-skip.js
Normal file
705
static/js/hf-skip.js
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
// HF skip / MUF explainer. Vanilla JS + 2D canvas. Scoped to one post.
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
bg: '#1a1d24',
|
||||||
|
panel: '#242932',
|
||||||
|
fg: '#d8dce4',
|
||||||
|
dim: '#8189a0',
|
||||||
|
grid: '#2d323d',
|
||||||
|
gridStrong: '#3a414e',
|
||||||
|
blue: '#78b5f3',
|
||||||
|
cyan: '#6dc5d3',
|
||||||
|
green: '#9dca83',
|
||||||
|
amber: '#d6a86a',
|
||||||
|
red: '#e58089',
|
||||||
|
yellow: '#e2c37d',
|
||||||
|
magenta: '#c78de0',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- framework ----
|
||||||
|
function setupCanvas(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||||
|
const fit = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
canvas.width = Math.round(rect.width * dpr);
|
||||||
|
canvas.height = Math.round(rect.height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
new ResizeObserver(fit).observe(canvas);
|
||||||
|
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
|
||||||
|
const root = document.getElementById(id);
|
||||||
|
if (!root) return null;
|
||||||
|
root.classList.add('scene-box');
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.style.width = '100%';
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
root.appendChild(canvas);
|
||||||
|
|
||||||
|
const readoutEl = document.createElement('div');
|
||||||
|
readoutEl.className = 'scene-readout';
|
||||||
|
const readoutSpans = {};
|
||||||
|
readout.forEach(r => {
|
||||||
|
const item = document.createElement('span');
|
||||||
|
item.className = 'scene-readout-item';
|
||||||
|
const label = document.createElement('span'); label.textContent = r.label + ':';
|
||||||
|
const val = document.createElement('span'); val.textContent = r.init ?? '';
|
||||||
|
item.appendChild(label); item.appendChild(val);
|
||||||
|
readoutEl.appendChild(item);
|
||||||
|
readoutSpans[r.key] = val;
|
||||||
|
});
|
||||||
|
if (readout.length) root.appendChild(readoutEl);
|
||||||
|
|
||||||
|
const controlsEl = document.createElement('div');
|
||||||
|
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
|
||||||
|
const values = {};
|
||||||
|
const inputs = {};
|
||||||
|
controls.forEach(c => {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'scene-control';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
const name = document.createElement('span'); name.textContent = c.label;
|
||||||
|
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
|
||||||
|
label.appendChild(name); label.appendChild(valSpan);
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'range';
|
||||||
|
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
|
||||||
|
input.value = c.value;
|
||||||
|
values[c.key] = parseFloat(input.value);
|
||||||
|
const fmt = c.format || (v => v.toFixed(2));
|
||||||
|
valSpan.textContent = fmt(values[c.key]);
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
values[c.key] = parseFloat(input.value);
|
||||||
|
valSpan.textContent = fmt(values[c.key]);
|
||||||
|
});
|
||||||
|
inputs[c.key] = { input, valSpan, fmt };
|
||||||
|
wrap.appendChild(label); wrap.appendChild(input);
|
||||||
|
controlsEl.appendChild(wrap);
|
||||||
|
});
|
||||||
|
if (controls.length) root.appendChild(controlsEl);
|
||||||
|
|
||||||
|
if (caption) {
|
||||||
|
const cap = document.createElement('div');
|
||||||
|
cap.className = 'scene-caption';
|
||||||
|
cap.textContent = caption;
|
||||||
|
root.appendChild(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ctx, getSize } = setupCanvas(canvas);
|
||||||
|
const setReadout = (key, text) => {
|
||||||
|
if (readoutSpans[key]) readoutSpans[key].textContent = text;
|
||||||
|
};
|
||||||
|
const setControl = (key, v) => {
|
||||||
|
const c = inputs[key];
|
||||||
|
if (!c) return;
|
||||||
|
c.input.value = v;
|
||||||
|
values[key] = parseFloat(c.input.value);
|
||||||
|
c.valSpan.textContent = c.fmt(values[key]);
|
||||||
|
};
|
||||||
|
return { canvas, ctx, getSize, values, setReadout, setControl, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
|
||||||
|
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12, family = 'system-ui, sans-serif') {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = `${size}px ${family}`;
|
||||||
|
ctx.textAlign = align;
|
||||||
|
ctx.textBaseline = baseline;
|
||||||
|
ctx.fillText(str, x, y);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
function roundRect(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(x, y, w, h, r);
|
||||||
|
else ctx.rect(x, y, w, h);
|
||||||
|
}
|
||||||
|
function pillLabel(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'middle', size = 12) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${size}px system-ui, sans-serif`;
|
||||||
|
ctx.textAlign = align;
|
||||||
|
ctx.textBaseline = baseline;
|
||||||
|
const tw = ctx.measureText(str).width;
|
||||||
|
const padX = 5, padY = 3;
|
||||||
|
const bh = size + 2 + padY * 2;
|
||||||
|
let bx;
|
||||||
|
if (align === 'right') bx = x - tw - padX;
|
||||||
|
else if (align === 'center') bx = x - tw / 2 - padX;
|
||||||
|
else bx = x - padX;
|
||||||
|
let by;
|
||||||
|
if (baseline === 'middle') by = y - bh / 2;
|
||||||
|
else if (baseline === 'top') by = y - padY;
|
||||||
|
else by = y - size - padY;
|
||||||
|
ctx.fillStyle = 'rgba(26, 29, 36, 0.85)';
|
||||||
|
roundRect(ctx, bx, by, tw + padX * 2, bh, 3); ctx.fill();
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillText(str, x, y);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ionospheric model (simplified but qualitatively correct) ----
|
||||||
|
//
|
||||||
|
// Everything below is approximate. The goal is a visualization that tells
|
||||||
|
// the right story when the sliders move, not a geophysics textbook. Real
|
||||||
|
// models (IRI, NeQuick) involve several thousand lines of Fortran and an
|
||||||
|
// entire career.
|
||||||
|
|
||||||
|
// hour in 0..24; sun at horizon at 6 and 18, zenith at 12 (local solar time)
|
||||||
|
function sunElevation(hour) {
|
||||||
|
const a = (hour - 12) / 12 * Math.PI / 2; // -pi/2 at 6, 0 at noon, pi/2 at 18
|
||||||
|
return Math.cos(a); // 1 at noon, 0 at 6 and 18, negative in the night
|
||||||
|
}
|
||||||
|
|
||||||
|
// layer density (peak electrons/cm^3), crude time-and-solar-activity model
|
||||||
|
function layerDensity(name, hour, sfu) {
|
||||||
|
// sfu: solar flux proxy, 60 (quiet) .. 300 (very active)
|
||||||
|
const sun = sunElevation(hour);
|
||||||
|
const day = Math.max(0, sun);
|
||||||
|
const s = (sfu - 60) / 240; // 0..1
|
||||||
|
switch (name) {
|
||||||
|
case 'D': return 1e4 * day * day * (0.7 + 0.6 * s); // collapses at night
|
||||||
|
case 'E': return 1.5e5 * Math.max(0, day) * (0.7 + 0.6 * s);
|
||||||
|
case 'F1': return day > 0.1 ? 3e5 * day * (0.7 + 0.6 * s) : 0;
|
||||||
|
case 'F2': {
|
||||||
|
// F2 survives the night but at reduced density
|
||||||
|
const nightFloor = 2e5 * (0.6 + 0.8 * s);
|
||||||
|
const daypeak = 2.2e6 * (0.35 + 1.4 * s);
|
||||||
|
return nightFloor + (daypeak - nightFloor) * Math.max(0, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// critical frequency in MHz from density (N in electrons/cm^3)
|
||||||
|
function critFreqMHz(N) { return 9e-3 * Math.sqrt(Math.max(0, N)); }
|
||||||
|
// MUF for a given takeoff angle, using Earth curvature so the secant
|
||||||
|
// factor saturates around 3-3.6 instead of blowing up at low angles.
|
||||||
|
// sin(theta_i) = (R / (R + h)) * cos(takeoff)
|
||||||
|
function mufMHz(fc, takeoffDeg, hKm = 350) {
|
||||||
|
const R = 6371;
|
||||||
|
const alpha = takeoffDeg * Math.PI / 180;
|
||||||
|
const sinThetaI = (R / (R + hKm)) * Math.cos(alpha);
|
||||||
|
const cosThetaI = Math.sqrt(Math.max(0.001, 1 - sinThetaI * sinThetaI));
|
||||||
|
return fc / cosThetaI;
|
||||||
|
}
|
||||||
|
|
||||||
|
// given a frequency and a set of layer densities, find the first layer that
|
||||||
|
// can refract the ray at a given takeoff angle; return the layer name and
|
||||||
|
// altitude in km, or null if all layers are transparent to it
|
||||||
|
const LAYERS = [
|
||||||
|
{ name: 'D', alt: 75 },
|
||||||
|
{ name: 'E', alt: 110 },
|
||||||
|
{ name: 'F1', alt: 220 },
|
||||||
|
{ name: 'F2', alt: 350 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function refractAt(freq, takeoffDeg, hour, sfu) {
|
||||||
|
// D layer doesn't refract HF, it absorbs. Check absorption separately.
|
||||||
|
for (const L of LAYERS) {
|
||||||
|
if (L.name === 'D') continue;
|
||||||
|
const N = layerDensity(L.name, hour, sfu);
|
||||||
|
const fc = critFreqMHz(N);
|
||||||
|
const muf = mufMHz(fc, takeoffDeg, L.alt);
|
||||||
|
if (freq <= muf) return { layer: L.name, alt: L.alt, fc, muf };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-layer one-way absorption in dB, roughly proportional to density / f^2.
|
||||||
|
function dAbsorptionDb(freq, hour, sfu) {
|
||||||
|
const N = layerDensity('D', hour, sfu);
|
||||||
|
// empirical-ish coefficient so daytime 7 MHz sees ~20 dB of D-layer loss
|
||||||
|
return (N / 1e4) * 12 / (freq * freq);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scene 1: Ionospheric layers with time of day and solar cycle
|
||||||
|
// ============================================================
|
||||||
|
function sceneLayers() {
|
||||||
|
const s = scene('scene-layers', {
|
||||||
|
height: 380,
|
||||||
|
controls: [
|
||||||
|
{ key: 'hour', label: 'Local solar time (hours)', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||||||
|
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
} },
|
||||||
|
{ key: 'sfu', label: 'Solar flux (SFU, proxy for sunspots)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||||||
|
{ key: 'play', label: 'Auto-advance time', min: 0, max: 1, value: 0, step: 1, format: v => v > 0.5 ? 'on' : 'off' },
|
||||||
|
],
|
||||||
|
readout: [
|
||||||
|
{ key: 'fD', label: 'fc(D)' },
|
||||||
|
{ key: 'fE', label: 'fc(E)' },
|
||||||
|
{ key: 'fF1', label: 'fc(F1)' },
|
||||||
|
{ key: 'fF2', label: 'fc(F2)' },
|
||||||
|
],
|
||||||
|
caption: 'Drag the time-of-day slider and watch the layers breathe. D collapses after sunset; E and F1 follow. F2 survives the night at reduced density, which is why 40 m is a nighttime DX band and 10 m is not. Solar flux scales the whole thing; at SFU 60 the ionosphere is anemic and at SFU 300 it is lit up.',
|
||||||
|
threeCol: true,
|
||||||
|
});
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
let last = performance.now();
|
||||||
|
|
||||||
|
function draw(ts) {
|
||||||
|
const dt = Math.min(100, ts - last);
|
||||||
|
last = ts;
|
||||||
|
if (s.values.play > 0.5) {
|
||||||
|
let h = s.values.hour + dt / 1000 * 0.5; // 0.5 hour per real second
|
||||||
|
if (h >= 24) h -= 24;
|
||||||
|
s.setControl('hour', h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { w, h } = s.getSize();
|
||||||
|
const ctx = s.ctx;
|
||||||
|
clear(ctx, w, h);
|
||||||
|
|
||||||
|
const m = { l: 50, r: 150, t: 20, b: 46 };
|
||||||
|
const pX = m.l, pY = m.t, pW = w - m.l - m.r, pH = h - m.t - m.b;
|
||||||
|
const altMax = 500;
|
||||||
|
const yOf = km => pY + pH - (km / altMax) * pH;
|
||||||
|
|
||||||
|
// sky gradient based on sun elevation
|
||||||
|
const sun = sunElevation(s.values.hour);
|
||||||
|
const dayness = Math.max(0, sun);
|
||||||
|
const skyTop = `rgba(${20 + 40 * dayness}, ${30 + 60 * dayness}, ${55 + 90 * dayness}, 1)`;
|
||||||
|
const skyBot = `rgba(${60 + 120 * dayness}, ${40 + 90 * dayness}, ${60 + 40 * dayness}, 1)`;
|
||||||
|
const gradSky = ctx.createLinearGradient(0, pY, 0, pY + pH);
|
||||||
|
gradSky.addColorStop(0, skyTop);
|
||||||
|
gradSky.addColorStop(1, skyBot);
|
||||||
|
ctx.fillStyle = gradSky;
|
||||||
|
ctx.fillRect(pX, pY, pW, pH);
|
||||||
|
|
||||||
|
// altitude ticks
|
||||||
|
for (const km of [100, 200, 300, 400, 500]) {
|
||||||
|
const y = yOf(km);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath(); ctx.moveTo(pX, y); ctx.lineTo(pX + pW, y); ctx.stroke();
|
||||||
|
text(ctx, km + ' km', pX - 6, y, C.dim, 'right', 'middle', 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// draw each layer as a translucent band, thickness set by density
|
||||||
|
function drawLayer(name, lo, hi, color) {
|
||||||
|
const N = layerDensity(name, s.values.hour, s.values.sfu);
|
||||||
|
const peak = { D: 2e4, E: 3e5, F1: 4e5, F2: 4e6 }[name];
|
||||||
|
const a = Math.min(1, N / peak);
|
||||||
|
if (a < 0.02) return;
|
||||||
|
const y0 = yOf(hi), y1 = yOf(lo);
|
||||||
|
const g = ctx.createLinearGradient(0, y0, 0, y1);
|
||||||
|
g.addColorStop(0, `rgba(${color}, 0)`);
|
||||||
|
g.addColorStop(0.5, `rgba(${color}, ${0.25 * a + 0.05})`);
|
||||||
|
g.addColorStop(1, `rgba(${color}, 0)`);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(pX, y0, pW, y1 - y0);
|
||||||
|
|
||||||
|
// a subtle noisy shimmer on the peak
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.35 * a;
|
||||||
|
ctx.fillStyle = `rgba(${color}, 0.4)`;
|
||||||
|
const peakY = yOf((lo + hi) / 2);
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const xx = pX + (i / 40) * pW;
|
||||||
|
const yy = peakY + Math.sin(i * 0.8 + performance.now() / 800) * 1.5;
|
||||||
|
ctx.fillRect(xx, yy, pW / 40 - 1, 0.8);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// label at right
|
||||||
|
pillLabel(ctx, `${name} fc=${critFreqMHz(N).toFixed(1)} MHz`,
|
||||||
|
pX + pW + 6, (y0 + y1) / 2, C.fg, 'left', 'middle', 11);
|
||||||
|
}
|
||||||
|
drawLayer('F2', 300, 500, '255, 180, 120');
|
||||||
|
drawLayer('F1', 150, 300, '255, 220, 140');
|
||||||
|
drawLayer('E', 90, 150, '140, 220, 255');
|
||||||
|
drawLayer('D', 60, 90, '235, 120, 140');
|
||||||
|
|
||||||
|
// ground
|
||||||
|
ctx.fillStyle = '#2a2f3a';
|
||||||
|
ctx.fillRect(pX, pY + pH, pW, m.b);
|
||||||
|
ctx.strokeStyle = '#3a414e';
|
||||||
|
ctx.beginPath(); ctx.moveTo(pX, pY + pH); ctx.lineTo(pX + pW, pY + pH); ctx.stroke();
|
||||||
|
|
||||||
|
// sun arc
|
||||||
|
const cx = pX + pW * 0.5, cy = pY + pH - 4, rSun = pW * 0.42;
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.08)';
|
||||||
|
ctx.setLineDash([3, 4]);
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, rSun, Math.PI, 2 * Math.PI); ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
const sunAng = Math.PI + (s.values.hour / 24) * 2 * Math.PI;
|
||||||
|
const sx = cx + Math.cos(sunAng) * rSun;
|
||||||
|
const sy = cy + Math.sin(sunAng) * rSun;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = sun > 0 ? '#f5d37b' : '#566';
|
||||||
|
ctx.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (sun > 0) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.25;
|
||||||
|
ctx.beginPath(); ctx.arc(sx, sy, 14, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#f5d37b'; ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// time axis
|
||||||
|
for (const h2 of [0, 6, 12, 18, 24]) {
|
||||||
|
const ang = Math.PI + (h2 / 24) * 2 * Math.PI;
|
||||||
|
const x2 = cx + Math.cos(ang) * rSun;
|
||||||
|
const y2 = cy + Math.sin(ang) * rSun;
|
||||||
|
text(ctx, String(h2).padStart(2, '0'), x2, pY + pH + 14, C.dim, 'center', 'top', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// readout
|
||||||
|
s.setReadout('fD', critFreqMHz(layerDensity('D', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||||||
|
s.setReadout('fE', critFreqMHz(layerDensity('E', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||||||
|
s.setReadout('fF1', critFreqMHz(layerDensity('F1', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||||||
|
s.setReadout('fF2', critFreqMHz(layerDensity('F2', s.values.hour, s.values.sfu)).toFixed(2) + ' MHz');
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scene 2: Ray fan, skip zone, MUF
|
||||||
|
// ============================================================
|
||||||
|
function sceneRays() {
|
||||||
|
const s = scene('scene-rays', {
|
||||||
|
height: 420,
|
||||||
|
controls: [
|
||||||
|
{ key: 'freq', label: 'Operating frequency (MHz)', min: 1.8, max: 50, value: 14, step: 0.1, format: v => v.toFixed(1) + ' MHz' },
|
||||||
|
{ key: 'hour', label: 'Local solar time (hours)', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||||||
|
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
} },
|
||||||
|
{ key: 'sfu', label: 'Solar flux (SFU)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||||||
|
],
|
||||||
|
readout: [
|
||||||
|
{ key: 'muf', label: 'MUF (F2, low angle)' },
|
||||||
|
{ key: 'skip', label: 'Skip distance' },
|
||||||
|
{ key: 'abs', label: 'D absorption' },
|
||||||
|
{ key: 'band', label: 'Band' },
|
||||||
|
],
|
||||||
|
caption: 'Rays launch at every takeoff angle from 5° to 85°. Each one finds the lowest layer that can bend it back, or punches through if every layer is too thin for its frequency. The dashed rays are the ones that escape; the solid ones land somewhere, drawing the skip zone as the dead gap between transmitter and the first place the band is audible.',
|
||||||
|
threeCol: true,
|
||||||
|
});
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
function bandName(f) {
|
||||||
|
if (f < 2) return '160 m'; if (f < 4) return '80 m'; if (f < 5.5) return '60 m';
|
||||||
|
if (f < 7.5) return '40 m'; if (f < 11) return '30 m'; if (f < 15.5) return '20 m';
|
||||||
|
if (f < 19.5) return '17 m'; if (f < 22.5) return '15 m'; if (f < 26) return '12 m';
|
||||||
|
if (f < 30) return '10 m'; if (f < 55) return '6 m'; return '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const { w, h } = s.getSize();
|
||||||
|
const ctx = s.ctx;
|
||||||
|
clear(ctx, w, h);
|
||||||
|
|
||||||
|
const m = { l: 60, r: 30, t: 20, b: 60 };
|
||||||
|
const pX = m.l, pY = m.t, pW = w - m.l - m.r, pH = h - m.t - m.b;
|
||||||
|
const altMax = 500;
|
||||||
|
const distMax = 4000; // km horizontally
|
||||||
|
const xOf = km => pX + (km / distMax) * pW;
|
||||||
|
const yOf = km => pY + pH - (km / altMax) * pH;
|
||||||
|
|
||||||
|
// background sky
|
||||||
|
const sun = sunElevation(s.values.hour);
|
||||||
|
const dayness = Math.max(0, sun);
|
||||||
|
const gradSky = ctx.createLinearGradient(0, pY, 0, pY + pH);
|
||||||
|
gradSky.addColorStop(0, `rgba(${15 + 40 * dayness}, ${22 + 60 * dayness}, ${45 + 90 * dayness}, 1)`);
|
||||||
|
gradSky.addColorStop(1, `rgba(${40 + 90 * dayness}, ${30 + 60 * dayness}, ${55 + 30 * dayness}, 1)`);
|
||||||
|
ctx.fillStyle = gradSky; ctx.fillRect(pX, pY, pW, pH);
|
||||||
|
|
||||||
|
// layers
|
||||||
|
function drawLayerBand(name, lo, hi, color) {
|
||||||
|
const N = layerDensity(name, s.values.hour, s.values.sfu);
|
||||||
|
const peak = { D: 2e4, E: 3e5, F1: 4e5, F2: 4e6 }[name];
|
||||||
|
const a = Math.min(1, N / peak);
|
||||||
|
if (a < 0.02) return;
|
||||||
|
ctx.fillStyle = `rgba(${color}, ${0.18 * a + 0.03})`;
|
||||||
|
ctx.fillRect(pX, yOf(hi), pW, yOf(lo) - yOf(hi));
|
||||||
|
}
|
||||||
|
drawLayerBand('F2', 300, 500, '255, 180, 120');
|
||||||
|
drawLayerBand('F1', 150, 300, '255, 220, 140');
|
||||||
|
drawLayerBand('E', 90, 150, '140, 220, 255');
|
||||||
|
drawLayerBand('D', 60, 90, '235, 120, 140');
|
||||||
|
|
||||||
|
// distance grid
|
||||||
|
for (let d = 0; d <= distMax; d += 500) {
|
||||||
|
const x = xOf(d);
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, pY); ctx.lineTo(x, pY + pH); ctx.stroke();
|
||||||
|
text(ctx, d + ' km', x, pY + pH + 4, C.dim, 'center', 'top', 10);
|
||||||
|
}
|
||||||
|
for (const a of [100, 200, 300, 400]) {
|
||||||
|
const y = yOf(a);
|
||||||
|
text(ctx, a + ' km', pX - 4, y, C.dim, 'right', 'middle', 10);
|
||||||
|
}
|
||||||
|
// ground
|
||||||
|
ctx.fillStyle = '#2a2f3a';
|
||||||
|
ctx.fillRect(pX, pY + pH, pW, 2);
|
||||||
|
|
||||||
|
// tx at the left
|
||||||
|
const txX = xOf(0), txY = yOf(0);
|
||||||
|
ctx.fillStyle = C.cyan;
|
||||||
|
ctx.beginPath(); ctx.arc(txX, txY, 5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
pillLabel(ctx, 'TX', txX, txY - 14, C.cyan, 'center', 'bottom', 11);
|
||||||
|
|
||||||
|
// for each takeoff angle: find refraction altitude, draw the ray
|
||||||
|
const freq = s.values.freq;
|
||||||
|
const dAbs = dAbsorptionDb(freq, s.values.hour, s.values.sfu);
|
||||||
|
let minLanding = Infinity, maxLanding = 0;
|
||||||
|
|
||||||
|
const angles = [];
|
||||||
|
for (let a = 5; a <= 85; a += 5) angles.push(a);
|
||||||
|
|
||||||
|
for (const angDeg of angles) {
|
||||||
|
const ref = refractAt(freq, angDeg, s.values.hour, s.values.sfu);
|
||||||
|
const angRad = angDeg * Math.PI / 180;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = 1.6;
|
||||||
|
if (ref) {
|
||||||
|
// bent by layer at altitude ref.alt
|
||||||
|
const altKm = ref.alt;
|
||||||
|
// landing distance = 2*h/tan(angle) (flat earth approximation)
|
||||||
|
const land = 2 * altKm / Math.tan(angRad);
|
||||||
|
minLanding = Math.min(minLanding, land);
|
||||||
|
maxLanding = Math.max(maxLanding, land);
|
||||||
|
// ray path: up to (land/2, altKm), then down to (land, 0). Draw as two segments with a quadratic bend at the apex.
|
||||||
|
const midX = xOf(land / 2);
|
||||||
|
const topY = yOf(altKm);
|
||||||
|
const endX = xOf(Math.min(distMax, land));
|
||||||
|
// loss-tint
|
||||||
|
const loss = 2 * dAbs; // up + down
|
||||||
|
const alpha = Math.max(0.15, 1 - loss / 30);
|
||||||
|
const col = ref.layer === 'F2' ? C.amber : ref.layer === 'F1' ? C.yellow : C.cyan;
|
||||||
|
ctx.strokeStyle = `rgba(${hexToRgb(col)}, ${alpha})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(txX, txY);
|
||||||
|
ctx.quadraticCurveTo(midX, topY - 12, endX, yOf(0));
|
||||||
|
ctx.stroke();
|
||||||
|
// landing marker
|
||||||
|
if (land <= distMax) {
|
||||||
|
ctx.fillStyle = col;
|
||||||
|
ctx.beginPath(); ctx.arc(xOf(land), yOf(0) - 2, 2.5, 0, Math.PI * 2); ctx.fill();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// escapes to space: straight line continuing until it leaves the box
|
||||||
|
const dx = Math.cos(angRad), dy = Math.sin(angRad);
|
||||||
|
// compute end where y reaches pY
|
||||||
|
const tToTop = (yOf(0) - pY) / (dy * (pH / altMax)); // km along ground dir
|
||||||
|
const endKm = tToTop; // horizontal km traveled when reaching top of plot
|
||||||
|
const endX = xOf(endKm);
|
||||||
|
const endY = pY;
|
||||||
|
ctx.strokeStyle = 'rgba(229, 128, 137, 0.55)';
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath(); ctx.moveTo(txX, txY); ctx.lineTo(endX, endY); ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip zone shading
|
||||||
|
if (isFinite(minLanding) && minLanding < distMax) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(229, 128, 137, 0.1)';
|
||||||
|
ctx.fillRect(xOf(0), yOf(0), xOf(minLanding) - xOf(0), 2);
|
||||||
|
// hatch
|
||||||
|
ctx.strokeStyle = 'rgba(229, 128, 137, 0.2)';
|
||||||
|
for (let xk = 0; xk < minLanding; xk += 50) {
|
||||||
|
const x1 = xOf(xk); const x2 = xOf(Math.min(minLanding, xk + 30));
|
||||||
|
ctx.beginPath(); ctx.moveTo(x1, yOf(0) + 4); ctx.lineTo(x2, yOf(0) + 12); ctx.stroke();
|
||||||
|
}
|
||||||
|
pillLabel(ctx, `skip zone: 0 > ${Math.round(minLanding)} km`, xOf(minLanding / 2), yOf(0) + 20, C.red, 'center', 'middle', 10);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// readouts
|
||||||
|
const fF2 = critFreqMHz(layerDensity('F2', s.values.hour, s.values.sfu));
|
||||||
|
const mufLow = mufMHz(fF2, 5);
|
||||||
|
s.setReadout('muf', mufLow.toFixed(1) + ' MHz');
|
||||||
|
s.setReadout('skip', isFinite(minLanding) ? `${Math.round(minLanding)} – ${Math.round(Math.min(distMax, maxLanding))} km` : '(no skip)');
|
||||||
|
s.setReadout('abs', (dAbs * 2).toFixed(1) + ' dB (one hop)');
|
||||||
|
s.setReadout('band', bandName(freq));
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Scene 3: Band clock
|
||||||
|
// ============================================================
|
||||||
|
function sceneBandClock() {
|
||||||
|
const s = scene('scene-bandclock', {
|
||||||
|
height: 460,
|
||||||
|
controls: [
|
||||||
|
{ key: 'sfu', label: 'Solar flux (SFU)', min: 60, max: 300, value: 140, step: 1, format: v => v.toFixed(0) },
|
||||||
|
{ key: 'hour', label: 'Hour marker', min: 0, max: 24, value: 12, step: 0.1, format: v => {
|
||||||
|
const h = Math.floor(v); const m = Math.round((v - h) * 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
} },
|
||||||
|
],
|
||||||
|
readout: [
|
||||||
|
{ key: 'open', label: 'Open now (DX)' },
|
||||||
|
{ key: 'muf', label: 'F2 MUF now' },
|
||||||
|
],
|
||||||
|
caption: 'Every band, every hour of the day, under the current solar flux. Green rings are open for DX. Amber is local/short only. Red is where D-layer absorption is too expensive to use. Dark is closed. Spin the hour marker or change the sunspots and watch the pattern for the bands you actually operate.',
|
||||||
|
});
|
||||||
|
if (!s) return;
|
||||||
|
|
||||||
|
const bands = [
|
||||||
|
{ f: 1.9, name: '160 m' },
|
||||||
|
{ f: 3.7, name: '80 m' },
|
||||||
|
{ f: 7.1, name: '40 m' },
|
||||||
|
{ f: 14.2, name: '20 m' },
|
||||||
|
{ f: 21.2, name: '15 m' },
|
||||||
|
{ f: 28.4, name: '10 m' },
|
||||||
|
{ f: 50.2, name: '6 m' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function bandStatus(freq, hour, sfu) {
|
||||||
|
// compute MUF using F2 at low angle
|
||||||
|
const fF2 = critFreqMHz(layerDensity('F2', hour, sfu));
|
||||||
|
const muf = mufMHz(fF2, 5);
|
||||||
|
const dAbs = dAbsorptionDb(freq, hour, sfu) * 2;
|
||||||
|
if (freq > muf) return 'closed';
|
||||||
|
if (dAbs > 25) return 'absorbed';
|
||||||
|
if (freq > muf * 0.85 || freq < 2.5) {
|
||||||
|
// near MUF or low bands with some absorption: short/local
|
||||||
|
if (dAbs > 6) return 'local';
|
||||||
|
}
|
||||||
|
// strong DX if between LUF and 0.85 MUF
|
||||||
|
if (freq > muf * 0.3 && freq < muf * 0.85 && dAbs < 12) return 'dx';
|
||||||
|
if (dAbs > 14) return 'absorbed';
|
||||||
|
return 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const { w, h } = s.getSize();
|
||||||
|
const ctx = s.ctx;
|
||||||
|
clear(ctx, w, h);
|
||||||
|
|
||||||
|
const cx = w / 2, cy = h / 2;
|
||||||
|
const rOuter = Math.min(w, h) / 2 - 30;
|
||||||
|
const rInner = rOuter * 0.28;
|
||||||
|
const ringW = (rOuter - rInner) / bands.length;
|
||||||
|
|
||||||
|
// outer hour ring
|
||||||
|
for (let hr = 0; hr < 24; hr++) {
|
||||||
|
const a0 = -Math.PI / 2 + (hr / 24) * 2 * Math.PI;
|
||||||
|
const a1 = -Math.PI / 2 + ((hr + 1) / 24) * 2 * Math.PI;
|
||||||
|
const amid = (a0 + a1) / 2;
|
||||||
|
const tx = cx + Math.cos(amid) * (rOuter + 14);
|
||||||
|
const ty = cy + Math.sin(amid) * (rOuter + 14);
|
||||||
|
if (hr % 3 === 0) text(ctx, String(hr).padStart(2, '0'), tx, ty, C.dim, 'center', 'middle', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rings per band
|
||||||
|
for (let bi = 0; bi < bands.length; bi++) {
|
||||||
|
const band = bands[bi];
|
||||||
|
const r0 = rInner + bi * ringW;
|
||||||
|
const r1 = r0 + ringW - 3;
|
||||||
|
for (let hr = 0; hr < 24; hr++) {
|
||||||
|
const a0 = -Math.PI / 2 + (hr / 24) * 2 * Math.PI;
|
||||||
|
const a1 = -Math.PI / 2 + ((hr + 1) / 24) * 2 * Math.PI;
|
||||||
|
const status = bandStatus(band.f, hr + 0.5, s.values.sfu);
|
||||||
|
let color;
|
||||||
|
switch (status) {
|
||||||
|
case 'dx': color = '#9dca83'; break;
|
||||||
|
case 'local': color = '#d6a86a'; break;
|
||||||
|
case 'absorbed': color = '#8a4a52'; break;
|
||||||
|
default: color = '#2d323d';
|
||||||
|
}
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r1, a0, a1);
|
||||||
|
ctx.arc(cx, cy, r0, a1, a0, true);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
// ring border
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, r0, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.arc(cx, cy, r1, 0, Math.PI * 2); ctx.stroke();
|
||||||
|
}
|
||||||
|
// band labels stacked vertically at the left edge, one per ring.
|
||||||
|
// A short leader dot sits on each ring at the 9 o'clock position.
|
||||||
|
for (let bi = 0; bi < bands.length; bi++) {
|
||||||
|
const band = bands[bi];
|
||||||
|
const r0 = rInner + bi * ringW;
|
||||||
|
const rMid = r0 + ringW / 2;
|
||||||
|
const labelX = 12;
|
||||||
|
const labelY = cy - (bands.length - 1) * 6 + bi * 12;
|
||||||
|
text(ctx, band.name, labelX, labelY, C.fg, 'left', 'middle', 10);
|
||||||
|
// leader from label to ring
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(labelX + 30, labelY);
|
||||||
|
ctx.lineTo(cx - rMid - 2, cy + (labelY - cy) * 0.15);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// now marker
|
||||||
|
const hrNow = s.values.hour;
|
||||||
|
const ang = -Math.PI / 2 + (hrNow / 24) * 2 * Math.PI;
|
||||||
|
ctx.strokeStyle = C.cyan; ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + Math.cos(ang) * (rInner - 8), cy + Math.sin(ang) * (rInner - 8));
|
||||||
|
ctx.lineTo(cx + Math.cos(ang) * (rOuter + 6), cy + Math.sin(ang) * (rOuter + 6));
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
|
// legend
|
||||||
|
const legY = h - 18;
|
||||||
|
const items = [
|
||||||
|
{ c: '#9dca83', t: 'DX open' },
|
||||||
|
{ c: '#d6a86a', t: 'Local / marginal' },
|
||||||
|
{ c: '#8a4a52', t: 'Absorbed' },
|
||||||
|
{ c: '#2d323d', t: 'Closed' },
|
||||||
|
];
|
||||||
|
let lx2 = 10;
|
||||||
|
items.forEach(it => {
|
||||||
|
ctx.fillStyle = it.c; ctx.fillRect(lx2, legY, 10, 10);
|
||||||
|
text(ctx, it.t, lx2 + 14, legY + 5, C.dim, 'left', 'middle', 10);
|
||||||
|
lx2 += ctx.measureText(it.t).width + 32;
|
||||||
|
});
|
||||||
|
|
||||||
|
// center UTC label
|
||||||
|
text(ctx, 'hour', cx, cy - 6, C.dim, 'center', 'middle', 10);
|
||||||
|
const hn = Math.floor(hrNow); const mn = Math.round((hrNow - hn) * 60);
|
||||||
|
text(ctx, `${String(hn).padStart(2, '0')}:${String(mn).padStart(2, '0')}`, cx, cy + 8, C.fg, 'center', 'middle', 14);
|
||||||
|
|
||||||
|
// readouts
|
||||||
|
const openNow = bands.filter(b => bandStatus(b.f, hrNow, s.values.sfu) === 'dx').map(b => b.name);
|
||||||
|
s.setReadout('open', openNow.length ? openNow.join(', ') : '(none)');
|
||||||
|
const fF2 = critFreqMHz(layerDensity('F2', hrNow, s.values.sfu));
|
||||||
|
s.setReadout('muf', mufMHz(fF2, 5).toFixed(1) + ' MHz');
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
sceneLayers();
|
||||||
|
sceneRays();
|
||||||
|
sceneBandClock();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -58,17 +58,17 @@
|
||||||
{% set root_section = get_section(path="_index.md") %}
|
{% set root_section = get_section(path="_index.md") %}
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">/home/</a>
|
<a href="/">home</a>
|
||||||
|
|
||||||
{# Render subsections #}
|
{# Render subsections #}
|
||||||
{% for subsection_path in root_section.subsections %}
|
{% for subsection_path in root_section.subsections %}
|
||||||
{% set subsection = get_section(path=subsection_path) %}
|
{% set subsection = get_section(path=subsection_path) %}
|
||||||
<a href="{{ subsection.permalink }}">{{ subsection.permalink | replace(from=config.base_url, to="") }}</a>
|
<a href="{{ subsection.permalink }}">{{ subsection.permalink | replace(from=config.base_url, to="") | trim_start_matches(pat="/") | trim_end_matches(pat="/") }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{# Render pages at the root #}
|
{# Render pages at the root #}
|
||||||
{% for page in root_section.pages %}
|
{% for page in root_section.pages %}
|
||||||
<a href="{{ page.permalink }}">{{ page.permalink | replace(from=config.base_url, to="") }}</a>
|
<a href="{{ page.permalink }}">{{ page.permalink | replace(from=config.base_url, to="") | trim_start_matches(pat="/") | trim_end_matches(pat="/") }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue