# LQG: estimate the cart state, then choose a force

This exercise reproduces the interactive lesson at
https://www.dustinturner.ai/writing/lqg-control.

Download `lqg.py`. It uses Python 3.10 or later and the standard library; no packages,
network requests, or separate data files are needed. In a terminal in that folder:

```sh
python3 lqg.py --check
python3 lqg.py --preset noisy-sensor --seed 7
python3 lqg.py --preset wrong-mass --seed 7
python3 lqg.py --preset baseline --seed 63
```

On Windows, use `py -3` instead of `python3` if that is how Python is installed.
`expected-output.txt` contains the output of the first command. The checks cover
a hand-worked Kalman update, finite-horizon gains, Bellman identities, and costs
recomputed from the three simulated controller traces. A passing check confirms
these calculations; it does not certify a real robot controller.

Use `--json` to print all samples, design gains, Riccati matrices, estimates,
covariances, and costs for one preset and seed. Do not combine it with `--check`
when a program expects JSON alone, because the check prints a status line first.

## What is simulated

The cart state is `[position, velocity]` in meters and meters per second. Force
is in newtons. The nominal mass is 2 kg, damping is 1 N s/m, and samples are
0.1 seconds apart. The discrete matrices are the exact zero-order-hold solution
of `p' = v`, `m v' = u - b v` for a constant force during each interval.

The finite objective is

```text
C = sum(k=0..79) 0.1 * (4*p[k]^2 + v[k]^2 + u[k]^2)
    + 4*p[80]^2 + v[80]^2
J = E[C]
```

The eight-second horizon, rectangular stage cost, and unscaled terminal cost
define this discrete problem. This is not an exact discretization of a
continuous-time objective. Cost uses position/velocity/force/time normalized by
1 m, 1 m/s, 1 N, and 1 s. Gains come from the backward discrete Riccati recursion
and change with time. Force is unconstrained; there is no clipping or delay.

Initial state: Gaussian mean `[1,0]`, covariance `diag(0.04,0.04)`. Process noise:
independent per-step Gaussian with covariance `diag(0.0004,0.0025)`. The sensor
observes position with Gaussian noise variance `0.01 m^2` normally and `0.09 m^2`
in the noisier-sensor preset. Initial error, process noise, and measurement noise
are mutually independent. Process covariance is specified per discrete sample,
not as a continuous-time spectral density.

## Three controllers and the event order

1. **LQG:** update the prior estimate with `y[k]`, choose
   `u[k] = -K[k] * estimate[k|k]`, then predict the next prior using that applied
   force. The covariance update uses Joseph form. The simulator's true state is
   absent from the filter update API.
2. **Raw:** use the current measured position and
   `(y[k] - y[k-1]) / 0.1` for velocity; set the first velocity estimate to zero.
   This deliberately simple baseline exposes how finite differences amplify
   measurement noise. It is not a tuned alternative estimator.
3. **Full state (`oracle` in the output):** apply the same gain to the true state.
   This is an information advantage for comparison, not an available sensor.

The simulator then advances the true plant with that force and the sampled
process disturbance. It makes no measurement and chooses no force at the final
sample `k=80`; it adds the terminal state cost there. Each decision row's
`cumulativeCost` includes its stage cost, so the value at `t=0` is already nonzero.
The final LQG estimate is a prediction, not a new posterior. The final raw
estimate field merely retains the previous raw estimate; it is not a fresh
measurement. `positionRmse` is tracking RMS about zero over samples 0..79, not
estimation RMSE. `effort` is the sum of `0.1*u[k]^2` over those same samples.

## Compare fairly, and interpret the numbers

Each controller starts at the same sampled initial state and receives the same
exogenous process and sensor noise samples. Their sensor *readings* can differ
because their actual trajectories differ. The browser and Python use the same
32-bit seed mixing, xorshift32 draws, and Box-Muller normal transform; tiny
floating-point differences across platforms are harmless.

The 64-seed means always use seeds 1 through 64, independently of the selected
trace. The paired difference is raw-controller cost minus LQG cost for each seed.
Its reported Monte Carlo standard error is the sample standard deviation of
those paired differences divided by the square root of 64. It describes sampling
variation in this modeled experiment. It is not a confidence interval, a proof
of optimality, or a guarantee about another robot.

In the matched-model presets, LQG minimizes expected cost given the specified
information and assumptions. That does not order every realized trajectory:
baseline seed 63 has slightly lower LQG cost than full-state cost. In the
wrong-mass preset the true cart is 4 kg but all designs remain at 2 kg, including
the full-state controller. It is therefore not a benchmark redesigned to be
optimal for the true plant. The filter's reported covariance also no longer
describes the actual error distribution under that mismatch.

To change the experiment, edit `DT`, `STEPS`, `Q`, `W`, initial covariance, and
the preset definitions together with their corresponding draw scales. The
current draw function assumes diagonal covariances; adding correlated noise
requires a matching covariance factorization. Recheck the model and the cost
when changing the sample time.
