Skip to main content

cmtool_core/model/
mod.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//We need to expose some grid interface to cfd-assemble
4//TODO: refractor model to put cfd oriented into separated mod and move "reactor model" such as 0d/pfr from assemble to here
5
6use crate::{
7    CoreError,
8    coordinates::{CartesianCoordinates, CartesianVec3},
9    errors::ModelError,
10    model::{
11        compartments::{CompartmentInfo, CountVolumeElement, ElementVolumeInfo},
12        interfaces::{AInterfacesInfo, InterfaceFlow, InterfaceInfo},
13    },
14};
15use std::{f64, sync::Arc};
16mod data;
17use cmtool_data::{FluxFileHeader, RawDataFlux, RawDataScalar, RawFlux};
18mod balance;
19mod compartments;
20mod geometry;
21mod interfaces;
22mod scalar;
23mod vectors;
24pub use scalar::Scalar;
25pub use vectors::Vector as ModelVector;
26pub use vectors::Vector;
27
28pub use balance::{BalanceReport, BalanceSettings};
29
30pub struct CMModel {
31    geometry: Arc<CMGeometry>,
32    c_info: CompartmentInfo,
33    interfaces: AInterfacesInfo,
34}
35
36pub use geometry::CMGeometry;
37
38fn get_data_flow(
39    n_zones: usize,
40    i_info: &[InterfaceInfo],
41    i_flow: &[InterfaceFlow],
42) -> RawDataFlux {
43    let fluxes: Vec<RawFlux> = i_info
44        .iter()
45        .zip(i_flow)
46        .map(|(k_i_nfo, k_i_flow)| RawFlux {
47            id_source: k_i_nfo.source_id as u32,
48            id_target: k_i_nfo.target_id as u32,
49            flux_source_target: k_i_flow.source_flow,
50            flux_target_source: k_i_flow.target_flow,
51        })
52        .collect();
53    RawDataFlux {
54        header: FluxFileHeader {
55            n_fluxes: i_flow.len() as u32,
56            n_zone: n_zones as u32,
57        },
58        fluxes,
59    }
60}
61
62impl CMModel {
63    ///Both directions must carry a usable flow and both ids must address a compartment,
64    ///check_flow indexes mass_balance with them right after
65    fn check_flux(n_zone: u32, rf: &RawFlux) -> bool {
66        let is_flow_valid = |flow: f64| flow.is_finite() && flow.is_sign_positive();
67
68        is_flow_valid(rf.flux_source_target)
69            && is_flow_valid(rf.flux_target_source)
70            && rf.id_source < n_zone
71            && rf.id_target < n_zone
72    }
73
74    pub fn check_flow(&self, raw: &RawDataFlux, max_divergence: f64) -> Result<(), ModelError> {
75        const ABS_TOLERANCE_DIVERGENCE_CELL: f64 = 1e-7;
76
77        //A field of zeros balances perfectly and transports nothing: it is missing data, not a
78        //valid flow map
79        if raw
80            .fluxes
81            .iter()
82            .all(|flux| flux.flux_source_target == 0. && flux.flux_target_source == 0.)
83        {
84            return Err(ModelError::EmptyFlow);
85        }
86
87        let mut mass_balance: Vec<InterfaceFlow> =
88            vec![InterfaceFlow::default(); raw.header.n_zone as usize];
89        let mut id_max = u32::MIN;
90
91        for flow in raw.fluxes.iter() {
92            if !Self::check_flux(raw.header.n_zone, flow) {
93                return Err(ModelError::InvalidFlow);
94            }
95
96            id_max = u32::max(id_max, flow.id_source);
97            //out
98            mass_balance[flow.id_source as usize].source_flow += flow.flux_target_source;
99            //in
100            mass_balance[flow.id_source as usize].target_flow += flow.flux_source_target;
101
102            //in
103            mass_balance[flow.id_target as usize].source_flow += flow.flux_source_target;
104            //out
105            mass_balance[flow.id_target as usize].target_flow += flow.flux_target_source;
106        }
107
108        for flow in &mass_balance {
109            let abs_diff = (flow.source_flow - flow.target_flow).abs();
110            let denom = flow.source_flow.abs() + flow.target_flow.abs();
111
112            let err = if denom > f64::EPSILON {
113                2.0 * abs_diff / denom
114            } else {
115                abs_diff
116            };
117            if abs_diff > ABS_TOLERANCE_DIVERGENCE_CELL && err > max_divergence {
118                return Err(ModelError::CellToCellDivergence(err, max_divergence));
119            }
120        }
121
122        Ok(())
123    }
124}
125
126impl CMModel {
127    pub fn grid(&self) -> &dyn crate::grid::CompartmentMesh {
128        self.geometry.get_grid().unwrap()
129    }
130
131    pub fn init(geometry: Arc<CMGeometry>) -> Self {
132        println!("Init model with {} compartment", geometry.n_zone());
133        let volume_element_count = geometry.get_count_volume_element_first_pass();
134
135        let n_zone_with_volume_element = volume_element_count.n_zone_with_volume_element();
136
137        if n_zone_with_volume_element != geometry.n_zone() {
138            unimplemented!(
139                "Detected compartment should be the same as given by user {} vs {}",
140                n_zone_with_volume_element,
141                geometry.n_zone()
142            )
143        }
144
145        let interface_count_raw = volume_element_count.at_interface.clone();
146
147        let (c_info, interfaces) = volume_element_count.into_reduce();
148
149        // if interfaces.n_interfaces() >= geometry.get_grid().as_ref().unwrap().n_maximum_interface()
150        if interfaces.n_interfaces() > geometry.get_grid().as_ref().unwrap().n_maximum_interface() {
151            unimplemented!(
152                "RCMTOOL: CMModel::init: should have intefaces  < n_maximum_interface {} {}",
153                interfaces.n_interfaces(),
154                geometry.get_grid().as_ref().unwrap().n_maximum_interface()
155            )
156        }
157
158        let mut model = Self {
159            geometry,
160            c_info,
161            interfaces,
162        };
163
164        model.c_info.fill(&model.geometry);
165
166        model.interfaces.fill(&model.geometry, &interface_count_raw);
167
168        model
169    }
170
171    #[allow(unused)]
172    fn compute_volume_integral_per_zone() -> Vec<f64> {
173        todo!()
174    }
175
176    ///Velocity of one element along the normal of the interface it crosses.
177    ///
178    ///The velocity stays a vector up to here and is projected on the *local* normal: on a radial
179    ///or theta face the normal turns with theta, so it has to be taken at the position of the
180    ///element and not once for the whole interface.
181    fn normal_velocity(&self, vector: &Vector, element_global_id: usize, axis: usize) -> f64 {
182        let velocity = CartesianVec3(*vector.get_slice_xyz(element_global_id));
183        let CartesianCoordinates(centroid) = self.geometry.volume_elements.xyz[element_global_id];
184        let theta = centroid[1].atan2(centroid[0]);
185
186        velocity.to_cylindrical_vec(theta).0[axis]
187    }
188
189    pub fn compute_flux_between_compartments(
190        &self,
191        vector: Vector,
192        settings: &BalanceSettings,
193    ) -> Result<cmtool_data::RawDataFlux, CoreError> {
194        let n_fluxes = self.interfaces.n_interfaces();
195        let mut flows: Vec<InterfaceFlow> = vec![Default::default(); n_fluxes];
196
197        //Flux of an interface is the surface integral of the velocity over the area the elements
198        //really cover, element by element: sum(a_e * v_e.n_e). Taking an average velocity times
199        //the geometric face instead would count area the vessel does not have, which is what
200        //inflates the flow of the compartments sitting on its boundary.
201        //
202        //Both directions are accumulated separately, so a counter current inside one interface is
203        //kept as gross exchange and neither direction can come out negative.
204        for (i_interface, flow) in flows.iter_mut().enumerate() {
205            let axis: usize = self.interfaces.normal_axis[i_interface];
206            let areas = &self.interfaces.area[i_interface];
207            let elements = &self.interfaces.global_id_from_interface[i_interface];
208
209            for (&element_global_id, area) in elements.iter().zip(areas) {
210                let f = area * self.normal_velocity(&vector, element_global_id, axis);
211
212                //The normal of the interface points from its source to its target
213                if f > 0. {
214                    flow.source_flow += f;
215                } else {
216                    flow.target_flow -= f;
217                }
218            }
219        }
220
221        let balance = self.balance(&mut flows, settings);
222        if balance.residual > settings.max_divergence {
223            return Err(ModelError::CellToCellDivergence(
224                balance.residual,
225                settings.max_divergence,
226            )
227            .into());
228        }
229
230        let data_flow = get_data_flow(self.geometry.n_zone(), &self.interfaces.ids, &flows);
231
232        self.check_flow(&data_flow, settings.max_divergence)?;
233        Ok(data_flow)
234    }
235
236    // pub fn export_volume_integral_per_zone(&self,scalar:&mut cmtool_data::RawDataScalar) {
237    //     todo!()
238    // }
239
240    ///Balance flow
241    ///
242    ///A flow is only ever multiplied by a positive factor. Scaling the
243    ///flows leaving a compartment by `sqrt(in / out)` moves it halfway to its balance, and
244    ///repeating it converges the same way Sinkhorn balancing does.
245    fn balance(&self, flows: &mut [InterfaceFlow], settings: &BalanceSettings) -> BalanceReport {
246        let n_zone = self.geometry.n_zone();
247        let mut inflow = vec![0.0f64; n_zone];
248        let mut outflow = vec![0.0f64; n_zone];
249        let mut report = BalanceReport {
250            iterations: 0,
251            residual: f64::INFINITY,
252        };
253
254        for iteration in 0..settings.max_iterations {
255            inflow.fill(0.);
256            outflow.fill(0.);
257            for (i_interface, flow) in flows.iter().enumerate() {
258                let source_id = self.interfaces.ids[i_interface].source_id;
259                let target_id = self.interfaces.ids[i_interface].target_id;
260                outflow[source_id] += flow.source_flow;
261                inflow[target_id] += flow.source_flow;
262                outflow[target_id] += flow.target_flow;
263                inflow[source_id] += flow.target_flow;
264            }
265
266            report.iterations = iteration;
267            report.residual = (0..n_zone)
268                .map(|zone| {
269                    let total = inflow[zone].abs() + outflow[zone].abs();
270                    if total > f64::EPSILON {
271                        2. * (inflow[zone] - outflow[zone]).abs() / total
272                    } else {
273                        0.
274                    }
275                })
276                .fold(0.0f64, f64::max);
277
278            if report.residual < settings.tolerance {
279                break;
280            }
281
282            //A compartment with no flow either way has nothing to scale
283            let factor: Vec<f64> = (0..n_zone)
284                .map(|zone| {
285                    if inflow[zone] > f64::EPSILON && outflow[zone] > f64::EPSILON {
286                        (inflow[zone] / outflow[zone]).sqrt()
287                    } else {
288                        1.
289                    }
290                })
291                .collect();
292
293            for (i_interface, flow) in flows.iter_mut().enumerate() {
294                let source_id = self.interfaces.ids[i_interface].source_id;
295                let target_id = self.interfaces.ids[i_interface].target_id;
296                flow.source_flow *= factor[source_id];
297                flow.target_flow *= factor[target_id];
298            }
299        }
300
301        report
302    }
303
304    pub fn export_volume_integral_per_zone(
305        &self,
306        model_scalar: Scalar,
307    ) -> Result<cmtool_data::RawDataScalar, CoreError> {
308        println!("Creating scalar {}", model_scalar.name.trim(),);
309        let mut scalar_field = RawDataScalar::new(self.geometry.n_zone());
310
311        scalar_field.values = vec![(0.).into(); self.geometry.n_zone()];
312
313        let iterator = self.c_info.volumes.iter().zip(&mut scalar_field.values);
314
315        for (volumes_i, field) in iterator {
316            field.value =
317                volumes_i
318                    .iter()
319                    .fold(0.0, |acc, ElementVolumeInfo { global_id, volume }| {
320                        acc + model_scalar[*global_id] * volume
321                    });
322        }
323
324        Ok(scalar_field)
325    }
326
327    pub fn compartments_volumes(&self) -> Vec<f64> {
328        todo!("grid compartment calculation")
329    }
330
331    pub fn get_real_volume(&self) -> Vec<f64> {
332        self.c_info
333            .volumes
334            .iter()
335            .map(|zone| zone.iter().map(|v| v.volume).sum())
336            .collect()
337    }
338}
339
340#[cfg(test)]
341mod test {
342    use super::*;
343
344    const N_ZONE: u32 = 2;
345
346    fn flux(id_source: u32, id_target: u32, source_target: f64, target_source: f64) -> RawFlux {
347        RawFlux {
348            id_source,
349            id_target,
350            flux_source_target: source_target,
351            flux_target_source: target_source,
352        }
353    }
354
355    #[test]
356    fn test_check_flux_accepts_valid_flux() {
357        assert!(CMModel::check_flux(N_ZONE, &flux(0, 1, 2., 0.)));
358    }
359
360    ///An id out of range would index mass_balance out of bounds in check_flow
361    #[test]
362    fn test_check_flux_rejects_unknown_compartment() {
363        assert!(!CMModel::check_flux(N_ZONE, &flux(N_ZONE, 1, 2., 0.)));
364        assert!(!CMModel::check_flux(N_ZONE, &flux(0, N_ZONE, 2., 0.)));
365    }
366
367    #[test]
368    fn test_check_flux_rejects_unusable_flow() {
369        assert!(!CMModel::check_flux(N_ZONE, &flux(0, 1, f64::NAN, 0.)));
370        assert!(!CMModel::check_flux(N_ZONE, &flux(0, 1, -2., 0.)));
371        //Both directions are checked, not only source to target
372        assert!(!CMModel::check_flux(N_ZONE, &flux(0, 1, 2., f64::INFINITY)));
373        assert!(!CMModel::check_flux(N_ZONE, &flux(0, 1, 2., -1.)));
374    }
375}