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

Improve predictability of DataQuery, DataID, and dependency tree #3018

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

djhoese
Copy link
Member

@djhoese djhoese commented Dec 13, 2024

Disclaimer: This PR's implementation is very rough at the time of writing.

This PR includes major changes to key parts of Satpy in order to resolve some inconsistencies noticed by users over the years. The high-level concepts that have been changed or updated are:

  1. DataQuery objects are now only equal to DataID objects that match all of the queries keys. Previously only the shared keys were compared. This old way meant that a DataQuery could match a DataID that didn't contain all the necessary information requested by the query.
  2. The "resolution" DataID key was not not transitive for all ID key sets. So for "default" ID keys it was False and for coordinate and minimal sets it was transitive. It made the most sense to set it to False. That is, a modified dataset is not required to have all dependencies be of the same resolution.
  3. Add and refactor a lot of tests regarding DataQuery and DataID comparisons.
  4. It should be possible to load a composite with two different sets of inputs (ex. DataQuery(name="comp", resolution=500), DataQuery(name="comp", resolution=1000)).

Remaining work

  • There are a lot of edge cases that need to be worked out. The biggest one is what happens when a DataQuery has a list of possible options. That is not handled in a lot of my dependency tree stuff.
  • See "Hindsight" below.
  • Refactoring
  • More explicit tests

Hindsight

For high-level change 1 above, I'm starting to think this was the wrong change or at least that the previous behavior had a good point. That is, if a user creates a query with a lot of keys to apply to many DataIDs from different sources, then not all DataIDs should be required to have all those keys. For example, if I specify a polarization in my query, then I don't think all composites or rather composite dependencies would be able to match that. There are currently no tests to verify this.

  • Closes #xxxx
  • Tests added
  • Fully documented
  • Add your name to AUTHORS.md if not there already

@djhoese djhoese added bug component:compositors component:dep_tree Dependency tree and dataset loading labels Dec 13, 2024
Copy link

codecov bot commented Dec 13, 2024

Codecov Report

Attention: Patch coverage is 96.96312% with 14 lines in your changes missing coverage. Please review.

Project coverage is 96.08%. Comparing base (5984c29) to head (c45ed8d).
Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
satpy/dataset/id_keys.py 96.15% 5 Missing ⚠️
satpy/dependency_tree.py 83.33% 5 Missing ⚠️
satpy/dataset/dataid.py 97.50% 2 Missing ⚠️
satpy/tests/test_dependency_tree.py 98.19% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3018      +/-   ##
==========================================
- Coverage   96.10%   96.08%   -0.02%     
==========================================
  Files         377      378       +1     
  Lines       55163    55213      +50     
==========================================
+ Hits        53012    53050      +38     
- Misses       2151     2163      +12     
Flag Coverage Δ
behaviourtests 3.98% <24.72%> (+0.04%) ⬆️
unittests 96.17% <96.96%> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@coveralls
Copy link

coveralls commented Dec 14, 2024

Pull Request Test Coverage Report for Build 12381834226

Details

  • 447 of 461 (96.96%) changed or added relevant lines in 25 files are covered.
  • 9 unchanged lines in 4 files lost coverage.
  • Overall coverage decreased (-0.02%) to 96.188%

Changes Missing Coverage Covered Lines Changed/Added Lines %
satpy/dataset/dataid.py 78 80 97.5%
satpy/tests/test_dependency_tree.py 109 111 98.2%
satpy/dataset/id_keys.py 125 130 96.15%
satpy/dependency_tree.py 25 30 83.33%
Files with Coverage Reduction New Missed Lines %
satpy/dependency_tree.py 1 95.77%
satpy/tests/utils.py 2 93.16%
satpy/tests/reader_tests/gms/test_gms5_vissr_l1b.py 3 98.67%
satpy/tests/reader_tests/gms/test_gms5_vissr_navigation.py 3 97.18%
Totals Coverage Status
Change from base Build 12299617024: -0.02%
Covered Lines: 53294
Relevant Lines: 55406

💛 - Coveralls

@djhoese djhoese force-pushed the bugfix-greedy-dataid branch from 46eafc6 to de36fb8 Compare December 17, 2024 21:20
new_id_dict = orig_id.to_dict()
orig_id_keys = orig_id.id_keys
for query_key, query_val in query_dict.items():
# XXX: What if the query_val is a list?
Copy link
Member Author

Choose a reason for hiding this comment

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

This is my main task remaining. I'm really not sure how I want to treat this. If you ask for a composite as DataQuery(name="some_comp", resolution=[500, 1000]), what do you set in the DataID? This is before we know what dependencies were found. So does the composite DataID become DataID(name="some_comp") with no resolution, resolution 500, or resolution 1000?

Copy link
Member

Choose a reason for hiding this comment

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

imo it should get the final resolution. So if the generated composite has a 500m resolution, the dataid should carry that.

Copy link
Member Author

Choose a reason for hiding this comment

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

And that may happen eventually, but we don't know that when this function is called. It is called here:

new_id = update_id_with_query(compositor.id, dataset_key)

There are the two separate processes of getting the compositor (the class instance that will be called) and the composite DataArray (generated by calling the compositor). This function is called to give the compositor an identity so it can be referred to for generating, but also if it is needed by some other compositor configuration (YAML prerequisites) or the user requested it. So overall the DataID generated by this function is "temporary" in that once composites are generated this DataID may be overwritten by the __call__ of the compositor.

Additionally, at least with the current calling order of things, we don't even know which prerequisites are going to be used when this function is called. And it seems the CompositorNode where this is DataID is used is needed before prerequisites and optionals are determined because we add the Nodes of those prereqs/optionals to the CompositorNode's children. There might be a way to get or hack around that, but I'm not sure.

So back to the original question:

If you ask for a composite as DataQuery(name="some_comp", resolution=[500, 1000]), what do you set in the DataID?

We don't know the prereqs or optionals and we're just updating the original request of "some_comp" with some resolution, but what one?

Copy link
Member

@mraspaud mraspaud left a comment

Choose a reason for hiding this comment

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

Ok, I made a first pass. Overall, thanks a lot for the clarifications and refactorings, the code reads better now.
I have comments inline, or rather mostly questions because I’m not following everything :)

"""
return self.equal(other, shared_keys=False)

def equal(self, other: Any, shared_keys: bool = False) -> bool:
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 not found of passing a bool here. Could it be passed something like keys_to_match instead, i.e. the list of keys to use for the matching?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not really as the ability to get shared keys (keys to match) only happens after we've extracted the dict version of each object. Additionally, this is a top-level public method now (I don't remember if it needs to be) so doing low-level stuff with the keys seems like too much work for a high-level user-facing method and would have to be repeated anywhere shared_keys=True is used. I could split it I suppose into two methods that call one shared method, but then again it is a lot of extra code for a single use.

satpy/dataset/dataid.py Show resolved Hide resolved
def _create_id_dict_from_any_key(dataset_key):
try:
def _create_id_dict_from_any_key(dataset_key: DataQuery | DataID | str | numbers.Number) -> dict[str, Any]:
if hasattr(dataset_key, "to_dict"):
Copy link
Member

Choose a reason for hiding this comment

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

"Easier to ask for forgiveness than permission"?

Copy link
Member Author

Choose a reason for hiding this comment

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

If I remember correctly I changed this because mypy was getting mad about the typing and the try/except being hard to parse. It might have also been CodeScene. I knew you would prefer try/except here, but the linter's didn't like it so I thought it was OK. I can do some type ignoring if you'd still prefer the try/except.

Copy link
Member

@mraspaud mraspaud Jan 15, 2025

Choose a reason for hiding this comment

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

that’s fine…

new_id_dict = orig_id.to_dict()
orig_id_keys = orig_id.id_keys
for query_key, query_val in query_dict.items():
# XXX: What if the query_val is a list?
Copy link
Member

Choose a reason for hiding this comment

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

imo it should get the final resolution. So if the generated composite has a 500m resolution, the dataid should carry that.

return new_id


def _keys_to_compare(sdict: dict, odict: dict, o_is_id: bool, shared_keys: bool) -> set:
Copy link
Member

Choose a reason for hiding this comment

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

what does o_is_id stand for?

Copy link
Member Author

Choose a reason for hiding this comment

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

other is DataID or at least looks like one. I could give the variable a longer name for sure. I went back and forth on how to handle the different cases, but at the end of the day my sanity was preserved by doing simple if/else statements on the DataID-ness of "other". Now that most of the logic is stabilizing I could revisit this. Ideas welcome.

Comment on lines +532 to +565
def _compare_key_equality(sdict: dict, odict: dict, key: str, o_is_id: bool) -> bool:
if key not in sdict:
return False
sval = sdict[key]
if sval == "*":
return True

if key not in odict:
return False
oval = odict[key]
if oval == "*":
# Gotcha: if a DataID contains a "*" this could cause
# unexpected matches. A DataID is not expected to use "*"
return True

return _compare_values(sval, oval, o_is_id)


def _compare_values(sval: Any, oval: Any, o_is_id: bool) -> bool:
if isinstance(sval, list) or isinstance(oval, list):
# multiple options to match
if not isinstance(sval, list):
# query to query comparison, make a list to iterate over
sval = [sval]
if o_is_id:
return oval in sval

# we're matching against a DataQuery who could have its own list
if not isinstance(oval, list):
oval = [oval]
s_in_o = any(_sval in oval for _sval in sval)
o_in_s = any(_oval in sval for _oval in oval)
return s_in_o or o_in_s
return oval == sval
Copy link
Member

Choose a reason for hiding this comment

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

It’s probably my doing, but could we take the opportunity to find better/longer names for sval, oval, sdict, odict, etc?

Copy link
Member Author

Choose a reason for hiding this comment

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

I really thought I answered a bunch of these comments already, but apparently not...

Yes, I can try to find better names. I think s is self or maybe source? And o is other. I'm not entirely upset with them as they are now, but yeah not super clear.

@@ -424,6 +430,7 @@ def _find_compositor(self, dataset_key, query):
# one or more modifications if it has modifiers see if we can find
# the unmodified version first

orig_query = dataset_key
Copy link
Member

Choose a reason for hiding this comment

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

  1. are we sure this is a query, not an id?
  2. should we rename the function argument instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm honestly not sure, but I think so. This is reminding me that I wanted to add more type annotations to this stuff for this reason, but forgot after needing some last minute changes in other parts of the code. If I remember correctly the information passed by the user gets turned into a DataQuery in one of the top-level tree methods and gets passed around from there.

Comment on lines +507 to +514
if key.get("name", default="*") != "*" and len(key.to_dict()) == 1:
# the query key is just the name and still couldn't be found
raise KeyError("Could not find compositor '{}'".format(key))

# Get the generic version of the compositor (by name only)
# then save our new version under the new name

return self._get_compositor_by_name(key)
Copy link
Member

Choose a reason for hiding this comment

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

What use cases in this covering?

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah the name of this method is incorrect after more refactoring was done. So the code above this is when the compositor exactly matches what the user asked for. This is usually the common case of just a compositor by name but could also include other filtering parameters like resolution or calibration or whatever else was asked for.

The method _get_compositor_by_name first gets the base compositor by just the name and then if more than one compositor matches it filters down using the other properties of the provided DataQuery. The cases where a compositor name returns more than one compositor would be if there are varying resolutions or calibrations for a single compositor. So again, not a common use case, but it was in the tests already.

Comment on lines +671 to +673
(DataQuery(name="1", resolution=[250, 500]), DataQuery(name="1", resolution=[500, 750])), # opposite order
(DataQuery(name="1", resolution=500), DataQuery(name="1", resolution=[500, 750])), # opposite order
(DataQuery(name="1", resolution=[250, 500]), DataQuery(name="1", resolution=500)), # opposite order
Copy link
Member

Choose a reason for hiding this comment

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

These look surprising?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a case of the code written first then the tests second. More importantly, this is testing code that I didn't write, but I could tell this functionality was intended. That is, I believe we support users specifying a list of things in their DataQuery (and in the filter kwargs passed to Scene.load) and the results should/could match any of the things in the list.

Or is there something else surprising about these. I'm mostly making sure that regardless of which DataQuery object's __eq__ is called (the first or the second) that things are still matched. I'm not sure if the # opposite order comment at the end of these 3 lines is valid. Most likely it is an artifact of copy/pasting the earlier test. Or maybe the comment doesn't apply to the 3rd to last case, but does for the last 2 cases.

(DataQuery(name="1", resolution="*"), dict(name="1")),
(DataQuery(name="1", resolution="*"), dict(name="1", resolution=500)),
# DataID shouldn't use * but we still test it:
(DataQuery(name="1", resolution=500), dict(name="1", resolution="*")),
Copy link
Member

Choose a reason for hiding this comment

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

is * valid for dataids?

Copy link
Member Author

Choose a reason for hiding this comment

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

It is technically allowed since it is a string and DataID does not do any validation against it. That's why the comment is there. There is also a comment somewhere in the equality method of the DataQuery about it. There is nothing stopping it in the code from happening (a "*" in a DataID), but it also isn't expected or wanted or supported entirely.

@djhoese
Copy link
Member Author

djhoese commented Jan 23, 2025

Ok I've been spending some time trying to get a better understanding of composite and modifier ID handling. Basically I wanted to be more confident in my fixes, add tests for edge cases, and resolve my TODO I mentioned in this comment. It comes down to compositors (and the Node objects wrapping them) and composites (the loaded DataArray) being separate identifiers, but treated as the same thing.

Series of events

  1. User calls Scene.load and the Scene determines what from the request is still needed. I'm still not sure how this code works between strings/DataIDs/DataQuerys and the DataIDs in the self._datasets but I'll figure that out.

  2. The Scene updates the dependency tree so it knows where and how to create the datasets requested by the user.

  3. The dependency tree, in the case of a compositor, will find the compositor usually by name, and let the compositor say what DataID it has by accessing the .id property of the Compositor object (compositor.id - see usage here). The dependency tree now stores the compositor by that DataID.

  4. The Scene gets the compositor nodes to be loaded (trunk nodes):

    satpy/satpy/scene.py

    Lines 1541 to 1542 in 3990f37

    trunk_nodes = self._dependency_tree.trunk(limit_nodes_to=self.missing_datasets,
    limit_children_to=self._datasets.keys())

  5. The Scene checks if the dataset in the DependencyTree already exists in the Scene's container of created datasets:

    satpy/satpy/scene.py

    Lines 1533 to 1537 in 3990f37

    loaded_data_ids = self._datasets.keys()
    for trunk_node in trunk_nodes:
    if trunk_node.name in loaded_data_ids:
    continue
    yield trunk_node

  6. Any compositor nodes from the dependency tree still needing loading is generated, but we first check if it is contained in the Scene's container...again:

    satpy/satpy/scene.py

    Lines 1564 to 1566 in 3990f37

    if self._datasets.contains(comp_node.name):
    # already loaded
    return

    Same check as step 5 above, but more strict as .contains must match the DataID exactly while step 5 is a "best match".

  7. After the DataArray is generated from the compositor we create a DataID from it's .attrs and update the Scene's wishlist and the dependency trees nodes with this new ID:

    satpy/satpy/scene.py

    Lines 1611 to 1618 in 3990f37

    cid = DataID.new_id_from_dataarray(composite)
    self._datasets[cid] = composite
    # update the node with the computed DataID
    if comp_node.name in self._wishlist:
    self._wishlist.remove(comp_node.name)
    self._wishlist.add(cid)
    self._dependency_tree.update_node_name(comp_node, cid)

Problems and Difficulties

  1. In step 3 above, the compositor's DataID and the resulting DataArray might not be the same. The compositor object's .id is not able to know what version of it's prerequisites will be used (the dependency tree hasn't determined that yet) and therefore can't use it to influence the DataID of the compositor. Historically we've allowed this so the compositor developer can do at-runtime updates to the DataID parameters to make them more accurate (ex. update "resolution") in the compositor's __call__.
  2. Due to problem 1, the checks in step 1, 5, and 6 may not be accurate depending on how the compositor updates the DataID for the resulting DataArray.
  3. In main, the compositor's ID does not take into account the extra query parameters passed by the user (either from the DataQuery from the user, or the **kwargs passed like resolution=[500, 1000]). This means a user could request the same composite multiple ways that should result in different inputs being used, but the DependencyTree would use the same compositor for all of them. In this PR I try to solve that, but there are problems with that as well.
  4. The whole compositor ID re-assigning is very unsettling.

Solutions?

🤷‍♂️

Overall (I think in main and this PR) if a compositor's __call__ modifies the resulting DataID in a way that conflicts with the previous compositor ID (changes a value that already existed) or makes it not match the users original request, then the resulting behavior is not well defined. This might just be something we have to deal with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug component:compositors component:dep_tree Dependency tree and dataset loading
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants