PWR-HYBRID-3/reachability/load_predicates.m
Dane Sabo a20d2a05e9 predicates: split operational deadbands from hard safety limits
Previously conflated two different kinds of constraint:
  - operational deadbands (|T_c - T_c0| <= 5 F) used by the DRC for mode
    transitions. Symmetric bands around setpoint. Violating these is an
    operator/operational issue, not a safety issue.
  - safety limits (T_f <= 1200 C, T_c <= 320 C, n <= 1.15, etc.) are
    hard one-sided halfspaces corresponding to physical damage mechanisms
    or reactor-trip setpoints. THESE are what a safety barrier/reach must
    discharge.

predicates.json now has three groups:
  - operational_deadbands (t_avg_above_min, t_avg_in_range, p_above_crit)
  - safety_limits (fuel_centerline, t_avg_high_trip, t_avg_low_trip,
    n_high_trip, n_low_operation, cold_leg_subcooled)
  - mode_invariants (inv1_holds, inv2_holds as conjunctions of safety_limits)

reach_operation.m and barrier_lyapunov.m both now report halfspace-by-
halfspace margins against inv2_holds. Attributable failure analysis:
we can see WHICH limit is tightest.

Reach tube (under +/-15% Q_sg load): passes all 6 safety halfspaces.
Tightest margin is n_high_trip at +0.138 (12% from trip). Temperature
directions have 10-870 K margin.

Lyapunov barrier (same): fails all 6. Worst is n_high_trip with -2365
margin — the ellipsoid says n could deviate by +/-2364, which is
physically meaningless. Anisotropy cost made visible per-direction.
Motivates SOS / polytopic barriers for the thesis chapter.

load_predicates.m now returns .operational_deadbands, .safety_limits,
and .mode_invariants. Existing callers that only used .constants or
.t_avg_in_range still work because those live under the old keys.

Hacker-Split: user caught that the barrier was checking the wrong
invariant; safety limits != operating deadband. Restructured so the
proof target matches the physical claim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:04:40 -04:00

108 lines
4.4 KiB
Matlab

function pred = load_predicates(plant)
% LOAD_PREDICATES Read predicates.json and resolve rhs_expr into numbers.
%
% Returns:
% pred.constants - struct with T_c0, T_cold0, T_f0, T_standby
% pred.operational_deadbands - struct of predicates (each: A_poly, b_poly, meaning)
% pred.safety_limits - struct of halfspace limits (each: A_poly, b_poly, meaning)
% pred.mode_invariants - struct mapping mode-invariant name to
% its conjoined (A_poly, b_poly, components)
%
% Each halfspace is of the form { x : A_poly * x <= b_poly }. Conjunctions
% (polytopes) stack rows into A_poly / b_poly.
%
% Usage:
% plant = pke_params();
% pred = load_predicates(plant);
% inv2 = pred.mode_invariants.inv2_holds;
% is_in = all(inv2.A_poly * x <= inv2.b_poly);
here = fileparts(mfilename('fullpath'));
raw = fileread(fullfile(here, 'predicates.json'));
J = jsondecode(raw);
% --- Constants used in rhs_expr evaluations ---
T_c0 = plant.T_c0; %#ok<NASGU>
T_f0 = plant.T_f0; %#ok<NASGU>
T_cold0 = plant.T_cold0; %#ok<NASGU>
T_standby_offset_C = J.derived.T_standby_offset_C;
T_standby = T_c0 + T_standby_offset_C; %#ok<NASGU>
pred.constants = struct( ...
'T_c0', plant.T_c0, ...
'T_f0', plant.T_f0, ...
'T_cold0', plant.T_cold0, ...
'T_standby', T_standby, ...
'T_standby_offset_C', T_standby_offset_C, ...
'T_standby_offset_F', J.derived.T_standby_offset_F, ...
't_avg_in_range_halfwidth_C', J.derived.t_avg_in_range_halfwidth_C, ...
'p_above_crit_threshold_n', J.derived.p_above_crit_threshold_n, ...
'T_fuel_limit_C', J.derived.T_fuel_limit_C, ...
'T_c_high_trip_C', J.derived.T_c_high_trip_C, ...
'n_high_trip', J.derived.n_high_trip);
% --- operational_deadbands ---
pred.operational_deadbands = parse_group(J.operational_deadbands, ...
T_c0, T_f0, T_cold0, T_standby);
% --- safety_limits ---
pred.safety_limits = parse_group(J.safety_limits, ...
T_c0, T_f0, T_cold0, T_standby);
% --- mode_invariants: conjunctions of safety_limits entries ---
inv_names = fieldnames(J.mode_invariants);
for k = 1:numel(inv_names)
name = inv_names{k};
if startsWith(name, '_') || startsWith(name, 'x_'), continue, end
entry = J.mode_invariants.(name);
if ~isstruct(entry) || ~isfield(entry, 'conjunction_of'), continue, end
components = entry.conjunction_of;
if ischar(components), components = {components}; end
A_all = [];
b_all = [];
for i = 1:numel(components)
comp = components{i};
A_all = [A_all; pred.safety_limits.(comp).A_poly]; %#ok<AGROW>
b_all = [b_all; pred.safety_limits.(comp).b_poly]; %#ok<AGROW>
end
pred.mode_invariants.(name).A_poly = A_all;
pred.mode_invariants.(name).b_poly = b_all;
pred.mode_invariants.(name).meaning = entry.meaning;
pred.mode_invariants.(name).components = components;
end
end
function group_out = parse_group(group_in, T_c0, T_f0, T_cold0, T_standby)
names = fieldnames(group_in);
group_out = struct();
for k = 1:numel(names)
name = names{k};
% MATLAB jsondecode renames "_comment" -> "x_comment" and similar
if startsWith(name, '_') || startsWith(name, 'x_'), continue, end
entry = group_in.(name);
if ~isstruct(entry) || ~isfield(entry, 'halfspaces'), continue, end
hs_list = entry.halfspaces;
if iscell(hs_list)
n_hs = numel(hs_list);
get_hs = @(i) hs_list{i};
else
n_hs = numel(hs_list);
get_hs = @(i) hs_list(i);
end
A_poly = zeros(n_hs, 10);
b_poly = zeros(n_hs, 1);
for i = 1:n_hs
hs = get_hs(i);
A_poly(i, hs.state_index) = hs.coeff;
b_poly(i) = evalin_context(hs.rhs_expr, T_c0, T_f0, T_cold0, T_standby);
end
group_out.(name).A_poly = A_poly;
group_out.(name).b_poly = b_poly;
group_out.(name).meaning = entry.meaning;
end
end
function val = evalin_context(expr, T_c0, T_f0, T_cold0, T_standby) %#ok<INUSD>
val = eval(expr);
end