Active thermal stabilization#

Why#

The quartz oscillator (XO, uncompensated) that clocks the system drifts by roughly 0.1 ppm per degree Celsius. If the enclosure’s temperature swings freely between 35 and 50°C depending on CPU load, that variation alone induces about 1.5 ppm of drift that chrony has to continuously compensate for, avoidable noise if temperature is kept stable. The next chapter places this work in the oscillator hierarchy (XO, TCXO, OCXO, rubidium, CSAC) and details how the combined steps of this guide turn this bare crystal into a software pseudo-TCXO.

How#

Default approach: fan-based regulation#

The Firmware chapter already set up a 4-tier PWM fan profile (fan_temp0 through fan_temp3 in config.txt), kicking in at 40°C. This is the passive approach used by default: it stabilizes temperature without permanent power draw, unlike the alternative below.

Optional alternative: controlled CPU load (ntpheat)#

A more aggressive approach maintains an artificial, controlled CPU load on core 0 to actively stabilize temperature at a fixed setpoint (typically 57°C), regardless of the system’s actual load.

The script below comes from the ntpheat utility of the NTPsec project (contrib/ntpheat, written by Gary E. Miller, BSD-2-Clause license), in the copy distributed by David Taylor on satsignal.eu, slightly adapted here:

mkdir -p /opt/ntpheat
cat > /opt/ntpheat/ntpheat.py <<'PYEOF'
#!/usr/bin/env python3
# Controlled CPU load to maintain a temperature setpoint
# Origin: NTPsec project, contrib/ntpheat (Gary E. Miller,
# BSD-2-Clause license), via David Taylor's satsignal.eu copy.
import argparse, hashlib, os, time
p = argparse.ArgumentParser()
p.add_argument('-c','--copies', type=int, default=1)
p.add_argument('-t','--temp',   type=float, default=57.0)
p.add_argument('-w','--wait',   type=float, default=0.001)
a = p.parse_args()
for _ in range(a.copies - 1):
    if os.fork(): break
zone = '/sys/class/thermal/thermal_zone0/temp'
m = hashlib.md5(); cnt = 0; max_cnt = a.wait * 200000; temp = 0
while True:
    delta = a.temp - temp
    if delta > 0:
        m.update(b'controlled load')
    else:
        cnt = max_cnt
        s = max(0.001, a.wait * 10.0 * -delta); time.sleep(s)
    cnt += 1
    if cnt > max_cnt:
        with open(zone) as f: temp = int(f.read())/1000.0
        cnt = 0
PYEOF
chmod +x /opt/ntpheat/ntpheat.py

cat > /etc/systemd/system/ntpheat.service <<'EOF'
[Unit]
Description=Active thermal stabilization (quartz)

[Service]
ExecStart=/usr/bin/python3 /opt/ntpheat/ntpheat.py -c 1 -t 57
Restart=on-failure
CPUAffinity=0
Nice=19
CPUSchedulingPolicy=idle
IOSchedulingClass=idle

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now ntpheat.service

Pitfalls to avoid#

Don’t enable ntpheat by default, observe first. This section is deliberately marked optional. The cost is real: roughly 1 W of permanent power draw, increased thermal wear, constant fan noise. This guide’s default recommendation is to skip this step and re-evaluate after 24-48h of observation via Munin (Munin chapter): enabling it is only justified when frequency variations correlated with temperature exceed 1 ppm, or root dispersion stays above 20 µs despite a working PPS.

  • Confusing passive and active regulation. The fan_temp profile from the Firmware chapter is reactive regulation (the fan kicks in as temperature rises, backs off as it falls), temperature still oscillates, just within a tighter range. ntpheat is active regulation targeting a fixed setpoint at all times, more stable, but with the cost mentioned above.
  • Forgetting CPUSchedulingPolicy=idle. Without this directive, the thermal stabilization load would compete with the system’s real services for CPU time, instead of only consuming otherwise-wasted cycles.

Verify#

# Current temperature (in millidegrees Celsius)
cat /sys/class/thermal/thermal_zone0/temp

# Frequency drift history, observe over several hours
chronyc tracking | grep Frequency

The right verification method for this chapter isn’t instantaneous: it requires watching the Munin temperature and frequency graphs (Munin chapter) over at least 24h, looking for a visual correlation between the day/night cycle (ambient temperature) and the frequency drift reported by chrony.