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.servicePitfalls to avoid#
Don’t enable
ntpheatby 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_tempprofile 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.ntpheatis 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 FrequencyThe 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.