Skip to content

Commit b40a54b

Browse files
authored
Merge pull request #239 from raphaelquast/dev
merge for v8.2
2 parents c83405b + 2aa99c9 commit b40a54b

55 files changed

Lines changed: 3075 additions & 2832 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/testMaps.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,14 @@ jobs:
3333
- name: Test Maps
3434
shell: bash -l {0}
3535
run: |
36-
pip install -e .[all]
36+
pip install -e .[test]
3737
python -m pytest -v --cov=eomaps --cov-report=xml
38+
- name: Upload Image Comparison Artefacts
39+
if: ${{ failure() }}
40+
uses: actions/upload-artifact@v4
41+
with:
42+
name: code-coverage-report
43+
path: img_comparison_results
3844
- name: Upload coverage to Codecov
3945
uses: codecov/codecov-action@v3
4046
with:

docs/api_data_visualization.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ Shade Raster
619619
:nosignatures:
620620
621621
Maps.set_shape.shade_raster
622+
Maps.set_shade_dpi
622623
623624
.. list-table::
624625
:header-rows: 1
@@ -640,6 +641,7 @@ Shade Raster
640641
agg_hook=None, # datashader aggregation hook callback
641642
)
642643
644+
643645
.. _shp_shade_points:
644646
645647
Shade Points
@@ -649,6 +651,7 @@ Shade Points
649651
:nosignatures:
650652
651653
Maps.set_shape.shade_raster
654+
Maps.set_shade_dpi
652655
653656
.. list-table::
654657
:header-rows: 1

docs/contribute.rst

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,26 +99,40 @@ The `environment.yml` file already contains the packages required to run the tes
9999

100100
- `pytest <https://docs.pytest.org>`__ to run the tests
101101
- `pytest-cov <https://github.com/pytest-dev/pytest-cov>`__ and `coveralls <https://github.com/TheKevJames/coveralls-python>`__ to track lines covered by the tests
102-
102+
- `pytest-mpl <https://pytest-mpl.readthedocs.io/en/stable/>`__ to perform image comparisons
103103

104104
To run the primary test suite and generate coverage report, navigate to the parent `eomaps` directory and run:
105105

106106
.. code-block:: console
107107
108-
python -m pytest -v --cov eomaps
108+
python -m pytest -v --cov eomaps --mpl
109+
110+
Some of the tests compare exported images with a set of baseline-images to ensure stable image exports and to catch
111+
potential issues that are not detected by the code based tests.
112+
113+
If changes require an update of the baseline images, you have to invoke
114+
`pytest-mpl <https://pytest-mpl.readthedocs.io/en/stable/>`__ with the `mpl-generate-path` option:
115+
116+
.. code-block:: console
117+
118+
python -m pytest -v --cov eomaps --mpl --mpl-generate-path=tests/baseline
119+
120+
This will update all images in the `tests/baseline` folder.
109121

110122
.. note::
111123

112124
During the tests, a lot of figures will be created and destroyed!
113125

126+
Before updating new baseline images, make sure to manually check that they look exactly as expected!
127+
114128

115129
.. tip::
116130

117131
You can run only a subset of the tests by using the ``-k`` flag! (This will select only tests whose names contain the provided keyword)
118132

119133
.. code-block:: console
120134
121-
python -m pytest -k <KEYWORD>
135+
python -m pytest -k <QUERY KEYWORD>
122136
123137
(see `pytest command-line-flags <https://docs.pytest.org/en/7.3.x/reference/reference.html#command-line-flags>`_ for more details)
124138

@@ -129,7 +143,7 @@ To run the primary test suite and generate coverage report, navigate to the pare
129143

130144
.. code-block:: console
131145
132-
python -m pytest -n <NUMCORE> --cov eomaps
146+
python -m pytest -n <NUMBER OF CORES>
133147
134148
Style Checking
135149
~~~~~~~~~~~~~~

docs/examples/example_multiple_maps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515
# initialize a grid of Maps objects
1616
m = Maps(ax=131, crs=4326, figsize=(11, 5))
17-
m2 = Maps(f=m.f, ax=132, crs=Maps.CRS.Stereographic())
18-
m3 = Maps(f=m.f, ax=133, crs=3035)
17+
m2 = m.new_map(ax=132, crs=Maps.CRS.Stereographic())
18+
m3 = m.new_map(ax=133, crs=3035)
1919

2020
# --------- set specs for the first map
2121
m.text(0.5, 1.1, "epsg=4326", transform=m.ax.transAxes)

docs/examples/example_scalebars.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
# EOmaps example: Adding scalebars - what about distances?
2-
32
from eomaps import Maps
43

54
m = Maps(figsize=(9, 5))
@@ -14,7 +13,9 @@
1413
n=10,
1514
scale_props=dict(width=5, colors=("k", ".25", ".5", ".75", ".95")),
1615
patch_props=dict(offsets=(1, 1.4, 1, 1), fc=(0.7, 0.8, 0.3, 1)),
17-
label_props=dict(offset=0.5, scale=1.4, every=5, weight="bold", family="Calibri"),
16+
label_props=dict(
17+
offset=0.5, scale=1.4, every=5, weight="bold" # , family="Calibri"
18+
),
1819
)
1920

2021
s3 = m.add_scalebar(
@@ -25,7 +26,7 @@
2526
scale_props=dict(width=3, colors=(*["w", "darkred"] * 2, *["w"] * 5, "darkred")),
2627
patch_props=dict(fc=(0.25, 0.25, 0.25, 0.8), ec="k", lw=0.5, offsets=(1, 1, 1, 2)),
2728
label_props=dict(
28-
every=(1, 4, 10), color="w", rotation=45, weight="bold", family="Impact"
29+
every=(1, 4, 10), color="w", rotation=45, weight="bold" # , family="Impact"
2930
),
3031
line_props=dict(color="w"),
3132
)

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies:
3636
- coveralls
3737
- pytest
3838
- pytest-cov
39+
- pytest-mpl
3940
# --------------for testing the docs
4041
# (e.g. parsing .rst code-blocks and Jupyter Notebooks)
4142
- docutils

eomaps/_blit_manager.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ def _get_renderer(self):
268268

269269
def _get_all_map_axes(self):
270270
maxes = {
271-
m.ax for m in (self._m.parent, *self._m.parent._children) if m._new_axis_map
271+
m.ax
272+
for m in (self._m.parent, *self._m.parent._children)
273+
if getattr(m, "_new_axis_map", False)
272274
}
273275
return maxes
274276

@@ -331,9 +333,9 @@ def _do_on_layer_change(self, layer, new=False):
331333
try:
332334
f = self._on_layer_change[False].pop(0)
333335
f(layer=layer)
334-
except Exception:
336+
except Exception as ex:
335337
_log.error(
336-
"EOmaps: Issue while executing a layer-change action",
338+
f"EOmaps: Issue during layer-change action: {ex}",
337339
exc_info=_log.getEffectiveLevel() <= logging.DEBUG,
338340
)
339341

@@ -352,9 +354,9 @@ def _do_on_layer_change(self, layer, new=False):
352354
try:
353355
f = single_shot_funcs.pop(0)
354356
f(layer=l)
355-
except Exception:
357+
except Exception as ex:
356358
_log.error(
357-
"EOmaps: Issue while executing a layer-change action",
359+
f"EOmaps: Issue during layer-change action: {ex}",
358360
exc_info=_log.getEffectiveLevel() <= logging.DEBUG,
359361
)
360362

@@ -430,7 +432,7 @@ def bg_layer(self, val):
430432
for m in [self._m.parent, *self._m.parent._children]:
431433
layer_visible = self._layer_is_subset(val, m.layer)
432434

433-
for cb in m._colorbars:
435+
for cb in getattr(m, "_colorbars", []):
434436
cb._hide_singular_axes()
435437

436438
if layer_visible:
@@ -727,8 +729,9 @@ def _do_fetch_bg(self, layer, bbox=None):
727729

728730
# update axes spines and patches since they are used to clip artists!
729731
for ax in self._get_all_map_axes():
730-
ax.spines["geo"]._adjust_location()
731-
ax.patch._adjust_location()
732+
if "geo" in ax.spines:
733+
ax.spines["geo"]._adjust_location()
734+
ax.patch._adjust_location()
732735

733736
# use contextmanagers to make sure the background patches are not stored
734737
# in the buffer regions!
@@ -978,8 +981,11 @@ def add_bg_artist(self, art, layer=None, draw=True):
978981
and not layer.startswith("__inset_")
979982
):
980983
layer = "__inset_" + str(layer)
984+
981985
if layer in self._bg_artists and art in self._bg_artists[layer]:
982-
_log.info(f"EOmaps: Background-artist '{art}' already added")
986+
_log.info(
987+
f"EOmaps: Background-artist '{art}' already added on layer '{layer}'"
988+
)
983989
return
984990

985991
art.set_animated(True)

eomaps/_data_manager.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,153 @@ def _get_current_datasize(self):
724724
else:
725725
return 99
726726

727+
def _sel_c_transp(self, c):
728+
return self._select_vals(
729+
c.T if self._z_transposed else c,
730+
qs=self._last_qs,
731+
slices=self._last_slices,
732+
)
733+
734+
def _handle_explicit_colors(self, color):
735+
if isinstance(color, (int, float, str, np.number)):
736+
# if a scalar is provided, broadcast it
737+
pass
738+
elif isinstance(color, (list, tuple)) and len(color) in [3, 4]:
739+
if all(map(lambda i: isinstance(i, (int, float, np.number)), color)):
740+
# check if a tuple of numbers is provided, and if so broadcast
741+
# it as a rgb or rgba tuple
742+
pass
743+
elif all(map(lambda i: isinstance(i, (list, np.ndarray)), color)):
744+
# check if a tuple of lists or arrays is provided, and if so,
745+
# broadcast them as RGB arrays
746+
color = self._sel_c_transp(
747+
np.rec.fromarrays(np.broadcast_arrays(*color))
748+
)
749+
elif isinstance(color, np.ndarray) and (color.shape[-1] in [3, 4]):
750+
color = self._sel_c_transp(np.rec.fromarrays(color.T))
751+
elif isinstance(color, np.ndarray) and (color.shape[-1] in [3, 4]):
752+
color = self._sel_c_transp(np.rec.fromarrays(color.T))
753+
else:
754+
# still use np.asanyarray in here in case lists are provided
755+
color = self._sel_c_transp(np.asanyarray(color).reshape(self.m._zshape))
756+
757+
return color
758+
759+
@staticmethod
760+
def _convert_1d_to_2d(data, x, y, fill_value=np.nan):
761+
"""A function to convert 1D vectors + data into 2D."""
762+
763+
if _log.getEffectiveLevel() <= logging.DEBUG:
764+
_log.debug(
765+
"EOmaps: Required conversion of 1D arrays to 2D for 'raster'"
766+
"shape might be slow and consume a lot of memory!"
767+
)
768+
769+
x, y, data = map(np.asanyarray, (x, y, data))
770+
assert (
771+
x.size == y.size == data.size
772+
), "EOmaps: You cannot use 1D arrays with different sizes for x, y and data"
773+
774+
x_vals, x_idx = np.unique(x, return_inverse=True)
775+
y_vals, y_idx = np.unique(y, return_inverse=True)
776+
# Get output array shape
777+
m, n = (x_vals.size, y_vals.size)
778+
779+
# Get linear indices to be used as IDs with bincount
780+
lidx = np.ravel_multi_index(np.vstack((x_idx, y_idx)), (m, n))
781+
idx2d = np.unravel_index(lidx, (m, n))
782+
783+
# Distribute data to 2D
784+
785+
if not np.issubdtype(data.dtype, np.integer):
786+
# Integer-dtypes do not support None!
787+
data2d = np.full((m, n), fill_value=fill_value, dtype=data.dtype)
788+
data2d[idx2d] = data
789+
790+
else:
791+
# use smallest possible value as fill-value
792+
fill_value = np.iinfo(data.dtype).min
793+
data2d = np.full((m, n), fill_value=fill_value, dtype=data.dtype)
794+
data2d[idx2d] = data
795+
796+
mask2d = np.full((m, n), fill_value=True, dtype=bool)
797+
mask2d[idx2d] = False
798+
799+
data2d = np.ma.masked_array(data2d, mask2d)
800+
801+
# Distribute coordinates to 2D
802+
x_vals, y_vals = np.meshgrid(x_vals, y_vals, indexing="ij")
803+
804+
return data2d, x_vals, y_vals
805+
806+
def _get_coll(self, props, **kwargs):
807+
# handle selection of explicitly provided facecolors
808+
# (e.g. for rgb composites)
809+
810+
# allow only one of the synonyms "color", "fc" and "facecolor"
811+
if (
812+
np.count_nonzero(
813+
[kwargs.get(i, None) is not None for i in ["color", "fc", "facecolor"]]
814+
)
815+
> 1
816+
):
817+
raise TypeError(
818+
"EOmaps: only one of 'color', 'facecolor' or 'fc' " "can be specified!"
819+
)
820+
821+
explicit_fc = False
822+
for key in ("color", "facecolor", "fc"):
823+
if kwargs.get(key, None) is not None:
824+
explicit_fc = True
825+
kwargs[key] = self._handle_explicit_colors(kwargs[key])
826+
827+
# don't pass the array if explicit facecolors are set
828+
if explicit_fc and self.m.shape.name not in ["contour"]:
829+
args = dict(array=None, cmap=None, norm=None, **kwargs)
830+
else:
831+
args = dict(
832+
array=props["z_data"],
833+
cmap=getattr(self.m, "_cbcmap", "Reds"),
834+
norm=getattr(self.m, "_norm", None),
835+
**kwargs,
836+
)
837+
838+
if (
839+
self.m.shape.name in ["contour"]
840+
and len(self.m._xshape) == 2
841+
and len(self.m._yshape) == 2
842+
):
843+
# if 2D data is provided for a contour plot, keep the data 2d!
844+
coll = self.m.shape.get_coll(props["xorig"], props["yorig"], "in", **args)
845+
elif self.m.shape.name in ["raster"]:
846+
# if input-data is 1D, try to convert data to 2D (required for raster)
847+
# TODO make an explicit data-conversion function for 2D-only shapes
848+
if len(self.m._xshape) == 2 and len(self.m._yshape) == 2:
849+
coll = self.m.shape.get_coll(
850+
props["xorig"], props["yorig"], "in", **args
851+
)
852+
else:
853+
data2d, x2d, y2d = self._convert_1d_to_2d(
854+
data=props["z_data"].ravel(),
855+
x=props["x0"].ravel(),
856+
y=props["y0"].ravel(),
857+
)
858+
859+
if args["array"] is not None:
860+
args["array"] = data2d
861+
862+
coll = self.m.shape.get_coll(x2d, y2d, "out", **args)
863+
864+
else:
865+
# convert to 1D for further processing
866+
if args["array"] is not None:
867+
args["array"] = args["array"].ravel()
868+
869+
coll = self.m.shape.get_coll(
870+
props["x0"].ravel(), props["y0"].ravel(), "out", **args
871+
)
872+
return coll
873+
727874
def on_fetch_bg(self, layer=None, bbox=None, check_redraw=True):
728875
# TODO support providing a bbox as extent?
729876
if layer is None:
@@ -757,7 +904,7 @@ def on_fetch_bg(self, layer=None, bbox=None, check_redraw=True):
757904
# remove previous collection from the map
758905
self._remove_existing_coll()
759906
# draw the new collection
760-
coll = self.m._get_coll(props, **self.m._coll_kwargs)
907+
coll = self._get_coll(props, **self.m._coll_kwargs)
761908
coll.set_clim(self.m._vmin, self.m._vmax)
762909

763910
coll.set_label("Dataset " f"({self.m.shape.name} | {self.z_data.shape})")

0 commit comments

Comments
 (0)