esox 0.1.6

Library for NISECI and HFBI calc
Documentation
// SPDX-License-Identifier: GPL-3.0-only
/*
    Copyright (C) 2024-2026 jgabaut, gioninjo

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, version 3 of the License.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

use crate::domain::hfbi::{AnagraficaHFBI, CampionamentoHFBI};
use crate::domain::posf32::PositiveF32;

/// This calculation is order-dependent due to calc_s90_b90() being order-dependent.
/// Proper ordering of `campionamento` is by descending `peso` (RecordHFBI.peso).
pub fn calc_ddom(
    campionamento: &CampionamentoHFBI,
    anagrafica: &AnagraficaHFBI,
) -> Result<f32, String> {
    let (s90, b90): (u32, f32) = calc_s90_b90(campionamento, anagrafica)?;

    let ddom = (((s90 as f32 - 1.0) / b90) + 1.0).ln();
    Ok((1000.0 * ddom).round() / 1000.0)
}

/// This calculation is order-dependent.
/// Proper ordering of `campionamento` is by descending `peso` (RecordHFBI.peso).
fn calc_s90_b90(
    campionamento: &CampionamentoHFBI,
    anagrafica: &AnagraficaHFBI,
) -> Result<(u32, f32), String> {
    let width = anagrafica.get_larghezza_media();
    let length = anagrafica.get_lunghezza_media();
    let width_checked = PositiveF32::new(width).map_err(|e| e.to_string())?;
    let length_checked = PositiveF32::new(length).map_err(|e| e.to_string())?;
    let area: f32 = *width_checked * *length_checked;
    let mut biomassa_tot = 0.0;
    for cattura in campionamento {
        biomassa_tot += cattura.peso;
    }

    let biomassa_90 = biomassa_tot * 0.9;

    let mut n_specie_90: u32 = 0;
    let mut biomassa_tmp: f32 = 0.0;
    for cattura in campionamento.sorted_by_peso_desc() {
        biomassa_tmp += cattura.peso;
        n_specie_90 += 1;
        if biomassa_tmp > biomassa_90 {
            break;
        }
    }

    let b90: f32 = ((biomassa_90 / area) * 100.0 + 1.0).ln();

    Ok((n_specie_90, b90))
}

#[cfg(test)]
mod ddom_private_tests {
    use super::*;
    use crate::domain::hfbi::{
        AnagraficaHFBI, CampionamentoHFBI, GruppoEcoHFBI, GruppoTrofHFBI, HabitatHFBI, RecordHFBI,
        SpecieHFBI, StagioneHFBI, TipoLagunaCostieraHFBI,
    };
    use crate::domain::location::Location;

    const EPSILON: f32 = 1e-6;

    // Helper to create AnagraficaHFBI for tests
    fn create_test_anagrafica(lunghezza: f32, larghezza: f32) -> AnagraficaHFBI {
        AnagraficaHFBI::new_raw_unchecked(
            "TestStazione".to_string(),
            "TestCorpoIdrico".to_string(),
            Location {
                regione: "Test".to_string(),
                provincia: "Test".to_string(),
            },
            "01/01/2025".to_string(),
            TipoLagunaCostieraHFBI::MAt1,
            StagioneHFBI::Primavera,
            HabitatHFBI::NonVegetato,
            lunghezza,
            larghezza,
        )
    }

    // Helper to create a dummy RecordHFBI, as only the peso is relevant here
    fn create_dummy_record(peso: f32) -> RecordHFBI {
        RecordHFBI {
            specie: SpecieHFBI {
                nome_comune: "Dummy".to_string(),
                codice_specie: "DM".to_string(),
                autoctono: true,
                gruppo_eco: GruppoEcoHFBI::ResidentiDiEstuario,
                gruppo_trofico: GruppoTrofHFBI {
                    microbentivori: 0.0,
                    macrobentivori: 0.0,
                    iperbentivori: 0.0,
                    erbivori: 0.0,
                    detritivori: 0.0,
                    planctivori: 0.0,
                    onnivori: 0.0,
                },
            },
            numero_individui: 1,
            peso,
        }
    }

    // --- Tests for the private helper function: calc_s90_b90 ---

    #[test]
    fn test_s90_b90_order_invariant() {
        let anagrafica = create_test_anagrafica(100.0, 5.0);
        let campione = CampionamentoHFBI::new_raw_unsorted(vec![
            create_dummy_record(1.0),
            create_dummy_record(1.0),
            create_dummy_record(900.0),
        ]);
        let sorted = CampionamentoHFBI::new_raw_unsorted(vec![
            create_dummy_record(900.0),
            create_dummy_record(1.0),
            create_dummy_record(1.0),
        ]);
        let (n_specie_90, b90) = calc_s90_b90(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        let (n_specie_90_sorted, b90_sorted) = calc_s90_b90(&sorted, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        assert!((b90 - b90_sorted).abs() < EPSILON);
        assert!(n_specie_90 == n_specie_90_sorted);
    }

    #[test]
    fn test_ddom_order_invariant() {
        let anagrafica = create_test_anagrafica(100.0, 5.0);
        let campione = CampionamentoHFBI::new_raw_unsorted(vec![
            create_dummy_record(1.0),
            create_dummy_record(1.0),
            create_dummy_record(900.0),
        ]);
        let sorted = CampionamentoHFBI::new_raw_unsorted(vec![
            create_dummy_record(900.0),
            create_dummy_record(1.0),
            create_dummy_record(1.0),
        ]);
        let ddom = calc_ddom(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        let ddom_sorted = calc_ddom(&sorted, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        assert!((ddom - ddom_sorted).abs() < EPSILON);
    }

    #[test]
    fn test_s90_b90_empty_input() {
        let anagrafica = create_test_anagrafica(100.0, 5.0);
        let campione = CampionamentoHFBI::new(vec![]);
        let (s90, b90) = calc_s90_b90(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");

        assert_eq!(s90, 0);
        // b90 = ln((0 / 500) * 100 + 1) = ln(1) = 0
        assert!((b90 - 0.0).abs() < EPSILON);
    }

    #[test]
    fn test_s90_b90_single_species() {
        let anagrafica = create_test_anagrafica(10.0, 10.0); // area = 100
        let campione = CampionamentoHFBI::new(vec![create_dummy_record(200.0)]);
        let (s90, b90) = calc_s90_b90(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");

        // n_specie_90 is 1 because the loop runs once and breaks.
        assert_eq!(s90, 1);
        // biomassa_90 = 200 * 0.9 = 180
        // b90 = ln((180 / 100) * 100 + 1) = ln(181)
        let expected_b90 = 181.0_f32.ln();
        assert!((b90 - expected_b90).abs() < EPSILON);
    }

    #[test]
    fn test_s90_b90_zero_area() {
        let anagrafica = create_test_anagrafica(10.0, 0.0); // area = 0
        let campione = CampionamentoHFBI::new(vec![create_dummy_record(100.0)]);
        let res = calc_s90_b90(&campione, &anagrafica);

        assert!(res.is_err());
    }

    // --- Tests for the public function: calc_ddom ---

    #[test]
    fn test_ddom_empty_input() {
        let anagrafica = create_test_anagrafica(100.0, 5.0);
        let campione = CampionamentoHFBI::new(vec![]);
        let result = calc_ddom(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        // s90=0, b90=0. Formula is ln(((0-1)/0)+1) = ln(-inf) = NaN
        assert!(result.is_nan());
    }

    #[test]
    fn test_ddom_single_species() {
        let anagrafica = create_test_anagrafica(100.0, 5.0);
        let campione = CampionamentoHFBI::new(vec![create_dummy_record(100.0)]);
        let result = calc_ddom(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");
        // s90=1. Formula is ln(((1-1)/b90)+1) = ln(1) = 0
        assert!((result - 0.0).abs() < EPSILON);
    }

    #[test]
    fn test_ddom_zero_area() {
        let anagrafica = create_test_anagrafica(10.0, 0.0); // area = 0
        let campione =
            CampionamentoHFBI::new(vec![create_dummy_record(100.0), create_dummy_record(50.0)]);
        let result = calc_ddom(&campione, &anagrafica);
        assert!(result.is_err());
    }

    #[test]
    fn test_ddom_standard_case() {
        let anagrafica = create_test_anagrafica(10.0, 10.0); // area = 100
        let campione = CampionamentoHFBI::new(vec![
            create_dummy_record(100.0),
            create_dummy_record(50.0),
            create_dummy_record(30.0),
            create_dummy_record(20.0), // 90% threshold (180) is crossed here
        ]);
        // From calc_s90_b90:
        // biomassa_tot = 200, biomassa_90 = 180.
        // Loop adds weights: 100, 150, 180, 200. It breaks after the 4th species.
        // s90 = 4
        // b90 = ln((180 / 100) * 100 + 1) = ln(181)
        let s90 = 4.0_f32;
        let b90 = 181.0_f32.ln();

        let expected_result = (1000.0 * (((s90 - 1.0) / b90) + 1.0).ln()).round() / 1000.0;
        let actual_result = calc_ddom(&campione, &anagrafica)
            .expect("Area fields of anagrafica should be positive and finite");

        assert!((actual_result - expected_result).abs() < EPSILON);
    }
}