pvoutput-client 0.0.3

Client for the PVOutput.org API
Documentation
use jiff::{
	Timestamp,
	civil::{Date, Time},
};
use proptest::property_test;

use crate::{
	Client, parser,
	types::{
		Conditions,
		params::{GetOutputParams, GetStatisticsParams, GetSystemParams},
		regions::Australia,
		response::{
			AggregateDate, ExtendedData, History, Insolation, Ladder, Statistics, Status, Supply, System, Tariffs, Team,
			TeamOutput,
		},
	},
};

#[test]
fn status() {
	let mut response = Client::reader(include_str!("../../../cases/get_status.csv"));
	let status: Status = response.deserialize().next().unwrap().unwrap();
	assert_eq!(status.date, Date::constant(2025, 9, 13));
	assert_eq!(status.time, Time::constant(17, 15, 0, 0));
	assert_eq!(status.extended_value_2, Some(2.0));
	assert!(status.voltage.is_some_and(|v| (234.6..=234.8).contains(&v)));
}

#[test]
fn statistics() {
	let statistics =
		parser::statistics(include_str!("../../../cases/get_statistics.csv"), &GetStatisticsParams::default()).unwrap();
	validate_statistics(&statistics);
	assert_eq!(statistics.consumption, None);
	assert_eq!(statistics.credit, None);
}

#[test]
fn statistics_with_consumption() {
	let statistics = parser::statistics(
		include_str!("../../../cases/get_statistics_consumption.csv"),
		&GetStatisticsParams {
			consumption: true,
			..Default::default()
		},
	)
	.unwrap();
	validate_statistics(&statistics);
	assert_eq!(statistics.consumption.unwrap().energy_consumed, 10800);
	assert_eq!(statistics.credit, None);
}

#[test]
fn statistics_with_credit_and_debit() {
	let statistics = parser::statistics(
		include_str!("../../../cases/get_statistics_credit_and_debit.csv"),
		&GetStatisticsParams {
			credit_and_debit: true,
			..Default::default()
		},
	)
	.unwrap();
	validate_statistics(&statistics);
	assert_eq!(statistics.consumption, None);
	assert!((12.2..12.4).contains(&statistics.credit.unwrap()));
}

#[test]
fn statistics_full() {
	let statistics = parser::statistics(
		include_str!("../../../cases/get_statistics_full.csv"),
		&GetStatisticsParams {
			consumption: true,
			credit_and_debit: true,
			..Default::default()
		},
	)
	.unwrap();
	validate_statistics(&statistics);
	assert_eq!(statistics.consumption.unwrap().energy_consumed, 10800);
	assert!((12.2..12.4).contains(&statistics.credit.unwrap()));
}

#[test]
fn system() {
	let system = parser::system(include_str!("../../../cases/get_system.csv"), &GetSystemParams::default()).unwrap();
	validate_system(&system.system);
	assert_eq!(system.donations, 1);
	assert_eq!(system.tariffs, Tariffs::default());
}

#[test]
fn system_tariffs() {
	let system = parser::system(
		include_str!("../../../cases/get_system_tariffs.csv"),
		&GetSystemParams {
			tariffs: true,
			..Default::default()
		},
	)
	.unwrap();
	validate_system(&system.system);
	assert_eq!(system.donations, 1);
	assert_eq!(system.tariffs.export.map(f32::round), Some(1.0));
}

#[test]
fn ladder() {
	let mut response = Client::reader(include_str!("../../../cases/get_ladder.csv"));
	let ladder: Ladder = response.deserialize().next().unwrap().unwrap();
	assert_eq!(ladder.date, Date::constant(2025, 9, 13));
	assert_eq!(ladder.generation_rank, Some(27351));
	assert_eq!(ladder.efficiency_rank, None);
}

#[test]
fn daily_status() {
	let status = parser::daily_status(include_str!("../../../cases/get_status_stats.csv")).unwrap();
	assert_eq!(status.peak_time, Time::constant(10, 45, 0, 0));

	assert!(status.consumption.is_some());
	let consumption = status.consumption.unwrap();
	assert_eq!(consumption.standby_power_time, Some(Time::constant(4, 5, 0, 0)));

	assert!(status.temperatures.is_some());
	let temperatures = status.temperatures.unwrap();
	assert!(temperatures.average_temperature.is_some_and(|t| (22.19..=22.21).contains(&t)));
}

#[test]
fn history_status() {
	let csv = include_str!("../../../cases/get_status_history.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let history: Vec<History> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(history.len(), 30);
	assert_eq!(history[0].time, Time::constant(11, 45, 0, 0));
}

#[test]
fn outputs() {
	let outputs = parser::outputs(include_str!("../../../cases/get_outputs.csv"), &GetOutputParams::default()).unwrap();
	assert_eq!(outputs.len(), 50);
	assert_eq!(outputs[0].conditions, Conditions::NotSure);
	assert_eq!(outputs[1].conditions, Conditions::MostlyCloudy);
}

#[test]
fn outputs_with_exports() {
	let outputs = parser::outputs(
		include_str!("../../../cases/get_outputs_exports.csv"),
		&GetOutputParams {
			time_of_export: true,
			..Default::default()
		},
	)
	.unwrap();
	assert_eq!(outputs.len(), 50);
	assert_eq!(outputs[0].conditions, Conditions::MostlyCloudy);
	assert!(outputs[0].export.is_some());
}

#[test]
fn outputs_with_insolation() {
	let outputs = parser::outputs(
		include_str!("../../../cases/get_outputs_insolation.csv"),
		&GetOutputParams {
			insolation: true,
			..Default::default()
		},
	)
	.unwrap();
	assert_eq!(outputs.len(), 50);
	assert_eq!(outputs[0].conditions, Conditions::MostlyCloudy);
	assert_eq!(outputs[0].insolation, Some(2_680_812));
}

#[test]
fn outputs_full() {
	let outputs = parser::outputs(
		include_str!("../../../cases/get_outputs_full.csv"),
		&GetOutputParams {
			insolation: true,
			time_of_export: true,
			..Default::default()
		},
	)
	.unwrap();
	assert_eq!(outputs.len(), 50);
	assert_eq!(outputs[0].conditions, Conditions::MostlyCloudy);
	assert_eq!(outputs[0].insolation, Some(2_680_812));
	assert!(outputs[0].export.is_some());
}

#[test]
fn aggregated_output() {
	let outputs = parser::aggregate_outputs(include_str!("../../../cases/get_outputs_aggregated.csv"), false).unwrap();
	assert_eq!(outputs.len(), 50);
	assert_eq!(outputs[0].date, AggregateDate::YearAndMonth(2025, 9));
	assert_eq!(outputs[0].outputs, 14);
}

#[test]
fn aggregated_output_full() {
	let outputs =
		parser::aggregate_outputs(include_str!("../../../cases/get_outputs_aggregated_full.csv"), true).unwrap();
	assert_eq!(outputs.len(), 2);
	assert_eq!(outputs[0].date, AggregateDate::Year(2025));
	assert_eq!(outputs[0].outputs, 258);
	assert!(outputs[0].export.is_some());
	assert_eq!(outputs[0].export.as_ref().unwrap().off_peak_energy_export, Some(0));
}

#[property_test]
#[allow(clippy::cast_possible_wrap)]
fn aggregated_date(#[strategy = 0..=9999u16] year: u16, #[strategy = 1..=12u8] month: u8, with_month: bool) {
	let (string, expected) = if with_month {
		(format!("{year:04}{month:02}"), AggregateDate::YearAndMonth(year, month))
	} else {
		(format!("{year:04}"), AggregateDate::Year(year))
	};

	let mut response = Client::reader(&string);
	let actual = response.deserialize().next().unwrap().unwrap();
	assert_eq!(expected, actual);
	assert_eq!(expected.year(), year);
	assert_eq!(
		expected.as_arbitrary_date().unwrap(),
		Date::new(year as i16, if with_month { month as i8 } else { 1 }, 1).unwrap()
	);
}

#[test]
fn team_outputs() {
	let csv = include_str!("../../../cases/get_outputs_team.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let outputs: Vec<TeamOutput> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(outputs[0].date, Date::constant(2025, 9, 14));
	assert_eq!(outputs[0].outputs, 4);
}

#[test]
fn extended_data() {
	let csv = include_str!("../../../cases/get_extended.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let extended: Vec<ExtendedData> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(extended.len(), 2);
	assert_eq!(extended[0].date, Date::constant(2021, 2, 27));
}

#[test]
fn favourites() {
	let favourites = parser::favourites(include_str!("../../../cases/get_favourites.csv")).unwrap();
	assert_eq!(favourites.len(), 2);
	validate_system(&favourites[0].system);
	assert_eq!(favourites[1].system.inverter_brand, Some(String::from("Lun-a-matic")));
}

#[test]
fn missing() {
	let mut response = Client::reader(include_str!("../../../cases/get_missing.csv"));
	let missing: Vec<Date> = response.deserialize().next().unwrap().unwrap();
	assert_eq!(missing.len(), 4);
	assert_eq!(missing[0], Date::constant(2021, 1, 1));
}

#[test]
fn insolation() {
	let csv = include_str!("../../../cases/get_insolation.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let insolation: Vec<Insolation> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(insolation.len(), 8);
	assert_eq!(insolation[0].time, Time::constant(6, 0, 0, 0));
}

#[test]
fn team() {
	let mut response = Client::semicolon_reader(include_str!("../../../cases/get_team.csv"));
	let team: Team = response.deserialize().next().unwrap().unwrap();
	assert_eq!(team.name, "PVOutput iOS app");
	assert_eq!(team.size, 21_834_213);
	assert_eq!(team.created, Date::constant(2014, 11, 12));
}

#[test]
fn regions() {
	let csv = include_str!("../../../cases/get_supply.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let supplies: Vec<Supply> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(supplies.len(), 30);
	assert_eq!(supplies[0].timestamp, Timestamp::constant(1_758_427_800, 0));
	assert_eq!(supplies[0].region, Australia::Queensland.into());
}

#[test]
fn regions_qld() {
	let csv = include_str!("../../../cases/get_supply_qld.csv").replace(';', "\n");
	let mut response = Client::reader(&csv);
	let supplies: Vec<Supply> = response.deserialize().collect::<Result<_, _>>().unwrap();
	assert_eq!(supplies.len(), 144);
	assert_eq!(supplies[0].timestamp, Timestamp::constant(1_760_542_200, 0));
	assert_eq!(supplies[0].region, Australia::Queensland.into());
}

fn validate_statistics(statistics: &Statistics) {
	assert_eq!(statistics.energy_generated, 11_353_459);
	assert_eq!(statistics.energy_exported, 1_476_067);
	assert_eq!(statistics.date_from, Date::constant(2010, 7, 5));
	assert_eq!(statistics.date_to, Date::constant(2013, 2, 25));
}

fn validate_system(system: &System) {
	assert_eq!(system.name, "Example System");
	assert_eq!(system.size, 6600);
	assert_eq!(system.panels, Some(14));
	assert_eq!(system.panel_power, Some(400));
	assert_eq!(system.brand, Some(String::from("Contoso Maximus")));
	assert_eq!(system.tilt, Some(20.0));
}