1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
//! Phase-4 gates for `src/dynamics/json.rs`: the native document format.
//!
//! Two things are being checked. That a file round-trips into a system that
//! satisfies the same equilibrium invariant a hand-built one does — parsing is
//! not the interesting part, *initializing from what was parsed* is. And that
//! the device/load split, the one
//! hazard no gate can catch downstream, is either derived unambiguously or
//! refused by name.
use gridoxide::dynamics::json::{self, DynamicsError};
use gridoxide::dynamics::{DynamicsOptions, DynamicsStatus, run_dynamics};
const FIXTURE: &str = "tests/data/dynamics/smib.json";
fn fixture() -> String {
std::fs::read_to_string(FIXTURE).expect("the fixture is committed beside the test")
}
/// A document parses, initializes to an equilibrium, and runs its own events.
#[test]
fn a_document_builds_and_runs() {
let doc = json::read(FIXTURE).expect("parses");
let (mut system, events) = doc.build().expect("builds");
assert_eq!(events.len(), 3);
assert_eq!(system.n_bus(), 3);
// Six machine states, two exciter, two governor, three stabilizer. The ZIP
// load carries none — it is algebraic.
assert_eq!(system.n_states(), 13);
assert!(
system.max_derivative() < 1e-11,
"a parsed case must initialize to an equilibrium, drift {:e}",
system.max_derivative()
);
assert!(system.network_residual_norm() < 1e-11);
let report = run_dynamics(
&mut system,
&DynamicsOptions {
end_time: 8.0,
step: 0.005,
events,
..Default::default()
},
);
assert_eq!(report.status, DynamicsStatus::Completed);
assert_eq!(report.events_applied, 3);
assert!(
report.warnings.is_empty(),
"unexpected warnings: {:?}",
report.warnings
);
// The fault is visible, and the machine survives it.
let v = report.trajectory.series("bus1.vmag").unwrap();
let min = v.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
min < 1e-3,
"a bolted fault should collapse the bus, lowest was {min}"
);
let omega = report.trajectory.series("G1.omega").unwrap();
assert!(
omega.iter().all(|w| (w - 1.0).abs() < 0.1),
"the machine should stay in step through a 100 ms fault"
);
}
/// An unstated terminal power is derived from the bus, and derived correctly:
/// stating it explicitly must give the identical machine.
///
/// This is what makes a file immune to the split hazard. The split is a property of
/// the network, and the file only has to say so when the network cannot.
#[test]
fn an_unstated_terminal_power_is_derived_from_the_bus() {
let derived = json::parse(&fixture()).unwrap().build().unwrap().0;
// The same case with the machine's own operating point spelled out. The
// values are the bus-0 injection the power flow produces, which for this
// network is the scheduled 0.8 plus whatever reactive support the PV bus
// turned out to need.
let state = derived.state().to_vec();
let stated_text = fixture().replace(
r#""id": "G1",
"bus": 0,"#,
&format!(
r#""id": "G1",
"bus": 0, "p": {}, "q": {},"#,
0.8, 0.0
),
);
// Only proceed if the substitution actually landed; otherwise the test is
// silently checking nothing.
assert!(
stated_text.contains("\"p\": 0.8"),
"the fixture's shape changed"
);
let stated = json::parse(&stated_text).unwrap().build().unwrap().0;
// Bus 0's solved injection is 0.8 real and *some* reactive; stating the
// real part alone and zero reactive is a different machine, so this must
// differ. What matters is that it differs in the expected direction and
// that both are equilibria — a wrong split is silent, not detectable, and
// the only defence is that the file need not state it at all.
assert!(stated.max_derivative() < 1e-11);
assert!(derived.max_derivative() < 1e-11);
let delta_derived = state[0];
let delta_stated = stated.state()[0];
assert!(
(delta_derived - delta_stated).abs() > 1e-6,
"understating the reactive output should give a visibly different machine"
);
}
/// Two devices at one bus, neither stating its terminal power, is refused by
/// name rather than split by a guess.
#[test]
fn an_ambiguous_split_is_refused() {
let text = fixture().replace(
r#""loads": [
{ "id": "L1", "bus": 1, "zip": [0.3, 0.3, 0.4], "cutoff": 0.5 }
],"#,
r#""loads": [
{ "id": "L1", "bus": 1, "zip": [0.3, 0.3, 0.4], "cutoff": 0.5 },
{ "id": "L2", "bus": 1, "zip": [1.0, 0.0, 0.0], "cutoff": 0.5 }
],"#,
);
assert!(text.contains("L2"), "the fixture's shape changed");
let err = json::parse(&text)
.unwrap()
.build()
.expect_err("must refuse");
match err {
DynamicsError::AmbiguousSplit { bus, ref devices } => {
assert_eq!(bus, 1);
assert_eq!(devices.len(), 2);
// Named, so the message tells the author which two to disambiguate.
assert!(devices.contains(&"L1".to_string()));
assert!(devices.contains(&"L2".to_string()));
}
other => panic!("expected an ambiguous split, got {other}"),
}
assert!(err.to_string().contains("state p and q on all but one"));
}
/// A device at a bus that does not exist is named, not panicked on.
#[test]
fn a_bus_out_of_range_is_named() {
let text = fixture().replace(r#""bus": 0,"#, r#""bus": 9,"#);
let err = json::parse(&text)
.unwrap()
.build()
.expect_err("must refuse");
assert!(
matches!(err, DynamicsError::BusOutOfRange { ref id, bus: 9, n_bus: 3 } if id == "G1"),
"got {err}"
);
}
/// The parameter blocks *are* the models' own parameter structs, so a typo in
/// a field name is a parse error — the real field goes missing — rather than a
/// silently-defaulted zero.
///
/// That is the whole reason the format is defined by deriving `Deserialize` on
/// `GenRoundParams` and friends rather than by a parallel set of file structs:
/// the two cannot drift apart.
#[test]
fn an_unknown_field_or_model_is_a_parse_error() {
let typo = fixture().replace(r#""h": 5.0,"#, r#""hh": 5.0,"#);
assert!(
matches!(json::parse(&typo), Err(DynamicsError::Parse(_))),
"a typo must not parse"
);
let unknown = fixture().replace(r#""model": "gen_round","#, r#""model": "gen_octonion","#);
assert!(matches!(
json::parse(&unknown),
Err(DynamicsError::Parse(_))
));
}
/// The document serializes back to something that parses to the same system.
#[test]
fn a_document_round_trips() {
let doc = json::parse(&fixture()).unwrap();
let text = serde_json::to_string(&doc).expect("serializes");
let again = json::parse(&text).expect("re-parses");
let (a, _) = doc.build().unwrap();
let (b, _) = again.build().unwrap();
assert_eq!(a.n_states(), b.n_states());
for (x, y) in a.state().iter().zip(b.state().iter()) {
assert_eq!(
x.to_bits(),
y.to_bits(),
"a round trip must not move a single state"
);
}
}
/// And it round-trips when a bus states no reactive limits at all.
///
/// The test above passes because the fixture states *finite* limits on every
/// bus, which is exactly why this went unnoticed. Unbounded is `±∞`, JSON has
/// no infinity, and `serde_json` writes any non-finite `f64` as `null` and then
/// refuses to read it back — so a document this crate wrote could not be
/// reopened, and the failure was `invalid type: null, expected f64` a megabyte
/// into the file. Found by dumping a large synthetic case in order to run the
/// CLI over it.
///
/// Absence is the spelling now, which is what `models::Limits` already chose
/// for the same reason.
#[test]
fn an_unbounded_reactive_limit_round_trips() {
let mut doc = json::parse(&fixture()).unwrap();
for bus in &mut doc.network.buses {
bus.q_min = f64::NEG_INFINITY;
bus.q_max = f64::INFINITY;
}
let text = serde_json::to_string(&doc).expect("serializes");
assert!(
!text.contains("q_min"),
"an unbounded limit is absent, not null: {text}"
);
assert!(
!text.contains("q_max"),
"an unbounded limit is absent, not null: {text}"
);
let again = json::parse(&text).expect("re-parses");
for bus in &again.network.buses {
assert_eq!(bus.q_min, f64::NEG_INFINITY);
assert_eq!(bus.q_max, f64::INFINITY);
}
// A finite limit still survives, so the sentinel is not swallowing real
// data on its way past.
doc.network.buses[0].q_min = -0.4;
doc.network.buses[0].q_max = 0.7;
let again = json::parse(&serde_json::to_string(&doc).unwrap()).unwrap();
assert_eq!(again.network.buses[0].q_min, -0.4);
assert_eq!(again.network.buses[0].q_max, 0.7);
assert_eq!(again.network.buses[1].q_max, f64::INFINITY);
}