Skip to main content

mesh_sieve/
diagnostics.rs

1//! Centralized mesh diagnostics and DMPLEX-style validation options.
2
3use serde::Serialize;
4use std::fmt::Write as _;
5
6use crate::data::coordinates::Coordinates;
7use crate::data::section::Section;
8use crate::data::storage::Storage;
9use crate::geometry::fvm::build_fvm_face_metrics;
10use crate::mesh_error::MeshSieveError;
11use crate::overlap::overlap::Overlap;
12use crate::topology::cell_type::CellType;
13use crate::topology::ownership::PointOwnership;
14use crate::topology::point::PointId;
15use crate::topology::sieve::strata::compute_strata;
16use crate::topology::sieve::{MeshSieve, Sieve};
17use crate::topology::validation::{
18    TopologyValidationOptions, validate_oriented_sieve_topology,
19    validate_overlap_ownership_topology,
20};
21
22/// Options controlling the DM-level prepare-for-solve diagnostic gate.
23#[derive(Clone, Copy, Debug, Serialize)]
24pub struct PrepareForSolveOptions {
25    /// Require coordinate metadata before solve preparation can proceed.
26    pub require_coordinates: bool,
27    /// Require cell-type metadata before solve preparation can proceed.
28    pub require_cell_types: bool,
29    /// Require ownership metadata before solve preparation can proceed.
30    pub require_ownership: bool,
31    /// Require an overlap graph even when no local ghost points are present.
32    pub require_overlap: bool,
33    /// Complete/synchronize coordinate and field sections for ghost points when overlap is available.
34    pub synchronize_ghost_sections: bool,
35    /// In a serial communicator, synthesize rank-local ownership when the DM has none.
36    pub create_serial_ownership: bool,
37}
38
39impl Default for PrepareForSolveOptions {
40    fn default() -> Self {
41        Self {
42            require_coordinates: true,
43            require_cell_types: true,
44            require_ownership: true,
45            require_overlap: false,
46            synchronize_ghost_sections: true,
47            create_serial_ownership: true,
48        }
49    }
50}
51
52/// Structured status for one prepare-for-solve prerequisite.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
54pub struct PrepareForSolvePrerequisiteDiagnostic {
55    pub name: &'static str,
56    pub required: bool,
57    pub present: bool,
58    pub complete: bool,
59    pub detail: String,
60}
61
62/// Structured status for one prepare-for-solve pipeline step.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
64pub struct PrepareForSolveStepDiagnostic {
65    pub name: &'static str,
66    pub status: &'static str,
67    pub detail: String,
68}
69
70/// Stable summary of the generated matrix-preallocation graph.
71#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
72pub struct PrepareForSolvePreallocationDiagnostic {
73    pub rows: usize,
74    pub edges: usize,
75    pub order: Vec<PointId>,
76    pub row_nnz: Vec<usize>,
77}
78
79/// Structured, serializable output of the DM prepare-for-solve pipeline.
80#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
81pub struct PrepareForSolveDiagnostics {
82    pub ready: bool,
83    pub prerequisites: Vec<PrepareForSolvePrerequisiteDiagnostic>,
84    pub steps: Vec<PrepareForSolveStepDiagnostic>,
85    pub global_sections: Vec<String>,
86    pub preallocation: Option<PrepareForSolvePreallocationDiagnostic>,
87    pub synchronized_sections: Vec<String>,
88}
89
90impl PrepareForSolveDiagnostics {
91    /// Return true if any required prerequisite is absent or incomplete.
92    pub fn has_missing_required_prerequisites(&self) -> bool {
93        self.prerequisites
94            .iter()
95            .any(|p| p.required && (!p.present || !p.complete))
96    }
97}
98
99/// Serialize prepare-for-solve diagnostics with stable field ordering.
100pub fn prepare_for_solve_diagnostics_json(
101    diagnostics: &PrepareForSolveDiagnostics,
102) -> Result<String, MeshSieveError> {
103    serde_json::to_string(diagnostics)
104        .map_err(|e| MeshSieveError::InvalidGeometry(format!("serialize prepare diagnostics: {e}")))
105}
106
107/// Build prerequisite diagnostics for a DM-level solve-preparation gate.
108pub fn prepare_for_solve_prerequisites<V, St, CtSt>(
109    sieve: &MeshSieve,
110    coords: Option<&Coordinates<V, St>>,
111    cell_types: Option<&Section<CellType, CtSt>>,
112    ownership: Option<&PointOwnership>,
113    overlap: Option<&Overlap>,
114    options: PrepareForSolveOptions,
115) -> Result<Vec<PrepareForSolvePrerequisiteDiagnostic>, MeshSieveError>
116where
117    St: Storage<V>,
118    CtSt: Storage<CellType>,
119{
120    let strata = compute_strata(sieve)?;
121    let vertices: Vec<PointId> = strata
122        .depth
123        .iter()
124        .filter_map(|(&point, &depth)| (depth == 0).then_some(point))
125        .collect();
126    let cells = strata.strata.first().cloned().unwrap_or_default();
127    let points = sieve.points_sorted();
128    let ghost_count = ownership
129        .map(|o| o.ghost_points().count())
130        .unwrap_or_default();
131    let overlap_required = options.require_overlap || ghost_count > 0;
132
133    let mut diagnostics = Vec::new();
134
135    let missing_coords = coords.map_or(vertices.len(), |coords| {
136        vertices
137            .iter()
138            .filter(|point| !coords.section().atlas().contains(**point))
139            .count()
140    });
141    diagnostics.push(PrepareForSolvePrerequisiteDiagnostic {
142        name: "coordinates",
143        required: options.require_coordinates,
144        present: coords.is_some(),
145        complete: coords.is_some() && missing_coords == 0,
146        detail: if coords.is_some() {
147            format!(
148                "vertex_points={}, missing_vertex_coordinates={missing_coords}",
149                vertices.len()
150            )
151        } else {
152            "coordinate section is not attached".to_string()
153        },
154    });
155
156    let missing_cell_types = cell_types.map_or(cells.len(), |cell_types| {
157        cells
158            .iter()
159            .filter(|point| !cell_types.atlas().contains(**point))
160            .count()
161    });
162    diagnostics.push(PrepareForSolvePrerequisiteDiagnostic {
163        name: "cell_types",
164        required: options.require_cell_types,
165        present: cell_types.is_some(),
166        complete: cell_types.is_some() && missing_cell_types == 0,
167        detail: if cell_types.is_some() {
168            format!(
169                "cells={}, missing_cell_types={missing_cell_types}",
170                cells.len()
171            )
172        } else {
173            "cell-type section is not attached".to_string()
174        },
175    });
176
177    let missing_ownership = ownership.map_or(points.len(), |ownership| {
178        points
179            .iter()
180            .filter(|point| ownership.entry(**point).is_none())
181            .count()
182    });
183    diagnostics.push(PrepareForSolvePrerequisiteDiagnostic {
184        name: "ownership",
185        required: options.require_ownership,
186        present: ownership.is_some(),
187        complete: ownership.is_some() && missing_ownership == 0,
188        detail: if ownership.is_some() {
189            format!("topology_points={}, missing_ownership={missing_ownership}, ghost_points={ghost_count}", points.len())
190        } else {
191            "ownership map is not attached".to_string()
192        },
193    });
194
195    diagnostics.push(PrepareForSolvePrerequisiteDiagnostic {
196        name: "overlap",
197        required: overlap_required,
198        present: overlap.is_some(),
199        complete: !overlap_required || overlap.is_some(),
200        detail: if overlap.is_some() {
201            format!("ghost_points={ghost_count}, overlap_required={overlap_required}")
202        } else {
203            format!("overlap graph is not attached; ghost_points={ghost_count}, overlap_required={overlap_required}")
204        },
205    });
206
207    Ok(diagnostics)
208}
209
210#[derive(Clone, Debug, Default, Serialize)]
211pub struct MetricSummary {
212    pub max: f64,
213    pub p95: f64,
214    pub mean: f64,
215    pub count: usize,
216}
217
218#[derive(Clone, Debug, Default, Serialize)]
219pub struct FvmQualityDiagnostics {
220    pub non_orthogonality_deg: MetricSummary,
221    pub skewness: MetricSummary,
222    pub cell_char_length: MetricSummary,
223}
224
225#[derive(Clone, Debug, Serialize)]
226pub struct FvmCellDiagnostic {
227    pub cell: PointId,
228    pub max_non_orthogonality_deg: f64,
229    pub max_skewness: f64,
230    pub char_length: f64,
231    pub boundary_face_count: usize,
232}
233
234#[derive(Clone, Copy, Debug, Serialize)]
235pub struct FvReadinessThresholds {
236    pub max_non_orthogonality_deg: f64,
237    pub max_skewness: f64,
238    pub min_cell_char_length: f64,
239}
240
241impl Default for FvReadinessThresholds {
242    fn default() -> Self {
243        Self {
244            max_non_orthogonality_deg: 75.0,
245            max_skewness: 4.0,
246            min_cell_char_length: 1.0e-8,
247        }
248    }
249}
250
251#[derive(Clone, Copy, Debug, Default)]
252pub struct FvReadinessOptions {
253    pub strict: bool,
254}
255
256#[derive(Clone, Debug, Serialize)]
257pub struct FvReadinessViolation {
258    pub metric: &'static str,
259    pub point: PointId,
260    pub value: f64,
261    pub threshold: f64,
262}
263
264#[derive(Clone, Debug, Default, Serialize)]
265pub struct FvReadinessSummary {
266    pub failed: usize,
267    pub total: usize,
268}
269
270#[derive(Clone, Debug, Default, Serialize)]
271pub struct FvReadinessReport {
272    pub passed: bool,
273    pub thresholds: FvReadinessThresholds,
274    pub non_orthogonality: FvReadinessSummary,
275    pub skewness: FvReadinessSummary,
276    pub cell_char_length: FvReadinessSummary,
277    pub violations: Vec<FvReadinessViolation>,
278}
279
280#[derive(Clone, Copy, Debug, Default)]
281pub struct MeshCheckOptions {
282    pub check_symmetry: bool,
283    pub check_skeleton: bool,
284    pub check_faces: bool,
285    pub check_geometry: bool,
286    pub check_overlap: bool,
287    pub check_ownership: bool,
288    pub check_sections: bool,
289}
290
291impl MeshCheckOptions {
292    pub fn all() -> Self {
293        Self {
294            check_symmetry: true,
295            check_skeleton: true,
296            check_faces: true,
297            check_geometry: true,
298            check_overlap: true,
299            check_ownership: true,
300            check_sections: true,
301        }
302    }
303}
304
305pub fn run_mesh_checks<'a, V, St, CtSt>(
306    sieve: &mut MeshSieve,
307    cell_types: Option<&Section<CellType, CtSt>>,
308    coords: Option<&Coordinates<V, St>>,
309    ownership: Option<&PointOwnership>,
310    overlap: Option<&Overlap>,
311    sections: impl Iterator<Item = &'a Section<V, St>>,
312    options: MeshCheckOptions,
313) -> Result<(), MeshSieveError>
314where
315    V: Clone + Default + 'a,
316    St: Storage<V> + Clone + 'a,
317    CtSt: Storage<CellType>,
318{
319    if options.check_skeleton {
320        compute_strata(sieve)?;
321    }
322
323    if options.check_symmetry
324        && let Some(cell_types) = cell_types
325    {
326        validate_oriented_sieve_topology(sieve, cell_types, TopologyValidationOptions::all())?;
327    }
328
329    if options.check_geometry
330        && let Some(coords) = coords
331    {
332        for (point, (_offset, len)) in coords.section().atlas().iter_entries() {
333            if len != coords.embedding_dimension() {
334                return Err(MeshSieveError::SliceLengthMismatch {
335                    point,
336                    expected: coords.embedding_dimension(),
337                    found: len,
338                });
339            }
340        }
341    }
342
343    if (options.check_overlap || options.check_ownership)
344        && let Some(ownership) = ownership
345    {
346        validate_overlap_ownership_topology(sieve, ownership, overlap, 0)?;
347    }
348
349    if options.check_sections {
350        for section in sections {
351            for (point, (_offset, len)) in section.atlas().iter_entries() {
352                if len == 0 {
353                    return Err(MeshSieveError::SliceLengthMismatch {
354                        point,
355                        expected: 1,
356                        found: 0,
357                    });
358                }
359            }
360        }
361    }
362    Ok(())
363}
364
365pub fn run_mesh_checks_f64_with_report<'a, St, CtSt>(
366    sieve: &mut MeshSieve,
367    cell_types: Option<&Section<CellType, CtSt>>,
368    coords: Option<&Coordinates<f64, St>>,
369    ownership: Option<&PointOwnership>,
370    overlap: Option<&Overlap>,
371    sections: impl Iterator<Item = &'a Section<f64, St>>,
372    options: MeshCheckOptions,
373) -> Result<Option<FvmQualityDiagnostics>, MeshSieveError>
374where
375    St: Storage<f64> + Clone + 'a,
376    CtSt: Storage<CellType>,
377{
378    run_mesh_checks(
379        sieve, cell_types, coords, ownership, overlap, sections, options,
380    )?;
381    if let (Some(ct), Some(cd)) = (cell_types, coords) {
382        Ok(Some(fvm_quality_diagnostics(sieve, ct, cd)?))
383    } else {
384        Ok(None)
385    }
386}
387
388pub fn fvm_quality_diagnostics<CT, CS>(
389    sieve: &MeshSieve,
390    cell_types: &Section<CellType, CT>,
391    coordinates: &Coordinates<f64, CS>,
392) -> Result<FvmQualityDiagnostics, MeshSieveError>
393where
394    CT: Storage<CellType>,
395    CS: Storage<f64>,
396{
397    let face_metrics = build_fvm_face_metrics(sieve, cell_types, coordinates)?;
398    let non_ortho: Vec<f64> = face_metrics
399        .iter()
400        .filter_map(|m| m.non_orthogonality_deg)
401        .collect();
402    let skewness: Vec<f64> = face_metrics
403        .iter()
404        .map(|m| {
405            let denom = m
406                .owner_to_neighbor
407                .map(norm3)
408                .unwrap_or_else(|| norm3(m.owner_to_face));
409            if denom > 1e-12 {
410                norm3(m.skewness_vector) / denom
411            } else {
412                0.0
413            }
414        })
415        .collect();
416    let mut cell_area_sum: std::collections::BTreeMap<PointId, f64> =
417        std::collections::BTreeMap::new();
418    for fm in &face_metrics {
419        *cell_area_sum.entry(fm.owner).or_insert(0.0) += fm.area_magnitude;
420        if let Some(n) = fm.neighbor {
421            *cell_area_sum.entry(n).or_insert(0.0) += fm.area_magnitude;
422        }
423    }
424    let mut char_lengths = Vec::with_capacity(cell_area_sum.len());
425    for (cell, area_sum) in cell_area_sum {
426        if let Some(cell_type) = cell_types
427            .try_restrict(cell)
428            .ok()
429            .and_then(|v| v.first().copied())
430        {
431            let mut verts: Vec<_> = sieve
432                .closure_iter([cell])
433                .filter(|p| sieve.cone_points(*p).next().is_none())
434                .collect();
435            verts.sort_unstable();
436            verts.dedup();
437            let quality = crate::geometry::quality::cell_quality_from_section(
438                cell_type,
439                &verts,
440                coordinates,
441            )?;
442            if area_sum > 1e-12 {
443                char_lengths.push(quality.jacobian_sign.abs() / area_sum);
444            }
445        }
446    }
447    Ok(FvmQualityDiagnostics {
448        non_orthogonality_deg: summarize(&non_ortho),
449        skewness: summarize(&skewness),
450        cell_char_length: summarize(&char_lengths),
451    })
452}
453
454pub fn fvm_cell_diagnostics<CT, CS>(
455    sieve: &MeshSieve,
456    cell_types: &Section<CellType, CT>,
457    coordinates: &Coordinates<f64, CS>,
458) -> Result<Vec<FvmCellDiagnostic>, MeshSieveError>
459where
460    CT: Storage<CellType>,
461    CS: Storage<f64>,
462{
463    let face_metrics = build_fvm_face_metrics(sieve, cell_types, coordinates)?;
464    let mut by_cell: std::collections::BTreeMap<PointId, FvmCellDiagnostic> =
465        std::collections::BTreeMap::new();
466    let mut cell_area_sum: std::collections::BTreeMap<PointId, f64> =
467        std::collections::BTreeMap::new();
468    for fm in &face_metrics {
469        let denom = fm
470            .owner_to_neighbor
471            .map(norm3)
472            .unwrap_or_else(|| norm3(fm.owner_to_face));
473        let skewness = if denom > 1e-12 {
474            norm3(fm.skewness_vector) / denom
475        } else {
476            0.0
477        };
478        let owner = by_cell.entry(fm.owner).or_insert(FvmCellDiagnostic {
479            cell: fm.owner,
480            max_non_orthogonality_deg: 0.0,
481            max_skewness: 0.0,
482            char_length: 0.0,
483            boundary_face_count: 0,
484        });
485        owner.max_non_orthogonality_deg = owner
486            .max_non_orthogonality_deg
487            .max(fm.non_orthogonality_deg.unwrap_or(0.0));
488        owner.max_skewness = owner.max_skewness.max(skewness);
489        if fm.neighbor.is_none() {
490            owner.boundary_face_count += 1;
491        }
492        *cell_area_sum.entry(fm.owner).or_insert(0.0) += fm.area_magnitude;
493        if let Some(n) = fm.neighbor {
494            let neigh = by_cell.entry(n).or_insert(FvmCellDiagnostic {
495                cell: n,
496                max_non_orthogonality_deg: 0.0,
497                max_skewness: 0.0,
498                char_length: 0.0,
499                boundary_face_count: 0,
500            });
501            neigh.max_non_orthogonality_deg = neigh
502                .max_non_orthogonality_deg
503                .max(fm.non_orthogonality_deg.unwrap_or(0.0));
504            neigh.max_skewness = neigh.max_skewness.max(skewness);
505            *cell_area_sum.entry(n).or_insert(0.0) += fm.area_magnitude;
506        }
507    }
508    for (cell, diag) in &mut by_cell {
509        if let Some(cell_type) = cell_types
510            .try_restrict(*cell)
511            .ok()
512            .and_then(|v| v.first().copied())
513        {
514            let mut verts: Vec<_> = sieve
515                .closure_iter([*cell])
516                .filter(|p| sieve.cone_points(*p).next().is_none())
517                .collect();
518            verts.sort_unstable();
519            verts.dedup();
520            let quality = crate::geometry::quality::cell_quality_from_section(
521                cell_type,
522                &verts,
523                coordinates,
524            )?;
525            let area_sum = *cell_area_sum.get(cell).unwrap_or(&0.0);
526            if area_sum > 1e-12 {
527                diag.char_length = quality.jacobian_sign.abs() / area_sum;
528            }
529        }
530    }
531    Ok(by_cell.into_values().collect())
532}
533
534pub fn fvm_quality_diagnostics_json<CT, CS>(
535    sieve: &MeshSieve,
536    cell_types: &Section<CellType, CT>,
537    coordinates: &Coordinates<f64, CS>,
538) -> Result<String, MeshSieveError>
539where
540    CT: Storage<CellType>,
541    CS: Storage<f64>,
542{
543    let report = fvm_quality_diagnostics(sieve, cell_types, coordinates)?;
544    serde_json::to_string(&report)
545        .map_err(|e| MeshSieveError::InvalidGeometry(format!("serialize diagnostics: {e}")))
546}
547
548pub fn fv_readiness_report<CT, CS>(
549    sieve: &MeshSieve,
550    cell_types: &Section<CellType, CT>,
551    coordinates: &Coordinates<f64, CS>,
552    thresholds: FvReadinessThresholds,
553    options: FvReadinessOptions,
554) -> Result<FvReadinessReport, MeshSieveError>
555where
556    CT: Storage<CellType>,
557    CS: Storage<f64>,
558{
559    let face_metrics = build_fvm_face_metrics(sieve, cell_types, coordinates)?;
560    let mut report = FvReadinessReport {
561        passed: true,
562        thresholds,
563        non_orthogonality: FvReadinessSummary {
564            total: face_metrics.len(),
565            ..FvReadinessSummary::default()
566        },
567        skewness: FvReadinessSummary {
568            total: face_metrics.len(),
569            ..FvReadinessSummary::default()
570        },
571        ..FvReadinessReport::default()
572    };
573    for fm in &face_metrics {
574        let non_ortho = fm.non_orthogonality_deg.unwrap_or(0.0);
575        if non_ortho > thresholds.max_non_orthogonality_deg {
576            report.non_orthogonality.failed += 1;
577            report.violations.push(FvReadinessViolation {
578                metric: "non_orthogonality_deg",
579                point: fm.face,
580                value: non_ortho,
581                threshold: thresholds.max_non_orthogonality_deg,
582            });
583        }
584        let denom = fm
585            .owner_to_neighbor
586            .map(norm3)
587            .unwrap_or_else(|| norm3(fm.owner_to_face));
588        let skewness = if denom > 1e-12 {
589            norm3(fm.skewness_vector) / denom
590        } else {
591            0.0
592        };
593        if skewness > thresholds.max_skewness {
594            report.skewness.failed += 1;
595            report.violations.push(FvReadinessViolation {
596                metric: "skewness",
597                point: fm.face,
598                value: skewness,
599                threshold: thresholds.max_skewness,
600            });
601        }
602    }
603    let mut cell_area_sum: std::collections::BTreeMap<PointId, f64> =
604        std::collections::BTreeMap::new();
605    for fm in &face_metrics {
606        *cell_area_sum.entry(fm.owner).or_insert(0.0) += fm.area_magnitude;
607        if let Some(n) = fm.neighbor {
608            *cell_area_sum.entry(n).or_insert(0.0) += fm.area_magnitude;
609        }
610    }
611    report.cell_char_length.total = cell_area_sum.len();
612    for (cell, area_sum) in cell_area_sum {
613        if let Some(cell_type) = cell_types
614            .try_restrict(cell)
615            .ok()
616            .and_then(|v| v.first().copied())
617        {
618            let mut verts: Vec<_> = sieve
619                .closure_iter([cell])
620                .filter(|p| sieve.cone_points(*p).next().is_none())
621                .collect();
622            verts.sort_unstable();
623            verts.dedup();
624            let quality = crate::geometry::quality::cell_quality_from_section(
625                cell_type,
626                &verts,
627                coordinates,
628            )?;
629            let char_len = if area_sum > 1e-12 {
630                quality.jacobian_sign.abs() / area_sum
631            } else {
632                0.0
633            };
634            if char_len < thresholds.min_cell_char_length {
635                report.cell_char_length.failed += 1;
636                report.violations.push(FvReadinessViolation {
637                    metric: "cell_char_length",
638                    point: cell,
639                    value: char_len,
640                    threshold: thresholds.min_cell_char_length,
641                });
642            }
643        }
644    }
645    report.passed = report.violations.is_empty();
646    if options.strict && !report.passed {
647        return Err(MeshSieveError::InvalidGeometry(format!(
648            "FV readiness failed with {} violation(s)",
649            report.violations.len()
650        )));
651    }
652    Ok(report)
653}
654
655pub fn fv_readiness_report_json(report: &FvReadinessReport) -> Result<String, MeshSieveError> {
656    serde_json::to_string(report)
657        .map_err(|e| MeshSieveError::InvalidGeometry(format!("serialize readiness report: {e}")))
658}
659
660pub fn fv_readiness_report_text(report: &FvReadinessReport) -> String {
661    let mut out = String::new();
662    let _ = writeln!(&mut out, "fv_readiness_passed={}", report.passed);
663    let _ = writeln!(
664        &mut out,
665        "thresholds: max_non_orthogonality_deg={}, max_skewness={}, min_cell_char_length={}",
666        report.thresholds.max_non_orthogonality_deg,
667        report.thresholds.max_skewness,
668        report.thresholds.min_cell_char_length
669    );
670    let _ = writeln!(
671        &mut out,
672        "non_orthogonality_failed={}/{}",
673        report.non_orthogonality.failed, report.non_orthogonality.total
674    );
675    let _ = writeln!(
676        &mut out,
677        "skewness_failed={}/{}",
678        report.skewness.failed, report.skewness.total
679    );
680    let _ = writeln!(
681        &mut out,
682        "cell_char_length_failed={}/{}",
683        report.cell_char_length.failed, report.cell_char_length.total
684    );
685    for v in &report.violations {
686        let _ = writeln!(
687            &mut out,
688            "violation metric={} point={} value={} threshold={}",
689            v.metric,
690            v.point.get(),
691            v.value,
692            v.threshold
693        );
694    }
695    out
696}
697
698fn summarize(values: &[f64]) -> MetricSummary {
699    if values.is_empty() {
700        return MetricSummary::default();
701    }
702    let mut sorted = values.to_vec();
703    sorted.sort_by(|a, b| a.total_cmp(b));
704    let p95_idx = ((sorted.len() - 1) as f64 * 0.95).round() as usize;
705    MetricSummary {
706        max: *sorted.last().unwrap_or(&0.0),
707        p95: sorted[p95_idx.min(sorted.len() - 1)],
708        mean: sorted.iter().sum::<f64>() / (sorted.len() as f64),
709        count: sorted.len(),
710    }
711}
712
713fn norm3(v: [f64; 3]) -> f64 {
714    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
715}
716
717pub fn mesh_text_summary<S: Sieve<Point = PointId>>(sieve: &S) -> Result<String, MeshSieveError> {
718    let strata = compute_strata(sieve)?;
719    Ok(format!(
720        "points={}, topological_dimension={}, strata={:?}",
721        strata.chart_points.len(),
722        strata.diameter,
723        strata.strata
724    ))
725}
726
727pub fn mesh_dot_graph<S: Sieve<Point = PointId>>(sieve: &S) -> String {
728    let mut out = String::from("digraph MeshSieve {\n");
729    for src in sieve.points() {
730        for dst in sieve.cone_points(src) {
731            let _ = writeln!(&mut out, "  \"{src:?}\" -> \"{dst:?}\";");
732        }
733    }
734    out.push_str("}\n");
735    out
736}
737
738pub fn mesh_json_debug_dump<S: Sieve<Point = PointId>>(sieve: &S) -> String {
739    let mut edges = Vec::new();
740    for src in sieve.points() {
741        for dst in sieve.cone_points(src) {
742            edges.push(format!("[{},{}]", src.get(), dst.get()));
743        }
744    }
745    format!("{{\"edges\":[{}]}}", edges.join(","))
746}
747
748pub fn mesh_tikz_viewer<S: Sieve<Point = PointId>>(sieve: &S, max_points: usize) -> Option<String> {
749    let points: Vec<_> = sieve.points().collect();
750    if points.len() > max_points {
751        return None;
752    }
753    let mut out = String::from("\\begin{tikzpicture}[scale=1.0]\n");
754    for (i, p) in points.iter().enumerate() {
755        let _ = writeln!(
756            &mut out,
757            "\\node (p{}) at ({},0) {{{}}};",
758            p.get(),
759            i,
760            p.get()
761        );
762    }
763    for src in sieve.points() {
764        for dst in sieve.cone_points(src) {
765            let _ = writeln!(&mut out, "\\draw[->] (p{}) -- (p{});", src.get(), dst.get());
766        }
767    }
768    out.push_str("\\end{tikzpicture}\n");
769    Some(out)
770}
771
772pub fn fem_diagnostics_report(
773    element_closure: &[PointId],
774    basis_values: &[f64],
775    quadrature_weights: &[f64],
776    local_matrix: &[f64],
777    local_vector: &[f64],
778) -> String {
779    format!(
780        "FEM diagnostics: closure={element_closure:?}, basis={basis_values:?}, quadrature={quadrature_weights:?}, Ke={local_matrix:?}, Fe={local_vector:?}"
781    )
782}
783
784pub fn fvm_flux_diagnostics(flux_updates: &[(PointId, f64)]) -> String {
785    let mut out = String::from("FVM flux updates:");
786    for (p, f) in flux_updates {
787        let _ = write!(&mut out, " ({p:?}: {f})");
788    }
789    out
790}