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

feat: IDSSE-256: GridProj #5

Merged
merged 20 commits into from
Aug 21, 2023
Merged

Conversation

mackenzie-grimes-noaa
Copy link
Contributor

@mackenzie-grimes-noaa mackenzie-grimes-noaa commented Aug 2, 2023

Linear Task

IDSSE-256

Changes

  • Move GridProj class from data-access-service to this project
    • Added optional rounding parameter for transforming other data formats into pixel ints. By default, return pixel values as floats.
  • Write unit tests for existing functionality: transforming between 3 data types:
    • Pixel
    • Projection
    • Geographic Coordinates
  • Cleanup any flake8 linter warnings

@mackenzie-grimes-noaa mackenzie-grimes-noaa marked this pull request as ready for review August 2, 2023 23:49
Copy link
Contributor

@paulhamer-noaa paulhamer-noaa left a comment

Choose a reason for hiding this comment

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

Looks reasonable.

Copy link
Contributor

@Geary-Layne Geary-Layne left a comment

Choose a reason for hiding this comment

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

LGTM
The only thing I wonder about is the mix of EXAMPLE data values defined at the top of unit test and literal values found in many test functions. To me there are two styles and I don't see the distinction. Not that the code needs to change, I was just wondering the reason.

Pixels are only identified by whole numbers when graphically rendered,
so the transformed numerical values (floats) can be safely rounded to ints
"""
return round((x - self._x_offset) / self._dx), round((y - self._y_offset) / self._dy)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Geary-Layne it's safe to assume that pixels can be rounded integers, correct? From my general understanding of how browsers actually paint onto a display, a fraction of a pixel is not possible.

Copy link
Contributor Author

@mackenzie-grimes-noaa mackenzie-grimes-noaa Aug 3, 2023

Choose a reason for hiding this comment

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

I ask because unit testing uncovered this strange precision behavior, before I made the change here to round the resulting pixel. I don't think this extra precision is meaningful (or comprehendible) for a browser client, but I could be wrong:

geo_x, geo_y = self.map_pixel_to_geo(0, 1)
pixel_x, pixel_y = self.map_geo_to_pixel(geo_x, geo_y)

print(pixel_x, pixel_y)  # (0.0 0.9999999999951331)

Copy link
Contributor

Choose a reason for hiding this comment

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

You are correct that for some uses, an non integer pixel value is not meaningful and is problematic. There are however some use case for this code (now that it is a general tool and not specific to the slice operator) where you would want floating point numbers. Also, sometime the use would dictate round and sometime floor.

In you test code the precision error is totally executable, and you should us approx().

When I'd implement code like this class in Java and python in the past, I supported an option argument with _to_pixel() function. Something like:
def map_geo_to_pixel(self, x: float, y: float, round: enum=None)
where round can be None, Round, Floor.
Not sure the best way to do this in python, I think I used magic strings before which isn't the best way, and actually making enum seems excessive.

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 feel like Enum is overkill here. It makes the comparison easy, although I didn't find an elegant way to directly map, and invoke, Enum members to functions.

Simple if statements seemed sufficient for now, and default to no rounding as previously defined in the data-access-service version of GridProj.

return (round(pixel_x), round(pixel_y))
if rounding is PixelRounding.FLOOR:
return (floor(pixel_x), floor(pixel_y))
return (pixel_x, pixel_y)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Geary-Layne I added a rounding argument to map_geo_to_pixel that is either ROUND, FLOOR or None, is this about what you envisioned?

Copy link
Contributor

@Geary-Layne Geary-Layne left a comment

Choose a reason for hiding this comment

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

Biggest thing is the need to update the rounding logic. Also make sure that the result from rounding are ints vs float that represent int values.

Copy link
Contributor

Choose a reason for hiding this comment

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

I found two real comments and a couple nice to haves.

from pyproj.enums import TransformDirection


class PixelRounding(Enum):
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to have: could we make this so the caller to _to_pixel could use strings, say 'ROUND', 'round', 'FLOOR', or 'floor'?

int(grid_args['w']), int(grid_args['h']),
grid_args['dx'], grid_args['dy'])

def map_proj_to_pixel(self, x, y) -> Tuple[float, float]:
Copy link
Contributor

Choose a reason for hiding this comment

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

It would be best of all _to_pixel functions supported rounding, so add the same logic to map_proj_to_pixel() as map_geo_to_pixel() has. It might be good to create a private function to round so that both _to_pixel functions can call the same code.

"""Map pixel to geographic coordinates"""
return x * self._dx + self._x_offset, y * self._dy + self._y_offset

def map_geo_to_proj(self, x: float, y: float) -> Tuple[Any, Any]:
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice to have: the return type of a tuple of Any is less helpful than Tuple[Union[int, float], Union[int, float]]. I think that is the correct syntax.

pixel_y = (y - self._y_offset) / self._dy

if rounding is PixelRounding.ROUND:
return (round(pixel_x), round(pixel_y))
Copy link
Contributor

Choose a reason for hiding this comment

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

Python 3.x switch rounding logic from 2.x and it is not what we want to use here. In 3.x the rounding logic is "round half to even" and it is better for our use to "round half away from zero". In place of round() use "return floor(x + 0.5) if x >= 0.0 else ceil(x - 0.5)". This works since we are always round to the ones place, it is more complex if we needed to round to a specified diget.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, didn't know our requirements were rounding away from zero instead of to the nearest even. From my reading, numpy.round() behaves the same as Python 3.x round().

IEEE-754: floating-point rounding

Do we already have a utility function for this rounding technique? Otherwise the one we're making here should probably be used across IDSS projects for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't believe we have a utility function for rounding. It would be straight forward to add a "round half away from zero, to the ones place" to utils.py. It would be better to implement to round to an optional decimal place. If you would like to do this (either one) and have GridProj call it, that would be great.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great, doing that now. Created a sub-task in linear to track this (IDSSE-306).

Tuple[Union[int, float], Union[int, float]):
x, y values for pixel, rounded to ints if rounding parameter passed, otherwise floats
"""
pixel_x = (x - self._x_offset) / self._dx
Copy link
Contributor

Choose a reason for hiding this comment

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

This is probably just my preference so feel free to ignore. I like to use i,j for pixel space vs x,y.


# convert geographic coordinates back to pixel, full circle, and data should be unchanged
pixel_x, pixel_y = grid_proj.map_geo_to_pixel(geo_x, geo_y)
assert (round(pixel_x, 6), round(pixel_y, 6)) == initial_pixel
Copy link
Contributor

Choose a reason for hiding this comment

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

This will have to change when you update the round logic in grid_proj.

Copy link
Contributor

Choose a reason for hiding this comment

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

Should the test now be using round_half_away, or is round correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was still using the incorrect round function, good catch. Fixed now.



# transformation methods testing
def test_map_proj_to_pixel(grid_proj: GridProj):
Copy link
Contributor

Choose a reason for hiding this comment

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

Should include testing rounding, once you add rounding option to map_proj_to_pixel().

@mackenzie-grimes-noaa mackenzie-grimes-noaa added the 🚧 DO_NOT_MERGE 🚧 PR in progress or blocked by outside forces label Aug 11, 2023
@mackenzie-grimes-noaa
Copy link
Contributor Author

mackenzie-grimes-noaa commented Aug 11, 2023

This PR is now blocked by #7 which contains the rounding utility function we need here.

@mackenzie-grimes-noaa
Copy link
Contributor Author

@Geary-Layne I remembered now that in the GridProj class, I chose not to call my custom rounding function from the 2nd pixel-converting method --map_proj_to_pixel()-- because I discovered that it simply calls the 1st pixel method --map_geo_to_pixel()-- which in turn calls the rounding function.

So map_proj_to_pixel() would just be rounding an already-rounded float. Instead it passes the rounding arguments (rounding and precision) down to map_geo_to_pixel() and lets it do the rounding.

I can refactor that to be less confusing if you think of a better way.

Copy link
Contributor

@Geary-Layne Geary-Layne left a comment

Choose a reason for hiding this comment

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

Rounding function looks good.
It makes me happy to see our test code being build out.

@@ -83,8 +84,7 @@ def exec_cmd(commands: Sequence[str], timeout: Optional[int] = None) -> Sequence
Sequence[str]: Result of executing the commands
"""
logger.debug('Making system call %s', commands)
# with Popen(commands, stdout=PIPE, stderr=PIPE) as proc:
# out = proc.readlines()
# pylint: disable=consider-using-with
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did you decide to not use "with Popen"? In the current implementation is process ever closed?

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 guess I assumed it was commented out for a reason and simply silenced the warning. We can re-enable those lines.

Copy link
Contributor

Choose a reason for hiding this comment

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

It is not clear to me the correct way to use subprocess. Maybe proc.kill() actually frees underlying resources, but if that is the case there is a few line lower that I'm not sure will always work. I think it good enough for now, but maybe we should create a task to figure out the correct to leverage subprocess.


# convert geographic coordinates back to pixel, full circle, and data should be unchanged
pixel_x, pixel_y = grid_proj.map_geo_to_pixel(geo_x, geo_y)
assert (round(pixel_x, 6), round(pixel_y, 6)) == initial_pixel
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the test now be using round_half_away, or is round correct?

@mackenzie-grimes-noaa mackenzie-grimes-noaa removed the 🚧 DO_NOT_MERGE 🚧 PR in progress or blocked by outside forces label Aug 21, 2023
@mackenzie-grimes-noaa mackenzie-grimes-noaa merged commit 4bb8e47 into main Aug 21, 2023
2 of 3 checks passed
@mackenzie-grimes-noaa mackenzie-grimes-noaa deleted the feat/idsse-246/grid-proj branch September 18, 2023 18:06
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.

3 participants