diff --git a/src/cli.rs b/src/cli.rs index 9396ab4..7da04b8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Parser, Subcommand, ValueEnum}; #[derive(Parser)] @@ -36,6 +38,8 @@ pub enum Commands { Dev { site: Site }, /// Déployer un site (build + rsync) Deploy { site: Site }, + /// Analyser un export CSV d'index EDF et comparer les tarifs + Edf { csv: PathBuf }, } #[derive(Subcommand)] diff --git a/src/commands/edf.rs b/src/commands/edf.rs new file mode 100644 index 0000000..56cc581 --- /dev/null +++ b/src/commands/edf.rs @@ -0,0 +1,348 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Result, bail}; + +struct Reading { + date: String, + hc_bleu: u64, + hp_bleu: u64, + hc_blanc: u64, + hp_blanc: u64, + hc_rouge: u64, + hp_rouge: u64, +} + +struct Consumption { + hc_bleu: u64, + hp_bleu: u64, + hc_blanc: u64, + hp_blanc: u64, + hc_rouge: u64, + hp_rouge: u64, +} + +impl Consumption { + fn total(&self) -> u64 { + self.hc_bleu + self.hp_bleu + self.hc_blanc + self.hp_blanc + self.hc_rouge + self.hp_rouge + } + + fn total_hc(&self) -> u64 { + self.hc_bleu + self.hc_blanc + self.hc_rouge + } + + fn total_hp(&self) -> u64 { + self.hp_bleu + self.hp_blanc + self.hp_rouge + } +} + +// Tarifs EDF 6 kVA (€/kWh et €/mois) +const BASE_ABO: f64 = 15.47; +const BASE_KWH: f64 = 0.1952; + +const HPHC_ABO: f64 = 15.74; +const HPHC_HP: f64 = 0.2081; +const HPHC_HC: f64 = 0.1635; + +const TEMPO_ABO: f64 = 15.50; +const TEMPO_BLEU_HC: f64 = 0.1232; +const TEMPO_BLEU_HP: f64 = 0.1494; +const TEMPO_BLANC_HC: f64 = 0.1391; +const TEMPO_BLANC_HP: f64 = 0.1730; +const TEMPO_ROUGE_HC: f64 = 0.1460; +const TEMPO_ROUGE_HP: f64 = 0.6468; + +pub fn run(csv_path: &Path) -> Result<()> { + let raw = fs::read(csv_path)?; + let content = String::from_utf8(raw.clone()) + .unwrap_or_else(|_| raw.iter().map(|&b| b as char).collect()); + + let readings = parse_readings(&content)?; + + if readings.len() < 2 { + bail!("pas assez de relevés dans le fichier"); + } + + let last = readings.first().unwrap(); + + // Chercher le relevé le plus proche de last.date - 1 an + let target_date = one_year_before(&last.date); + let start = readings + .iter() + .min_by_key(|r| date_distance(&r.date, &target_date)) + .unwrap(); + + let conso = Consumption { + hc_bleu: last.hc_bleu - start.hc_bleu, + hp_bleu: last.hp_bleu - start.hp_bleu, + hc_blanc: last.hc_blanc - start.hc_blanc, + hp_blanc: last.hp_blanc - start.hp_blanc, + hc_rouge: last.hc_rouge - start.hc_rouge, + hp_rouge: last.hp_rouge - start.hp_rouge, + }; + + println!("Période : {} → {} ({} relevés)", start.date, last.date, readings.len()); + println!(); + + print_consumption(&conso); + println!(); + + print_tariffs(&conso); + + Ok(()) +} + +fn parse_readings(content: &str) -> Result> { + let mut readings = Vec::new(); + + for line in content.lines() { + let parts: Vec<&str> = line.split(';').collect(); + if parts.len() != 8 { + continue; + } + + let Ok(hc_bleu) = parts[2].parse::() else { + continue; + }; + + readings.push(Reading { + date: parts[0].to_string(), + hc_bleu, + hp_bleu: parts[3].parse().unwrap_or(0), + hc_blanc: parts[4].parse().unwrap_or(0), + hp_blanc: parts[5].parse().unwrap_or(0), + hc_rouge: parts[6].parse().unwrap_or(0), + hp_rouge: parts[7].parse().unwrap_or(0), + }); + } + + Ok(readings) +} + +fn print_consumption(c: &Consumption) { + let total = c.total() as f64; + + println!("Consommation sur la période :"); + println!(); + println!(" {:10} {:>8} {:>8}", "Type", "kWh", "%"); + println!(" {:10} {:>8} {:>8}", "──────────", "────────", "────────"); + print_conso_line("HC Bleu", c.hc_bleu, total); + print_conso_line("HP Bleu", c.hp_bleu, total); + print_conso_line("HC Blanc", c.hc_blanc, total); + print_conso_line("HP Blanc", c.hp_blanc, total); + print_conso_line("HC Rouge", c.hc_rouge, total); + print_conso_line("HP Rouge", c.hp_rouge, total); + println!(" {:10} {:>8} {:>8}", "──────────", "────────", "────────"); + println!(" {:10} {:>8}", "TOTAL", format_kwh(c.total())); +} + +fn print_conso_line(label: &str, kwh: u64, total: f64) { + let pct = format!("{:.1}", (kwh as f64 / total) * 100.0).replace('.', ","); + println!(" {:10} {:>8} {:>7}%", label, format_kwh(kwh), pct); +} + +fn print_tariffs(c: &Consumption) { + let total = c.total(); + + let base_abo = BASE_ABO * 12.0; + let base_conso = total as f64 * BASE_KWH; + let hphc_abo = HPHC_ABO * 12.0; + let tempo_abo = TEMPO_ABO * 12.0; + + let (base_total, hphc_total, tempo_total) = compute_tariffs(c); + + println!("Comparatif des tarifs (6 kVA, 12 mois) :"); + println!(); + + println!(" Base :"); + print_tariff_line("Abonnement", base_abo, 0, 0.0); + print_tariff_line("Consommation", base_conso, total, BASE_KWH); + println!(" {:20} {:>9} €", "Total", format_eur(base_total)); + println!(); + + println!(" HP/HC :"); + print_tariff_line("Abonnement", hphc_abo, 0, 0.0); + print_tariff_line("Heures Pleines", c.total_hp() as f64 * HPHC_HP, c.total_hp(), HPHC_HP); + print_tariff_line("Heures Creuses", c.total_hc() as f64 * HPHC_HC, c.total_hc(), HPHC_HC); + println!(" {:20} {:>9} €", "Total", format_eur(hphc_total)); + println!(); + + println!(" Tempo :"); + print_tariff_line("Abonnement", tempo_abo, 0, 0.0); + print_tariff_line("Bleu HC", c.hc_bleu as f64 * TEMPO_BLEU_HC, c.hc_bleu, TEMPO_BLEU_HC); + print_tariff_line("Bleu HP", c.hp_bleu as f64 * TEMPO_BLEU_HP, c.hp_bleu, TEMPO_BLEU_HP); + print_tariff_line("Blanc HC", c.hc_blanc as f64 * TEMPO_BLANC_HC, c.hc_blanc, TEMPO_BLANC_HC); + print_tariff_line("Blanc HP", c.hp_blanc as f64 * TEMPO_BLANC_HP, c.hp_blanc, TEMPO_BLANC_HP); + print_tariff_line("Rouge HC", c.hc_rouge as f64 * TEMPO_ROUGE_HC, c.hc_rouge, TEMPO_ROUGE_HC); + print_tariff_line("Rouge HP", c.hp_rouge as f64 * TEMPO_ROUGE_HP, c.hp_rouge, TEMPO_ROUGE_HP); + println!(" {:20} {:>9} €", "Total", format_eur(tempo_total)); + println!(); + + // Résumé + println!("Résumé :"); + println!(); + println!(" {:8} {:>12} {:>16}", "Option", "Coût annuel", "vs Base"); + println!(" {:8} {:>12} {:>16}", "────────", "────────────", "────────────────"); + + let mut options = vec![ + ("Tempo", tempo_total), + ("HP/HC", hphc_total), + ("Base", base_total), + ]; + options.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); + + for (name, cost) in &options { + let diff = cost - base_total; + let pct = (diff / base_total) * 100.0; + if *name == "Base" { + println!(" {:8} {:>10} € référence", name, format_eur(*cost)); + } else { + println!(" {:8} {:>10} € {} € ({:+.0}%)", name, format_eur(*cost), format_eur(diff), pct); + } + } + + println!(); + println!("Coût moyen au kWh ({} kWh) :", format_kwh(total)); + println!(); + for (name, cost) in &options { + println!(" {:8} {} cts/kWh", name, format_eur((cost / total as f64) * 100.0)); + } +} + +fn print_tariff_line(label: &str, cost: f64, kwh: u64, rate: f64) { + if kwh == 0 { + println!(" {:20} {:>9} €", label, format_eur(cost)); + } else { + println!(" {:20} {:>9} € ({:>5} kWh × {} €)", label, format_eur(cost), format_kwh(kwh), format_rate(rate)); + } +} + +fn format_eur(value: f64) -> String { + let abs = value.abs(); + let integer = abs as u64; + let decimals = format!("{:.2}", abs.fract()).trim_start_matches('0').replace('.', ","); + let sign = if value < 0.0 { "-" } else { "" }; + + if integer >= 1000 { + format!("{}{} {:03}{}", sign, integer / 1000, integer % 1000, decimals) + } else { + format!("{}{}{}", sign, integer, decimals) + } +} + +fn format_rate(value: f64) -> String { + format!("{:.4}", value).replace('.', ",") +} + +fn format_kwh(kwh: u64) -> String { + if kwh >= 1000 { + let thousands = kwh / 1000; + let rest = kwh % 1000; + format!("{} {:03}", thousands, rest) + } else { + kwh.to_string() + } +} + +// DD/MM/YYYY → (YYYY, MM, DD) +fn parse_date(date: &str) -> (i32, u32, u32) { + let parts: Vec<&str> = date.split('/').collect(); + let day: u32 = parts[0].parse().unwrap(); + let month: u32 = parts[1].parse().unwrap(); + let year: i32 = parts[2].parse().unwrap(); + (year, month, day) +} + +fn one_year_before(date: &str) -> String { + let (year, month, day) = parse_date(date); + format!("{:02}/{:02}/{}", day, month, year - 1) +} + +fn date_distance(a: &str, b: &str) -> i32 { + let (ya, ma, da) = parse_date(a); + let (yb, mb, db) = parse_date(b); + let days_a = ya * 365 + ma as i32 * 30 + da as i32; + let days_b = yb * 365 + mb as i32 * 30 + db as i32; + (days_a - days_b).abs() +} + +fn compute_tariffs(c: &Consumption) -> (f64, f64, f64) { + let total = c.total(); + + let base_total = BASE_ABO * 12.0 + total as f64 * BASE_KWH; + + let hphc_total = + HPHC_ABO * 12.0 + c.total_hp() as f64 * HPHC_HP + c.total_hc() as f64 * HPHC_HC; + + let tempo_total = TEMPO_ABO * 12.0 + + c.hc_bleu as f64 * TEMPO_BLEU_HC + + c.hp_bleu as f64 * TEMPO_BLEU_HP + + c.hc_blanc as f64 * TEMPO_BLANC_HC + + c.hp_blanc as f64 * TEMPO_BLANC_HP + + c.hc_rouge as f64 * TEMPO_ROUGE_HC + + c.hp_rouge as f64 * TEMPO_ROUGE_HP; + + (base_total, hphc_total, tempo_total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_and_tariffs() { + let csv = "\ +Récapitulatif\n\ +\n\ +Date;Type;HC Bleu;HP Bleu;HC Blanc;HP Blanc;HC Rouge;HP Rouge\n\ +01/01/2025;Index;13000;6500;2800;1400;800;500\n\ +15/06/2024;Index;11500;5700;2400;1200;650;400\n\ +01/01/2024;Index;10000;5000;2000;1000;500;300\n"; + + let readings = parse_readings(csv).unwrap(); + assert_eq!(readings.len(), 3); + + let last = readings.first().unwrap(); + let target_date = one_year_before(&last.date); + let start = readings + .iter() + .min_by_key(|r| date_distance(&r.date, &target_date)) + .unwrap(); + + assert_eq!(last.date, "01/01/2025"); + assert_eq!(start.date, "01/01/2024"); + + let conso = Consumption { + hc_bleu: last.hc_bleu - start.hc_bleu, + hp_bleu: last.hp_bleu - start.hp_bleu, + hc_blanc: last.hc_blanc - start.hc_blanc, + hp_blanc: last.hp_blanc - start.hp_blanc, + hc_rouge: last.hc_rouge - start.hc_rouge, + hp_rouge: last.hp_rouge - start.hp_rouge, + }; + + assert_eq!(conso.hc_bleu, 3000); + assert_eq!(conso.hp_bleu, 1500); + assert_eq!(conso.hc_blanc, 800); + assert_eq!(conso.hp_blanc, 400); + assert_eq!(conso.hc_rouge, 300); + assert_eq!(conso.hp_rouge, 200); + assert_eq!(conso.total(), 6200); + + let (base, hphc, tempo) = compute_tariffs(&conso); + + assert!((base - 1395.88).abs() < 0.01); + assert!((hphc - 1296.24).abs() < 0.01); + assert!((tempo - 1133.34).abs() < 0.01); + } + + #[test] + fn test_utf8_parsing() { + let csv = "Récapitulatif\n\nDate;Type;HC Bleu;HP Bleu;HC Blanc;HP Blanc;HC Rouge;HP Rouge\n01/01/2024;Index;1000;500;100;50;10;5\n01/01/2025;Index;2000;800;200;100;30;15\n"; + let readings = parse_readings(csv).unwrap(); + + assert_eq!(readings.len(), 2); + assert_eq!(readings[0].hc_bleu, 1000); + assert_eq!(readings[1].hc_bleu, 2000); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 392fc84..23a24a1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod desktop; pub mod dev; pub mod deploy; +pub mod edf; pub mod site; diff --git a/src/main.rs b/src/main.rs index d7179b9..4dfba80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,5 +13,6 @@ async fn main() -> Result<()> { Commands::Desktop(cmd) => commands::desktop::run(&cmd), Commands::Dev { site } => commands::dev::run(site).await, Commands::Deploy { site } => commands::deploy::run(site).await, + Commands::Edf { csv } => commands::edf::run(&csv), } }