aquarium_control/database/sql_interface_heating_stats.rs
1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Manages the aggregation and persistence of daily heating statistics.
11//!
12//! This module provides a dedicated interface, `SqlInterfaceHeatingStats`, for handling
13//! all SQL operations related to the `heatingstats` table. It is responsible for
14//! tracking daily energy consumption, average temperatures, and heater runtime.
15//!
16//! ## Key Components
17//!
18//! - **`SqlInterfaceHeatingStats` Struct**: The primary interface that holds a database
19//! connection and provides methods for all heating statistics operations.
20//!
21//! - **`HeatingStatsEntry` Struct**: A data structure representing the aggregated
22//! statistics for a single day. It includes an `update` method for incrementally
23//! calculating running averages.
24//!
25//! - **`new()` Constructor**: A critical entry point that performs "fail-fast"
26//! validation at initialization. It checks the database to ensure:
27//! - The `heatingstats` table contains no `NULL` values.
28//! - The table's row count does not exceed the configured maximum.
29//!
30//! - **`get_single_heating_stats_from_database()`**: Retrieves the statistics record
31//! for the current day.
32//!
33//! - **`insert_heating_stats_entry()`**: Writes a `HeatingStatsEntry` to the database
34//! using an `INSERT ... ON DUPLICATE KEY UPDATE` query to either create a new
35//! daily record or update an existing one.
36//!
37//! ## Design and Purpose
38//!
39//! The main goal of this module is to provide a robust and encapsulated interface
40//! for managing the application's historical heating data.
41//!
42//! - **Encapsulation**: All SQL logic specific to the `heatingstats` table is
43//! contained within this module, separating concerns.
44//!
45//! - **Data Integrity**: The constructor's validation logic enforces data integrity
46//! rules at startup, preventing runtime errors caused by an invalid database state.
47//!
48//! - **Time-Based Logic**: It relies on a `SqlInterfaceMidnightCalculatorTrait` to
49//! determine when a day has ended, which is crucial for finalizing one day's
50//! statistics and starting a new record.
51
52use crate::database::{
53 sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError,
54 sql_interface_midnight::SqlInterfaceMidnightCalculatorTrait, sql_query_strings,
55};
56
57use crate::database::sql_query_strings::{
58 SQL_QUERY_CHECK_HEATING_STATS_COUNT, SQL_QUERY_CHECK_HEATING_STATS_NULL,
59};
60use chrono::*;
61use log::warn;
62use mysql::prelude::*;
63use mysql::*;
64use std::fmt;
65
66/// Holds the statistical data of heating for one day.
67/// It is filled with post-processed data from a database query which is then updated by the control application.
68#[derive(Clone)]
69pub struct HeatingStatsEntry {
70 /// calendar date of the entry after having postprocessed the SQL data read as String
71 pub date: NaiveDate,
72
73 /// energy used for heating on a specific date in kWh
74 pub energy: f64,
75
76 /// average ambient temperature value on a specific date in °C
77 pub ambient_temperature_average_value: f64,
78
79 /// counter how many samples were used to calculate the average value
80 pub ambient_temperature_average_counter: i64,
81
82 /// average water temperature value on a specific date in °C
83 pub water_temperature_average_value: f64,
84
85 /// counter how many samples were used to calculate the average value
86 pub water_temperature_average_counter: i64,
87
88 /// counter how many seconds the heating control was active on a specific date
89 pub heating_control_runtime: i64,
90}
91
92impl HeatingStatsEntry {
93 /// Creates a new `HeatingStatsEntry` initialized for the current local date.
94 ///
95 /// This constructor is used when there's no existing heating statistics
96 /// record for the current day in the database. It sets the `date` field to today's
97 /// date and initializes all statistical counters and values to zero.
98 ///
99 /// # Returns
100 /// A new `HeatingStatsEntry` struct with default values for the current date.
101 pub fn new(conn: &mut PooledConn) -> HeatingStatsEntry {
102 // Get the current date from the database
103 let today: NaiveDate = SqlInterface::get_current_date(conn).unwrap_or_else(|_| {
104 warn!(target: module_path!(), "Could not get current date from database.");
105 // fall back: calculation using machine-local time
106 let now = Local::now();
107 let year = now.year();
108 let month = now.month();
109 let day = now.day();
110 // Create NaiveDate from components - unwrap is safe in this context
111 NaiveDate::from_ymd_opt(year, month, day).unwrap()
112 });
113
114 Self {
115 date: today,
116 energy: 0.0,
117 ambient_temperature_average_value: 0.0,
118 ambient_temperature_average_counter: 0,
119 water_temperature_average_value: 0.0,
120 water_temperature_average_counter: 0,
121 heating_control_runtime: 0,
122 }
123 }
124
125 /// Updates the heating statistics entry with new sensor readings and energy consumption data.
126 ///
127 /// This method incorporates new samples to incrementally calculate average water and ambient
128 /// temperatures, sum up energy consumption, and track the heating control's active runtime.
129 ///
130 /// # Arguments
131 /// * `water_temperature_opt` - An `Option<f64>` representing the latest water temperature reading.
132 /// If `Some`, it contributes to the average; if `None`, the water temperature average remains unchanged.
133 /// * `ambient_temperature_opt` - An `Option<f64>` representing the latest ambient air temperature reading.
134 /// If `Some`, it contributes to the average; if `None`, the ambient temperature average remains unchanged.
135 /// * `energy_increment` - The amount of energy (in kWh) consumed by the heater since the last update.
136 pub fn update(
137 &mut self,
138 water_temperature_opt: Option<f64>,
139 ambient_temperature_opt: Option<f64>,
140 energy_increment: f64,
141 ) {
142 // add energy to the sum of heating energy
143 self.energy += energy_increment;
144
145 // calculate new average water temperature value
146 self.water_temperature_average_value = match water_temperature_opt {
147 Some(water_temperature) => {
148 self.water_temperature_average_counter += 1;
149 if self.water_temperature_average_counter > 1 {
150 // multiple samples existing, so mix value proportionally
151 water_temperature / self.water_temperature_average_counter as f64
152 + self.water_temperature_average_value
153 * (1.0 - 1.0 / self.water_temperature_average_counter as f64)
154 } else {
155 water_temperature
156 }
157 }
158 None => {
159 // do nothing - wait for the next valid sample
160 self.water_temperature_average_value
161 }
162 };
163
164 // calculate new average ambient air temperature value
165 self.ambient_temperature_average_value = match ambient_temperature_opt {
166 Some(ambient_temperature) => {
167 self.ambient_temperature_average_counter += 1;
168 if self.ambient_temperature_average_counter > 1 {
169 // multiple samples existing, so mix value proportionally
170 ambient_temperature / self.ambient_temperature_average_counter as f64
171 + self.ambient_temperature_average_value
172 * (1.0 - 1.0 / self.ambient_temperature_average_counter as f64)
173 } else {
174 ambient_temperature
175 }
176 }
177 None => {
178 // do nothing - wait for the next valid sample
179 self.ambient_temperature_average_value
180 }
181 };
182
183 self.heating_control_runtime += 1;
184 }
185
186 #[cfg(test)]
187 pub fn default() -> Self {
188 Self {
189 date: Local::now().date_naive(),
190 energy: 0.0,
191 ambient_temperature_average_value: 0.0,
192 ambient_temperature_average_counter: 0,
193 water_temperature_average_value: 0.0,
194 water_temperature_average_counter: 0,
195 heating_control_runtime: 0,
196 }
197 }
198
199 #[cfg(test)]
200 pub fn increment_date(&mut self) {
201 let tomorrow = self.date.checked_add_days(Days::new(1));
202 self.date = tomorrow.unwrap();
203 }
204}
205
206impl fmt::Display for HeatingStatsEntry {
207 /// Formats the `HeatingStatsEntry` struct into a multi-line, human-readable string.
208 ///
209 /// This implementation is primarily intended for debugging and logging, providing
210 /// a clear overview of the heating statistics for a given date, including
211 /// average temperatures, energy consumption, and control runtime.
212 ///
213 /// # Arguments
214 /// * `f` - A mutable reference to the formatter, as required by the `fmt::Display` trait.
215 ///
216 /// # Returns
217 /// A `fmt::Result` indicating whether the formatting operation was successful.
218 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
219 write!(
220 f,
221 "Heating Stats Entry for {}: \n\
222 Water temperature: average={}°C, counter={} \n\
223 Ambient temperature: average={}°C, counter={} \n\
224 Energy: {}kWh\n\
225 Control runtime: {}s",
226 self.date,
227 self.water_temperature_average_value,
228 self.water_temperature_average_counter,
229 self.ambient_temperature_average_value,
230 self.ambient_temperature_average_counter,
231 self.energy,
232 self.heating_control_runtime
233 )
234 }
235}
236
237/// Contains the configuration and the implementation of the SQL interface for heating.
238pub struct SqlInterfaceHeatingStats {
239 /// Connection to the database
240 pub conn: PooledConn,
241
242 /// Struct with implementation of trait for calculation of the time interval until midnight in seconds
243 pub sql_interface_heating_midnight_calculator:
244 Box<dyn SqlInterfaceMidnightCalculatorTrait + Sync + Send>,
245}
246
247impl SqlInterfaceHeatingStats {
248 /// Creates a new `SqlInterfaceHeatingStats` instance.
249 ///
250 /// This constructor initializes the SQL interface for heating statistics. It performs
251 /// several pre-flight checks to ensure data integrity, such as verifying that the
252 /// `heatingstats` table contains no NULL values and that its row count is within
253 /// the configured limit.
254 ///
255 /// # Arguments
256 /// * `conn` - An active, pooled database connection.
257 /// * `max_rows_heating_stats` - The maximum allowed number of rows in the `heatingstats` table.
258 /// * `sql_interface_heating_midnight_calculator` - A boxed trait object that provides
259 /// the implementation for calculating the duration until midnight.
260 ///
261 /// # Returns
262 /// A `Result` containing a new `SqlInterfaceHeatingStats` instance on success.
263 ///
264 /// # Errors
265 /// This function will return an error if:
266 /// - Any of the initial database queries to get table counts fail (`DatabaseCheckHeatingStatsFailure`).
267 /// - Any of the retrieved counts are negative, indicating a database issue (`DatabaseHeatingStatsTableNegativeValue`).
268 /// - The `heatingstats` table contains entries with `NULL` values (`DatabaseHeatingStatsTableContainsNull`).
269 /// - The number of rows in the `heatingstats` table exceeds `max_rows_heating_stats` (`DatabaseHeatingStatsTableContainsTooManyRows`).
270 pub fn new(
271 mut conn: PooledConn,
272 max_rows_heating_stats: u64,
273 sql_interface_heating_midnight_calculator: Box<
274 dyn SqlInterfaceMidnightCalculatorTrait + Sync + Send,
275 >,
276 ) -> Result<SqlInterfaceHeatingStats, SqlInterfaceError> {
277 let count_null_values = SqlInterface::get_single_integer_from_database(
278 &mut conn,
279 SQL_QUERY_CHECK_HEATING_STATS_NULL,
280 )
281 .map_err(|e| SqlInterfaceError::DatabaseCheckHeatingStatsFailure {
282 location: module_path!().to_string(),
283 source: Box::new(e),
284 })?;
285 let count_rows = SqlInterface::get_single_integer_from_database(
286 &mut conn,
287 SQL_QUERY_CHECK_HEATING_STATS_COUNT,
288 )
289 .map_err(|e| SqlInterfaceError::DatabaseCheckHeatingStatsFailure {
290 location: module_path!().to_string(),
291 source: Box::new(e),
292 })?;
293
294 // check the query results
295 if count_null_values < 0 || count_rows < 0 {
296 return Err(SqlInterfaceError::DatabaseHeatingStatsTableNegativeValue(
297 module_path!().to_string(),
298 count_null_values,
299 count_rows,
300 ));
301 }
302
303 if count_null_values > 0 {
304 return Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsNull(
305 module_path!().to_string(),
306 count_null_values,
307 ));
308 }
309
310 if max_rows_heating_stats > 0 {
311 // execute the check only when the limit is greater than zero
312 if count_rows > max_rows_heating_stats.cast_signed() {
313 return Err(
314 SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(
315 module_path!().to_string(),
316 count_rows.cast_unsigned(),
317 max_rows_heating_stats,
318 ),
319 );
320 }
321 }
322
323 Ok(SqlInterfaceHeatingStats {
324 conn,
325 sql_interface_heating_midnight_calculator,
326 })
327 }
328
329 /// Retrieves a single `HeatingStatsEntry` for the current date from the database.
330 ///
331 /// This function executes an SQL query to fetch heating statistical data that
332 /// corresponds to the database's current date. It is designed to retrieve
333 /// exactly one record for "today."
334 ///
335 /// # Returns
336 /// A `Result` containing the `HeatingStatsEntry` for the current date on success.
337 ///
338 /// # Errors
339 /// This function will return an error if:
340 /// - The underlying database query fails (`SingleHeatingStatsRequestFailure`). This can be
341 /// due to a connection issue or an invalid query.
342 /// - The query returns zero rows, indicating no statistics have been recorded for
343 /// the current day (`SingleHeatingStatsRequestEmptyResponse`).
344 /// - The query returns more than one row, which signifies a data inconsistency
345 /// (`SingleHeatingStatsRequestNoSingleResponse`).
346 /// - The date string retrieved from the database cannot be parsed into a `NaiveDate`
347 /// (`TimestampConversionFailure`).
348 pub fn get_single_heating_stats_from_database(
349 &mut self,
350 ) -> Result<HeatingStatsEntry, SqlInterfaceError> {
351 // Get single heating stats entry from database
352 let single_heating_stats_entry_array = self
353 .conn
354 .query_map(
355 sql_query_strings::SQL_QUERY_READ_HEATING_STATS,
356 |(
357 date,
358 energy,
359 ambient_temperature_average_value,
360 ambient_temperature_average_counter,
361 water_temperature_average_value,
362 water_temperature_average_counter,
363 heating_control_runtime,
364 )| HeatingStatsEntry {
365 date,
366 energy,
367 ambient_temperature_average_value,
368 ambient_temperature_average_counter,
369 water_temperature_average_value,
370 water_temperature_average_counter,
371 heating_control_runtime,
372 },
373 )
374 .map_err(|e| SqlInterfaceError::SingleHeatingStatsRequestFailure {
375 location: module_path!().to_string(),
376 query: sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
377 source: e,
378 })?;
379
380 // check the error case if there is no response
381 if single_heating_stats_entry_array.is_empty() {
382 return Err(SqlInterfaceError::SingleHeatingStatsRequestEmptyResponse(
383 module_path!().to_string(),
384 sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
385 ));
386 }
387 // check the error case if there is more than one response
388 if single_heating_stats_entry_array.len() > 1 {
389 return Err(
390 SqlInterfaceError::SingleHeatingStatsRequestNoSingleResponse(
391 module_path!().to_string(),
392 sql_query_strings::SQL_QUERY_READ_HEATING_STATS.to_string(),
393 ),
394 );
395 }
396 Ok(single_heating_stats_entry_array[0].clone())
397 }
398
399 /// Inserts new heating statistical data into the database or updates an existing entry for a specific date.
400 ///
401 /// This function attempts to write a `HeatingStatsEntry` into the `heatingstats` table.
402 /// It's designed to manage daily statistics, where only one unique entry per date is expected.
403 ///
404 /// The underlying SQL query utilizes an `INSERT ... ON DUPLICATE KEY UPDATE` clause:
405 /// - If no record exists in the `heatingstats` table for the `date` specified in `heating_stats_entry`,
406 /// a new row containing all provided statistical data is inserted.
407 /// - There is a `PRIMARY KEY` constraint for the `Date` column in the `heatingstats` table.
408 /// - If a record *already exists* for `heating_stats_entry.date`, the existing row's
409 /// `Energy`, `AmbientTempAverage`, `AmbientTempCounter`, `WaterTempAverage`, `WaterTempCounter`,
410 /// and `HeatingControlRuntime` columns are updated with the new values from `heating_stats_entry`.
411 ///
412 /// **Important Note on Query Construction: **
413 /// The query is constructed in two parts:
414 /// 1. The `INSERT` clause uses **parameterized queries** (e.g., `:date`, `:energy`), which is the
415 /// recommended and secure way to prevent SQL injection.
416 /// 2. The `ON DUPLICATE KEY UPDATE` clause, however, is built by **directly embedding string
417 /// representations** of the data (e.g., `Energy=#Energy`).
418 ///
419 /// # Arguments
420 ///
421 /// * `heating_stats_entry` - A reference to the `HeatingStatsEntry` struct containing all the
422 /// statistical data (date, energy, temperatures, runtime) to be inserted or updated.
423 /// The `date` field is crucial as it identifies the unique daily record.
424 ///
425 /// # Returns
426 /// An empty `Result` (`Ok(())`) if the data was successfully inserted or updated.
427 ///
428 /// # Errors
429 /// Returns `SqlInterfaceError::InsertHeatingStatsEntryFailure` if the database
430 /// operation fails. This can be caused by a lost connection, constraint violations,
431 /// incorrect data types, or a malformed SQL query. The original `mysql::Error` is
432 /// included as the source for detailed debugging.
433 pub fn insert_heating_stats_entry(
434 &mut self,
435 heating_stats_entry: &HeatingStatsEntry,
436 ) -> Result<(), SqlInterfaceError> {
437 let params = params! {
438 "date" => heating_stats_entry.date,
439 "energy" => heating_stats_entry.energy,
440 "ambient_temperature_average" => heating_stats_entry.ambient_temperature_average_value,
441 "ambient_temperature_counter" => heating_stats_entry.ambient_temperature_average_counter,
442 "water_temperature_average" => heating_stats_entry.water_temperature_average_value,
443 "water_temperature_counter" => heating_stats_entry.water_temperature_average_counter,
444 "heating_control_runtime" => heating_stats_entry.heating_control_runtime,
445 };
446
447 self.conn
448 .exec_drop(sql_query_strings::SQL_QUERY_WRITE_HEATING_STATS, params)
449 .map_err(|e| SqlInterfaceError::InsertHeatingStatsEntryFailure {
450 location: module_path!().to_string(),
451 query: sql_query_strings::SQL_QUERY_WRITE_HEATING_STATS.to_string(),
452 source: e,
453 })
454 }
455
456 /// Calculates the duration in seconds until the next midnight.
457 ///
458 /// This function delegates the calculation to the `sql_interface_heating_midnight_calculator`
459 /// trait object, which was provided during construction. This allows for flexible
460 /// and testable time calculations.
461 ///
462 /// # Returns
463 /// A `Result` containing the number of seconds until midnight on success.
464 ///
465 /// # Errors
466 /// This function will return an error if the underlying calculator implementation
467 /// fails. This is typically a pass-through of the error from the calculator's
468 /// `get_duration_until_midnight` method.
469 pub fn get_duration_until_midnight(&mut self) -> Result<i64, SqlInterfaceError> {
470 self.sql_interface_heating_midnight_calculator
471 .get_duration_until_midnight()
472 }
473}
474
475#[cfg(test)]
476pub mod tests {
477 use crate::database::sql_interface_heating_stats::{
478 HeatingStatsEntry, SqlInterfaceHeatingStats,
479 };
480 use crate::database::sql_interface_midnight::SqlInterfaceMidnightCalculator;
481 use crate::database::sql_query_strings::SQL_TABLE_HEATING_STATS;
482 use crate::database::{sql_interface::SqlInterface, sql_interface_error::SqlInterfaceError};
483 use crate::utilities::config::{read_config_file_with_test_database, ConfigData};
484 use chrono::{Datelike, Local, NaiveDate, NaiveDateTime};
485
486 #[test]
487 // This test case verifies the validation logic within the `SqlInterfaceHeatingStats::new()` constructor.
488 // It covers the following scenarios:
489 // 1. Happy Path: Initialization succeeds when the table has a valid number of rows.
490 // 2. Failure on Row Limit: Fails when `heatingstats` contains more rows than the configured limit.
491 // 3. Deactivated Check: Succeeds even with excess rows if the limit is set to 0.
492 // Test case uses test database #61.
493 fn test_sql_interface_heating_stats_new() {
494 // --- Common Setup ---
495 let config: ConfigData = read_config_file_with_test_database(
496 "/config/aquarium_control_test_generic.toml".to_string(),
497 61, // Using database #61 as requested
498 );
499 println!("Testing with database {}", config.sql_interface.db_name);
500 let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface.clone())
501 .expect("Initialization of SQL interface for test failed.");
502
503 // --- Test Case 1: Happy Path (empty table) ---
504 println!("* Testing new() with an empty table (Happy Path)...");
505 SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
506 .expect("Test setup failed: Could not truncate table.");
507
508 let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
509 sql_interface.get_connection().unwrap(),
510 ));
511 let result_happy_case = SqlInterfaceHeatingStats::new(
512 sql_interface.get_connection().unwrap(),
513 10,
514 midnight_calc,
515 );
516 assert!(
517 result_happy_case.is_ok(),
518 "Expected new() to succeed with an empty table, but it failed: {:?}",
519 result_happy_case.err()
520 );
521 println!("* Succeeded: Happy path initialization is successful on an empty table.");
522
523 // --- Test Case 2: Failure on too many rows ---
524 println!("* Testing new() with more rows than the limit...");
525 SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
526 .expect("Test setup failed: Could not truncate table.");
527 let conn = sql_interface.get_connection().unwrap();
528
529 let mut heating_stats_entry = HeatingStatsEntry::default();
530 let mut sql_interface_heating_stats_for_test_setup = result_happy_case.unwrap();
531 _ = sql_interface_heating_stats_for_test_setup
532 .insert_heating_stats_entry(&heating_stats_entry);
533 heating_stats_entry.increment_date();
534 _ = sql_interface_heating_stats_for_test_setup
535 .insert_heating_stats_entry(&heating_stats_entry);
536
537 let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
538 sql_interface.get_connection().unwrap(),
539 ));
540 // Set the limit to 1
541 let result = SqlInterfaceHeatingStats::new(conn, 1, midnight_calc);
542 assert!(matches!(
543 result,
544 Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(_, _, _))
545 ));
546 println!("* Succeeded: Initialization fails if table exceeds row limit.");
547
548 // --- Test Case 3: Deactivated check (limit is 0) ---
549 println!("* Testing new() with a deactivated row limit check (limit = 0)...");
550 // We reuse the state from the previous test (2 rows in the table)
551 let midnight_calc = Box::new(SqlInterfaceMidnightCalculator::new(
552 sql_interface.get_connection().unwrap(),
553 ));
554 // Set the limit to 0
555 let result = SqlInterfaceHeatingStats::new(
556 sql_interface.get_connection().unwrap(),
557 0,
558 midnight_calc,
559 );
560 assert!(
561 result.is_ok(),
562 "Expected new() to succeed with deactivated check, but it failed: {:?}",
563 result.err()
564 );
565 println!("* Succeeded: Initialization passes when row limit check is deactivated.");
566 }
567
568 #[test]
569 // Test case includes all checks for this table combined in one function to limit the number of additional databases.
570 // Test case uses test database #36.
571 pub fn test_sql_interface_heating() {
572 let config: ConfigData = read_config_file_with_test_database(
573 "/config/aquarium_control_test_generic.toml".to_string(),
574 36,
575 );
576 println!("Testing with database {}", config.sql_interface.db_name);
577 let max_rows_heating_stats = config.sql_interface.max_rows_heating_stats;
578 let mut sql_interface: SqlInterface = SqlInterface::new(config.sql_interface)
579 .expect("Initialization of SQL interface for test failed.");
580 let sql_interface_heating_midnight_calculator = Box::new(
581 SqlInterfaceMidnightCalculator::new(sql_interface.get_connection().unwrap()),
582 );
583 let mut sql_interface_heating = SqlInterfaceHeatingStats::new(
584 sql_interface.get_connection().unwrap(),
585 max_rows_heating_stats,
586 sql_interface_heating_midnight_calculator,
587 )
588 .unwrap();
589
590 // *** retrieve heating stats - ************************************************************
591 match SqlInterface::truncate_table(&mut sql_interface, SQL_TABLE_HEATING_STATS.to_string())
592 {
593 Ok(_) => {}
594 Err(e) => panic!("Could not prepare test case: {e:?}"),
595 }
596 match sql_interface_heating.get_single_heating_stats_from_database() {
597 Ok(_) => {
598 panic!("Call to get_single_heating_stats_from_database should have failed.");
599 }
600 Err(e) => {
601 assert!(matches!(
602 e,
603 SqlInterfaceError::SingleHeatingStatsRequestEmptyResponse(_, _)
604 ));
605 }
606 }
607 println!("* checking reading from empty heating stat table succeeded.");
608 // *****************************************************************************************
609
610 // *** insert heating stats ****************************************************************
611 // prepare data for inserting into the database
612 let today =
613 match SqlInterface::get_current_date(&mut sql_interface.get_connection().unwrap()) {
614 Ok(c) => c,
615 Err(_) => panic!("Could not get current date."),
616 };
617 let mut heating_stats_entry = HeatingStatsEntry {
618 date: today.into(),
619 energy: 0.9,
620 ambient_temperature_average_value: 22.0,
621 ambient_temperature_average_counter: 72000,
622 water_temperature_average_value: 25.0,
623 water_temperature_average_counter: 78000,
624 heating_control_runtime: 64000,
625 };
626 assert!(matches!(
627 sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
628 Ok(())
629 ));
630 println!("* checking inserting heating stats succeeded.");
631 // *****************************************************************************************
632
633 // *** retrieve stats **********************************************************************
634 match sql_interface_heating.get_single_heating_stats_from_database() {
635 Ok(c) => {
636 assert_eq!(c.date, today.into());
637 assert_eq!(c.energy, 0.9);
638 assert_eq!(c.ambient_temperature_average_value, 22.0);
639 assert_eq!(c.ambient_temperature_average_counter, 72000);
640 assert_eq!(c.water_temperature_average_value, 25.0);
641 assert_eq!(c.water_temperature_average_counter, 78000);
642 assert_eq!(c.heating_control_runtime, 64000);
643 }
644 Err(_) => {
645 panic!("Call to get_single_heating_stats_from_database has failed.");
646 }
647 }
648 println!("* checking retrieving heating stats succeeded.");
649
650 // *** retrieve stats **********************************************************************
651 // update data and trigger SQL statement (ON DUPLICATE UPDATE)
652 heating_stats_entry.energy += 0.1;
653 heating_stats_entry.ambient_temperature_average_value += 0.1;
654 heating_stats_entry.ambient_temperature_average_counter += 1;
655 heating_stats_entry.water_temperature_average_value += 0.1;
656 heating_stats_entry.water_temperature_average_counter += 1;
657 heating_stats_entry.heating_control_runtime += 1;
658 assert!(matches!(
659 sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
660 Ok(())
661 ));
662 match sql_interface_heating.get_single_heating_stats_from_database() {
663 Ok(c) => {
664 assert_eq!(c.date, today.into());
665 assert_eq!(c.energy, 1.0);
666 assert_eq!(c.ambient_temperature_average_value, 22.1);
667 assert_eq!(c.ambient_temperature_average_counter, 72001);
668 assert_eq!(c.water_temperature_average_value, 25.1);
669 assert_eq!(c.water_temperature_average_counter, 78001);
670 assert_eq!(c.heating_control_runtime, 64001);
671 }
672 Err(_) => {
673 panic!("Call to get_single_heating_stats_from_database has failed.");
674 }
675 }
676 println!("* checking retrieving updated heating stats succeeded.");
677 // *****************************************************************************************
678
679 // *** calculate duration until midnight ***************************************************
680 // reference calculation in Rust
681 // Get current date components
682 let now = Local::now();
683 let year = now.year();
684 let month = now.month();
685 let day = now.day();
686
687 // Create NaiveDate from components
688 let today: NaiveDate = NaiveDate::from_ymd_opt(year, month, day).unwrap();
689
690 // Create a NaiveDateTime for midnight
691 let midnight = NaiveDateTime::new(today, chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap());
692 // Calculate the difference
693 let duration_until_midnight = 24 * 3600
694 - SqlInterface::get_duration_from_naive_timestamp_to_local_now(midnight)
695 .unwrap()
696 .num_seconds();
697 match sql_interface_heating.get_duration_until_midnight() {
698 Ok(c) => {
699 assert_eq!(c, duration_until_midnight);
700 }
701 Err(_) => {
702 panic!("Call to get_duration_until_midnight failed.");
703 }
704 }
705 println!("* checking duration until midnight in seconds succeeded.");
706
707 // *** check if too many rows are detected ***
708 // insert one more entry with modified date
709 heating_stats_entry.date =
710 SqlInterface::get_tomorrow_date(&mut sql_interface.get_connection().unwrap()).unwrap();
711 assert!(matches!(
712 sql_interface_heating.insert_heating_stats_entry(&heating_stats_entry),
713 Ok(())
714 ));
715 let sql_interface_heating_midnight_calculator = Box::new(
716 SqlInterfaceMidnightCalculator::new(sql_interface.get_connection().unwrap()),
717 );
718 let test_result = SqlInterfaceHeatingStats::new(
719 sql_interface.get_connection().unwrap(),
720 1,
721 sql_interface_heating_midnight_calculator,
722 );
723 assert!(matches!(
724 test_result,
725 Err(SqlInterfaceError::DatabaseHeatingStatsTableContainsTooManyRows(_, _, _))
726 ));
727 // *****************************************************************************************
728 }
729}