PWR-HYBRID-3/plant-model/test_linearize.m
Dane Sabo d2997c2861 plant-model: add shutdown/heatup/scram controllers and LQR, linearize
Fill out the DRC mode set with ctrl_shutdown (u = -5*beta), ctrl_scram
(u = -8*beta), and ctrl_heatup (feedback-linearizing P on ramped T_avg
reference, saturated u, no integrator). Add ctrl_operation_lqr as a
full-state-feedback counterpart to ctrl_operation — K cached, closed-loop
essentially perfect under the 100%->80% Q_sg step where plain P has ~5F
overshoot.

Add pke_linearize for numerical (A, B, B_w) Jacobians at any operating
point; test_linearize confirms ~4e-4 rel err vs nonlinear sim for a
5% Q_sg step. Extend pke_solver with an optional x0 argument so each
mode can start from a plausible IC.

main_mode_sweep.m exercises all five modes back-to-back and saves the
4-panel plots. CLAUDE.md updated with model-validity-range note (trust
region is ~+/-50C around operating point; true cold shutdown is out of
scope for the linear feedback coefficients).

Hacker-Split: build out control layer end-to-end for reachability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:52:03 -04:00

107 lines
3.8 KiB
Matlab

%% test_linearize.m — sanity-check the Jacobians against the nonlinear sim
%
% Simulate two parallel trajectories under a small Q_sg step (5% down):
% 1. Full nonlinear plant with u = 0
% 2. Linear dx/dt = A*dx + B_w*dw propagated by ode45
% Compare deviations from x_op. For a small-enough disturbance, the
% trajectories should overlay to within a few percent over a few minutes.
clear; clc; close all;
addpath('controllers');
plant = pke_params();
x_op = pke_initial_conditions(plant);
[A, B, B_w, xs, us, Qs] = pke_linearize(plant, x_op, 0, plant.P0);
%% ===== Eigenvalue/conditioning check =====
eigs_A = eig(A);
fprintf('\n=== Linearization at operating point ===\n');
fprintf(' ||A|| = %.3e\n', norm(A));
fprintf(' ||B|| = %.3e\n', norm(B));
fprintf(' ||B_w|| = %.3e\n', norm(B_w));
fprintf(' stable? = %s (max Re(eig) = %.3e)\n', ...
iif(all(real(eigs_A) < 1e-8), 'YES', 'NO'), max(real(eigs_A)));
fprintf(' fastest pole ~ %.3e /s\n', max(abs(real(eigs_A))));
fprintf(' slowest pole ~ %.3e /s\n', min(abs(real(eigs_A(real(eigs_A) < -1e-10)))));
%% ===== Linear vs nonlinear trajectory under small Q_sg step =====
% 5% down step at t = 30s.
dQ = -0.05 * plant.P0;
Q_sg = @(t) plant.P0 + dQ * (t >= 30);
tspan = [0, 300];
% Nonlinear
opts = odeset('RelTol', 1e-8, 'AbsTol', 1e-10);
rhs_nl = @(t, x) pke_th_rhs(t, x, plant, Q_sg, 0);
[t_nl, X_nl] = ode15s(rhs_nl, tspan, x_op, opts);
% Linear (deviation)
dQ_of_t = @(t) dQ * (t >= 30);
rhs_lin = @(t, dx) A*dx + B_w*dQ_of_t(t);
[t_lin, DX_lin] = ode15s(rhs_lin, tspan, zeros(size(x_op)), opts);
% Reconstruct absolute linear state for plotting
X_lin = DX_lin + x_op.';
%% ===== Plot comparison — focus on thermal states =====
CtoF = @(T) T*9/5 + 32;
figdir = fullfile('..', 'docs', 'figures');
if ~exist(figdir, 'dir'), mkdir(figdir); end
figure('Position', [100 80 1100 700], 'Name', 'Linear vs nonlinear sanity');
subplot(2,2,1);
plot(t_nl, X_nl(:,1), 'b-', 'LineWidth', 1.5); hold on;
plot(t_lin, X_lin(:,1), 'r--','LineWidth', 1.5);
xlabel('t [s]'); ylabel('n'); grid on;
title('Normalized power'); legend('nonlinear', 'linear', 'Location', 'best');
subplot(2,2,2);
plot(t_nl, CtoF(X_nl(:,8)), 'b-', 'LineWidth', 1.5); hold on;
plot(t_lin, CtoF(X_lin(:,8)), 'r--','LineWidth', 1.5);
xlabel('t [s]'); ylabel('T_f [F]'); grid on;
title('Fuel temperature');
subplot(2,2,3);
plot(t_nl, CtoF(X_nl(:,9)), 'b-', 'LineWidth', 1.5); hold on;
plot(t_lin, CtoF(X_lin(:,9)), 'r--','LineWidth', 1.5);
xlabel('t [s]'); ylabel('T_c [F]'); grid on;
title('Avg coolant');
subplot(2,2,4);
plot(t_nl, CtoF(X_nl(:,10)), 'b-', 'LineWidth', 1.5); hold on;
plot(t_lin, CtoF(X_lin(:,10)),'r--','LineWidth', 1.5);
xlabel('t [s]'); ylabel('T_{cold} [F]'); grid on;
title('Cold-leg coolant');
sgtitle('Linear-model sanity check: 5% Q_{sg} down-step, u = 0');
exportgraphics(gcf, fullfile(figdir, 'linearize_sanity.png'), 'Resolution', 150);
%% ===== Quantitative error at a few times =====
fprintf('\n=== Max |linear - nonlinear| at sampled times ===\n');
fprintf(' (normalized by operating-point magnitude where nonzero)\n');
for ts = [60, 120, 300]
xi_nl = interp1(t_nl, X_nl, ts);
xi_lin = interp1(t_lin, X_lin, ts);
rel_err = abs(xi_nl - xi_lin) ./ max(abs(x_op.'), 1e-6);
fprintf(' t = %3d s: max rel err = %.2e (%s)\n', ts, max(rel_err), ...
state_name(find(rel_err == max(rel_err), 1)));
end
%% ===== Save linearization =====
save(fullfile('..', 'reachability', 'linearization_at_op.mat'), ...
'A', 'B', 'B_w', 'xs', 'us', 'Qs', '-v7');
fprintf('\nSaved A/B/B_w to ../reachability/linearization_at_op.mat\n');
%% ===== helpers =====
function s = state_name(k)
names = {'n','C1','C2','C3','C4','C5','C6','T_f','T_c','T_cold'};
s = names{k};
end
function y = iif(cond, a, b)
if cond, y = a; else, y = b; end
end