feat(core): add mock flow solver with conservation-based distribution

Treats each relation network as a conductance network (diameter^4 / length),
solves nodal balance per connected component, so flow is conserved at every
junction and wider pipes carry proportionally more flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ilya Ashikhmin 2026-07-02 23:14:11 +02:00
parent 18d614ec67
commit c2501ff04e
5 changed files with 332 additions and 0 deletions

View File

@ -8,6 +8,8 @@ add_library(diagcore STATIC
core/NetworkModel.cpp core/NetworkModel.cpp
core/Router.h core/Router.h
core/Router.cpp core/Router.cpp
core/FlowSolver.h
core/FlowSolver.cpp
) )
target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(diagcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui) target_link_libraries(diagcore PUBLIC Qt6::Core Qt6::Gui)

177
src/core/FlowSolver.cpp Normal file
View File

@ -0,0 +1,177 @@
#include "FlowSolver.h"
#include "NetworkModel.h"
#include <QtMath>
#include <QVector>
#include <functional>
#include <numeric>
namespace diag {
namespace {
// Conductance mock: wider and shorter pipes carry more flow.
double conductance(const NetworkModel& model, QUuid edgeId)
{
const double d = model.edgeProperty(edgeId, QStringLiteral("diameter")).toDouble();
const double len = model.edgeProperty(edgeId, QStringLiteral("length")).toDouble();
const double dm = d > 0 ? d : 100.0;
const double lm = len > 0 ? len : 10.0;
return qPow(dm / 100.0, 4) / lm;
}
double injection(const NetworkModel& model, QUuid nodeId)
{
const double supply = model.nodeProperty(nodeId, QStringLiteral("supply")).toDouble();
const double demand = model.nodeProperty(nodeId, QStringLiteral("demand")).toDouble();
return supply - demand;
}
// Solve A x = b in-place; returns false on a singular matrix.
bool gauss(QVector<QVector<double>>& a, QVector<double>& b, QVector<double>& x)
{
const int n = b.size();
for (int col = 0; col < n; ++col) {
int pivot = col;
for (int row = col + 1; row < n; ++row)
if (qAbs(a[row][col]) > qAbs(a[pivot][col]))
pivot = row;
if (qAbs(a[pivot][col]) < 1e-12)
return false;
a.swapItemsAt(col, pivot);
std::swap(b[col], b[pivot]);
for (int row = col + 1; row < n; ++row) {
const double f = a[row][col] / a[col][col];
if (f == 0.0)
continue;
for (int k = col; k < n; ++k)
a[row][k] -= f * a[col][k];
b[row] -= f * b[col];
}
}
x.resize(n);
for (int row = n - 1; row >= 0; --row) {
double sum = b[row];
for (int k = row + 1; k < n; ++k)
sum -= a[row][k] * x[k];
x[row] = sum / a[row][row];
}
return true;
}
} // namespace
FlowResult FlowSolver::solve(const NetworkModel& model, const QString& relationId)
{
FlowResult result;
// Edges of this relation and the nodes they touch.
QList<QUuid> edges;
QList<QUuid> nodes;
QHash<QUuid, int> nodeIndex;
for (QUuid id : model.edgeIds()) {
const Edge* e = model.edge(id);
if (!e || e->relation != relationId)
continue;
edges.append(id);
for (QUuid n : {e->fromNode, e->toNode}) {
if (!nodeIndex.contains(n)) {
nodeIndex.insert(n, nodes.size());
nodes.append(n);
}
}
}
if (edges.isEmpty()) {
result.ok = true;
result.message = QStringLiteral("No %1 edges").arg(relationId);
return result;
}
// Connected components (union-find).
QVector<int> parent(nodes.size());
for (int i = 0; i < parent.size(); ++i)
parent[i] = i;
std::function<int(int)> find = [&](int i) {
while (parent[i] != i)
i = parent[i] = parent[parent[i]];
return i;
};
for (QUuid id : edges) {
const Edge* e = model.edge(id);
parent[find(nodeIndex[e->fromNode])] = find(nodeIndex[e->toNode]);
}
// Per component: balance injections to zero-sum, then solve G*p = inj
// with the component's last node grounded (p = 0).
QHash<int, QList<int>> components; // root -> node indices
for (int i = 0; i < nodes.size(); ++i)
components[find(i)].append(i);
QHash<QUuid, double> potentials;
for (auto it = components.begin(); it != components.end(); ++it) {
const QList<int>& comp = it.value();
const int n = comp.size();
QHash<int, int> local; // global node index -> local index
for (int i = 0; i < n; ++i)
local.insert(comp.at(i), i);
QVector<double> inj(n, 0.0);
for (int i = 0; i < n; ++i)
inj[i] = injection(model, nodes.at(comp.at(i)));
const double imbalance = std::accumulate(inj.begin(), inj.end(), 0.0);
for (int i = 0; i < n; ++i)
inj[i] -= imbalance / n;
if (n < 2)
continue;
// Laplacian, reduced by grounding the last local node.
const int m = n - 1;
QVector<QVector<double>> a(m, QVector<double>(m, 0.0));
QVector<double> b(m, 0.0);
for (int i = 0; i < m; ++i)
b[i] = inj[i];
for (QUuid id : edges) {
const Edge* e = model.edge(id);
if (!local.contains(nodeIndex[e->fromNode]))
continue; // edge belongs to another component
const int u = local[nodeIndex[e->fromNode]];
const int v = local[nodeIndex[e->toNode]];
const double g = conductance(model, id);
if (u < m)
a[u][u] += g;
if (v < m)
a[v][v] += g;
if (u < m && v < m) {
a[u][v] -= g;
a[v][u] -= g;
}
}
QVector<double> p;
if (!gauss(a, b, p)) {
result.message = QStringLiteral("Singular network (parallel zero paths)");
return result;
}
for (int i = 0; i < n; ++i)
potentials.insert(nodes.at(comp.at(i)), i < m ? p[i] : 0.0);
}
for (QUuid id : edges) {
const Edge* e = model.edge(id);
const double g = conductance(model, id);
const double flow = g * (potentials.value(e->fromNode) - potentials.value(e->toNode));
result.edgeFlow.insert(id, flow);
}
result.ok = true;
return result;
}
void FlowSolver::apply(NetworkModel& model, const FlowResult& result)
{
for (auto it = result.edgeFlow.constBegin(); it != result.edgeFlow.constEnd(); ++it)
model.setEdgeFlow(it.key(), it.value());
}
} // namespace diag

29
src/core/FlowSolver.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
#include "Types.h"
#include <QHash>
namespace diag {
class NetworkModel;
struct FlowResult {
bool ok = false;
QString message;
QHash<QUuid, double> edgeFlow; // signed; positive flows from -> to
};
// Mock flow-distribution calculation. Treats each relation network as a
// resistor-style network: edge conductance derives from pipe diameter and
// length, node injections from "supply"/"demand" properties. Solves nodal
// balance (Kirchhoff) per connected component with dense Gaussian
// elimination, so flow is conserved at every junction.
class FlowSolver {
public:
static FlowResult solve(const NetworkModel& model, const QString& relationId);
// Writes flows into the model (setEdgeFlow); edges of other relations keep theirs.
static void apply(NetworkModel& model, const FlowResult& result);
};
} // namespace diag

View File

@ -9,3 +9,4 @@ endfunction()
diag_add_test(tst_model diagcore) diag_add_test(tst_model diagcore)
diag_add_test(tst_properties diagcore) diag_add_test(tst_properties diagcore)
diag_add_test(tst_router diagcore) diag_add_test(tst_router diagcore)
diag_add_test(tst_solver diagcore)

123
tests/tst_solver.cpp Normal file
View File

@ -0,0 +1,123 @@
#include "TestFixtures.h"
#include "core/FlowSolver.h"
#include "core/NetworkModel.h"
#include <QSignalSpy>
#include <QTest>
using namespace diag;
class TestSolver : public QObject {
Q_OBJECT
private slots:
void init()
{
fixtures::fillRegistry(m_reg);
delete m_model;
m_model = new NetworkModel(&m_reg, this);
}
void chain_carriesSupplyThrough()
{
// source(10) -> pump -> consumer(10)
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid pump = m_model->addNode(QStringLiteral("pump"), {200, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {400, 0});
const QUuid e1 = m_model->addEdge(src, "out", pump, "in");
const QUuid e2 = m_model->addEdge(pump, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
QVERIFY(qAbs(r.edgeFlow.value(e1) - 10.0) < 1e-6);
QVERIFY(qAbs(r.edgeFlow.value(e2) - 10.0) < 1e-6);
}
void junction_conservesFlow()
{
// source(10) -> junction -> two consumers (arbitrary demands).
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid j = m_model->addNode(QStringLiteral("junction"), {200, 0});
const QUuid c1 = m_model->addNode(QStringLiteral("consumer"), {400, -100});
const QUuid c2 = m_model->addNode(QStringLiteral("consumer"), {400, 100});
m_model->setNodeProperty(c1, "demand", 4.0);
m_model->setNodeProperty(c2, "demand", 6.0);
const QUuid eIn = m_model->addEdge(src, "out", j, "w");
const QUuid eOut1 = m_model->addEdge(j, "n", c1, "in");
const QUuid eOut2 = m_model->addEdge(j, "s", c2, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
const double in = r.edgeFlow.value(eIn);
const double out = r.edgeFlow.value(eOut1) + r.edgeFlow.value(eOut2);
QVERIFY(qAbs(in - out) < 1e-6); // Kirchhoff at the junction
QVERIFY(qAbs(in - 10.0) < 1e-6); // all supply enters
QVERIFY(qAbs(r.edgeFlow.value(eOut1) - 4.0) < 1e-6);
QVERIFY(qAbs(r.edgeFlow.value(eOut2) - 6.0) < 1e-6);
}
void parallelPipes_widerCarriesMore()
{
// Two parallel pipes between two junctions; DN150 vs DN75.
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid j1 = m_model->addNode(QStringLiteral("junction"), {200, 0});
const QUuid j2 = m_model->addNode(QStringLiteral("junction"), {400, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {600, 0});
m_model->addEdge(src, "out", j1, "w");
const QUuid wide = m_model->addEdge(j1, "n", j2, "n");
const QUuid narrow = m_model->addEdge(j1, "s", j2, "s");
m_model->addEdge(j2, "e", c, "in");
m_model->setEdgeProperty(wide, "diameter", 150.0);
m_model->setEdgeProperty(narrow, "diameter", 75.0);
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
const double fWide = r.edgeFlow.value(wide);
const double fNarrow = r.edgeFlow.value(narrow);
QVERIFY(fWide > 0 && fNarrow > 0);
QVERIFY(qAbs(fWide + fNarrow - 10.0) < 1e-6);
// Conductance ~ d^4: ratio (150/75)^4 = 16.
QVERIFY(qAbs(fWide / fNarrow - 16.0) < 1e-6);
}
void unbalancedInjections_areNormalized()
{
// Supply 10 vs demand 4: imbalance is spread, but flow stays conserved.
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
m_model->setNodeProperty(c, "demand", 4.0);
const QUuid e = m_model->addEdge(src, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("water"));
QVERIFY(r.ok);
QVERIFY(qAbs(r.edgeFlow.value(e) - 7.0) < 1e-6); // (10 + 4) / 2
}
void otherRelation_untouched()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
m_model->addEdge(src, "out", c, "in");
const FlowResult r = FlowSolver::solve(*m_model, QStringLiteral("power"));
QVERIFY(r.ok);
QVERIFY(r.edgeFlow.isEmpty());
}
void apply_writesFlowsAndSignals()
{
const QUuid src = m_model->addNode(QStringLiteral("source"), {0, 0});
const QUuid c = m_model->addNode(QStringLiteral("consumer"), {200, 0});
const QUuid e = m_model->addEdge(src, "out", c, "in");
QSignalSpy spy(m_model, &NetworkModel::flowChanged);
FlowSolver::apply(*m_model, FlowSolver::solve(*m_model, QStringLiteral("water")));
QCOMPARE(spy.count(), 1);
QVERIFY(m_model->edge(e)->flowRate > 0);
}
private:
TypeRegistry m_reg;
NetworkModel* m_model = nullptr;
};
QTEST_GUILESS_MAIN(TestSolver)
#include "tst_solver.moc"