---
title: "RF Signal Generator Simulation: AM, FM and PM"
description: "RF signal generator simulation for test automation: carrier and level ranges, exact AM, Bessel FM and PM sidebands, list and step sweeps, and REF_OUT timebase."
url: https://galoislabs.ai/blog/rf-signal-generator-modulation
author: Alex Hernandez
author_url: https://galoislabs.ai/blog/authors/alex-hernandez
published: "2026-10-08"
topic: Instrument automation
publisher: Galois Labs
---

# RF signal generator simulation: AM, FM and PM sidebands, sweeps and a measurable timebase

![A signal generator cabled to a spectrum analyzer whose display shows five falling peaks above the noise floor, a marker on the fourth.](https://galoislabs.ai/blog/figures/sim-3.light.webp)

*FIG. 1 — GENERATOR INTO ANALYZER, FIVE PEAKS*

To simulate an RF signal generator for test automation, put a simulated generator on a virtual bench and measure it with a simulated spectrum analyzer. AM is a carrier plus two sidebands, FM and PM are Bessel sidebands cut at Carson's rule, and the analyzer's markers read each line against a closed form.

EdgeSim is the open-source bench simulator from Galois Labs: simulated instruments wired into simulated benches that PyVISA scripts, pytest, galois-edge and AI agents can't tell from real hardware. EdgeSim's open-source release, with the `edgesim` package, is coming soon; every code block below was run on EdgeSim 0.2.0 with Python 3.13 on October 7, 2026, on a bench file built for this guide. The generator is one virtual instrument for the whole class, not a copy of one model. The sources are EdgeSim's generator profile, its behavior module and the semantics contract; nothing here was measured on a real generator. The analyzer half of the bench is covered in [simulate a spectrum analyzer for test automation](https://galoislabs.ai/blog/spectrum-analyzer-simulation), and [the EdgeSim announcement](https://galoislabs.ai/blog/edgesim-open-source-bench-simulator) places both in the wider set. Évariste, the agent in the Galois platform, does the same work in [a later section](https://galoislabs.ai/blog/rf-signal-generator-modulation#how-do-i-run-this-in-galois-with-évariste).

## What does the simulated generator produce?

| Feature          | Range or rule                                                                               |
| ---------------- | ------------------------------------------------------------------------------------------- |
| Carrier          | 9 kHz to 6 GHz, `:SOURce:FREQuency`                                                         |
| Level            | -130 to +20 dBm, `:SOURce:POWer`                                                            |
| AM               | Depth 0 to 100 percent, internal rate 0.1 Hz to 1 MHz; exact                                |
| FM               | Deviation 0 to 10 MHz, rate 0.1 Hz to 1 MHz; Bessel lines to Carson's rule                  |
| PM               | Deviation 0 to 100 rad, rate 0.1 Hz to 1 MHz; Bessel lines to Carson's rule                 |
| Sweeps           | Step (start, stop, points, linear or log) and list (up to 1601 points), stepped by commands |
| Timebase         | 10 MHz, internal or locked to REF_IN within 10 ppm; REF_OUT carries its error               |
| Pulse modulation | Refused with -221                                                                           |

**Level is power into a matched load.** The dBm setting refers to a matched 50 Ω load. RF_OUT carries the open-circuit voltage behind a 50 Ω output resistance, so an unterminated, high-impedance input reads 6.02 dB above the setting. The analyzer's 50 Ω input is matched, so a 0 dBm setting reads 0 dBm. The leveling is ideal: no droop, no settling.

**The carrier is never sampled.** The output is a descriptor built from sine components. AM, FM and PM add lines to it, so an analyzer that works on lines reads them exactly. A DMM or scope measures a multi-line signal over a 1 ms window, so their RMS reading is exact only when the window holds whole modulation periods (a rate that is a multiple of 1 kHz). The behavior module documents that as a limit of the World's measurement window, not of the generator, and it is one reason to prove modulation on the analyzer.

## Which bench wires the generator to the analyzer?

The bench is a topology file plus `ext.sim` bags. The generator's RF_OUT goes to the analyzer's RF_IN and its REF_OUT to the analyzer's REF_IN, with the generator's timebase 2 ppm fast. `timebase.offset_ppm` is latent: no command reports it, and only a counter or analyzer shows it.

```yaml title="rfgen-specan.bench.yaml (excerpt)"
nodes:
  - id: inst-rfgen-sim1
    instrumentId: sim-rfgen-1
    ext:
      sim:
        profile: galois_sim-rfgen-1
        state: {frequency.cw: 1.0e9, power.level: 0.0, output.enabled: true, timebase.offset_ppm: 2.0}
  - id: inst-specan-sim1
    instrumentId: sim-specan-1
    ext:
      sim:
        profile: galois_sim-specan-1
        state: {frequency.center: 1.0e9, frequency.span: 5.0e6, amplitude.ref_level: 10.0}
edges:
  - {id: e-rf,  source: inst-rfgen-sim1, target: inst-specan-sim1, ext: {sim: {sourcePort: RF_OUT,  targetPort: RF_IN}}}
  - {id: e-ref, source: inst-rfgen-sim1, target: inst-specan-sim1, ext: {sim: {sourcePort: REF_OUT, targetPort: REF_IN}}}
```

The excerpt drops positions, labels and handles. Validate the full file, then open it in process with a small helper that sends SCPI to a named instrument and moves nothing else:

```sh title="shell"
edgesim validate rfgen-specan.bench.yaml; echo "exit $?"
```

```text title="output"
exit 0
```

```python title="rig.py"
from contextlib import contextmanager

import numpy as np

import edgesim

SA, RF = "sim-specan-1", "sim-rfgen-1"


class Rig:
    def __init__(self, world):
        self.world = world

    def ask(self, cmd: str, inst: str = SA) -> str:
        reply = self.world.scpi(inst, cmd)
        return "" if reply is None else reply.decode().strip()

    def num(self, cmd: str, inst: str = SA) -> float:
        return float(self.ask(cmd, inst))

    def errors(self, inst: str = SA) -> list[str]:
        out = []
        while not (e := self.ask("SYST:ERR?", inst)).startswith("0,"):
            out.append(e)
        return out


@contextmanager
def open_rig(bench: str):
    with edgesim.open_bench(bench) as world:
        yield Rig(world)
```

Each instrument keeps its own error queue, so `errors(RF)` reads the generator's and `errors()` the analyzer's. The analyzer is set to a 5 MHz span around 1 GHz: 1001 points 5 kHz apart, so offsets of 1 and 2 MHz land on points, an auto RBW of 30 kHz, and a reference level of +10 dBm, which makes the auto attenuation 20 dB.

## How do AM, FM and PM appear on the analyzer?

The closed forms are short. With carrier A and modulation source x(t) = sin(2π·f_m·t):

- **AM** is A(1 + m·x(t)) sin(ω_c·t): exactly the carrier plus two sidebands of A·m/2 at f_c ± f_m. At m = 0.3 each sideband sits 20·log10(0.15) = -16.478 dBc.
- **PM** with deviation d radians has lines at f_c + n·f_m of amplitude A·J_n(d), where J_n is the Bessel function of the first kind.
- **FM** with deviation Δf has the same lines with β = Δf / f_m. At β = 1, J₀ = 0.7652, J₁ = 0.4401 and J₂ = 0.1149, which are -2.325, -7.130 and -18.793 dBc.

An analyzer reads magnitudes, so FM and PM at equal β are identical on it. The generator's lines differ in phase (FM carries φ − nπ/2), and that needs a demodulator, not a swept analyzer. `lines.py` walks the marker over the strongest peaks of each modulation and compares them with those formulas:

```python title="lines.py"
import math

import numpy as np

from rig import RF, open_rig

CENTER_HZ = 1e9


def bessel_j(n: int, beta: float) -> float:
    """J_n(beta) by its integral, J_n = (1/pi) * integral_0^pi cos(n t - beta sin t) dt (no scipy needed)."""
    t = np.linspace(0, math.pi, 20001)
    return float(np.trapezoid(np.cos(n * t - beta * np.sin(t)), t) / math.pi)


def dbc(amplitude: float) -> float:
    return 20 * math.log10(max(abs(amplitude), 1e-30))


def measure(r, count: int) -> list[tuple[float, float]]:
    """Walk the marker over the `count` strongest peaks: (offset from the carrier in Hz, level in dBm), by frequency."""
    found = []
    for i in range(count):
        r.ask(":CALC:MARK:MAX" if i == 0 else ":CALC:MARK:MAX:NEXT")
        found.append((r.num(":CALC:MARK:X?") - CENTER_HZ, r.num(":CALC:MARK:Y?")))
    return sorted(found)


def show(label: str, got, want) -> None:
    print(label)
    print("   offset Hz    measured dBc   closed form dBc   difference")
    for (x, y), (_, wy) in zip(got, want):          # the carrier is 0 dBm, so dBm and dBc agree
        print(f"  {x:+9.0f}   {y:+11.3f}   {wy:+14.3f}   {y - wy:+10.3f}")


def main() -> None:
    with open_rig("rfgen-specan.bench.yaml") as r:
        r.ask(":SENS:ROSC:SOUR EXT")                      # share the generator's timebase through REF_OUT
        print(f"center {r.num(':SENS:FREQ:CENT?') / 1e9:.3f} GHz, span {r.num(':SENS:FREQ:SPAN?') / 1e6:.0f} MHz, "
              f"RBW {r.num(':SENS:BAND:RES?') / 1e3:.0f} kHz, {r.ask(':SENS:SWE:POIN?')} points (5 kHz apart), "
              f"attenuation {r.num(':SENS:POW:RF:ATT?'):.0f} dB, overload {r.ask(':SENS:POW:RF:OVER?')}")
        r.ask(":CALC:MARK:PEAK:THR -60")                  # the floor is near -85 dBm; keep it out of the peak list

        # AM: carrier plus two sidebands of m/2 each, at the modulation rate
        m, fm = 0.30, 1e6
        r.ask(f":SOUR:AM:DEPT {100 * m}; :SOUR:AM:INT:FREQ {fm}; :SOUR:AM:STAT ON", RF)
        print(f"AM on: overload {r.ask(':SENS:POW:RF:OVER?')}")
        show("AM 30 % at 1 MHz", measure(r, 3), [(-fm, dbc(m / 2)), (0.0, 0.0), (fm, dbc(m / 2))])
        r.ask(":SOUR:AM:STAT OFF", RF)

        # FM and PM: Bessel lines J_n(beta), orders to Carson's rule, floor(beta) + 1
        beta = 1.0
        want = [(n * fm, dbc(bessel_j(n, beta))) for n in range(-2, 3)]
        r.ask(f":SOUR:FM:DEV {beta * fm}; :SOUR:FM:INT:FREQ {fm}; :SOUR:FM:STAT ON", RF)
        show("FM, deviation 1 MHz at 1 MHz (beta = 1)", measure(r, 5), want)
        r.ask(":SOUR:FM:STAT OFF", RF)
        r.ask(f":SOUR:PM:DEV {beta}; :SOUR:PM:INT:FREQ {fm}; :SOUR:PM:STAT ON", RF)
        show("PM, deviation 1 rad at 1 MHz (beta = 1)", measure(r, 5), want)
        r.ask(":SOUR:PM:STAT OFF", RF)
        print(r.errors(), r.errors(RF))


if __name__ == "__main__":
    main()
```

```text title="output of python lines.py"
center 1.000 GHz, span 5 MHz, RBW 30 kHz, 1001 points (5 kHz apart), attenuation 20 dB, overload 0
AM on: overload 0
AM 30 % at 1 MHz
   offset Hz    measured dBc   closed form dBc   difference
   -1000000       -16.478          -16.478       +0.000
         +0        +0.000           +0.000       +0.000
   +1000000       -16.478          -16.478       +0.000
FM, deviation 1 MHz at 1 MHz (beta = 1)
   offset Hz    measured dBc   closed form dBc   difference
   -2000000       -18.793          -18.793       +0.000
   -1000000        -7.130           -7.130       -0.000
         +0        -2.325           -2.325       +0.000
   +1000000        -7.130           -7.130       -0.000
   +2000000       -18.793          -18.793       -0.000
PM, deviation 1 rad at 1 MHz (beta = 1)
   offset Hz    measured dBc   closed form dBc   difference
   -2000000       -18.793          -18.793       +0.000
   -1000000        -7.130           -7.130       -0.000
         +0        -2.325           -2.325       +0.000
   +1000000        -7.130           -7.130       -0.000
   +2000000       -18.793          -18.793       -0.000
[] []
```

The differences are zero to the digits shown. AM is exact by construction, and the Bessel lines are the closed form the generator itself uses, so the check proves that the code finds the right lines, in the right order and units, and that the bench is wired the way the script assumes. A real generator and analyzer add level error, and the hardware version of this check carries a tolerance.

**The reference level matters with AM.** AM adds m²/2 to the total power: a 0 dBm carrier at 30 percent carries 1.045 times the power, +0.19 dBm. At the analyzer's default reference level of 0 dBm the auto attenuation is 10 dB, which leaves -9.81 dBm at the first mixer against a limit of -10 dBm, and the `overload` flag reads 1. In a run at that setting it read 0 unmodulated, 1 with 30 percent AM and 0 with FM at β = 5, whose envelope is constant. The bench sets +10 dBm, so the attenuation is 20 dB and the flag stays 0. A script that measures modulated signals should read `:SENSe:POWer:RF:OVERload?` after turning the modulation on.

![EdgeSim console instrument screen for the spectrum analyzer centered on 1.000 GHz with a 5 MHz span, 30 kHz RBW and the header clock running. A SPECTRUM box draws a single line, the carrier, above a noise floor, with the marker diamond on it. A stream of AM depth commands raises the depth to 30 percent at a 1 MHz rate, and two sidebands rise out of the floor at plus and minus 1 MHz. A peak search and a level query put the marker on the carrier, which reads 0.00 dBm. A next-peak search moves the diamond to 1.001 GHz and the readout to -16.48 dBm; a second moves it to 999.000 MHz, again -16.48 dBm. The readout and the last log line are outlined in amber.](https://galoislabs.ai/blog/rf-signal-generator-modulation/rfgen-am-sidebands-poster.webp)

*FIG. 2 — Analyzer: AM sidebands by marker*

With AM at 1 MHz rising to 30 percent on a 1 GHz carrier, two sidebands grow out of the floor, a peak search reads 0.00 dBm, and each next-peak search lands 1 MHz away, at 1.001 GHz and 999.000 MHz, reading -16.48 dBm in both. [Watch the video (MP4)](https://galoislabs.ai/blog/rf-signal-generator-modulation/rfgen-am-sidebands.mp4)

The clip starts from the bare carrier on the analyzer's SPECTRUM display, locked to REF_OUT with the peak threshold at -60 dBm. It raises the AM depth to 30 percent one command per frame, so the sidebands rise out of the floor, and then walks the marker over the three lines.

## How is the sideband set truncated, and how large is the error?

FM and PM have infinitely many lines. The generator keeps orders |n| ≤ floor(β) + 1 on each side, a bandwidth of 2·f_m·(β + 1), which is Carson's rule. The omitted power is 1 minus the sum of J_n² over the kept orders, and the behavior module states its bound: it never exceeds about 4.1 percent of the carrier power, -13.9 dB, for any β. The measured supremum is 0.04098, reached as β approaches 6 from below, where the rule has kept only six orders. In the time domain the waveform error is bounded by A times the sum of the omitted |J_n|. `limits.py` prints the omitted power from the module's own function, then looks for the line the truncation removed:

```python title="limits.py (first half)"
import math

from edgesim.behaviors.rfgen import CARSON_OMITTED_POWER_MAX, carson_sidebands, omitted_power_fraction

from lines import bessel_j, dbc
from rig import RF, open_rig

print("beta   orders each side   omitted power   omitted dB")
for beta in (0.5, 1.0, 2.0, 5.99, 6.0, 10.0):
    f = omitted_power_fraction(beta)
    print(f"{beta:5.2f}  {carson_sidebands(beta):16d}   {f:13.5f}   {10 * math.log10(f):+10.2f}")
print(f"documented bound over every beta: {CARSON_OMITTED_POWER_MAX:.4f} of the carrier power "
      f"({10 * math.log10(CARSON_OMITTED_POWER_MAX):+.1f} dB)")

with open_rig("rfgen-specan.bench.yaml") as r:
    r.ask(":SENS:ROSC:SOUR EXT"); r.ask(":CALC:MARK:PEAK:THR -60")

    # Truncation at beta = 1: five lines, then nothing above the floor. The exact set has a third order at -34 dBc.
    r.ask(":SOUR:FM:DEV 1e6; :SOUR:FM:INT:FREQ 1e6; :SOUR:FM:STAT ON", RF)
    for i in range(6):
        r.ask(":CALC:MARK:MAX" if i == 0 else ":CALC:MARK:MAX:NEXT")
    print(f"sixth peak above -60 dBm: {r.errors()}; the exact J3(1) would sit at {dbc(bessel_j(3, 1.0)):+.1f} dBc")

    # The carrier null: J0(2.404826) = 0
    r.ask(":SOUR:FM:DEV 2404826", RF)
    r.ask(":CALC:MARK:X 1e9")
    print(f"beta 2.404826: carrier marker {r.num(':CALC:MARK:Y?'):+.1f} dBm (the floor is near -85 dBm)")
    r.ask(":SOUR:FM:STAT OFF", RF)
```

```text title="output of python limits.py (first half)"
beta   orders each side   omitted power   omitted dB
 0.50                 1         0.00189       -27.24
 1.00                 2         0.00078       -31.09
 2.00                 3         0.00241       -26.17
 5.99                 6         0.04035       -13.94
 6.00                 7         0.00739       -21.31
10.00                11         0.01004       -19.98
documented bound over every beta: 0.0410 of the carrier power (-13.9 dB)
sixth peak above -60 dBm: ['-200,"Execution error"']; the exact J3(1) would sit at -34.2 dBc
beta 2.404826: carrier marker -85.1 dBm (the floor is near -85 dBm)
```

**The bound is a power bound, not a level bound.** At β = 1 the generator drops J₃ and everything above it, 0.078 percent of the power. The exact third order would read -34.2 dBc, well above the -60 dBm threshold, so a sixth peak would exist. The marker search finds none and queues -200, which is the proof that the set stops at order 2. A check that compares a measured spectrum with the full Bessel series must allow for the lines the rule removes, or limit itself to the orders it keeps.

**The worst case is just under an integer.** At β = 5.99 six orders are kept and 4.0 percent of the power is omitted (-13.94 dB); at β = 6.00 the rule keeps seven and the omitted power falls to 0.7 percent. A test that sweeps β across an integer sees that step in the sidebands' sum, and it is the rule at work.

**The carrier null works.** J₀(2.404826) = 0, so at 1 MHz rate and 2.404826 MHz deviation the carrier disappears: the marker at 1 GHz reads -85.1 dBm, the analyzer's floor. A carrier null is a classic way to calibrate an FM deviation setting, and the model reproduces it.

The clip glides the deviation from 0 to 2.404826 MHz at the same 1 MHz rate, one command per frame, with the marker on the carrier. The sidebands rise, the second-order pair appears as the deviation passes 1 MHz, where the set grows to order 2, and the carrier falls with J₀ until the marker reads the floor, about -85 dBm and different on every sweep:

![EdgeSim console instrument screen for the spectrum analyzer centered on 1.000 GHz with a 5 MHz span and the header clock running. A SPECTRUM box draws one line, the carrier, with the marker diamond on it and a MKR readout of 0.00 dBm. A command turns FM on at a 1 MHz rate, and a stream of deviation commands, one per frame, raises the deviation to 2.404826 MHz over three seconds. Sidebands at plus and minus 1 MHz rise, a second pair at plus and minus 2 MHz appears, and the carrier's line falls until it is gone; the amber-outlined readout falls from 0.00 dBm to the noise floor, near -85 dBm.](https://galoislabs.ai/blog/rf-signal-generator-modulation/rfgen-fm-carrier-null-poster.webp)

*FIG. 3 — Analyzer: FM glide nulls the carrier*

With the marker on the carrier, the FM deviation glides from 0 to 2.404826 MHz at a 1 MHz rate, one command per frame, and the carrier readout falls from 0.00 dBm to the noise floor near -85 dBm as the sidebands grow. [Watch the video (MP4)](https://galoislabs.ai/blog/rf-signal-generator-modulation/rfgen-fm-carrier-null.mp4)

**The line budget is a cap with an error.** The World drives a signal of more than 1000 components as open, so the generator keeps at most 999 lines per output and refuses a setting that does not fit. A β of 10⁸ (10 MHz deviation at a 0.1 Hz rate) is refused with -222, and the FM state stays off.

## How do list and step sweeps work?

The output sits at one sweep point, set by `:SOURce:LIST:MANual` (1-based), when `:SOURce:FREQuency:MODE` is SWEep or LIST. `:INITiate` arms the sweep at point 1 and each `:TRIGger` steps to the next; it holds at the last point. The dwell is stored and sets the reported sweep time, but a free-running sweep is not simulated. Sweeps are stepped by commands, which is how an automated test steps them anyway.

A list is a table of up to 1601 frequencies. The unquoted comma form real instruments take (`:LIST:FREQ 1e9,2.4e9`) is rejected with -108, because the profile model has no list-valued parameter. Pass the table as a quoted string:

```python title="sweeps.py"
from rig import RF, open_rig


def where(r) -> str:
    """Peak search over the full span, then the counter reading at the marker."""
    r.ask(":CALC:MARK:MAX")
    return f"{r.num(':CALC:MARK:FCO:X?') / 1e9:.9f} GHz at {r.num(':CALC:MARK:Y?'):+.2f} dBm"


with open_rig("rfgen-specan.bench.yaml") as r:
    r.ask(":SENS:FREQ:SPAN 7.5e9"); r.ask(":SENS:FREQ:CENT 3.75e9")     # the whole range
    r.ask(":SENS:BAND:RES:AUTO ON")
    print("analyzer RBW", r.ask(":SENS:BAND:RES?"), "Hz, points", r.ask(":SENS:SWE:POIN?"))

    r.ask(':SOUR:LIST:FREQ 1e9,2.4e9,5.8e9', RF)
    print("unquoted list ->", r.errors(RF))
    r.ask(':SOUR:LIST:FREQ "1e9,2.4e9,5.8e9"', RF)
    print("quoted list   ->", r.errors(RF), "points", r.ask(":SOUR:LIST:FREQ:POIN?", RF))
    r.ask(":SOUR:FREQ:MODE LIST", RF); r.ask(":INIT", RF)
    print("list sweep, internal timebase (the generator runs 2 ppm fast)")
    for k in range(3):
        print(f"  point {r.ask(':SOUR:LIST:MAN?', RF)}: {where(r)}")
        r.ask(":TRIG", RF)
    r.ask(":TRIG", RF)
    print(f"  after the last point, still point {r.ask(':SOUR:LIST:MAN?', RF)}")

    r.ask(":SENS:ROSC:SOUR EXT")
    print("step sweep 1 to 2 GHz in 5 points, analyzer locked to REF_OUT")
    r.ask(":SOUR:FREQ:STAR 1e9; :SOUR:FREQ:STOP 2e9; :SOUR:SWE:POIN 5; :SOUR:SWE:SPAC LIN; :SOUR:FREQ:MODE SWE", RF)
    r.ask(":INIT", RF)
    for k in range(5):
        print(f"  point {r.ask(':SOUR:LIST:MAN?', RF)}: {where(r)}")
        r.ask(":TRIG", RF)
    print(r.errors(RF), r.errors())
```

```text title="output of python sweeps.py"
analyzer RBW +3.000000E+06 Hz, points 1001
unquoted list -> ['-108,"Parameter not allowed"']
quoted list   -> [] points 3
list sweep, internal timebase (the generator runs 2 ppm fast)
  point 1: 1.000002000 GHz at +0.00 dBm
  point 2: 2.400004800 GHz at +0.00 dBm
  point 3: 5.800011600 GHz at +0.00 dBm
  after the last point, still point 3
step sweep 1 to 2 GHz in 5 points, analyzer locked to REF_OUT
  point 1: 1.000000000 GHz at +0.00 dBm
  point 2: 1.250000000 GHz at +0.00 dBm
  point 3: 1.500000000 GHz at +0.00 dBm
  point 4: 1.750000000 GHz at +0.00 dBm
  point 5: 2.000000000 GHz at +0.00 dBm
[] []
```

The analyzer spans the generator's whole range at once, 0 to 7.5 GHz, with a 3 MHz RBW, and the counter reads the frequency at each point. A 5-point linear step from 1 to 2 GHz lands on 1.25, 1.5 and 1.75 GHz. The list points read 2 ppm high on the analyzer's own timebase: 1.000002000 GHz for 1 GHz, 5.800011600 GHz for 5.8 GHz, which is 5.8 GHz × 2×10⁻⁶ = 11.6 kHz. With the analyzer locked to REF_OUT the error cancels.

## What does REF_OUT carry, and what is refused?

REF_OUT is a 10 MHz sine at +4 dBm into a matched load, carrying the instrument's timebase error, and it is on at reset whatever the RF output switch says. Every frequency the generator makes, the carrier, the sidebands and the reference alike, is scaled by (1 + the fractional error). The analyzer's `EXTernal` source locks to a sine within 10 ppm of 10 MHz on REF_IN and inherits its error. `timebase.py` shows the three states:

```python title="timebase.py"
from rig import RF, open_rig

with open_rig("rfgen-specan.bench.yaml") as r:
    for net in r.world.nets():
        ((c,),) = [[c for c in net.descriptor["components"] if c["kind"] == "sine"]]
        print(f"{net.members[0]:28s} sine {c['args'][0]:.1f} Hz, {c['args'][1]:.4f} V peak")

    def carrier() -> str:
        r.ask(":CALC:MARK:MAX")
        return f"{r.num(':CALC:MARK:FCO:X?'):,.1f} Hz"

    print("analyzer internal   ", carrier(), "| locked", r.ask(":SENS:ROSC:LOCK?"))
    r.ask(":SENS:ROSC:SOUR EXT")
    print("analyzer on REF_OUT ", carrier(), "| locked", r.ask(":SENS:ROSC:LOCK?"), "| in use", r.ask(":SENS:ROSC:ACT?"))
    r.ask(":SOUR:ROSC:OUTP OFF", RF)
    print("REF_OUT switched off", carrier(), "| locked", r.ask(":SENS:ROSC:LOCK?"), "| in use", r.ask(":SENS:ROSC:ACT?"))
    print(r.errors())
```

```text title="output of python timebase.py"
inst-rfgen-sim1:RF_OUT       sine 1000002000.0 Hz, 0.3162 V peak
inst-rfgen-sim1:REF_OUT      sine 10000020.0 Hz, 0.5012 V peak
analyzer internal    1,000,002,000.0 Hz | locked 0
analyzer on REF_OUT  1,000,000,000.0 Hz | locked 1 | in use EXT
REF_OUT switched off 1,000,002,000.0 Hz | locked 0 | in use INT
['-221,"Settings conflict; external reference unusable: REF_IN needs a 10 MHz sine within 10 ppm, the analyzer stays on its internal reference"']
```

The 0 dBm carrier is 0.3162 V peak across 50 Ω, and REF_OUT's 0.5012 V is +4 dBm. The carrier sits 2 kHz high at 1 GHz on the analyzer's internal timebase, the same 2 ppm the reference carries, and exactly on 1 GHz once the analyzer shares the generator's clock. Switch REF_OUT off and the analyzer drops back to its internal reference with a queued -221. That is how a test that depends on a shared reference learns that it lost it: read `:SENSe:ROSCillator:LOCKed?` and the error queue before a frequency measurement.

Everything else the generator cannot take, it refuses with a code and leaves its state unchanged:

```python title="limits.py (second half)"
    # Refusals, read from the generator's own queue
    for label, cmd in [("pulse modulation on", ":SOUR:PULM:STAT ON"),
                       ("FM on, then PM on", ":SOUR:FM:STAT ON; :SOUR:PM:STAT ON"),
                       ("level +25 dBm", ":POW 25"), ("level -131 dBm", ":POW -131"),
                       ("carrier 7 GHz", ":FREQ 7e9"), ("carrier 8 kHz", ":FREQ 8e3"),
                       ("FM beta of 1e8", ":SOUR:PM:STAT OFF; :SOUR:FM:DEV 1e7; :SOUR:FM:INT:FREQ 0.1; :SOUR:FM:STAT ON")]:
        r.ask(cmd, RF)
        print(f"  {label:20s} -> {r.errors(RF)}   PULM {r.ask(':SOUR:PULM:STAT?', RF)} FM {r.ask(':SOUR:FM:STAT?', RF)} PM {r.ask(':SOUR:PM:STAT?', RF)}")
```

```text title="output of python limits.py (second half)"
  pulse modulation on  -> ['-221,"Settings conflict; pulse modulation is not simulated"']   PULM 0 FM 0 PM 0
  FM on, then PM on    -> ['-221,"Settings conflict"']   PULM 0 FM 0 PM 1
  level +25 dBm        -> ['-222,"Data out of range"']   PULM 0 FM 0 PM 1
  level -131 dBm       -> ['-222,"Data out of range"']   PULM 0 FM 0 PM 1
  carrier 7 GHz        -> ['-222,"Data out of range"']   PULM 0 FM 0 PM 1
  carrier 8 kHz        -> ['-222,"Data out of range"']   PULM 0 FM 0 PM 1
  FM beta of 1e8       -> ['-222,"Data out of range"']   PULM 0 FM 0 PM 0
```

**Pulse modulation answers with an error.** It cannot be written as a sum of sines without cutting an infinite line set, so the generator refuses it with -221 and the switch stays at 0. A test of pulse-modulated output needs a different generator model or the real instrument.

**FM and PM do not stack.** Switching PM on while FM is on queues -221 and leaves PM on and FM off: the format switched on later wins. AM does stack on either one, and each angle-modulation line then becomes three.

**Range errors are -222.** Level outside -130 to +20 dBm and a carrier outside 9 kHz to 6 GHz are refused, and so is an FM or PM index that does not fit the line budget. The codes are -221, -222 and -224, never -241, which hosts read as a disconnect. State written by a bench file or the console is held to the same rules: it is corrected on the next solve and the error is queued once.

## How do I run this in Galois with Évariste?

Galois is agent-driven test engineering for hardware teams: agents generate tests and instrument drivers, run them on real benches through the open-source galois-edge daemon, and turn the results into reports and a shared engineering record. The [instrument library](https://galoislabs.ai/instruments) holds 573 profiles across 135 manufacturers, and EdgeSim plugs into galois-edge as an instrument backend, so Évariste works the virtual bench with the tools it uses on a real one. This section is a worked example, not a captured session.

Start the daemon with `SIM_MODE` on, `SIM_BENCH` at `rfgen-specan.bench.yaml` and `SIM_MARK_INSTRUMENTS=true`, so `list_instruments` and `get_status` mark each instrument `is_simulated`. Open Évariste from the app sidebar (Ctrl+Shift+E) and ask "List connected instruments": `sim-rfgen-1` and `sim-specan-1` appear. State the measurement with its limits:

> On the virtual bench, lock the spectrum analyzer to the generator's reference output, set a 5 MHz span at 1 GHz and the reference level to +10 dBm. First write down the expected carrier and sideband levels in dBc for 30 percent AM at 1 MHz and for FM at a modulation index of 1: the AM sidebands are m/2, the FM lines are the Bessel functions J0, J1 and J2. Then turn each modulation on, raise the peak threshold to -60 dBm, run a peak search and next-peak searches, and read each marker's frequency and level. Stop and report if any line differs from your expected level by more than 0.05 dB, if the analyzer's overload flag reads 1 or if either instrument queues an error.

**Draft.** Évariste reads both profiles and drafts named commands. The first record:

```yaml title="am_sidebands.yaml (draft, version 1, excerpt)"
name: "AM sidebands on the virtual analyzer"
steps:
  - name: "Analyzer on the generator's reference"
    type: action
    config:
      instrument_id: "sim-specan-1"
      command_name: "timebase.source"
      parameters: { source: "EXTernal" }

  - name: "AM depth 30 percent"
    type: action
    config:
      instrument_id: "sim-rfgen-1"
      command_name: "modulation.am.depth"
      parameters: { depth: "30" }

  - name: "AM rate 1 MHz"
    type: action
    config:
      instrument_id: "sim-rfgen-1"
      command_name: "modulation.am.rate"
      parameters: { rate: "1000000" }

  - name: "AM on"
    type: action
    config:
      instrument_id: "sim-rfgen-1"
      command_name: "modulation.am.enabled"
      parameters: { state: "ON" }

  - name: "Peak search"
    type: action
    config:
      instrument_id: "sim-specan-1"
      command_name: "marker.peak.search"

  - name: "Carrier level"
    type: measure
    config:
      instrument_id: "sim-specan-1"
      command_name: "marker.y"
      unit: "dBm"

  # next-peak searches and levels for the two sidebands, then the FM run; the comparison with the expected levels is Évariste's analysis
```

**Review and approve.** A draft cannot run until you approve it, and every edit is a new version with a diff. Check the lock to the reference, the reference level, the threshold against the floor, the expected levels and the tolerance ([how to review an AI-generated test plan](https://galoislabs.ai/blog/review-ai-generated-test-plan) has the checklist).

**Run and check.** galois-edge runs the approved sequence against the virtual bench, and each step records its command, raw response and timestamps. The code path predicts the answer: 0.000, -16.478 and -16.478 dBc for AM, and -2.325, -7.130 and -18.793 dBc for FM. Check Évariste's figures against `lines.py`, and ask for the overload flag and both error queues with them.

**One change.** Ask for a 2.404826 MHz FM deviation at a 1 MHz rate: the draft changes as a new version with a diff, and the run should report the carrier at the analyzer's floor, the Bessel null. A result that does not is a defect in the sequence or in the setup, found before the real generator is cabled.

**The real instruments.** Bind the real generator and analyzer in place of the virtual pair, as a new version with its own diff and approval. The steps stay, the 0.05 dB tolerance widens to the pair's level accuracy, and pulse modulation, which the virtual generator refuses, becomes testable. Then ask for "Generate a test report from the last run".

The sequence rehearsal gate is in build: every sequence will dry-run on a virtual bench built from the project's topology before approval, so a draft arrives with its sideband arithmetic already exercised, and hosted virtual edge will run that bench without you starting a daemon.

You no longer write the PyVISA sessions, the marker loop, the comparison or the report script. The modulation settings, the expected levels, the tolerance, review and approval, and the bench, from cabling the generator's output and reference to the analyzer to choosing its level range, stay yours.

| Step         | Code path (this guide)       | Galois with Évariste                                   |
| ------------ | ---------------------------- | ------------------------------------------------------ |
| Bench        | `rfgen-specan.bench.yaml`    | The same file through `SIM_MODE` and `SIM_BENCH`       |
| Known answer | `bessel_j()`, `dbc(m / 2)`   | Expected levels first; markers compared within 0.05 dB |
| Setup        | `rig.py`, `lines.py`         | Drafted named commands; settings in the objective      |
| Measure      | `measure()`                  | Peak search and next-peak steps; marker levels         |
| Review       | Code review                  | Engineer approves the draft                            |
| Errors       | `r.errors(RF)`, `r.errors()` | Both queues reported with the results                  |
| Report       | `print()`                    | "Generate a test report from the last run"             |

## What does the simulation never tell me, and when is plain code enough?

The generator is ideal: exact level, no phase noise, no spurs, no harmonics and no settling. Whatever it leaves out, the real generator and your cabling decide:

| The simulation settles                                               | The real generator and bench settle                    |
| -------------------------------------------------------------------- | ------------------------------------------------------ |
| Which commands set which modulation, and in what order               | Modulation accuracy, distortion and sideband asymmetry |
| That the script finds sideband levels from the right lines and units | Phase noise, spurs, harmonics and leveling error       |
| List and step logic, including the quoted table                      | Switching time and dwell on the real sweep             |
| Whether the script reads the reference lock and the error queue      | Whether the real reference is cabled and good enough   |
| That the script copes with a refused setting                         | Pulse modulation itself                                |

**Plain code is enough** for one engineer, one generator and a verification script already trusted on the bench: keep `lines.py` as a check that needs no instrument and run it in CI ([hardware tests in CI](https://galoislabs.ai/blog/hardware-tests-in-ci)). The simulation pays when the script feeds a decision, when the generator is booked by someone else, and when several people run the same measurement. A pass here proves the script's logic against a model; only the real generator answers for the hardware.

Next in this series, [lock-in amplifier simulation](https://galoislabs.ai/blog/lock-in-amplifier-simulation) settles a lock-in's filter in virtual time and runs a Bode sweep across an RC corner.

## Frequently asked questions

### How do I simulate an RF signal generator with AM, FM and PM?

Put a simulated generator on a virtual bench, wire its RF output to a simulated spectrum analyzer and drive both with SCPI. EdgeSim's generator builds AM as exactly a carrier plus two sidebands of m/2, and FM and PM as Bessel sidebands J_n(β) cut at Carson's rule, so the analyzer's markers read each line against a closed form.

### How accurate is a simulated FM signal truncated at Carson's rule?

The set keeps orders up to floor(β) + 1 each side. The power in the omitted lines never exceeds about 4.1 percent of the carrier power, -13.9 dB, for any modulation index, and it is 0.078 percent, -31.1 dB, at β = 1. In the time domain the error is bounded by the carrier amplitude times the sum of the omitted |J_n|.

### Does the simulated generator support pulse modulation?

No. Pulse modulation needs an infinite line set, so the generator refuses it: turning it on queues error -221 "Settings conflict; pulse modulation is not simulated" and the state stays off. Turning FM on with PM on, or the reverse, also queues -221 and keeps the one switched on later.

### How do I run a list sweep on a simulated RF generator?

Pass the table as a quoted string: :SOURce:LIST:FREQuency "1e9,2.4e9,5.8e9". The unquoted form real instruments also take is rejected with -108. Set :SOURce:FREQuency:MODE LIST, arm with :INITiate, and step with :TRIGger; the output holds at the last point. A free-running sweep is not simulated.

### Can Évariste verify a modulated carrier on a virtual bench?

Yes. Évariste, the agent in the Galois platform, drafts the sequence from a plain-English objective, writes the expected sideband levels first and compares them with the analyzer's marker readings. You approve the draft before it runs, and every edit is a new version with a diff. The real generator and analyzer follow through galois-edge. The automatic rehearsal gate before approval is in build.
