Extract common image_enc module at crate root with encode_grayscale_png
and encode_rgb_png helpers. Both NOAA APT and Meteor-M LRPT now use PNG
as the output format through the shared encoder. Drop jpeg image feature
dependency.
https://claude.ai/code/session_01JA13DHuzuHUL4nSBBRU83f
Signed-off-by: Claude <noreply@anthropic.com>
Restructure trx-wxsat into noaa/ (APT) and lrpt/ (Meteor-M LRPT) submodules
with shared crate base. Add QPSK demodulator, CCSDS CADU framer, MCU channel
assembler for LRPT. Wire LRPT through full stack (core types, protocol, server
decoder task, client). Add Weather Satellites sub-tab in Digital Modes with
toggle buttons for NOAA APT and Meteor LRPT, descriptions, and image history.
https://claude.ai/code/session_01JA13DHuzuHUL4nSBBRU83f
Signed-off-by: Claude <noreply@anthropic.com>
Rename the crate from trx-noaa to trx-wxsat (weather satellite) across
the entire workspace. Add full NOAA satellite decode support:
- Telemetry frame parsing: extract 16-wedge calibration data from the
128-line telemetry frames embedded in APT lines
- Radiometric calibration: piecewise-linear LUT built from wedges 1-8
to correct pixel values against known reference levels
- Channel identification: detect AVHRR sensor channels (VIS, NIR, MIR,
TIR) from wedge 9 values per APT sub-channel
- Satellite identification: heuristic NOAA-15/18/19 detection from
channel A/B sensor pairings
- Histogram equalisation: per-channel contrast enhancement for improved
image output
- WxsatImage now carries satellite name and channel labels in decoded
message broadcasts
https://claude.ai/code/session_01JA13DHuzuHUL4nSBBRU83f
Signed-off-by: Claude <noreply@anthropic.com>
New trx-noaa crate: FFT-based Hilbert transform (rustfft) for 2400 Hz
AM demodulation, sync A detection via cross-correlation, line assembly
at 4160 Hz, and JPEG output via the image crate.
- trx-core: NoaaImage type, DecodedMessage::NoaaImage variant,
noaa_decode_enabled/noaa_decode_reset_seq on RigState/RigSnapshot,
AUDIO_MSG_NOAA_IMAGE = 0x16
- trx-server: DecoderHistories::noaa, run_noaa_decoder task (activates
on noaa_decode_enabled, auto-finalises after 30 s silence), saves
JPEGs to ~/.cache/trx-rs/noaa/<YYYY-MM-DD_HH-MM-SS>.jpg, forwards
events over TCP audio channel and history replay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The closed-loop Gardner Timing Error Detector was causing decoder
freezes under real-world conditions. Remove all TED state and logic,
reverting to the simpler open-loop fixed clock_inc approach. The
8-candidate parallel architecture already provides adequate timing
coverage via phase offsets without needing closed-loop tracking.
All other improvements (adaptive Costas bandwidth, syndrome-based OSD,
OSD(3/4), PI LLR accumulation) are retained.
https://claude.ai/code/session_01FsK5hZWGpAaaCpmWupN5AD
Signed-off-by: Claude <noreply@anthropic.com>
The span-5 reduction passed synthetic tests because both the TX and RX
filters used the same truncated pulse shape (perfect matched filtering).
On real signals, the transmitter uses a full RRC pulse, and our truncated
RX filter couldn't match it — the weaker stopband rejection (~25% less
than pre-TED at α=0.30) allowed adjacent-channel interference through,
degrading soft confidence values and block decode rate, which caused
poor PS accumulation.
Span 10 at α=0.30 gives 50% better stopband rejection than the pre-TED
α=0.50/span=4 configuration, at the cost of 2048 vs 1024 FFT size.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
https://claude.ai/code/session_01Sw9esAuic8KHP1t8nZgvH2
Signed-off-by: Claude <noreply@anthropic.com>
Three root causes for the post-TED decode quality regression:
1. OSD(4) at cost ceiling 0.60 produced excessive false positives at
marginal SNR. Tightened to OSD(2)/0.45 baseline, OSD(3)/0.50 only
after 2+ successful groups.
2. Gardner TED activated after just 1 group (score >= 1), but a single
false OSD match could trigger timing adjustments that injected jitter
into soft values. Raised lock gate to score >= 3 so the TED only
engages after the candidate has proven itself on a real signal.
3. RRC filter span of 10 chips doubled FFT size to 2048 with negligible
sensitivity gain over span 5 at α=0.30 (sidelobes beyond ±2.5 chips
contribute <5% energy). Reduced to span 5 → FFT 1024, matching
pre-TED efficiency.
Additional optimizations (no quality impact):
- Syndrome-based OSD: replaces per-trial CRC recomputation with a single
XOR per trial (CRC linearity), and sorts bit positions by ascending
soft confidence so inner loops break early instead of continuing.
- Pre-allocated FFT scratch buffer: eliminates ~234 heap allocations/sec
in the overlap-save convolution.
- PI_ACC_THRESHOLD reduced from 8 to 5 for faster acquisition while
retaining reliable majority voting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
https://claude.ai/code/session_01Sw9esAuic8KHP1t8nZgvH2
Signed-off-by: Claude <noreply@anthropic.com>
The Gardner TED (Tech 11) caused PI instability and worse weak-signal
pickup due to three issues:
1. Loop gains too aggressive: noise×noise error products at low SNR
injected sub-chip jitter that degraded OSD soft confidence and PI
LLR accumulation. Reduced Kp from 4e-4→1.5e-4, Ki from 8e-8→2e-8
(loop BW 0.11→0.053 Hz).
2. TED active during acquisition: before any group is decoded, the
error signal is unreliable. Now lock-gated (score >= 1) so the
TED only engages after the first successful group decode, when
timing is already close. During acquisition, the 8-candidate
architecture with fixed clocks provides adequate timing coverage.
3. Slow power estimate convergence: ted_power_est took ~420 ms to
settle (0.999 alpha), causing the TED to over-steer during startup.
Now uses 0.995 alpha (~84 ms convergence).
Additionally, when TED is gated off, the integrator decays toward zero
so stale corrections from a previous strong-signal period don't persist.
https://claude.ai/code/session_01KcVUcQQXrFyFA9NEjLhr9J
Signed-off-by: Claude <noreply@anthropic.com>
After completing a group (Block D), the decoder dropped lock and
reverted to search mode which only uses hard CRC. On weak signals,
Block A frequently has bit errors that OSD could correct but hard
decode cannot, causing the decoder to freeze after 2-3 successful
groups. Stay locked with ExpectBlock::A so the next Block A benefits
from OSD soft decoding.
https://claude.ai/code/session_015Ds9dxpeyFimYHySBuzbFw
Signed-off-by: Claude <noreply@anthropic.com>
If the incumbent candidate has not produced a state update in 2 seconds,
clear its score advantage so any candidate can take over. This prevents
the decoder from "freezing" on stale data when the incumbent's timing or
carrier tracking degrades — particularly important for dynamic PS where
the station rotates program service text.
Signed-off-by: Claude <noreply@anthropic.com>
https://claude.ai/code/session_0136sPdLUpYgvskrzbi2Epkv
Signed-off-by: Claude <noreply@anthropic.com>
Fix Gardner TED loop structure bug (type-3 → type-2 PLL) and tune
gains for ζ=0.707 damping. Add adaptive Costas loop bandwidth that
narrows from ~22 Hz to ~5.5 Hz once carrier is locked, reducing phase
noise at low SNR. Narrow RRC matched filter (α=0.30, span=10 chips)
for ~0.6 dB noise BW gain. Add OSD(4) for locked-mode blocks after
first successful group, and increase PI accumulation threshold to 8.
TED bug details: the original code used `clock_inc += correction`
which added the full integrator value at every chip, creating an
extra integration (type-3 loop) that is unconditionally unstable.
Fixed to `clock_inc = nominal + correction` (standard type-2 PLL).
Gains retuned: Kp=4e-4, Ki=8e-8 for ζ≈0.707 and loop BW≈0.11 Hz.
Signed-off-by: Claude <noreply@anthropic.com>
https://claude.ai/code/session_0136sPdLUpYgvskrzbi2Epkv
Signed-off-by: Claude <noreply@anthropic.com>
- RRC span 4→6 chips: better ISI rejection and pulse energy capture
- PI_ACC_THRESHOLD 3→5: more Block A votes before committing PI at weak signal
- OSD(3): add C(26,3)=2600 triple-bit search under same cost gate as OSD(2)
- Tech 11 Gardner TED: closed-loop symbol timing PI loop per Candidate;
replaces open-loop NCO with mid-chip capture, power-normalised error signal,
anti-windup integrator, and ±1% pull-in range (±23.75 Hz at 2375 chips/s)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Add OSD_MAX_FLIP_COST (0.45) to reject OSD corrections where the flipped
bits had high confidence — a strong false-decode indicator. Genuine errors
at 9-10 dB SNR have cost ≲0.3; noise matches cost 0.6-1.2.
Add PI consistency gate in process_group: reject groups whose Block A PI
differs from the candidate's established PI, preventing noise from
polluting accumulated PS/RT/PTYN text fields.
Raise PI_ACC_THRESHOLD from 2 to 3 for stronger PI voting.
Extend noise rejection test from 0.5s to 2s. Add 9 dB SNR sensitivity
test (all 16 tests pass).
https://claude.ai/code/session_01GYax4BQ9ZV9ZZfMjmmzgbh
Signed-off-by: Claude <noreply@anthropic.com>
The WSPR decoder was producing many false positive decodes due to
several overly permissive thresholds that allowed noise to reach the
Fano sequential decoder, which could then converge on random data:
- Raise normalized sync score threshold from 0.10 to 0.20 to reject
noise candidates before attempting expensive Fano decoding
- Add minimum SNR gate (-20 dB) to skip candidates where the signal
is indistinguishable from noise
- Return and check the Fano decoder's cumulative path metric, rejecting
low-confidence decodes (metric < 20) that are likely noise artifacts
- Raise RMS threshold from 0.0005 to 0.005 to reject near-silent audio
- Add near-frequency deduplication to prevent the same signal decoded
at slightly different (freq, dt) offsets from appearing multiple times
- Add noise-only regression test to verify no false positives on random
input
https://claude.ai/code/session_01HTBoEsD1hp99TiYMSaHMVG
Signed-off-by: Claude <noreply@anthropic.com>
The `chips_to_rds_signal` test helper was generating rectangular chip
pulses, but the receiver expects RRC-shaped transmit pulses so that
RRC(tx) × RRC(rx) = raised cosine with zero ISI. The rectangular
pulses caused ISI that drifted the symbol clock sampling point,
consistently skipping PS segment 2 in the end-to-end test.
Replace rectangular pulses with an impulse train convolved with the
same RRC taps used by the receiver. All 15 tests now pass including
`end_to_end_clean_signal_decodes_ps`.
https://claude.ai/code/session_01N2UcGaLDzYiM3gNrZ6kFBj
Signed-off-by: Claude <noreply@anthropic.com>
- RRC_ALPHA 0.75→0.50: narrower noise BW, ~0.6 dB SNR gain
- COSTAS_KI 3.5e-7: maintain ζ≈0.68 (1e-6 caused loop instability)
- Soft confidence: use biphase_i.abs() instead of full vector magnitude
so OSD confidence is aligned with bit-decision sign; suppresses
false groups under noise with residual Costas phase error
- OSD(2) in locked mode: corrects ≤2-bit errors after block sync
- Search mode: hard decode only for Block A; OSD(1) in search yielded
~13% false Block A rate per bit, letting wrong clock candidates
accumulate false groups as fast as the correct candidate
- Incumbent candidate tracking (best_candidate_idx): the winning
candidate updates best_state at equal score; challengers need strictly
higher score; best_score tracks incumbent even on no-state-change
groups so challengers can't leapfrog on a single false group
- blocks_to_chips: add NRZI (NRZ-Mark) pre-encoding so the differential
biphase decoder recovers actual data bits rather than XOR-of-pairs
- Add blocks_to_chips_round_trips_all_groups test: verifies all 16 blocks
across 4 PS segments round-trip correctly without BPSK modulation
[fix](trx-backend-soapysdr): lower pilot lock threshold for weak-signal RDS
- PILOT_LOCK_THRESHOLD 0.25→0.20, add PILOT_LOCK_ONSET=0.30 constant
- Pilot reference engages at coherence ≥0.36 (was ≥0.45)
WIP: end_to_end_clean_signal_decodes_ps still failing (13/15 pass).
Decoder skips segment 2 due to ISI from rectangular test chips through
RRC receive filter. chips_to_rds_signal needs RRC pulse shaping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Add single-bit flip fallback in search mode (push_bit_soft) so Block A
can be acquired with one bit error, matching locked-mode OSD(1) behaviour
- Lower MIN_PUBLISH_QUALITY 0.38 -> 0.20 for earlier publish on noisy signals
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Replace FirFilter (ring-buffer FIR) with FftRrcFilter using overlap-save
FFT convolution; I and Q are processed together as a single complex FFT,
halving filter cost (~10x fewer operations than direct convolution)
- Reduce PHASE_CANDIDATES 16 -> 8 (reasonable, double the original)
- Lower MIN_PUBLISH_QUALITY 0.55 -> 0.38 (more permissive acquisition)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
- Downgrade OSD from distance-2 to distance-1 (removes 325-iteration
double-bit flip loop per block, main source of both false positives
and excess CPU)
- Reduce phase candidates from 8 to 4 (halves per-sample work)
- Raise MIN_PUBLISH_QUALITY from 0.45 to 0.65 (requires stronger
signal confidence before emitting decoded state)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Tech 1: replace one-pole baseband LPF with FIR RRC matched filter
(alpha=0.75, 4-chip span) — largest single measured improvement per
empirical comparison (gr-rds RRC vs plain FIR: 32/38 vs 18/38 stations).
Tech 2: 19 kHz pilot x3 -> 57 kHz coherent carrier reference via the
triple-angle formula; fed from the WFM pilot Costas PLL when
pilot_lock_level > 0.5, clearing to NCO fallback otherwise.
Tech 3/7/8: OSD(2) soft-decision block decoder replaces hard CRC check.
Per-bit soft magnitudes accumulated in Candidate::block_soft[26].
decode_block_soft() searches Hamming distance 0/1/2 (352 trials total)
and returns the minimum Euclidean-cost valid codeword; ~2-3 dB gain.
Tech 4: 8th-order 57 kHz BPF (4 cascaded biquads at Q=5) in wfm.rs
replaces the previous single Q=10 biquad; ~6x steeper ACI stopband.
Tech 5: Costas loop with tanh soft phase detector drives the RDS carrier
NCO when no pilot reference is available (P+I, B_L ~20 Hz).
Tech 6: Block A PI field LLR accumulation — signed per-bit LLR summed
over 3 independent Block A observations before committing the PI value,
correcting weak-signal false locks without delaying strong-signal lock.
Tech 9: 8-tap complex CMA blind equalizer applied to IQ samples before
FM discrimination; constant-modulus error (|y|^2 - R^2) drives tap
adaptation without a training sequence, suppressing adjacent-channel
interference at the source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The map module was tagging all decode markers (APRS, AIS, VDES,
FT8/FT4/FT2/WSPR locators) with the global rig picker's active rig
instead of the actual source rig. This made the map's own rig filter
dropdown ineffective in multi-rig setups.
- Add rig_id field to all decode message structs (AisMessage,
VdesMessage, AprsPacket, CwEvent, Ft8Message, WsprMessage)
- Set rig_id on messages in audio_client before broadcasting, using
the actual rig connection identifier
- Update history collector to prefer message rig_id over the global
active rig fallback
- Pass rig_id through plugin normalize functions (AIS, APRS, VDES,
HF-APRS) so it reaches the map add functions
- Update all map marker functions (aprsMapAddStation, aisMapAddVessel,
vdesMapAddPoint, mapAddLocator) to use the message's rig_id with
fallback to the global picker for backward compatibility
https://claude.ai/code/session_015gC7axHk2jmp7HbFPdbivN
Signed-off-by: Claude <noreply@anthropic.com>
Reflect the common/ft8/ft4/ft2 directory reorganization in the
architecture diagram, file tree, and signal flow description.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Move ft2_encode from ft4/ to ft2/ where it belongs. Remove all
module-level #[allow] suppressions and fix the underlying issues:
- Remove dead code: wf_mag_at, xor_rows, unused Monitor IFFT fields, OsdBox.size
- Gate encode174_to_bits with #[cfg(test)] (only used in tests)
- Convert 40+ C-style index loops to idiomatic iterators
- Add targeted #[allow(clippy::too_many_arguments)] on two OSD functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Move ft2/osd.rs, ft2/bitmetrics.rs, ft2/downsample.rs, ft2/sync.rs
out of the ft2/ directory into src/ as top-level modules. Convert
ft2/mod.rs to ft2.rs. Update all imports from super:: to crate::ft2::.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace FTX_CRATE.md with README.md documenting upstream origins
(kgoba/ft8_lib for FT8/FT4, iu8lmc/Decodium-3.0 for FT2) and a
Mermaid diagram of the crate architecture.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Remove normalize_llr which was undoing the scalefac=2.83 scaling,
causing LLRs to be 2.83x too small for the BP+OSD decoder. Align
sync thresholds with reference: coarse 0.50->0.40, decode 0.65->0.55,
sync quality 10->9, maxosd 3->4. Revert norm_sqr back to norm in
bitmetrics since the metric difference is nonlinear.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace Vec<Vec<f32>> with flat stack arrays in ldpc_decode (~114KB),
convert 19+ Vec allocations to stack arrays in osd174_91, eliminate
per-call temp Vec in nextpat91 via in-place mutation, and replace
norm() with norm_sqr() in bitmetrics hot loop (~5.4M calls/frame).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Eliminate duplicated code between FT2 and FT8/shared modules:
- Share parity8() from encode.rs, remove copies in ft2/mod.rs and osd.rs
- Share pack_bits() from decode.rs, remove pack_bits91() from osd.rs
- Add verify_crc_and_build_message() to decode.rs, used by both FT8 and FT2
- Add normalize_llr() to decode.rs, replacing per-module normalization
- Make encode174() pub(crate), add encode174_to_bits() for bit-array output
- Wire FT2 decode_hit to use full BP+OSD decoder from osd.rs instead of
separate BP + sum-product + OSD-lite flow
- Align LLR scale factor to 2.83 matching reference implementation
Net -178 lines removed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
add() and lookup() had no wrap-around guard in their linear-probe loops.
Once 256 unique callsigns filled the table, any subsequent add or lookup
for an absent hash would cycle through all 256 slots forever, hanging the
FT8 decoder task permanently inside block_in_place. On a busy band this
could happen within a few minutes of operation.
- add(): evict the probe-start slot when a full cycle completes
- lookup(): return None after a full probe cycle
- reset(): call cleanup(10) each slot boundary to age out stale entries
- Add regression tests for both infinite-loop scenarios
Also includes cargo fmt reformatting of pre-existing style issues.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Reuse FT2 downsample and bitmetric work buffers, speed up\nsync2d_score with precomputed references, and cache peak-search\nFFT state on the pipeline.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Delete the obsolete ft8_lib submodule and update documentation to point at the pure Rust trx-ftx decoder.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Normalize tracked SPDX headers to the 2026 Stan Grams identity.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Quiet compiler and clippy warnings in the translated decoder modules.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Delete trx-ft8 (C wrapper around ft8_lib + ft2_ldpc) and update
trx-server to depend on trx-ftx (pure Rust) directly. Removes
~2,900 lines of C code and all unsafe FFI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Replace the C FFI-based trx-ft8 with a pure Rust implementation
supporting FT8, FT4, and FT2 protocols. Eliminates cc/libc build
dependencies and all unsafe FFI code while providing the same
Ft8Decoder/Ft8DecodeResult public API.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Restrict accepted power levels to the 19 valid WSPR values instead of
any 0-60. Require a digit at position 1 or 2 of the trimmed callsign
per the WSPR encoding rules. Skip candidates whose sync correlation
score falls below a minimum threshold before attempting Fano decode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
The WSPR 7-bit power field contains the raw dBm value (0-60) with no
offset. The decoder was subtracting 64, turning valid power values into
negative numbers that always failed the range check, causing
unpack_message to return None for every real signal. Also fix callsign
trimming to strip leading spaces from space-padded callsigns.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
The deinterleave function had its indices swapped: it wrote
out[j] = symbols[p] instead of out[p] = symbols[j]. This fed
completely scrambled data to the Fano decoder, making convergence
impossible. Matched against the reference implementation in
raptor/lib/wsprd/wsprd_utils.c which does tmp[p] = sym[j].
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
The OSD-lite decoder was the source of FT2 false positives. It tries
685 CRC-14 checks across 5 passes (1 + 16 + 120 per pass), giving a
~4% chance of accepting random noise as a valid decode.
The reference implementation (decode174_91) verifies OSD results
against the received signal; the trx-rs OSD-lite only checked CRC.
Add ft2_count_hard_errors_vs_llr() which counts how many of the 174
coded bits in an OSD candidate disagree with the received hard
decisions. A legitimate correction disagrees in very few positions;
a false CRC match on noise disagrees in ~40-50 parity positions.
Reject OSD results with more than 36 hard errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>