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

add a special projection for EPSG:27572 #2427

Merged
merged 9 commits into from
Oct 7, 2024

Conversation

ThibHlln
Copy link
Contributor

@ThibHlln ThibHlln commented Aug 27, 2024

Rationale

The use of cartopy.crs.epsg(27572) for the Lambert Zone II projection does not always work properly when provided as a keyword argument for GeoAxes. Yet, this is still a relevant and widely used projection for mainland France, so it would be very helpful to have a working alternative in cartopy, like other national CRSs such as cartopy.crs.OSGB for example.

Implications

This change will make it possible for users to plot geospatial datasets in this projection.

Example

import matplotlib.pyplot as plt
import cartopy

ax = plt.subplot(projection=cartopy.crs.L2E())
ax.set_extent([0, 1.2e6, 1.6e6, 5.7e6], crs=cartopy.crs.L2E())
ax.add_feature(cartopy.feature.BORDERS)
ax.add_feature(cartopy.feature.COASTLINE)
ax.scatter(564055, 2571176, transform=cartopy.crs.L2E())
plt.show()

myplot-1

vs.

import matplotlib.pyplot as plt
import cartopy

ax = plt.subplot(projection=cartopy.crs.epsg(27572))
ax.set_extent([0, 1.2e6, 1.6e6, 5.7e6], crs=cartopy.crs.epsg(27572))
ax.add_feature(cartopy.feature.BORDERS)
ax.add_feature(cartopy.feature.COASTLINE)
ax.scatter(564055, 2571176, transform=cartopy.crs.epsg(27572))
plt.show()

myplot-2

Thank you to @muniers for originally implementing the solution contained in this PR, and for allowing me to submit this PR.

this projection is relevant for mainland France, and is necessary
because `cartopy.crs.epsg(27572)` does not work when used as a
``projection`` argument for geoaxes
@CLAassistant
Copy link

CLAassistant commented Aug 27, 2024

CLA assistant check
All committers have signed the CLA.

@ThibHlln
Copy link
Contributor Author

ThibHlln commented Aug 27, 2024

There seems to be a precision problem between the conversion using pyproj directly and the method transform_point of Projection.

>>> cartopy.crs.L2E().transform_point(1.4868268900254693, 48.13277955695077, src_crs=cartopy.crs.PlateCarree())
(np.float64(536690.1861966171), np.float64(2348515.622484158))

vs.

>>> pyproj.Transformer.from_crs("EPSG:4326", "EPSG:27572").transform(48.13277955695077, 1.4868268900254693)
(536745.8962648578, 2348522.778985058)

Yet:

>>> cartopy.crs.L2E().to_proj4()
/home/user/miniforge3/envs/env/lib/python3.12/site-packages/pyproj/crs/crs.py:1293: UserWarning: You will likely lose important projection information when converting to a PROJ string from another format. See: https://proj.org/faq.html#what-is-the-best-format-for-describing-coordinate-reference-systems
  proj = self._crs.to_proj4(version=version)
'+proj=lcc +lat_1=46.8 +lat_0=46.8 +lon_0=0 +k_0=0.99987742 +x_0=600000 +y_0=2200000 +ellps=clrk80ign +pm=paris +towgs84=-168,-60,320,0,0,0,0 +units=m +no_defs +type=crs'
>>> pyproj.CRS.from_epsg(27572).to_proj4()
/home/user/miniforge3/envs/env/lib/python3.12/site-packages/pyproj/crs/crs.py:1293: UserWarning: You will likely lose important projection information when converting to a PROJ string from another format. See: https://proj.org/faq.html#what-is-the-best-format-for-describing-coordinate-reference-systems
  proj = self._crs.to_proj4(version=version)
'+proj=lcc +lat_1=46.8 +lat_0=46.8 +lon_0=0 +k_0=0.99987742 +x_0=600000 +y_0=2200000 +ellps=clrk80ign +pm=paris +units=m +no_defs +type=crs'

Here are the packages versions I am working with:

$ mamba list
cartopy                   0.23.0          py312h1d6d2e6_1    conda-forge
...
proj                      9.4.1                h54d7996_1    conda-forge
...
pyproj                    3.6.1           py312h01329cd_8    conda-forge

Is this expected? Should I adjust the expected values in the unit test accordingly?

@ThibHlln ThibHlln changed the title add a projection for L2E add a special projection for EPSG:27572 Aug 28, 2024
@ThibHlln
Copy link
Contributor Author

Hi there,

Could anyone have a look at this PR please?

Thank you.

Copy link
Contributor

@greglucas greglucas left a comment

Choose a reason for hiding this comment

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

Looks like a great start! Similar comments as here to update the test images too:
#2442 (review)

Regarding your comment

There seems to be a precision problem between the conversion using pyproj directly and the method transform_point of Projection.

This could be due to using PlateCarree and not using a geodetic coordinate system?

pyproj.CRS.from_epsg(4326).to_proj4()
'+proj=longlat +datum=WGS84 +no_defs +type=crs'

cartopy.crs.PlateCarree().to_proj4()
'+proj=eqc +lat_ts=0 +lat_0=0 +lon_0=0 +x_0=0 +y_0=0 +ellps=WGS84 +to_meter=111319.490793274 +no_defs +type=crs'

@@ -1815,6 +1815,60 @@ def y_limits(self):
return self._y_limits


class L2E(Projection):
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this inherit from LambertConformal if it is that type of projection? I think the naming should contain Lambert at least, here is a quick suggestion.

Suggested change
class L2E(Projection):
class LambertZoneII(LambertConformal):

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 have not inherited from LambertConformal because it does not make it possible to set some PROJ4 parameters, such as the scaling factor k_0.

@ThibHlln
Copy link
Contributor Author

Hi @greglucas,

Thank you very much for reviewing my PR.

I have renamed the class, updated the expected values for the unit test (given the PlateCarree vs. EPSG:4326 inherent differences), and added a test image for this new projection.

Copy link
Contributor

@greglucas greglucas left a comment

Choose a reason for hiding this comment

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

I think it might help to add some comments/links for the values you've chosen here. It is unclear whether some of them are what you think looks good for this projection versus parameters that are dictated by some standard.

I think it might make sense to add a scale_factor or k_0 keyword argument to the LambertConformal class which would allow you to sublcass this and then set the explicit values through the super call (similar to what OSGB is doing). Although you also have a different boundary which is currently just the corner points and thus won't be "curved" like the general LambertConformal class which has a boundary made from linspace (not sure if that is preferable or not?).

Feel free to disagree with any of these comments, most of them are just my ignorance of the projection itself.

@@ -1815,6 +1815,59 @@ def y_limits(self):
return self._y_limits


class LambertZoneII(Projection):
"""
Lambert zone II (extended) projection (https://epsg.io/27572).
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you add some more text about this being used for France or general information about this projection so someone reading this would know more at first glance?

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 have added slightly more information in the docstring.

Comment on lines 1835 to 1837
globe = Globe(semimajor_axis='6378249.2',
semiminor_axis='6356515.0',
towgs84='-168,-60,320,0,0,0,0')
Copy link
Contributor

Choose a reason for hiding this comment

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

Where do these values come from? (I didn't see anything about this in the EPSG website) Is this projection only valid with this Globe and do we need to allow users to override this default value?

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 ellipsoid corresponds to EPSG:7011. These values (and the conversion to WGS84) can be found in a report [in French] produced by the French National Institute of Geographic and Forest Information in sections 3.1.1 and 4.3.1, respectively. As EPSG:27572 clearly defines the ellipsoid to use, I don't think we need to allow users to override them.

Copy link
Contributor

Choose a reason for hiding this comment

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

This isn't a super helpful map after all with the limited boundary :) But it is small enough and shows the boundaries so perhaps still useful? Up to you whether you think it is worthwhile to keep this or not, I could be convinced either way on it.

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, unfortunately we don't see the red and blue lines as on the other test maps. But I guess this is still another useful additional unit test to have. And it does allow to see its boundaries. All in all, I would be in favour to keep it. :)


super().__init__(proj4_params, globe=globe)

x0, x1, y0, y1 = [0, 1.2e6, 1.6e6, 2.7e6]
Copy link
Contributor

Choose a reason for hiding this comment

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

Same question about these bounds 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.

Given the purpose of this projection is to cover hexagonal France and Corsica, these values include those two objects. They do not come from an official standard though. How else could they be chosen? I would gladly welcome suggestions on this and amend accordingly. :)

Copy link
Contributor

Choose a reason for hiding this comment

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

I had a quick look at the EPSG version in your original post, and realized that the boundary there is being set by the area_of_use, which should be a good quantity we can reference for our boundary. The interesting thing here is that I think the ellipsoid/globe being used may not correspond to the area of use coordinates being reported.

Here is we are calling self.as_geodetic()

points = self.transform_points(self.as_geodetic(), lons, lats)

but here it says that it is values reported using the WGS84 ellipse.
# Geographic area of the entire dataset referenced to WGS 84

So, I think this may be a bug in the epsg code path and we should be doing something like:
points = self.transform_points(PlateCarree().as_geodetic(), lons, lats)

Thoughts on how that looks?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replacing with PlateCarree does fix the initial problem mentioned in the opening message of this PR. However, the resulting bounds are not wide enough to include the southmost part of hexagonal France and Corsica.

import matplotlib.pyplot as plt
import cartopy

ax = plt.subplot(projection=cartopy.crs.epsg(27572))
ax.set_extent(cartopy.crs.epsg(27572).bounds, crs=cartopy.crs.epsg(27572))
ax.add_feature(cartopy.feature.BORDERS)
ax.add_feature(cartopy.feature.COASTLINE)
ax.scatter(564055, 2571176, transform=cartopy.crs.epsg(27572))
plt.show()

myplot

Copy link
Contributor

Choose a reason for hiding this comment

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

👍 My proposal would be to do something like this then to update the bounds manually after initialization from the EPSG crs.

class LambertZoneII(Projection):
    def __init__(self):
        crs = pyproj.CRS.from_epsg(27572)
        super().__init__(crs.to_wkt())
        # Projected bounds from https://epsg.io/27572
        self.bounds = [-5242.32, 1212512.16, 1589155.51, 2706796.21]

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I've done just that. Works just fine. :-)
I have updated the test image too, as the extent is now slightly different from the one I had arbitrarily set before.
Thanks.

@ThibHlln
Copy link
Contributor Author

I think it might make sense to add a scale_factor or k_0 keyword argument to the LambertConformal class which would allow you to sublcass this and then set the explicit values through the super call (similar to what OSGB is doing). Although you also have a different boundary which is currently just the corner points and thus won't be "curved" like the general LambertConformal class which has a boundary made from linspace (not sure if that is preferable or not?).

Ok, I will consider this and get back to you on this soon. Thanks.

Copy link
Contributor

@greglucas greglucas left a comment

Choose a reason for hiding this comment

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

Nice! Thank you for going back and forth on the implementation with me and sorry for sending you down a few different paths. I think this makes for a nice example of how we can add other named projections following this pattern.

I'm going to leave this open for a day or two in case anyone else wants to chime in with anything.

@ThibHlln
Copy link
Contributor Author

ThibHlln commented Oct 1, 2024

Thank you very much for your help and advice @greglucas. :-)

@greglucas greglucas merged commit c054cdf into SciTools:main Oct 7, 2024
21 checks passed
@QuLogic QuLogic added this to the 0.24 milestone Oct 7, 2024
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.

4 participants