Skip to content

Support list of weather input for ModelChain with a single-array PVSystem #1157

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

Merged
merged 10 commits into from
Feb 4, 2021

Conversation

wfvining
Copy link
Contributor

@wfvining wfvining commented Feb 2, 2021

Adds a layer of indirection for assigning attributes of ModelChain.results that ensures the types of the results are consistent with the type of the input (i.e. ModelChain.weather).

Covers passing a length-one list of weather DataFrames to each of
- `ModelChain.run_model()`
- `ModelChain.run_model_from_poa()`
- `ModelChain.run_model_from_effective_irradiance()`
When the PVSystem being modeled has only one Array. In this case
the output stored in each field of `ModelChain.results` should be
a length-one tuple.
Allow p_dc and v_dc to be length-1 tuples even when the system has
only one Array. This helps keep the API consistent so that if the
dc power methods are called with `unwrap=False` their output can be
passed directly to `PVSystem.get_ac()`.
This method is responsible for ensuring that per-array results match
the type of ModelChain.weather. This is an issue when
ModelChain.system has only one Array, but ModelChain.weather is a
tuple of length 1. In this case we want the results attributes to also
be length-1 tuples for the sake of consistency and to ensure that
calculations down-stream can proceed without needing to worry about
mixed types (e.g. total_irrad is a Series, but weather is a tuple).
Accidentally under-indented a whole test:
test_run_model_from_poa_singleton_weather_single_array()
@wholmgren
Copy link
Member

At the risk of making things more complicated... what do you think about using a setter instead of explicitly adding the private function call to many of the assignment lines? I'm undecided.

@cwhanse cwhanse added the bug label Feb 2, 2021
@cwhanse cwhanse added this to the 0.9.0 milestone Feb 2, 2021
if multiple_arrays:
p_dc = self._validate_per_array(p_dc)
Copy link
Member

@cwhanse cwhanse Feb 2, 2021

Choose a reason for hiding this comment

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

why move the _validate_per_array outside if multiple_arrays? IIRC, that doesn't work when p_dc and v_dc are floats.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If p_dc and v_dc are floats they will get wrapped up as (p_dc,) and (v_dc,). Doing this regardless of the number of arrays lets us always subscript when passing to the single array inverter models. If we didn't wrap them up we would need extra logic to handle unwrapping if the input was initially a singleton tuple.

The goal is to be able to do both of these and have them act the same:

system = PVSystem(arrays=[Array()], ...)
system.get_ac(p_dc=50, model='pvwatts')
system.get_ac(p_dc=(50,), model='pvwatts')

Copy link
Member

Choose a reason for hiding this comment

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

As written, get_ac references the inverter functions directly, not through a PVSystem method for each inverter function. The inverter functions don't know what to do with a tuple of values; they assume numeric.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The tuples are always subscripted before being passed to the inverter functions when multiple_arrays is False. For example:

        if model == 'sandia':
            p_dc = self._validate_per_array(p_dc)
            v_dc = self._validate_per_array(v_dc)
            if multiple_arrays:
                return inverter.sandia_multi(
                    v_dc, p_dc, self.inverter_parameters)
            return inverter.sandia(v_dc[0], p_dc[0], self.inverter_parameters)

When multiple_arrays is True the tuples are passed directly to inverter.sandia_multi, but p_dc[0] and v_dc[0] is used for inverter.sandia.

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 now move the _validate_per_array calls above the if model ==... block and save a few lines:

p_dc = self._validate_per_array(p_dc)
if v_dc is not None:
    v_dc = self._validate_per_array(v_dc)

@wfvining
Copy link
Contributor Author

wfvining commented Feb 2, 2021

At the risk of making things more complicated... what do you think about using a setter instead of explicitly adding the private function call to many of the assignment lines? I'm undecided.

The problem with a setter is that it is setting fields on ModelChainResult which doesn't know what the right type should be. That information is only accessible in ModelChain.weather. Maybe that can be addressed by something like self.results._tuple_results(self.system.num_arrays > 1 or isinstance(self.weather, tuple))
Moving the coercion inside ModelChain would have the benefit of hiding the distinction between per-array and system-wide results. Currently that distinction is ad-hoc and handled by either using or not using _assign_result()

@cwhanse
Copy link
Member

cwhanse commented Feb 2, 2021

self.results._tuple_results(self.system.num_arrays > 1 or isinstance(self.weather, tuple))

Related note: should we infer the type of a result value from the type of weather, or from system.num_arrays value? Current code may be using both techniques in different places.

@wfvining
Copy link
Contributor Author

wfvining commented Feb 2, 2021

Related note: should we infer the type of a result value from the type of weather, or from system.num_arrays value? Current code may be using both techniques in different places.

It needs to be based on weather to handle the run_model([weather]) case correctly. There are a good number of places where we use num_arrays, but it is typically when we are manufacturing data (e.g. setting the loss for each array to 1.0) or expanding non-tuple weather to be a tuple. I think these cases are okay, everywhere we are concerned with type we are using isinstance or _assign_result.

@cwhanse
Copy link
Member

cwhanse commented Feb 2, 2021

Related note: should we infer the type of a result value from the type of weather, or from system.num_arrays value? Current code may be using both techniques in different places.

It needs to be based on weather to handle the run_model([weather]) case correctly. There are a good number of places where we use num_arrays, but it is typically when we are manufacturing data (e.g. setting the loss for each array to 1.0) or expanding non-tuple weather to be a tuple. I think these cases are okay, everywhere we are concerned with type we are using isinstance or _assign_result.

run_model([weather]) with a 2-array PVSystem produces modelchain.result.total_irrad = [(df1, df2)], a list with a single element which is a two-tuple? Asking so I better understand the intent.

@wfvining
Copy link
Contributor Author

wfvining commented Feb 2, 2021

run_model([weather]) with a two array system raises a ValueError. If you pass weather as a list or tuple it must be the same length as system.arrays.

@wfvining
Copy link
Contributor Author

wfvining commented Feb 2, 2021

Oops... forgot to address a couple TODOs. Adding tests with spectral_loss != 'no_loss' and aoi_loss != 'no_loss'

@wfvining
Copy link
Contributor Author

wfvining commented Feb 2, 2021

Here is an interesting use case. If I want to set constant spectral loss as in this test, but I am calling run_model([weather]) then ModelChain.results.spectral_modifier will have the wrong type.

def constant_spectral_loss(mc):
    mc.results.spectral_modifier = 0.9


@pytest.mark.parametrize('spectral_model', [
    'sapm', 'first_solar', 'no_loss', constant_spectral_loss
])
def test_spectral_models(sapm_dc_snl_ac_system, location, spectral_model,
                         weather):
    # add pw to weather dataframe
    weather['precipitable_water'] = [0.3, 0.5]
    mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
                    aoi_model='no_loss', spectral_model=spectral_model)
    spectral_modifier = mc.run_model(weather).results.spectral_modifier
    assert isinstance(spectral_modifier, (pd.Series, float, int))

Since execution of constant_spectral_loss is deferred (+1 for whoever made that a thunk) until after we know the right types this could be handled by the setter approach discussed above.

Things might get weird when there are multiple arrays though; since there currently is not mechanism to turn the constant in to a tuple as we do for no_loss, users need to anticipate this and set the correct type. Not totally unreasonable since they have access to self.system.

Add tests for spectral_model != 'no_loss' and aoi_model != 'no_loss'
when calling ModelChain.run_model([weather]) on a system with 1 Array.

Handles the type of ModelChain.weather correctly in
ModelChain.first_solar_spectral_loss() by using _tuple_from_dfs() to
get 'precipitable_water'.

Still needs support for constant spectral loss
Extends `PVSystem.first_solar_spectral_loss()` to treat `pw` as a
per-Array or system wide parameter.
A bit cleaner than using a generator expression.
Add a configuration step in ModelChain._assign_weather() that
sets a flag on the ModelChain.results to indicate whether non-tuples
assigned to per-array fields should be wrapped in length-1 tuples.

This is accomplished by a custom __setattr__ methon on
ModelChainResult. The setter checks whether the attribute is a
per-array attribute and coerces it if the _singleton_tuples flag
is set.
Covers the case where the aoi_model is 'no_loss'.
@wfvining
Copy link
Contributor Author

wfvining commented Feb 3, 2021

@wholmgren I have refactored to use a setter-based approach. Take a look and let me know what you think. I believe it is an improvement.

Copy link
Member

@wholmgren wholmgren left a comment

Choose a reason for hiding this comment

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

@wfvining I like the approach. A couple of comments below - I've not looked carefully at the pvsystem changes or tests.

return value

def __setattr__(self, key, value):
if key in ModelChainResult._per_array_fields:
Copy link
Member

Choose a reason for hiding this comment

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

could we use typing.get_type_hints and delete the private set?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That would be nice, but I can't seem to figure out how to compare the concrete types returned from get_type_hints with the generic Optional[PerArray[_T]] type.

Copy link
Member

@wholmgren wholmgren Feb 4, 2021

Choose a reason for hiding this comment

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

After further research, my idea seems like a misuse of the typing module at this point in time, so I'm good with your solution.

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'm a little bummed... I was excited to actually put the types to use.

@@ -1271,6 +1291,17 @@ def _verify(data, index=None):
for (i, array_data) in enumerate(data):
_verify(array_data, i)

def _configure_results(self):
Copy link
Member

Choose a reason for hiding this comment

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

an alternative to this method might be to instantiate ModelChain with self.results = None (or not at all), then put this line at the start of each run_model workflow:

self.results = ModelChainResults(singleton_tuples=(self.system.num_arrays == 1 and isinstance(self.weather, tuple))

I guess maybe you'd still want to extract that logic into a method like this one. Then the difference comes down to setting the right value vs instantiating the object with the right value. 🤷

Copy link
Contributor Author

@wfvining wfvining Feb 3, 2021

Choose a reason for hiding this comment

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

This would basically be a transformation from _configure_results to _prepare_results. I kind of like that better, but I don't think it makes a big difference. It would also take a little bit of work, since some of the tests (and ModelChain methods) make the (reasonable) assumption that all the results fields exist before one of the run_model workflows is triggered (e.g. test__assign_total_irrad and ModelChain.complete_irradiance).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should _configure_results() (or some ModelChainResult method) iterate over per-array fields to and wrap them in tuple if _singleton_tuples is changed from False to True?

Copy link
Member

Choose a reason for hiding this comment

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

I'm viewing ModelChainResult as a single use object so I don't think we need to watch for that kind of state change.

if multiple_arrays:
p_dc = self._validate_per_array(p_dc)
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 now move the _validate_per_array calls above the if model ==... block and save a few lines:

p_dc = self._validate_per_array(p_dc)
if v_dc is not None:
    v_dc = self._validate_per_array(v_dc)

arrays=[pvsystem.Array()],
inverter_parameters=inverter_parameters[model]
)
ac = system.get_ac(p_dc=(pdcs[model],), v_dc=(vdcs[model],), model=model)
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 actually want to support v_dc=(None,) for model='pvwatts'? If we do support that, then my suggestion to move the _validate_per_array calls won't work.

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 think I started with these calls above the control flow, but ran in to this. I don't really like them inside the control flow, but I don't see a better way.

Re-order fields and comments. Add docstring to _result_type()
method.
Copy link
Member

@wholmgren wholmgren left a comment

Choose a reason for hiding this comment

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

thanks @wfvining. I also confirmed that the example in #1139 no longer fails.

@wholmgren wholmgren merged commit 47654a0 into pvlib:master Feb 4, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ModelChainResult.cell_temperature is not always a pandas.Series
3 participants