Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: feat: switch from time-rs to chrono and implement non-daily timesteps #115

Merged
merged 9 commits into from
Feb 24, 2024

Conversation

Batch21
Copy link
Contributor

@Batch21 Batch21 commented Feb 15, 2024

No description provided.

@Batch21 Batch21 changed the title feat: switch from time-rs to chrono and implement non-daily timesteps WIP: feat: switch from time-rs to chrono and implement non-daily timesteps Feb 15, 2024
@Batch21 Batch21 requested a review from jetuk February 16, 2024 12:15
@Batch21
Copy link
Contributor Author

Batch21 commented Feb 16, 2024

@jetuk would be good to get some feedback on this. One potential change I've just noticed is that the virtual storage state will need updating to store timestep length, otherwise there will be no way to know the amount of flow to recover for each timestep.

@jetuk
Copy link
Member

jetuk commented Feb 16, 2024

One potential change I've just noticed is that the virtual storage state will need updating to store timestep length, otherwise there will be no way to know the amount of flow to recover for each timestep.

I'm not sure I understand? The current implementation uses Timestep.days() the same as the regular storage state?

Copy link
Member

@jetuk jetuk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had a quick read through and left some comments.

pywr-core/src/models/mod.rs Outdated Show resolved Hide resolved
pywr-core/src/parameters/discount_factor.rs Show resolved Hide resolved
pywr-core/src/parameters/profiles/monthly.rs Show resolved Hide resolved
@@ -76,13 +86,16 @@ impl Parameter for MonthlyProfileParameter {
let v = match &self.interp_day {
Some(interp_day) => match interp_day {
MonthlyInterpDay::First => {
let next_month = (timestep.date.month() % 12) + 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this correct? .month() is 1-based here?

// Returns the zero-based next month
let next_month0 = (timestep.date.month0() + 1) % 12;

let last_value = self.values[next_month0 as usize];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chrono::Month?


interpolate_first(&timestep.date, first_value, last_value)
}
MonthlyInterpDay::Last => {
let first_value = self.values[timestep.date.month().previous() as usize - 1];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The time API is much nicer here.

pywr-core/src/recorders/aggregator.rs Show resolved Hide resolved
start: Date,
end: Date,
timestep: Duration,
start: NaiveDateTime,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a concious decision to add time into the time-stepping?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, to make sub-daily timesteps possible. If we didn't want to allow these at this point we could revert to a NaiveDate

pywr-core/src/recorders/aggregator.rs Outdated Show resolved Hide resolved
pywr-core/src/virtual_storage.rs Show resolved Hide resolved
@@ -48,20 +49,40 @@ impl From<pywr_v1_schema::model::Timestep> for Timestep {
}
}

#[derive(serde::Deserialize, serde::Serialize, Clone, Copy)]
pub struct DateTimeComponents {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is because you can't deserialise a string without a time component if the type is a NaiveDateTime?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, at a minimum I think the string has to have hour and minute components

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could leave it as a date that automatically gets added 00:00:00. Or make a untagged enum for a Date string or this date-time struct?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone with an untagged enum which seems to work.

@Batch21
Copy link
Contributor Author

Batch21 commented Feb 17, 2024

One potential change I've just noticed is that the virtual storage state will need updating to store timestep length, otherwise there will be no way to know the amount of flow to recover for each timestep.

I'm not sure I understand? The current implementation uses Timestep.days() the same as the regular storage state?

In recover_virtual_storage_last_historical_flow the current timestep is used but its length might be different to the timestep when the flow added to the storage state?

@jetuk
Copy link
Member

jetuk commented Feb 17, 2024 via email

@jetuk
Copy link
Member

jetuk commented Feb 18, 2024

I wonder if it would be beneficial to have two different TimeDomain types? One for fixed time-step another for variable? I'm not sure how this would interact with things like the virtual storage history though.

@jetuk jetuk mentioned this pull request Feb 20, 2024
This wraps the Chrono TimeDelta type and provides a fractional_days method that can be used for metric aggregation.
@Batch21
Copy link
Contributor Author

Batch21 commented Feb 21, 2024

@jetuk I think this is ready for you to take another look

Copy link
Member

@jetuk jetuk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some quick comments on the latest changes. I'll do a separate review of the whole thing.

}
}

impl PywrDuration {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to add some simple doc strings to these methods and the struct itself.

}

pub fn whole_days(&self) -> Option<i64> {
if self.fractional_days() % 1.0 == 0.0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this pass clippy checks of comparing against floating point values?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get a warning locally. Is there going to be an issue with floating point precision here? Might be better to use the float_cmp crate?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was possible expecting clippy to show this: https://rust-lang.github.io/rust-clippy/master/index.html#/float_cmp_const

However, it is part of the "restriction" group which means it should be considered case-by-case. The question is whether there's any reasonable output of fractional_days() which we expect to be treated as a whole day, but would not be by this test? Leap seconds? Are those still a thing?

If there's not already just add a test for this method.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Batch21 I did a bit of reading here. I think modulo on floats is a bit weird sometimes. It might be better to do the modulo on the num_seconds() as a i64.

Something like this might be better?

const SECS_IN_DAY: i64 = 60 * 60 * 24;

  pub fn whole_days(&self) -> Option<i64> {
      if self.0.num_seconds() % SECS_IN_DAY == 0 {
           Some(self.0.num_days())
      } else {
           None
      }
  }

  pub fn fractional_days(&self) -> f64 {
      self.0.num_seconds() as f64 / SECS_IN_DAY as f64
  }

@@ -47,7 +48,7 @@ impl AggregationFrequency {
let mut sub_values = Vec::new();

let mut current_date = value.start;
let end_date = value.start + value.duration;
let end_date = value.start + *value.duration.time_delta();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One alternative here would be to implement Add for PywrDuration with rhs = NaiveDateTime

pub fn from(timestepper: Timestepper, scenario_collection: ScenarioGroupCollection) -> Self {
Self {
time: timestepper.into(),
pub fn from(timestepper: Timestepper, scenario_collection: ScenarioGroupCollection) -> Result<Self, PywrError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this to try_from

@@ -5,8 +5,8 @@ use crate::state::{ParameterState, State};
use crate::timestep::Timestep;
use crate::PywrError;
use std::any::Any;
use time::util::days_in_year_month;
use time::Date;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove empty line

@@ -120,7 +121,7 @@ impl Parameter for PyParameter {
py,
timestep.date.year(),
timestep.date.month() as u8,
timestep.date.day(),
timestep.date.day() as u8,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use the chrono feature in pyo3 to pass the date directly as an argument.

@@ -154,7 +155,7 @@ impl Parameter for PyParameter {
py,
timestep.date.year(),
timestep.date.month() as u8,
timestep.date.day(),
timestep.date.day() as u8,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above.

month: ts.date.month().into(),
day: ts.date.day(),
month: ts.date.month() as u8,
day: ts.date.day() as u8,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to support saving the HMS data in the HDF file?

}

pub fn default_time_domain() -> TimeDomain {
default_timestepper().into()
TimeDomain::try_from(default_timestepper()).unwrap()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default_timestepper().try_into().unwrap() should also work here and not require the TimeDomain type to written twice.

use pyo3::prelude::*;
use std::ops::Add;
use time::{Date, Duration};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove empty line.

timesteps.push(current);
current = next;
}
timesteps
}

fn generate_timesteps_from_frequency(&self, frequency: String) -> Result<Vec<Timestep>, PywrError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a docstring here. Also it would be more idiomatic to use frequency: &str.

@@ -352,7 +375,8 @@ mod tests {
let recorder = AssertionFnRecorder::new("link-1-flow", Metric::NodeOutFlow(idx), expected, None, None);
network.add_recorder(Box::new(recorder)).unwrap();

let model = Model::new(default_timestepper().into(), network);
let domain = ModelDomain::try_from(default_timestepper()).unwrap();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let domain = default_timestepper().try_into().unwrap(); is slightly shorter.

let start =
Date::from_calendar_date(start.get_year(), start.get_month().try_into().unwrap(), start.get_day()).unwrap();
let end = Date::from_calendar_date(end.get_year(), end.get_month().try_into().unwrap(), end.get_day()).unwrap();
let start = DateType::Date(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support datetime in this API as well?

@Batch21
Copy link
Contributor Author

Batch21 commented Feb 23, 2024

@jetuk I think the latest commits address all your comments

Copy link
Member

@jetuk jetuk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Good update!

@jetuk jetuk merged commit 8db2928 into main Feb 24, 2024
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants