Skip to content

Commit d66a05a

Browse files
Phase 3.1 started: added thermal_mass solver strategies (Euler/RK4) with method-dependent stability, updated interfaces/docs, and introduced regression and determinism test coverage
1 parent f997bcf commit d66a05a

9 files changed

Lines changed: 354 additions & 18 deletions

File tree

docs/api-reference.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ model.params["ambient_signal"] = std::string("ambient.temp");
241241
model.params["thermal_mass"] = 1000.0; // J/degC
242242
model.params["heat_transfer_coeff"] = 10.0; // W/degC
243243
model.params["initial_temp"] = 25.0; // degC
244+
model.params["integration_method"] = std::string("forward_euler"); // optional: "forward_euler" (default) or "rk4"
244245
spec.models.push_back(model);
245246
```
246247

@@ -504,8 +505,12 @@ Parameters:
504505
- h: heat_transfer_coeff (W/degC) - Convection coefficient
505506
- P: power_signal (W) - Heat input
506507
- T_ambient: ambient_signal (degC) - Ambient temperature
508+
- integration_method (optional): `"forward_euler"` (default) or `"rk4"`
507509
508-
**Stability limit:** dt < 2\*C/h
510+
**Stability limit:**
511+
512+
- `forward_euler`: dt < 2\*C/h
513+
- `rk4`: dt < 2.785293563\*C/h (negative real-axis stability bound)
509514
510515
Example usage:
511516
@@ -518,6 +523,7 @@ model.params["ambient_signal"] = std::string("ambient");
518523
model.params["thermal_mass"] = 1000.0;
519524
model.params["heat_transfer_coeff"] = 10.0;
520525
model.params["initial_temp"] = 25.0;
526+
model.params["integration_method"] = std::string("rk4"); // optional
521527
```
522528

523529
---

docs/numerical-methods.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Numerical Methods Policy
2+
3+
## Scope
4+
5+
This document defines the numerical integration policy for FluxGraph models.
6+
It complements `docs/semantics_spec.md` and is authoritative for solver
7+
selection and stability interpretation.
8+
9+
## Current Policy (Phase 3.1 baseline)
10+
11+
1. Integration method selection is explicit per model via model parameters.
12+
2. If not specified, the deterministic default is `forward_euler`.
13+
3. Methods are selected at compile time and remain fixed at runtime.
14+
4. Runtime behavior must be deterministic for identical inputs and `dt`.
15+
16+
## ThermalMassModel Integration Methods
17+
18+
`thermal_mass` supports:
19+
20+
1. `forward_euler` (default)
21+
2. `rk4` (classic fourth-order Runge-Kutta)
22+
23+
Selection is provided via:
24+
25+
```cpp
26+
model.params["integration_method"] = std::string("forward_euler");
27+
// or
28+
model.params["integration_method"] = std::string("rk4");
29+
```
30+
31+
Unknown method names are compile-time errors.
32+
33+
## Stability Limits
34+
35+
For the linear thermal model `dT/dt = (P - h*(T - T_amb)) / C`, with
36+
`k = h/C`:
37+
38+
1. `forward_euler`: `dt < 2/k = 2*C/h`
39+
2. `rk4`: `dt < 2.785293563/k = 2.785293563*C/h`
40+
41+
If `h <= 0`, the model is treated as unconditionally stable for this criterion.
42+
43+
## Validation Expectations
44+
45+
Phase 3 validation must report:
46+
47+
1. Error metrics (`L2`, `Linf`) versus analytical references.
48+
2. Convergence behavior as `dt` is refined.
49+
3. Determinism checks for each supported integration method.
50+
51+
## Forward Compatibility
52+
53+
Future methods (for example trapezoidal or implicit schemes) must define:
54+
55+
1. Stability policy and limits.
56+
2. Deterministic selection and defaults.
57+
3. Regression and analytical validation coverage before release.

include/fluxgraph/model/interface.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class IModel {
2121
virtual void reset() = 0;
2222

2323
/// Compute maximum stable time step for this model
24-
/// Based on Forward Euler stability criteria
24+
/// Based on the model's configured integration method
2525
/// @return Maximum safe dt in seconds
2626
virtual double compute_stability_limit() const = 0;
2727

include/fluxgraph/model/thermal_mass.hpp

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77

88
namespace fluxgraph {
99

10+
enum class ThermalIntegrationMethod {
11+
ForwardEuler,
12+
Rk4,
13+
};
14+
15+
const char *to_string(ThermalIntegrationMethod method);
16+
ThermalIntegrationMethod
17+
parse_thermal_integration_method(const std::string &method_name);
18+
1019
/// Thermal mass model: simple heat capacity with power input and ambient
1120
/// cooling Physics: dT/dt = (P_in - h*(T - T_amb)) / C Where:
1221
/// T = temperature (degC)
@@ -32,12 +41,14 @@ class ThermalMassModel : public IModel {
3241
double heat_transfer_coeff, double initial_temp,
3342
const std::string &temp_signal_path,
3443
const std::string &power_signal_path,
35-
const std::string &ambient_signal_path, SignalNamespace &ns);
44+
const std::string &ambient_signal_path, SignalNamespace &ns,
45+
ThermalIntegrationMethod integration_method =
46+
ThermalIntegrationMethod::ForwardEuler);
3647

3748
void tick(double dt, SignalStore &store) override;
3849
void reset() override;
3950

40-
/// Stability limit for Forward Euler: dt < 2*C/h
51+
/// Stability limit for the selected integration method
4152
double compute_stability_limit() const override;
4253

4354
std::string describe() const override;
@@ -52,6 +63,10 @@ class ThermalMassModel : public IModel {
5263
double heat_transfer_coeff_; // h (W/K)
5364
double temperature_; // Current temp (degC)
5465
double initial_temp_; // Initial temp for reset (degC)
66+
ThermalIntegrationMethod integration_method_;
67+
68+
double derivative(double temperature, double net_power,
69+
double ambient) const;
5570
};
5671

5772
} // namespace fluxgraph

src/graph/compiler.cpp

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,23 @@ void ensure_default_factories_registered_locked(FactoryRegistry &registry) {
293293
std::string ambient_path =
294294
as_string(require_param(spec.params, "ambient_signal", context),
295295
context + "/ambient_signal");
296+
ThermalIntegrationMethod integration_method =
297+
ThermalIntegrationMethod::ForwardEuler;
298+
if (auto it = spec.params.find("integration_method");
299+
it != spec.params.end()) {
300+
const std::string method_name =
301+
as_string(it->second, context + "/integration_method");
302+
try {
303+
integration_method = parse_thermal_integration_method(method_name);
304+
} catch (const std::invalid_argument &e) {
305+
throw std::runtime_error("Invalid parameter at " + context +
306+
"/integration_method: " + e.what());
307+
}
308+
}
296309

297310
return std::make_unique<ThermalMassModel>(
298311
spec.id, thermal_mass, heat_transfer_coeff, initial_temp, temp_path,
299-
power_path, ambient_path, ns);
312+
power_path, ambient_path, ns, integration_method);
300313
});
301314

302315
registry.defaults_registered = true;

src/model/thermal_mass.cpp

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,77 @@
11
#include "fluxgraph/model/thermal_mass.hpp"
22
#include <limits>
3+
#include <stdexcept>
34
#include <sstream>
45

56
namespace fluxgraph {
67

8+
namespace {
9+
10+
constexpr double kRk4NegativeRealAxisStabilityLimit = 2.785293563405282;
11+
12+
} // namespace
13+
14+
const char *to_string(ThermalIntegrationMethod method) {
15+
switch (method) {
16+
case ThermalIntegrationMethod::ForwardEuler:
17+
return "forward_euler";
18+
case ThermalIntegrationMethod::Rk4:
19+
return "rk4";
20+
}
21+
return "forward_euler";
22+
}
23+
24+
ThermalIntegrationMethod
25+
parse_thermal_integration_method(const std::string &method_name) {
26+
if (method_name == "forward_euler") {
27+
return ThermalIntegrationMethod::ForwardEuler;
28+
}
29+
if (method_name == "rk4") {
30+
return ThermalIntegrationMethod::Rk4;
31+
}
32+
33+
throw std::invalid_argument(
34+
"Unknown thermal integration method '" + method_name +
35+
"' (expected one of: forward_euler, rk4)");
36+
}
37+
738
ThermalMassModel::ThermalMassModel(const std::string &id, double thermal_mass,
839
double heat_transfer_coeff,
940
double initial_temp,
1041
const std::string &temp_signal_path,
1142
const std::string &power_signal_path,
1243
const std::string &ambient_signal_path,
13-
SignalNamespace &ns)
44+
SignalNamespace &ns,
45+
ThermalIntegrationMethod integration_method)
1446
: id_(id), temp_signal_(ns.intern(temp_signal_path)),
1547
power_signal_(ns.intern(power_signal_path)),
1648
ambient_signal_(ns.intern(ambient_signal_path)),
1749
thermal_mass_(thermal_mass), heat_transfer_coeff_(heat_transfer_coeff),
18-
temperature_(initial_temp), initial_temp_(initial_temp) {}
50+
temperature_(initial_temp), initial_temp_(initial_temp),
51+
integration_method_(integration_method) {}
52+
53+
double ThermalMassModel::derivative(double temperature, double net_power,
54+
double ambient) const {
55+
const double heat_loss = heat_transfer_coeff_ * (temperature - ambient);
56+
return (net_power - heat_loss) / thermal_mass_;
57+
}
1958

2059
void ThermalMassModel::tick(double dt, SignalStore &store) {
2160
// Read inputs
2261
double net_power = store.read_value(power_signal_);
2362
double ambient = store.read_value(ambient_signal_);
2463

25-
// Compute heat loss to ambient
26-
double heat_loss = heat_transfer_coeff_ * (temperature_ - ambient);
27-
28-
// Forward Euler integration: T += dT/dt * dt
29-
double dT = (net_power - heat_loss) / thermal_mass_ * dt;
30-
temperature_ += dT;
64+
if (integration_method_ == ThermalIntegrationMethod::ForwardEuler) {
65+
// Forward Euler integration: T += dT/dt * dt
66+
temperature_ += derivative(temperature_, net_power, ambient) * dt;
67+
} else {
68+
// Classic RK4 with fixed inputs over the tick interval.
69+
const double k1 = derivative(temperature_, net_power, ambient);
70+
const double k2 = derivative(temperature_ + 0.5 * dt * k1, net_power, ambient);
71+
const double k3 = derivative(temperature_ + 0.5 * dt * k2, net_power, ambient);
72+
const double k4 = derivative(temperature_ + dt * k3, net_power, ambient);
73+
temperature_ += (dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4);
74+
}
3175

3276
// Write output with unit
3377
store.write(temp_signal_, temperature_, "degC");
@@ -37,21 +81,26 @@ void ThermalMassModel::tick(double dt, SignalStore &store) {
3781
void ThermalMassModel::reset() { temperature_ = initial_temp_; }
3882

3983
double ThermalMassModel::compute_stability_limit() const {
40-
// Forward Euler stability for dT/dt = -k*T: dt < 2/k
41-
// For this model: k = h/C
42-
// Therefore: dt < 2*C/h
84+
// Stability for dT/dt = -k*T with k = h/C.
4385
if (heat_transfer_coeff_ <= 0.0) {
4486
return std::numeric_limits<double>::infinity(); // No cooling =
4587
// unconditionally stable
4688
}
47-
return 2.0 * thermal_mass_ / heat_transfer_coeff_;
89+
90+
const double tau = thermal_mass_ / heat_transfer_coeff_;
91+
if (integration_method_ == ThermalIntegrationMethod::ForwardEuler) {
92+
return 2.0 * tau;
93+
}
94+
95+
return kRk4NegativeRealAxisStabilityLimit * tau;
4896
}
4997

5098
std::string ThermalMassModel::describe() const {
5199
std::ostringstream oss;
52100
oss << "ThermalMass(id=" << id_ << ", C=" << thermal_mass_ << " J/K"
53101
<< ", h=" << heat_transfer_coeff_ << " W/K"
54-
<< ", T0=" << initial_temp_ << " degC)";
102+
<< ", T0=" << initial_temp_ << " degC"
103+
<< ", method=" << to_string(integration_method_) << ")";
55104
return oss.str();
56105
}
57106

tests/integration/determinism_test.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,66 @@ TEST(DeterminismTest, SameInputSameOutput) {
139139
}
140140
}
141141

142+
TEST(DeterminismTest, SameInputSameOutputRk4) {
143+
auto build_graph = []() {
144+
GraphSpec spec;
145+
146+
ModelSpec model;
147+
model.id = "thermal";
148+
model.type = "thermal_mass";
149+
model.params["temp_signal"] = std::string("chamber.temp");
150+
model.params["power_signal"] = std::string("chamber.power");
151+
model.params["ambient_signal"] = std::string("ambient");
152+
model.params["thermal_mass"] = 1000.0;
153+
model.params["heat_transfer_coeff"] = 10.0;
154+
model.params["initial_temp"] = 25.0;
155+
model.params["integration_method"] = std::string("rk4");
156+
spec.models.push_back(model);
157+
158+
return spec;
159+
};
160+
161+
SignalNamespace ns1;
162+
FunctionNamespace fn1;
163+
SignalStore store1;
164+
Engine engine1;
165+
166+
GraphCompiler compiler1;
167+
auto program1 = compiler1.compile(build_graph(), ns1, fn1);
168+
engine1.load(std::move(program1));
169+
170+
SignalNamespace ns2;
171+
FunctionNamespace fn2;
172+
SignalStore store2;
173+
Engine engine2;
174+
175+
GraphCompiler compiler2;
176+
auto program2 = compiler2.compile(build_graph(), ns2, fn2);
177+
engine2.load(std::move(program2));
178+
179+
auto power_id1 = ns1.resolve("chamber.power");
180+
auto ambient_id1 = ns1.resolve("ambient");
181+
auto temp_id1 = ns1.resolve("chamber.temp");
182+
auto power_id2 = ns2.resolve("chamber.power");
183+
auto ambient_id2 = ns2.resolve("ambient");
184+
auto temp_id2 = ns2.resolve("chamber.temp");
185+
186+
store1.write(ambient_id1, 20.0, "degC");
187+
store2.write(ambient_id2, 20.0, "degC");
188+
189+
for (int i = 0; i < 500; ++i) {
190+
const double power = (i % 200 < 100) ? 500.0 : 100.0;
191+
store1.write(power_id1, power, "W");
192+
store2.write(power_id2, power, "W");
193+
194+
engine1.tick(0.1, store1);
195+
engine2.tick(0.1, store2);
196+
197+
EXPECT_DOUBLE_EQ(store1.read_value(temp_id1), store2.read_value(temp_id2))
198+
<< "Mismatch at tick " << i;
199+
}
200+
}
201+
142202
TEST(DeterminismTest, NoDriftOver10kTicks) {
143203
// Verify no floating-point drift over long simulations
144204

tests/unit/compiler_test.cpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,43 @@ TEST(GraphCompilerTest, ParseThermalMassModel) {
269269
delete model;
270270
}
271271

272+
TEST(GraphCompilerTest, ParseThermalMassModelWithRk4) {
273+
ModelSpec spec;
274+
spec.id = "chamber_air";
275+
spec.type = "thermal_mass";
276+
spec.params["thermal_mass"] = 1000.0;
277+
spec.params["heat_transfer_coeff"] = 10.0;
278+
spec.params["initial_temp"] = 25.0;
279+
spec.params["temp_signal"] = std::string("chamber_air/temperature");
280+
spec.params["power_signal"] = std::string("chamber_air/power");
281+
spec.params["ambient_signal"] = std::string("chamber_air/ambient");
282+
spec.params["integration_method"] = std::string("rk4");
283+
284+
SignalNamespace ns;
285+
GraphCompiler compiler;
286+
std::unique_ptr<IModel> model(compiler.parse_model(spec, ns));
287+
288+
ASSERT_NE(model, nullptr);
289+
EXPECT_NE(model->describe().find("method=rk4"), std::string::npos);
290+
}
291+
292+
TEST(GraphCompilerTest, ParseThermalMassModelWithInvalidIntegrationMethodThrows) {
293+
ModelSpec spec;
294+
spec.id = "chamber_air";
295+
spec.type = "thermal_mass";
296+
spec.params["thermal_mass"] = 1000.0;
297+
spec.params["heat_transfer_coeff"] = 10.0;
298+
spec.params["initial_temp"] = 25.0;
299+
spec.params["temp_signal"] = std::string("chamber_air/temperature");
300+
spec.params["power_signal"] = std::string("chamber_air/power");
301+
spec.params["ambient_signal"] = std::string("chamber_air/ambient");
302+
spec.params["integration_method"] = std::string("invalid_method");
303+
304+
SignalNamespace ns;
305+
GraphCompiler compiler;
306+
EXPECT_THROW(compiler.parse_model(spec, ns), std::runtime_error);
307+
}
308+
272309
TEST(GraphCompilerTest, RegisterModelFactoryRejectsInvalidInputs) {
273310
GraphCompiler::ModelFactory empty_factory;
274311

0 commit comments

Comments
 (0)