Files
GridFire/src/network/lib/engine/views/engine_multiscale.cpp

624 lines
27 KiB
C++
Raw Normal View History

#include "gridfire/engine/views/engine_multiscale.h"
#include <vector>
#include <unordered_map>
#include <unordered_set>
#include <queue>
namespace gridfire {
using fourdst::atomic::Species;
MultiscalePartitioningEngineView::MultiscalePartitioningEngineView(
GraphEngine& baseEngine
) : m_baseEngine(baseEngine) {}
const std::vector<Species> & MultiscalePartitioningEngineView::getNetworkSpecies() const {
return m_baseEngine.getNetworkSpecies();
}
StepDerivatives<double> MultiscalePartitioningEngineView::calculateRHSAndEnergy(
const std::vector<double> &Y,
const double T9,
const double rho
) const {
return m_baseEngine.calculateRHSAndEnergy(Y, T9, rho);
}
void MultiscalePartitioningEngineView::generateJacobianMatrix(
const std::vector<double> &Y_dynamic,
const double T9,
const double rho
) {
std::vector<double> Y_full(m_baseEngine.getNetworkSpecies().size(), 0.0);
for (size_t i = 0; i < m_dynamic_species_indices.size(); ++i) {
Y_full[m_dynamic_species_indices[i]] = Y_dynamic[i];
}
// solveQSEAbundances(Y_full, T9, rho);
m_baseEngine.generateJacobianMatrix(Y_dynamic, T9, rho);
}
double MultiscalePartitioningEngineView::getJacobianMatrixEntry(
const int i,
const int j
) const {
return m_baseEngine.getJacobianMatrixEntry(i, j);
}
void MultiscalePartitioningEngineView::generateStoichiometryMatrix() {
m_baseEngine.generateStoichiometryMatrix();
}
int MultiscalePartitioningEngineView::getStoichiometryMatrixEntry(
const int speciesIndex,
const int reactionIndex
) const {
return m_baseEngine.getStoichiometryMatrixEntry(speciesIndex, reactionIndex);
}
double MultiscalePartitioningEngineView::calculateMolarReactionFlow(
const reaction::Reaction &reaction,
const std::vector<double> &Y,
double T9,
double rho
) const {
return m_baseEngine.calculateMolarReactionFlow(reaction, Y, T9, rho);
}
const reaction::LogicalReactionSet & MultiscalePartitioningEngineView::getNetworkReactions() const {
return m_baseEngine.getNetworkReactions();
}
std::unordered_map<Species, double> MultiscalePartitioningEngineView::getSpeciesTimescales(
const std::vector<double> &Y,
const double T9,
const double rho
) const {
return m_baseEngine.getSpeciesTimescales(Y, T9, rho);
}
void MultiscalePartitioningEngineView::update(const NetIn &netIn) {
return; // call partition eventually
}
void MultiscalePartitioningEngineView::setScreeningModel(
const screening::ScreeningType model
) {
m_baseEngine.setScreeningModel(model);
}
screening::ScreeningType MultiscalePartitioningEngineView::getScreeningModel() const {
return m_baseEngine.getScreeningModel();
}
const DynamicEngine & MultiscalePartitioningEngineView::getBaseEngine() const {
return m_baseEngine;
}
void MultiscalePartitioningEngineView::partitionNetwork(
const std::vector<double> &Y,
const double T9,
const double rho,
const double dt_control
) {
// --- Step 0. Clear previous state ---
m_qse_groups.clear();
m_dynamic_species.clear();
m_dynamic_species_indices.clear();
m_connectivity_graph.clear();
// --- Step 1. Identify fast reactions ---
const std::unordered_set<size_t> fast_reaction_indices = identifyFastReactions(Y, T9, rho, dt_control);
// --- Step 2. Build connectivity graph based on fast reactions ---
buildConnectivityGraph(fast_reaction_indices);
// --- Step 3. Find connected components (candidate QSE clusters) ---
findConnectedComponents();
// --- Step 4. Validate clusters via flux analysis ---
validateGroupsWithFluxAnalysis(Y, T9, rho);
// --- Step 5. Identify dynamic species ---
const auto [fst, snd] = identifyDynamicSpecies(Y, m_qse_groups, T9, rho);
m_dynamic_species = fst;
m_dynamic_species_indices = snd;
}
void MultiscalePartitioningEngineView::partitionNetwork(
const NetIn &netIn,
const double dt_control
) {
std::vector<double> Y(m_baseEngine.getNetworkSpecies().size(), 0.0);
for (const auto& [symbol, entry]: netIn.composition ) {
if (!m_baseEngine.involvesSpecies(entry.isotope())) {
LOG_ERROR(m_logger, "Species {} is not part of the network. Exiting...", entry.isotope().name());
throw std::runtime_error("Species " + std::string(entry.isotope().name()) + " is not part of the network.");
}
const size_t species_index = m_baseEngine.getSpeciesIndex(entry.isotope());
Y[species_index] = netIn.composition.getMolarAbundance(symbol);
}
const double T9 = netIn.temperature / 1e9; // Convert temperature from Kelvin to T9 (T9 = T / 1e9)
const double rho = netIn.density; // Density in g/cm^3
partitionNetwork(Y, T9, rho, dt_control);
}
void MultiscalePartitioningEngineView::exportToDot(const std::string &filename) const {
std::ofstream dotFile(filename);
if (!dotFile.is_open()) {
LOG_ERROR(m_logger, "Failed to open file for writing: {}", filename);
throw std::runtime_error("Failed to open file for writing: " + filename);
}
dotFile << "digraph PartitionedNetwork {\n";
dotFile << " graph [rankdir=LR, splines=true, overlap=false, bgcolor=\"#fdfdfd\", label=\"Multiscale Partitioned Network View\"];\n";
dotFile << " node [shape=circle, style=filled, fillcolor=\"#eafaf1\", fontname=\"Helvetica\"];\n";
dotFile << " edge [fontname=\"Helvetica\", fontsize=10, color=\"#5d6d7e\"];\n\n";
// 1. Define all species from the base network as nodes
const auto& all_species = m_baseEngine.getNetworkSpecies();
dotFile << " // --- Species Nodes ---\n";
for (const auto& species : all_species) {
dotFile << " \"" << species.name() << "\" [label=\"" << species.name() << "\"];\n";
}
dotFile << "\n";
// 2. Define all reactions and their connections from the base network
dotFile << " // --- Reaction Edges ---\n";
for (const auto& reaction : m_baseEngine.getNetworkReactions()) {
std::string reactionNodeId = "reaction_" + std::string(reaction.id());
dotFile << " \"" << reactionNodeId << "\" [shape=point, fillcolor=black, width=0.08, height=0.08];\n";
for (const auto& reactant : reaction.reactants()) {
dotFile << " \"" << reactant.name() << "\" -> \"" << reactionNodeId << "\";\n";
}
for (const auto& product : reaction.products()) {
dotFile << " \"" << reactionNodeId << "\" -> \"" << product.name() << "\";\n";
}
dotFile << "\n";
}
// 3. Draw clusters around the identified QSE groups
dotFile << " // --- QSE Group Clusters ---\n";
int group_counter = 0;
for (const auto& group : m_qse_groups) {
dotFile << " subgraph cluster_" << group_counter << " {\n";
dotFile << " style = \"filled,rounded\";\n";
if (group.is_in_equilibrium) {
dotFile << " label = \"Valid QSE Group" << group_counter + 1 << "\";\n";
dotFile << " color = \"#fdebd0\";\n";
} else {
dotFile << " label = \"Non valid QSE Group" << group_counter + 1 << "\";\n";
dotFile << " color = \"#d5f5e3\";\n"; // Different color for non-equilibrium groups
}
for (const size_t species_idx : group.species_indices) {
dotFile << " \"" << all_species[species_idx].name() << "\";\n";
}
dotFile << " }\n";
group_counter++;
}
dotFile << "}\n";
dotFile.close();
}
std::vector<double> MultiscalePartitioningEngineView::mapNetInToMolarAbundanceVector(const NetIn &netIn) const {
std::vector<double> Y(m_dynamic_species.size(), 0.0); // Initialize with zeros
for (const auto& [symbol, entry] : netIn.composition) {
Y[getSpeciesIndex(entry.isotope())] = netIn.composition.getMolarAbundance(symbol); // Map species to their molar abundance
}
return Y; // Return the vector of molar abundances
}
std::vector<Species> MultiscalePartitioningEngineView::getFastSpecies() const {
const auto& all_species = m_baseEngine.getNetworkSpecies();
std::vector<Species> fast_species;
fast_species.reserve(all_species.size() - m_dynamic_species.size());
for (const auto& species : all_species) {
auto it = std::ranges::find(m_dynamic_species, species);
if (it == m_dynamic_species.end()) {
fast_species.push_back(species);
}
}
return fast_species;
}
const std::vector<Species> & MultiscalePartitioningEngineView::getDynamicSpecies() const {
return m_dynamic_species;
}
void MultiscalePartitioningEngineView::equilibrateNetwork(
const std::vector<double> &Y,
const double T9,
const double rho,
const double dt_control
) {
partitionNetwork(Y, T9, rho, dt_control);
std::vector<double> Y_equilibrated = solveQSEAbundances(Y, T9, rho);
}
void MultiscalePartitioningEngineView::equilibrateNetwork(
const NetIn& netIn,
const double dt_control
) {
std::vector<double> Y(m_baseEngine.getNetworkSpecies().size(), 0.0);
for (const auto& [symbol, entry]: netIn.composition ) {
if (!m_baseEngine.involvesSpecies(entry.isotope())) {
LOG_ERROR(m_logger, "Species {} is not part of the network. Exiting...", entry.isotope().name());
throw std::runtime_error("Species " + std::string(entry.isotope().name()) + " is not part of the network.");
}
const size_t species_index = m_baseEngine.getSpeciesIndex(entry.isotope());
Y[species_index] = netIn.composition.getMolarAbundance(symbol);
}
const double T9 = netIn.temperature / 1e9; // Convert temperature from Kelvin to T9 (T9 = T / 1e9)
const double rho = netIn.density; // Density in g/cm^3
partitionNetwork(Y, T9, rho, dt_control);
std::vector<double> Y_equilibrated = solveQSEAbundances(Y, T9, rho);
}
int MultiscalePartitioningEngineView::getSpeciesIndex(const fourdst::atomic::Species &species) const {
return m_baseEngine.getSpeciesIndex(species);
}
std::unordered_set<size_t> MultiscalePartitioningEngineView::identifyFastReactions(
const std::vector<double> &Y_full,
const double T9,
const double rho,
const double dt_control
) const {
std::unordered_set<size_t> fast_reaction_indices;
std::unordered_set<size_t> fast_species_indices;
const double timescale_threshold = 0.01 * dt_control;
constexpr int FAST_BOOTSTRAPPING_ITERATIONS = 2;
const auto& all_reactions = m_baseEngine.getNetworkReactions();
// Loop a few times to refine the fast species and reactions.
for (int iteration = 0; iteration < FAST_BOOTSTRAPPING_ITERATIONS; ++iteration) {
// --- Step A: Identify fast species based on their individual reaction timescales ---
for (size_t i = 0; i < all_reactions.size(); ++i) {
const auto& reaction = all_reactions[i];
// Calculate the forward molar flow, which represents the rate of destruction.
// Note: We use the base engine's direct calculation method.
const double forward_flow = m_baseEngine.calculateMolarReactionFlow(reaction, Y_full, T9, rho);
if (forward_flow == 0.0) continue;
// Determine the timescale for each reactant against this specific reaction.
for (const auto& reactant : reaction.reactants()) {
// ~ Timescale = Abundance / Destruction Rate
// TODO: Should this call base engine getSpeciesIndex or this view's getSpeciesIndex?
const size_t reactant_idx = m_baseEngine.getSpeciesIndex(reactant);
const double abundance = Y_full[reactant_idx];
if (abundance > 0) {
const double timescale = abundance / forward_flow;
if (timescale < timescale_threshold) {
fast_species_indices.insert(reactant_idx);
}
}
}
}
// --- Step B: Identify fast reactions based on reactants ---
for (size_t i = 0; i < all_reactions.size(); ++i) {
const auto& reaction = all_reactions[i];
bool all_reactants_are_fast = true;
if (reaction.reactants().empty()) {
all_reactants_are_fast = false;
}
for (const auto& reactant : reaction.reactants()) {
if (!fast_species_indices.contains(m_baseEngine.getSpeciesIndex(reactant))) {
all_reactants_are_fast = false;
break; // No need to check further if one reactant is not fast
}
}
if (all_reactants_are_fast) {
fast_reaction_indices.insert(i);
}
}
// --- Step C: Propagate "fast" status to products of the fast reactions (this handles things like n-1) ---
for (size_t reaction_idx : fast_reaction_indices) {
for (const auto& product : all_reactions[reaction_idx].products()) {
fast_species_indices.insert(m_baseEngine.getSpeciesIndex(product));
}
}
}
return fast_reaction_indices;
}
void MultiscalePartitioningEngineView::buildConnectivityGraph(
const std::unordered_set<size_t> &fast_reaction_indices
) {
const auto& all_reactions = m_baseEngine.getNetworkReactions();
for (const size_t reaction_idx : fast_reaction_indices) {
const auto& reaction = all_reactions[reaction_idx];
const auto& reactants = reaction.reactants();
const auto& products = reaction.products();
// For each fast reaction, create edges between all reactants and all products.
// This represents that nucleons can flow quickly between these species.
for (const auto& reactant : reactants) {
const size_t reactant_idx = m_baseEngine.getSpeciesIndex(reactant);
for (const auto& product : products) {
const size_t product_idx = m_baseEngine.getSpeciesIndex(product);
// Add a two-way edge to the adjacency list.
m_connectivity_graph[reactant_idx].push_back(product_idx);
m_connectivity_graph[product_idx].push_back(reactant_idx);
}
}
}
}
void MultiscalePartitioningEngineView::findConnectedComponents() {
const size_t num_total_species = m_baseEngine.getNetworkSpecies().size();
std::vector<bool> visited(num_total_species, false);
for (size_t i = 0; i < num_total_species; ++i) {
if (!visited[i]) {
// Start a new BFS traversal from this unvisited node.
// This will find one complete connected component.
QSEGroup new_group;
std::queue<size_t> q;
q.push(i);
visited[i] = true;
while (!q.empty()) {
const size_t current_species_idx = q.front();
q.pop();
new_group.species_indices.push_back(current_species_idx);
// Check if the node exists in the graph (it might be an isolated species)
if (m_connectivity_graph.contains(current_species_idx)) {
for (const size_t neighbor_idx : m_connectivity_graph.at(current_species_idx)) {
if (!visited[neighbor_idx]) {
visited[neighbor_idx] = true;
q.push(neighbor_idx);
}
}
}
}
// A "group" must contain more than one species to be a cluster.
// Isolated species are treated as dynamic by default.
if (new_group.species_indices.size() > 1) {
m_qse_groups.push_back(new_group);
}
}
}
}
void MultiscalePartitioningEngineView::validateGroupsWithFluxAnalysis(
const std::vector<double> &Y,
const double T9,
const double rho
) {
constexpr double FLUX_RATIO_THRESHOLD = 100;
for (auto& group : m_qse_groups) {
double internal_flux = 0.0;
double external_flux = 0.0;
const std::unordered_set<size_t> group_members(
group.species_indices.begin(),
group.species_indices.end()
);
for (const auto& reaction: m_baseEngine.getNetworkReactions()) {
const double flow = std::abs(m_baseEngine.calculateMolarReactionFlow(reaction, Y, T9, rho));
if (flow == 0.0) {
continue; // Skip reactions with zero flow
}
bool has_internal_reactant = false;
bool has_external_reactant = false;
for (const auto& reactant : reaction.reactants()) {
if (group_members.contains(m_baseEngine.getSpeciesIndex(reactant))) {
has_internal_reactant = true;
} else {
has_external_reactant = true;
}
}
bool has_internal_product = false;
bool has_external_product = false;
for (const auto& product : reaction.products()) {
if (group_members.contains(m_baseEngine.getSpeciesIndex(product))) {
has_internal_product = true;
} else {
has_external_product = true;
}
}
// Classify the reaction based on its participants
if ((has_internal_reactant && has_internal_product) && !(has_external_reactant || has_external_product)) {
internal_flux += flow; // Internal flux within the group
} else if ((has_internal_reactant || has_internal_product) && (has_external_reactant || has_external_product)) {
external_flux += flow; // External flux to/from the group
}
// otherwise the reaction is fully decoupled from the QSE group and can be ignored.
}
if (internal_flux > FLUX_RATIO_THRESHOLD * external_flux) {
group.is_in_equilibrium = true; // This group is in equilibrium if internal flux is significantly larger than external flux.
} else {
group.is_in_equilibrium = false;
}
}
}
std::pair<std::vector<Species>, std::vector<size_t>> MultiscalePartitioningEngineView::identifyDynamicSpecies(
const std::vector<double>& Y,
const std::vector<QSEGroup>& qse_groups,
const double T9,
const double rho
) const {
// This set will contain the indices of species that are truly in qse (as opposed to those which might be in a qse group but are not in equilibrium).
std::unordered_set<size_t> algebraic_qse_species_indices;
auto species_timescales = m_baseEngine.getSpeciesTimescales(Y, T9, rho);
for (auto& group : qse_groups) {
if (!group.is_in_equilibrium || group.species_indices.empty()) continue;
for (size_t species_idx : group.species_indices) {
const double timescale = species_timescales.at(m_baseEngine.getNetworkSpecies()[species_idx]);
if (timescale < 1) { // If the timescale is less than 1 second, we consider it algebraic.
algebraic_qse_species_indices.insert(species_idx);
}
}
}
std::vector<fourdst::atomic::Species> dynamic_species;
std::unordered_set<size_t> qse_species_indices_set;
std::vector<size_t> dynamic_species_indices;
const auto& all_base_species = m_baseEngine.getNetworkSpecies();
for (size_t i = 0; i < all_base_species.size(); ++i) {
if (!algebraic_qse_species_indices.contains(i)) {
dynamic_species.push_back(all_base_species[i]);
dynamic_species_indices.push_back(i);
}
}
return {dynamic_species, dynamic_species_indices};
}
std::vector<double> MultiscalePartitioningEngineView::solveQSEAbundances(
const std::vector<double> &Y_full,
const double T9,
const double rho
) {
auto Y_out = Y_full;
auto species_timescales = m_baseEngine.getSpeciesTimescales(Y_full, T9, rho);
for (const auto& qse_group : m_qse_groups) {
if (!qse_group.is_in_equilibrium || qse_group.species_indices.empty()) continue;
std::vector<size_t> qse_solve_indices;
std::vector<size_t> seed_indices;
for (size_t species_idx : qse_group.species_indices) {
const double timescale = species_timescales.at(m_baseEngine.getNetworkSpecies()[species_idx]);
if (timescale < 1.0) { // If the timescale is less than 1 second, we consider it algebraic.
qse_solve_indices.push_back(species_idx);
} else {
seed_indices.push_back(species_idx);
}
}
if (qse_solve_indices.empty()) continue;
Eigen::VectorXd Y_scale(qse_solve_indices.size());
Eigen::VectorXd v_initial(qse_solve_indices.size());
for (size_t i = 0; i < qse_solve_indices.size(); ++i) {
constexpr double abundance_floor = 1.0e-15;
const double initial_abundance = Y_full[qse_solve_indices[i]];
Y_scale(i) = std::max(initial_abundance, abundance_floor);
v_initial(i) = std::asinh(initial_abundance / Y_scale(i)); // Scale the initial abundances using asinh
}
EigenFunctor functor(*this, qse_solve_indices, Y_full, T9, rho, Y_scale);
Eigen::LevenbergMarquardt lm(functor);
lm.parameters.ftol = 1.0e-10;
lm.parameters.xtol = 1.0e-10;
Eigen::LevenbergMarquardtSpace::Status status = lm.minimize(v_initial);
if (status <= 0 || status >= 4) {
std::stringstream msg;
msg << "QSE solver failed with status: " << status << " for group with seed ";
if (seed_indices.size() == 1) {
msg << "nucleus " << m_baseEngine.getNetworkSpecies()[seed_indices[0]].name();
} else {
msg << "nuclei ";
// TODO: Refactor nice list printing into utility function somewhere
int counter = 0;
for (const auto& seed_idx : seed_indices) {
msg << m_baseEngine.getNetworkSpecies()[seed_idx].name();
if (counter < seed_indices.size() - 2) {
msg << ", ";
} else if (counter == seed_indices.size() - 2) {
if (seed_indices.size() < 2) {
msg << " and ";
} else {
msg << ", and ";
}
}
++counter;
}
}
throw std::runtime_error(msg.str());
}
Eigen::VectorXd Y_final_qse = Y_scale.array() * v_initial.array().sinh(); // Convert back to physical abundances using asinh scaling
for (size_t i = 0; i < qse_solve_indices.size(); ++i) {
Y_out[qse_solve_indices[i]] = Y_final_qse(i);
std::cout << "Updating species " << m_baseEngine.getNetworkSpecies()[qse_solve_indices[i]].name()
<< " to QSE abundance: " << Y_final_qse(i) << std::endl;
}
}
return Y_out;
}
int MultiscalePartitioningEngineView::EigenFunctor::operator()(const InputType &v_qse, OutputType &f_qse) const {
std::vector<double> y_trial = m_Y_full_initial;
Eigen::VectorXd y_qse = m_Y_scale.array() * v_qse.array().sinh(); // Convert to physical abundances using asinh scaling
for (size_t i = 0; i < m_qse_solve_indices.size(); ++i) {
y_trial[m_qse_solve_indices[i]] = y_qse(i);
}
const auto [dydt, energy] = m_view->calculateRHSAndEnergy(y_trial, m_T9, m_rho);
f_qse.resize(m_qse_solve_indices.size());
for (size_t i = 0; i < m_qse_solve_indices.size(); ++i) {
f_qse(i) = dydt[m_qse_solve_indices[i]];
}
return 0; // Success
}
int MultiscalePartitioningEngineView::EigenFunctor::df(const InputType &v_qse, JacobianType &J_qse) const {
std::vector<double> y_trial = m_Y_full_initial;
Eigen::VectorXd y_qse = m_Y_scale.array() * v_qse.array().sinh(); // Convert to physical abundances using asinh scaling
for (size_t i = 0; i < m_qse_solve_indices.size(); ++i) {
y_trial[m_qse_solve_indices[i]] = y_qse(i);
}
// TODO: Think about if the jacobian matrix should be mutable so that generateJacobianMatrix can be const
m_view->generateJacobianMatrix(y_trial, m_T9, m_rho);
// TODO: Think very carefully about the indices here.
J_qse.resize(m_qse_solve_indices.size(), m_qse_solve_indices.size());
for (size_t i = 0; i < m_qse_solve_indices.size(); ++i) {
for (size_t j = 0; j < m_qse_solve_indices.size(); ++j) {
J_qse(i, j) = m_view->getJacobianMatrixEntry(
m_qse_solve_indices[i],
m_qse_solve_indices[j]
);
}
}
// Chain rule for asinh scaling:
for (long j = 0; j < J_qse.cols(); ++j) {
const double dY_dv = m_Y_scale(j) * std::cosh(v_qse(j));
J_qse.col(j) *= dY_dv; // Scale the column by the derivative of the asinh scaling
}
return 0; // Success
}
}