solar_api/
site.rs

1use serde::{Deserialize, Deserializer};
2use std::collections::HashMap;
3use uom::si::{
4    energy::watt_hour,
5    f64::{Energy, Power},
6    power::{kilowatt, watt},
7};
8
9pub const REFRESH_TIME_IN_M: i64 = 15;
10
11#[derive(Debug, Clone, Deserialize)]
12pub(crate) struct SitesReply {
13    sites: Sites,
14}
15
16impl SitesReply {
17    pub fn sites(&self) -> &Vec<Site> {
18        &self.sites.site
19    }
20}
21
22#[derive(Debug, Clone, Deserialize)]
23pub struct Sites {
24    #[serde(rename = "count")]
25    _count: u32,
26    site: Vec<Site>,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30pub struct SiteDetails {
31    pub details: Site,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35pub struct Site {
36    /// the site id
37    pub id: u32,
38    /// the site name
39    pub name: String,
40    /// the account this site belongs to
41    #[serde(rename = "accountId")]
42    pub account_id: u32,
43    /// the site status
44    pub status: String,
45    /// site peak power
46    #[serde(rename = "peakPower", deserialize_with = "parse_power_kw")]
47    pub peak_power: Power,
48    #[serde(rename = "lastUpdateTime", deserialize_with = "parse_date")]
49    pub last_update_time: chrono::NaiveDate,
50    /// site installation date
51    #[serde(rename = "installationDate", deserialize_with = "parse_date")]
52    pub installation_date: chrono::NaiveDate, 
53    /// permission to operate date
54    #[serde(rename = "ptoDate")]
55    pub pto_date: Option<String>,
56    pub notes: String,
57    /// site type
58    #[serde(rename = "type")]
59    pub site_type: String,
60    /// includes country, state, city, address, secondary address, time zone and zip
61    pub location: Location,
62    #[serde(rename = "primaryModule")]
63    pub primary_module: PrimaryModule,
64    pub uris: HashMap<String, String>,
65    ///  includes if this site is public and its public name
66    #[serde(rename = "publicSettings")]
67    pub public_settings: PublicSettings,
68}
69
70/// Location of a site
71#[derive(Debug, Clone, Deserialize)]
72pub struct Location {
73    pub country: String,
74    pub city: String,
75    pub address: String,
76    pub zip: String,
77    #[serde(rename = "timeZone")]
78    pub time_zone: String,
79    #[serde(rename = "countryCode")]
80    pub country_code: String,
81}
82
83/// The information about the model of the primary module of the site
84#[derive(Debug, Clone, Deserialize)]
85pub struct PrimaryModule {
86    #[serde(rename = "manufacturerName")]
87    pub manufacturer_name: String,
88    #[serde(rename = "modelName")]
89    pub model_name: String,
90    #[serde(rename = "maximumPower", deserialize_with = "parse_power_kw")]
91    pub maximum_power: Power,
92    #[serde(rename = "temperatureCoef")]
93    pub temperature_coef: f32,
94}
95
96/// Setting showing if information about this site is public
97#[derive(Debug, Clone, Deserialize)]
98pub struct PublicSettings {
99    #[serde(rename = "isPublic")]
100    pub public: bool,
101}
102
103/// The period defined by start_date and end_date that this site is producting energy
104#[derive(Debug, Clone, Deserialize)]
105pub struct DataPeriod {
106    #[serde(rename = "startDate", deserialize_with = "parse_date")]
107    pub start_date: chrono::NaiveDate,
108    #[serde(rename = "endDate", deserialize_with = "parse_date")]
109    pub end_date: chrono::NaiveDate,
110}
111
112impl DataPeriod {
113    /// create a formatted [`String`] for the start date 
114    /// in `%Y-%m-%d` format, i.e. `2023-11-9` for november 9th 2023
115    pub fn formatted_start_date(&self) -> String {
116        Self::formatted_date(&self.start_date)
117    }
118
119    /// create a formatted [`String`] for the end date 
120    /// in `%Y-%m-%d` format, i.e. `2023-11-9` for november 9th 2023
121    pub fn formatted_end_date(&self) -> String {
122        Self::formatted_date(&self.end_date)
123    }
124
125    fn formatted_date(date: &chrono::NaiveDate) -> String {
126        date.format("%Y-%m-%d").to_string()
127    }
128}
129
130#[derive(Debug, Clone, Deserialize)]
131pub(crate) struct DataPeriodReply {
132    #[serde(rename = "dataPeriod")]
133    pub(crate) data_period: DataPeriod,
134}
135
136#[derive(Debug, Clone, Deserialize)]
137pub(crate) struct OverviewReply {
138    pub(crate) overview: Overview,
139}
140
141/// The overview of a site includes the site current power, daily energy, monthly energy, yearly energy and life time energy.
142#[derive(Debug, Clone, Deserialize)]
143pub struct Overview {
144    #[serde(rename = "lastUpdateTime", deserialize_with = "parse_date_time")]
145    pub last_updated_time: chrono::NaiveDateTime,
146    #[serde(rename = "lifeTimeData")]
147    pub life_time_data: TimeData,
148    #[serde(rename = "lastYearData")]
149    pub last_year_data: TimeData,
150    #[serde(rename = "lastMonthData")]
151    pub last_month_data: TimeData,
152    #[serde(rename = "lastDayData")]
153    pub last_day_data: TimeData,
154    #[serde(rename = "currentPower")]
155    pub current_power: GeneratedPowerW,
156    #[serde(rename = "measuredBy")]
157    pub measured_by: String,
158}
159
160impl Overview {
161    /// Calculates the next timestamp and the duration from now when new data 
162    /// should be available on the API. It uses `last_update_time` and 15 
163    /// minutes and 10 seconds as delta between updates
164    pub fn estimated_next_update(&self) -> (chrono::NaiveDateTime, chrono::Duration) {
165        // add 10s extra time
166        let next = self.last_updated_time + chrono::Duration::seconds(REFRESH_TIME_IN_M * 60 + 10);
167        let delta = next - chrono::Local::now().naive_local();
168        (next, delta)
169    }
170}
171
172/// Amount of [`Energy`] and optional the revenue of this energy
173#[derive(Debug, Clone, Deserialize)]
174pub struct TimeData {
175    #[serde(deserialize_with = "parse_energy_wh")]
176    pub energy: Energy,
177    pub revenue: Option<f32>,
178}
179
180/// Generated power in Kw
181#[derive(Debug, Clone, Deserialize)]
182pub struct GeneratedPower {
183    #[serde(deserialize_with = "parse_power_kw")]
184    pub power: Power,
185}
186
187/// Generated power in W
188#[derive(Debug, Clone, Deserialize)]
189pub struct GeneratedPowerW {
190    #[serde(deserialize_with = "parse_power_w")]
191    pub power: Power,
192}
193
194#[derive(Debug, Clone, Deserialize)]
195pub enum TimeUnit {
196    QuarterOfAnHour,
197    Hour,
198    Day,
199    Week,
200    Month,
201    Year,
202}
203
204const QUARTER_OF_AN_HOUR: &str = "QUARTER_OF_AN_HOUR";
205const HOUR: &str = "HOUR";
206const DAY: &str = "DAY";
207const WEEK: &str = "WEEK";
208const MONTH: &str = "MONTH";
209const YEAR: &str = "YEAR";
210
211impl TimeUnit {
212    pub fn to_param(&self) -> &'static str {
213        match self {
214            TimeUnit::QuarterOfAnHour => QUARTER_OF_AN_HOUR,
215            TimeUnit::Hour => HOUR,
216            TimeUnit::Day => DAY,
217            TimeUnit::Week => WEEK,
218            TimeUnit::Month => MONTH,
219            TimeUnit::Year => YEAR,
220        }
221    }
222
223    pub fn from_const<'de, D>(deserializer: D) -> Result<TimeUnit, D::Error>
224    where
225        D: Deserializer<'de>,
226    {
227        let s: String = String::deserialize(deserializer)?;
228        match s.as_str() {
229            QUARTER_OF_AN_HOUR => Ok(TimeUnit::QuarterOfAnHour),
230            HOUR => Ok(TimeUnit::Hour),
231            DAY => Ok(TimeUnit::Day),
232            WEEK => Ok(TimeUnit::Week),
233            MONTH => Ok(TimeUnit::Month),
234            YEAR => Ok(TimeUnit::Year),
235            _ => Err(serde::de::Error::custom("Cannot parse value")),
236        }
237    }
238}
239
240#[derive(Debug, Clone, Deserialize)]
241pub(crate) struct GeneratedEnergyReply {
242    pub(crate) energy: GeneratedEnergy,
243}
244
245/// Contains all values of the generated energy per time unit
246#[derive(Debug, Clone, Deserialize)]
247pub struct GeneratedEnergy {
248    #[serde(rename = "timeUnit", deserialize_with = "TimeUnit::from_const")]
249    pub time_unit: TimeUnit,
250    unit: String,
251    values: Vec<RawGeneratedEnergyValue>,
252}
253
254impl GeneratedEnergy {
255    /// returns the timestamped energy values
256    pub fn values(&self) -> Vec<GeneratedEnergyValue> {
257        self.values
258            .iter()
259            .map(|raw| raw.convert(&self.unit))
260            .collect()
261    }
262}
263
264// struct used to parse reply from API. Can be converted to 
265//[`GeneratedEnergyValue`] to contain correct unit of measurement 
266// using the unit value returned by [`GeneratedEnergy`]
267#[derive(Debug, Clone, Deserialize, Copy)]
268struct RawGeneratedEnergyValue {
269    #[serde(deserialize_with = "parse_date_time")]
270    date: chrono::NaiveDateTime,
271    value: Option<f64>,
272}
273
274impl RawGeneratedEnergyValue {
275    // converts f64 value to [`Energy`] using supplied `unit`. 
276    // Currenty only `Wh` is supported
277    fn convert(&self, unit: &str) -> GeneratedEnergyValue {
278        let value = match unit {
279            "Wh" => self.value.map(Energy::new::<watt_hour>),
280            _ => todo!("unsupported unit: {unit}"),
281        };
282        GeneratedEnergyValue {
283            date: self.date,
284            value,
285        }
286    }
287}
288
289/// A timestamped [`Energy`] value. The value may be None when there wasn't a 
290/// value at that timestamp
291#[derive(Debug, Clone, Copy)]
292pub struct GeneratedEnergyValue {
293    /// timestamp of value
294    pub date: chrono::NaiveDateTime,
295    /// the value measures at the timestamp or None if there wasn't a value at
296    /// that timestamp
297    pub value: Option<Energy>,
298}
299
300// struct used to parse the API reply for Power
301#[derive(Debug, Clone, Deserialize)]
302pub(crate) struct GeneratedPowerReply {
303    pub(crate) power: GeneratedPowerPerTimeUnit,
304}
305
306/// Contains all values of the generated power per time unit
307#[derive(Debug, Clone, Deserialize)]
308pub struct GeneratedPowerPerTimeUnit {
309    #[serde(rename = "timeUnit", deserialize_with = "TimeUnit::from_const")]
310    pub time_unit: TimeUnit,
311    unit: String,
312    values: Vec<RawGeneratedPowerValue>,
313}
314
315impl GeneratedPowerPerTimeUnit {
316    /// returns all Power values that were present in the time period
317    pub fn values(&self) -> Vec<GeneratedPowerValue> {
318        self.values
319            .iter()
320            .map(|raw| raw.convert(&self.unit))
321            .collect()
322    }
323}
324
325#[derive(Debug, Clone, Deserialize)]
326struct RawGeneratedPowerValue {
327    #[serde(deserialize_with = "parse_date_time")]
328    date: chrono::NaiveDateTime,
329    value: Option<f64>,
330}
331
332impl RawGeneratedPowerValue {
333    // converts f64 value to [`Power`] using supplied `unit`. 
334    // Currenty only `W` is supported
335    pub fn convert(&self, unit: &str) -> GeneratedPowerValue {
336        let value: Option<Power> = match unit {
337            "W" => self.value.map(Power::new::<watt>),
338            _ => todo!("unsupported unit: {unit}"),
339        };
340        GeneratedPowerValue {
341            date: self.date,
342            value,
343        }
344    }
345}
346
347/// A timestamped [`Power`] value. The value may be None when there wasn't a 
348/// value at that timestamp
349#[derive(Debug, Clone)]
350pub struct GeneratedPowerValue {
351    pub date: chrono::NaiveDateTime,
352    pub value: Option<Power>,
353}
354
355// parse a datetime value that the API returned to a [`NaiveDateTime`]
356fn parse_date_time<'de, D>(deserializer: D) -> Result<chrono::NaiveDateTime, D::Error>
357where
358    D: Deserializer<'de>,
359{
360    let s: String = String::deserialize(deserializer)?;
361    chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S")
362        .map_err(|_| serde::de::Error::custom("Cannot parse value"))
363}
364
365// parse a datetime value that the API returned to a [`NaiveDate`]
366fn parse_date<'de, D>(deserializer: D) -> Result<chrono::NaiveDate, D::Error>
367where
368    D: Deserializer<'de>,
369{
370    let s: String = String::deserialize(deserializer)?;
371    chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
372        .map_err(|_| serde::de::Error::custom("Cannot parse value"))
373}
374
375// parse a float value that the API returned to a [`Power`] value. Assumes the value is in kilowatt
376fn parse_power_kw<'de, D>(deserializer: D) -> Result<Power, D::Error>
377where
378    D: Deserializer<'de>,
379{
380    let value: f64 = f64::deserialize(deserializer)?;
381    Ok(Power::new::<kilowatt>(value))
382}
383
384// parse a float value that the API returned to a [`Power`] value. Assumes the value is in watt
385fn parse_power_w<'de, D>(deserializer: D) -> Result<Power, D::Error>
386where
387    D: Deserializer<'de>,
388{
389    let value: f64 = f64::deserialize(deserializer)?;
390    Ok(Power::new::<watt>(value))
391}
392
393// parse a float value that the API returned to a [`Energy`] value. Assumes the value is in watt-hours
394fn parse_energy_wh<'de, D>(deserializer: D) -> Result<Energy, D::Error>
395where
396    D: Deserializer<'de>,
397{
398    let value: f64 = f64::deserialize(deserializer)?;
399    Ok(Energy::new::<watt_hour>(value))
400}
401
402#[test]
403fn test_parse_sites_data() {
404    let output = r#"
405       {"sites":{
406           "count":1,
407           "site":[
408               {"id":1234123,
409                "name":"MySiteName",
410                "accountId":123456,
411                "status":"Active",
412                "peakPower":7.41,
413                "lastUpdateTime":"2021-04-29",
414                "installationDate":"2021-02-25",
415                "ptoDate":null,
416                "notes":"",
417                "type":"Optimizers & Inverters",
418                "location":{
419                    "country":"Netherlands",
420                    "city":"A city",
421                    "address":"Some address",
422                    "zip":"zipy",
423                    "timeZone":"Europe/Amsterdam",
424                    "countryCode":"NL"
425                },
426                "primaryModule":{
427                    "manufacturerName":"JinkoSolar",
428                    "modelName":"390",
429                    "maximumPower":0.0,
430                    "temperatureCoef":0.0
431                },
432                "uris":{
433                    "SITE_IMAGE":"/site/1234123/siteImage/file12341234.jpg",
434                    "DATA_PERIOD":"/site/1234123/dataPeriod",
435                    "DETAILS":"/site/1234123/details",
436                    "OVERVIEW":"/site/1234123/overview"
437                },
438                "publicSettings":{
439                    "isPublic":false
440                }}
441            ]
442        }
443    }"#;
444
445    let reply: SitesReply = serde_json::from_str(output).unwrap();
446    println!("{:?}", reply);
447    assert_eq!(reply.sites._count, 1);
448    let power = Power::new::<kilowatt>(7.41);
449    assert_eq!(power, reply.sites.site[0].peak_power);
450}
451
452#[test]
453fn test_parse_data_period() {
454    let reply = r#"{"dataPeriod":{"startDate":"2021-02-25","endDate":"2021-05-03"}}"#;
455    println!("{}", reply);
456    let parsed: DataPeriodReply = serde_json::from_str(reply).unwrap();
457    assert_eq!("2021-02-25", parsed.data_period.formatted_start_date());
458    assert_eq!("2021-05-03", parsed.data_period.formatted_end_date());
459}
460
461#[test]
462fn test_energy() {
463    use uom::si::energy::watt_hour;
464
465    let reply = r#"
466    {"energy":{
467        "timeUnit":"MONTH",
468        "unit":"Wh",
469        "measuredBy":"INVERTER",
470        "values":[
471            {"date":"2021-02-01 00:00:00","value":45718.0},
472            {"date":"2021-03-01 00:00:00","value":504857.0},
473            {"date":"2021-04-01 00:00:00","value":800476.0},
474            {"date":"2021-05-01 00:00:00","value":89913.0}]}}
475    "#;
476
477    let parsed: GeneratedEnergyReply = serde_json::from_str(reply).unwrap();
478    assert_eq!(
479        45718.0,
480        parsed.energy.values()[0]
481            .value
482            .map(|e| e.get::<watt_hour>())
483            .unwrap()
484    );
485}
486
487#[test]
488fn test_overview() {
489    let reply = r#"
490    {"overview":{
491        "lastUpdateTime":"2023-11-09 10:28:56",
492        "lifeTimeData":{"energy":1.9191678E7},
493        "lastYearData":{"energy":6143745.0},
494        "lastMonthData":{"energy":38709.0},
495        "lastDayData":{"energy":2028.0},
496        "currentPower":{"power":1173.7279},
497        "measuredBy":"INVERTER"}
498    }
499    "#;
500
501    let parsed: OverviewReply = serde_json::from_str(reply).unwrap();
502    assert_eq!(
503        Energy::new::<watt_hour>(1.9191678E7),
504        parsed.overview.life_time_data.energy
505    );
506    assert_eq!(
507        Power::new::<watt>(1173.7279),
508        parsed.overview.current_power.power
509    );
510}
511
512#[test]
513fn test_energy_in_period() {
514    let reply = r#"
515    {"energy":{
516        "timeUnit":"HOUR",
517        "unit":"Wh",
518        "measuredBy":"INVERTER",
519        "values":[
520            {"date":"2023-11-09 00:00:00","value":null},
521            {"date":"2023-11-09 01:00:00","value":null},
522            {"date":"2023-11-09 02:00:00","value":null},
523            {"date":"2023-11-09 03:00:00","value":null},
524            {"date":"2023-11-09 04:00:00","value":0.0},
525            {"date":"2023-11-09 05:00:00","value":null},
526            {"date":"2023-11-09 06:00:00","value":null},
527            {"date":"2023-11-09 07:00:00","value":0.0},
528            {"date":"2023-11-09 08:00:00","value":256.0},
529            {"date":"2023-11-09 09:00:00","value":827.0},
530            {"date":"2023-11-09 10:00:00","value":1390.0},
531            {"date":"2023-11-09 11:00:00","value":222.0},
532            {"date":"2023-11-09 12:00:00","value":null},
533            {"date":"2023-11-09 13:00:00","value":null},
534            {"date":"2023-11-09 14:00:00","value":null},
535            {"date":"2023-11-09 15:00:00","value":null},
536            {"date":"2023-11-09 16:00:00","value":null},
537            {"date":"2023-11-09 17:00:00","value":null},
538            {"date":"2023-11-09 18:00:00","value":null},
539            {"date":"2023-11-09 19:00:00","value":null},
540            {"date":"2023-11-09 20:00:00","value":null},
541            {"date":"2023-11-09 21:00:00","value":null},
542            {"date":"2023-11-09 22:00:00","value":null},
543            {"date":"2023-11-09 23:00:00","value":null}
544            ]
545        }
546    }
547    "#;
548
549    let parsed: GeneratedEnergyReply = serde_json::from_str(reply).unwrap();
550    assert_eq!(24, parsed.energy.values().len());
551    assert_eq!(
552        Some(Energy::new::<watt_hour>(222.0)),
553        parsed.energy.values()[11].value
554    );
555}
556
557#[test]
558fn test_power_in_period() {
559    let reply = r#"
560    {"power":{
561        "timeUnit":"QUARTER_OF_AN_HOUR",
562        "unit":"W",
563        "measuredBy":"INVERTER",
564        "values":[
565            {"date":"2023-11-09 12:15:00","value":761.538},
566            {"date":"2023-11-09 12:30:00","value":822.26117},
567            {"date":"2023-11-09 12:45:00","value":746.9589},
568            {"date":"2023-11-09 13:00:00","value":563.11},
569            {"date":"2023-11-09 13:15:00","value":554.06836}
570        ]
571    }}
572    "#;
573
574    let parsed: GeneratedPowerReply = serde_json::from_str(reply).unwrap();
575    assert_eq!(5, parsed.power.values().len());
576    assert_eq!(
577        Some(Power::new::<watt>(761.538)),
578        parsed.power.values()[0].value
579    );
580}