diff options
author | robot-contrib <robot-contrib@yandex-team.com> | 2023-11-18 09:46:15 +0300 |
---|---|---|
committer | robot-contrib <robot-contrib@yandex-team.com> | 2023-11-18 10:08:57 +0300 |
commit | 4ab41f5f07dd3ea0071546538fdcce8922290766 (patch) | |
tree | 6fdbde3dd782e545de4241c33ba9cb1ba2918b86 | |
parent | df03ef42eed0a5b601011204e47b72d37c6373a9 (diff) | |
download | ydb-4ab41f5f07dd3ea0071546538fdcce8922290766.tar.gz |
Update contrib/python/contourpy to 1.2.0
30 files changed, 1401 insertions, 228 deletions
diff --git a/contrib/python/contourpy/.dist-info/METADATA b/contrib/python/contourpy/.dist-info/METADATA index 62b336ae70..ae131f08d6 100644 --- a/contrib/python/contourpy/.dist-info/METADATA +++ b/contrib/python/contourpy/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: contourpy -Version: 1.1.1 +Version: 1.2.0 Summary: Python library for calculating contours of 2D quadrilateral grids Author-Email: Ian Thomas <ianthomas23@gmail.com> License: BSD 3-Clause License @@ -38,7 +38,6 @@ Classifier: Intended Audience :: Science/Research Classifier: License :: OSI Approved :: BSD License Classifier: Programming Language :: C++ Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 @@ -50,9 +49,8 @@ Project-URL: Homepage, https://github.com/contourpy/contourpy Project-URL: Changelog, https://contourpy.readthedocs.io/en/latest/changelog.html Project-URL: Documentation, https://contourpy.readthedocs.io Project-URL: Repository, https://github.com/contourpy/contourpy -Requires-Python: >=3.8 -Requires-Dist: numpy<2.0,>=1.16; python_version <= "3.11" -Requires-Dist: numpy<2.0,>=1.26.0rc1; python_version >= "3.12" +Requires-Python: >=3.9 +Requires-Dist: numpy<2.0,>=1.20 Requires-Dist: furo; extra == "docs" Requires-Dist: sphinx>=7.2; extra == "docs" Requires-Dist: sphinx-copybutton; extra == "docs" @@ -60,13 +58,14 @@ Requires-Dist: bokeh; extra == "bokeh" Requires-Dist: selenium; extra == "bokeh" Requires-Dist: contourpy[bokeh,docs]; extra == "mypy" Requires-Dist: docutils-stubs; extra == "mypy" -Requires-Dist: mypy==1.4.1; extra == "mypy" +Requires-Dist: mypy==1.6.1; extra == "mypy" Requires-Dist: types-Pillow; extra == "mypy" Requires-Dist: contourpy[test-no-images]; extra == "test" Requires-Dist: matplotlib; extra == "test" Requires-Dist: Pillow; extra == "test" Requires-Dist: pytest; extra == "test-no-images" Requires-Dist: pytest-cov; extra == "test-no-images" +Requires-Dist: pytest-xdist; extra == "test-no-images" Requires-Dist: wurlitzer; extra == "test-no-images" Provides-Extra: docs Provides-Extra: bokeh diff --git a/contrib/python/contourpy/contourpy/__init__.py b/contrib/python/contourpy/contourpy/__init__.py index 006d5f5598..7b85874c73 100644 --- a/contrib/python/contourpy/contourpy/__init__.py +++ b/contrib/python/contourpy/contourpy/__init__.py @@ -10,6 +10,8 @@ from contourpy._contourpy import ( ) from contourpy._version import __version__ from contourpy.chunk import calc_chunk_sizes +from contourpy.convert import convert_filled, convert_lines +from contourpy.dechunk import dechunk_filled, dechunk_lines from contourpy.enum_util import as_fill_type, as_line_type, as_z_interp if TYPE_CHECKING: @@ -22,6 +24,10 @@ if TYPE_CHECKING: __all__ = [ "__version__", "contour_generator", + "convert_filled", + "convert_lines", + "dechunk_filled", + "dechunk_lines", "max_threads", "FillType", "LineType", @@ -74,10 +80,10 @@ def contour_generator( z_interp: ZInterp | str | None = ZInterp.Linear, thread_count: int = 0, ) -> ContourGenerator: - """Create and return a contour generator object. + """Create and return a :class:`~contourpy._contourpy.ContourGenerator` object. - The class and properties of the contour generator are determined by the function arguments, - with sensible defaults. + The class and properties of the returned :class:`~contourpy._contourpy.ContourGenerator` are + determined by the function arguments, with sensible defaults. Args: x (array-like of shape (ny, nx) or (nx,), optional): The x-coordinates of the ``z`` values. @@ -96,12 +102,14 @@ def contour_generator( If ``True``, only the triangular corners of quads nearest these points are always masked out, other triangular corners comprising three unmasked points are contoured as usual. If not specified, uses the default provided by the algorithm ``name``. - line_type (LineType, optional): The format of contour line data returned from calls to - :meth:`~contourpy.ContourGenerator.lines`. If not specified, uses the default provided - by the algorithm ``name``. - fill_type (FillType, optional): The format of filled contour data returned from calls to - :meth:`~contourpy.ContourGenerator.filled`. If not specified, uses the default provided - by the algorithm ``name``. + line_type (LineType or str, optional): The format of contour line data returned from calls + to :meth:`~contourpy.ContourGenerator.lines`, specified either as a + :class:`~contourpy.LineType` or its string equivalent such as ``"SeparateCode"``. + If not specified, uses the default provided by the algorithm ``name``. + fill_type (FillType or str, optional): The format of filled contour data returned from calls + to :meth:`~contourpy.ContourGenerator.filled`, specified either as a + :class:`~contourpy.FillType` or its string equivalent such as ``"OuterOffset"``. + If not specified, uses the default provided by the algorithm ``name``. chunk_size (int or tuple(int, int), optional): Chunk size in (y, x) directions, or the same size in both directions if only one value is specified. chunk_count (int or tuple(int, int), optional): Chunk count in (y, x) directions, or the @@ -113,9 +121,10 @@ def contour_generator( at the centre (mean x, y of the corner points) and a contour line is piecewise linear within those triangles. Corner-masked triangles are not affected by this setting, only full unmasked quads. - z_interp (ZInterp): How to interpolate ``z`` values when determining where contour lines - intersect the edges of quads and the ``z`` values of the central points of quads, - default ``ZInterp.Linear``. + z_interp (ZInterp or str, optional): How to interpolate ``z`` values when determining where + contour lines intersect the edges of quads and the ``z`` values of the central points of + quads, specified either as a :class:`~contourpy.ZInterp` or its string equivalent such + as ``"Log"``. Default is ``ZInterp.Linear``. thread_count (int): Number of threads to use for contour calculation, default 0. Threads can only be used with an algorithm ``name`` that supports threads (currently only ``name="threaded"``) and there must be at least the same number of chunks as threads. diff --git a/contrib/python/contourpy/contourpy/_version.py b/contrib/python/contourpy/contourpy/_version.py index a82b376d2d..c68196d1cb 100644 --- a/contrib/python/contourpy/contourpy/_version.py +++ b/contrib/python/contourpy/contourpy/_version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.2.0" diff --git a/contrib/python/contourpy/contourpy/array.py b/contrib/python/contourpy/contourpy/array.py new file mode 100644 index 0000000000..fbc53d6c4c --- /dev/null +++ b/contrib/python/contourpy/contourpy/array.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from itertools import chain +from typing import TYPE_CHECKING + +import numpy as np + +from contourpy.typecheck import check_code_array, check_offset_array, check_point_array +from contourpy.types import CLOSEPOLY, LINETO, MOVETO, code_dtype, offset_dtype, point_dtype + +if TYPE_CHECKING: + import contourpy._contourpy as cpy + + +def codes_from_offsets(offsets: cpy.OffsetArray) -> cpy.CodeArray: + """Determine codes from offsets, assuming they all correspond to closed polygons. + """ + check_offset_array(offsets) + + n = offsets[-1] + codes = np.full(n, LINETO, dtype=code_dtype) + codes[offsets[:-1]] = MOVETO + codes[offsets[1:] - 1] = CLOSEPOLY + return codes + + +def codes_from_offsets_and_points( + offsets: cpy.OffsetArray, + points: cpy.PointArray, +) -> cpy.CodeArray: + """Determine codes from offsets and points, using the equality of the start and end points of + each line to determine if lines are closed or not. + """ + check_offset_array(offsets) + check_point_array(points) + + codes = np.full(len(points), LINETO, dtype=code_dtype) + codes[offsets[:-1]] = MOVETO + + end_offsets = offsets[1:] - 1 + closed = np.all(points[offsets[:-1]] == points[end_offsets], axis=1) + codes[end_offsets[closed]] = CLOSEPOLY + + return codes + + +def codes_from_points(points: cpy.PointArray) -> cpy.CodeArray: + """Determine codes for a single line, using the equality of the start and end points to + determine if the line is closed or not. + """ + check_point_array(points) + + n = len(points) + codes = np.full(n, LINETO, dtype=code_dtype) + codes[0] = MOVETO + if np.all(points[0] == points[-1]): + codes[-1] = CLOSEPOLY + return codes + + +def concat_codes(list_of_codes: list[cpy.CodeArray]) -> cpy.CodeArray: + """Concatenate a list of codes arrays into a single code array. + """ + if not list_of_codes: + raise ValueError("Empty list passed to concat_codes") + + return np.concatenate(list_of_codes, dtype=code_dtype) + + +def concat_codes_or_none(list_of_codes_or_none: list[cpy.CodeArray | None]) -> cpy.CodeArray | None: + """Concatenate a list of codes arrays or None into a single code array or None. + """ + list_of_codes = [codes for codes in list_of_codes_or_none if codes is not None] + if list_of_codes: + return concat_codes(list_of_codes) + else: + return None + + +def concat_offsets(list_of_offsets: list[cpy.OffsetArray]) -> cpy.OffsetArray: + """Concatenate a list of offsets arrays into a single offset array. + """ + if not list_of_offsets: + raise ValueError("Empty list passed to concat_offsets") + + n = len(list_of_offsets) + cumulative = np.cumsum([offsets[-1] for offsets in list_of_offsets], dtype=offset_dtype) + ret: cpy.OffsetArray = np.concatenate( + (list_of_offsets[0], *(list_of_offsets[i+1][1:] + cumulative[i] for i in range(n-1))), + dtype=offset_dtype, + ) + return ret + + +def concat_offsets_or_none( + list_of_offsets_or_none: list[cpy.OffsetArray | None], +) -> cpy.OffsetArray | None: + """Concatenate a list of offsets arrays or None into a single offset array or None. + """ + list_of_offsets = [offsets for offsets in list_of_offsets_or_none if offsets is not None] + if list_of_offsets: + return concat_offsets(list_of_offsets) + else: + return None + + +def concat_points(list_of_points: list[cpy.PointArray]) -> cpy.PointArray: + """Concatenate a list of point arrays into a single point array. + """ + if not list_of_points: + raise ValueError("Empty list passed to concat_points") + + return np.concatenate(list_of_points, dtype=point_dtype) + + +def concat_points_or_none( + list_of_points_or_none: list[cpy.PointArray | None], +) -> cpy.PointArray | None: + """Concatenate a list of point arrays or None into a single point array or None. + """ + list_of_points = [points for points in list_of_points_or_none if points is not None] + if list_of_points: + return concat_points(list_of_points) + else: + return None + + +def concat_points_or_none_with_nan( + list_of_points_or_none: list[cpy.PointArray | None], +) -> cpy.PointArray | None: + """Concatenate a list of points or None into a single point array or None, with NaNs used to + separate each line. + """ + list_of_points = [points for points in list_of_points_or_none if points is not None] + if list_of_points: + return concat_points_with_nan(list_of_points) + else: + return None + + +def concat_points_with_nan(list_of_points: list[cpy.PointArray]) -> cpy.PointArray: + """Concatenate a list of points into a single point array with NaNs used to separate each line. + """ + if not list_of_points: + raise ValueError("Empty list passed to concat_points_with_nan") + + if len(list_of_points) == 1: + return list_of_points[0] + else: + nan_spacer = np.full((1, 2), np.nan, dtype=point_dtype) + list_of_points = [list_of_points[0], + *list(chain(*((nan_spacer, x) for x in list_of_points[1:])))] + return concat_points(list_of_points) + + +def insert_nan_at_offsets(points: cpy.PointArray, offsets: cpy.OffsetArray) -> cpy.PointArray: + """Insert NaNs into a point array at locations specified by an offset array. + """ + check_point_array(points) + check_offset_array(offsets) + + if len(offsets) <= 2: + return points + else: + nan_spacer = np.array([np.nan, np.nan], dtype=point_dtype) + # Convert offsets to int64 to avoid numpy error when mixing signed and unsigned ints. + return np.insert(points, offsets[1:-1].astype(np.int64), nan_spacer, axis=0) + + +def offsets_from_codes(codes: cpy.CodeArray) -> cpy.OffsetArray: + """Determine offsets from codes using locations of MOVETO codes. + """ + check_code_array(codes) + + return np.append(np.nonzero(codes == MOVETO)[0], len(codes)).astype(offset_dtype) + + +def offsets_from_lengths(list_of_points: list[cpy.PointArray]) -> cpy.OffsetArray: + """Determine offsets from lengths of point arrays. + """ + if not list_of_points: + raise ValueError("Empty list passed to offsets_from_lengths") + + return np.cumsum([0] + [len(line) for line in list_of_points], dtype=offset_dtype) + + +def outer_offsets_from_list_of_codes(list_of_codes: list[cpy.CodeArray]) -> cpy.OffsetArray: + """Determine outer offsets from codes using locations of MOVETO codes. + """ + if not list_of_codes: + raise ValueError("Empty list passed to outer_offsets_from_list_of_codes") + + return np.cumsum([0] + [np.count_nonzero(codes == MOVETO) for codes in list_of_codes], + dtype=offset_dtype) + + +def outer_offsets_from_list_of_offsets(list_of_offsets: list[cpy.OffsetArray]) -> cpy.OffsetArray: + """Determine outer offsets from a list of offsets. + """ + if not list_of_offsets: + raise ValueError("Empty list passed to outer_offsets_from_list_of_offsets") + + return np.cumsum([0] + [len(offsets)-1 for offsets in list_of_offsets], dtype=offset_dtype) + + +def remove_nan(points: cpy.PointArray) -> tuple[cpy.PointArray, cpy.OffsetArray]: + """Remove NaN from a points array, also return the offsets corresponding to the NaN removed. + """ + check_point_array(points) + + nan_offsets = np.nonzero(np.isnan(points[:, 0]))[0] + if len(nan_offsets) == 0: + return points, np.array([0, len(points)], dtype=offset_dtype) + else: + points = np.delete(points, nan_offsets, axis=0) + nan_offsets -= np.arange(len(nan_offsets)) + offsets: cpy.OffsetArray = np.empty(len(nan_offsets)+2, dtype=offset_dtype) + offsets[0] = 0 + offsets[1:-1] = nan_offsets + offsets[-1] = len(points) + return points, offsets + + +def split_codes_by_offsets(codes: cpy.CodeArray, offsets: cpy.OffsetArray) -> list[cpy.CodeArray]: + """Split a code array at locations specified by an offset array into a list of code arrays. + """ + check_code_array(codes) + check_offset_array(offsets) + + if len(offsets) > 2: + return np.split(codes, offsets[1:-1]) + else: + return [codes] + + +def split_points_by_offsets( + points: cpy.PointArray, + offsets: cpy.OffsetArray, +) -> list[cpy.PointArray]: + """Split a point array at locations specified by an offset array into a list of point arrays. + """ + check_point_array(points) + check_offset_array(offsets) + + if len(offsets) > 2: + return np.split(points, offsets[1:-1]) + else: + return [points] + + +def split_points_at_nan(points: cpy.PointArray) -> list[cpy.PointArray]: + """Split a points array at NaNs into a list of point arrays. + """ + check_point_array(points) + + nan_offsets = np.nonzero(np.isnan(points[:, 0]))[0] + if len(nan_offsets) == 0: + return [points] + else: + nan_offsets = np.concatenate(([-1], nan_offsets, [len(points)])) # type: ignore[arg-type] + return [points[s+1:e] for s, e in zip(nan_offsets[:-1], nan_offsets[1:])] diff --git a/contrib/python/contourpy/contourpy/convert.py b/contrib/python/contourpy/contourpy/convert.py new file mode 100644 index 0000000000..75a54b0863 --- /dev/null +++ b/contrib/python/contourpy/contourpy/convert.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import numpy as np + +from contourpy._contourpy import FillType, LineType +import contourpy.array as arr +from contourpy.enum_util import as_fill_type, as_line_type +from contourpy.typecheck import check_filled, check_lines +from contourpy.types import MOVETO, offset_dtype + +if TYPE_CHECKING: + import contourpy._contourpy as cpy + + +def _convert_filled_from_OuterCode( + filled: cpy.FillReturn_OuterCode, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.OuterCode: + return filled + elif fill_type_to == FillType.OuterOffset: + return (filled[0], [arr.offsets_from_codes(codes) for codes in filled[1]]) + + if len(filled[0]) > 0: + points = arr.concat_points(filled[0]) + codes = arr.concat_codes(filled[1]) + else: + points = None + codes = None + + if fill_type_to == FillType.ChunkCombinedCode: + return ([points], [codes]) + elif fill_type_to == FillType.ChunkCombinedOffset: + return ([points], [None if codes is None else arr.offsets_from_codes(codes)]) + elif fill_type_to == FillType.ChunkCombinedCodeOffset: + outer_offsets = None if points is None else arr.offsets_from_lengths(filled[0]) + ret1: cpy.FillReturn_ChunkCombinedCodeOffset = ([points], [codes], [outer_offsets]) + return ret1 + elif fill_type_to == FillType.ChunkCombinedOffsetOffset: + if codes is None: + ret2: cpy.FillReturn_ChunkCombinedOffsetOffset = ([None], [None], [None]) + else: + offsets = arr.offsets_from_codes(codes) + outer_offsets = arr.outer_offsets_from_list_of_codes(filled[1]) + ret2 = ([points], [offsets], [outer_offsets]) + return ret2 + else: + raise ValueError(f"Invalid FillType {fill_type_to}") + + +def _convert_filled_from_OuterOffset( + filled: cpy.FillReturn_OuterOffset, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.OuterCode: + separate_codes = [arr.codes_from_offsets(offsets) for offsets in filled[1]] + return (filled[0], separate_codes) + elif fill_type_to == FillType.OuterOffset: + return filled + + if len(filled[0]) > 0: + points = arr.concat_points(filled[0]) + offsets = arr.concat_offsets(filled[1]) + else: + points = None + offsets = None + + if fill_type_to == FillType.ChunkCombinedCode: + return ([points], [None if offsets is None else arr.codes_from_offsets(offsets)]) + elif fill_type_to == FillType.ChunkCombinedOffset: + return ([points], [offsets]) + elif fill_type_to == FillType.ChunkCombinedCodeOffset: + if offsets is None: + ret1: cpy.FillReturn_ChunkCombinedCodeOffset = ([None], [None], [None]) + else: + codes = arr.codes_from_offsets(offsets) + outer_offsets = arr.offsets_from_lengths(filled[0]) + ret1 = ([points], [codes], [outer_offsets]) + return ret1 + elif fill_type_to == FillType.ChunkCombinedOffsetOffset: + if points is None: + ret2: cpy.FillReturn_ChunkCombinedOffsetOffset = ([None], [None], [None]) + else: + outer_offsets = arr.outer_offsets_from_list_of_offsets(filled[1]) + ret2 = ([points], [offsets], [outer_offsets]) + return ret2 + else: + raise ValueError(f"Invalid FillType {fill_type_to}") + + +def _convert_filled_from_ChunkCombinedCode( + filled: cpy.FillReturn_ChunkCombinedCode, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.ChunkCombinedCode: + return filled + elif fill_type_to == FillType.ChunkCombinedOffset: + codes = [None if codes is None else arr.offsets_from_codes(codes) for codes in filled[1]] + return (filled[0], codes) + else: + raise ValueError( + f"Conversion from {FillType.ChunkCombinedCode} to {fill_type_to} not supported") + + +def _convert_filled_from_ChunkCombinedOffset( + filled: cpy.FillReturn_ChunkCombinedOffset, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.ChunkCombinedCode: + chunk_codes: list[cpy.CodeArray | None] = [] + for points, offsets in zip(*filled): + if points is None: + chunk_codes.append(None) + else: + if TYPE_CHECKING: + assert offsets is not None + chunk_codes.append(arr.codes_from_offsets_and_points(offsets, points)) + return (filled[0], chunk_codes) + elif fill_type_to == FillType.ChunkCombinedOffset: + return filled + else: + raise ValueError( + f"Conversion from {FillType.ChunkCombinedOffset} to {fill_type_to} not supported") + + +def _convert_filled_from_ChunkCombinedCodeOffset( + filled: cpy.FillReturn_ChunkCombinedCodeOffset, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.OuterCode: + separate_points = [] + separate_codes = [] + for points, codes, outer_offsets in zip(*filled): + if points is not None: + if TYPE_CHECKING: + assert codes is not None + assert outer_offsets is not None + separate_points += arr.split_points_by_offsets(points, outer_offsets) + separate_codes += arr.split_codes_by_offsets(codes, outer_offsets) + return (separate_points, separate_codes) + elif fill_type_to == FillType.OuterOffset: + separate_points = [] + separate_offsets = [] + for points, codes, outer_offsets in zip(*filled): + if points is not None: + if TYPE_CHECKING: + assert codes is not None + assert outer_offsets is not None + separate_points += arr.split_points_by_offsets(points, outer_offsets) + separate_codes = arr.split_codes_by_offsets(codes, outer_offsets) + separate_offsets += [arr.offsets_from_codes(codes) for codes in separate_codes] + return (separate_points, separate_offsets) + elif fill_type_to == FillType.ChunkCombinedCode: + ret1: cpy.FillReturn_ChunkCombinedCode = (filled[0], filled[1]) + return ret1 + elif fill_type_to == FillType.ChunkCombinedOffset: + all_offsets = [None if codes is None else arr.offsets_from_codes(codes) + for codes in filled[1]] + ret2: cpy.FillReturn_ChunkCombinedOffset = (filled[0], all_offsets) + return ret2 + elif fill_type_to == FillType.ChunkCombinedCodeOffset: + return filled + elif fill_type_to == FillType.ChunkCombinedOffsetOffset: + chunk_offsets: list[cpy.OffsetArray | None] = [] + chunk_outer_offsets: list[cpy.OffsetArray | None] = [] + for codes, outer_offsets in zip(*filled[1:]): + if codes is None: + chunk_offsets.append(None) + chunk_outer_offsets.append(None) + else: + if TYPE_CHECKING: + assert outer_offsets is not None + offsets = arr.offsets_from_codes(codes) + outer_offsets = np.array([np.nonzero(offsets == oo)[0][0] for oo in outer_offsets], + dtype=offset_dtype) + chunk_offsets.append(offsets) + chunk_outer_offsets.append(outer_offsets) + ret3: cpy.FillReturn_ChunkCombinedOffsetOffset = ( + filled[0], chunk_offsets, chunk_outer_offsets, + ) + return ret3 + else: + raise ValueError(f"Invalid FillType {fill_type_to}") + + +def _convert_filled_from_ChunkCombinedOffsetOffset( + filled: cpy.FillReturn_ChunkCombinedOffsetOffset, + fill_type_to: FillType, +) -> cpy.FillReturn: + if fill_type_to == FillType.OuterCode: + separate_points = [] + separate_codes = [] + for points, offsets, outer_offsets in zip(*filled): + if points is not None: + if TYPE_CHECKING: + assert offsets is not None + assert outer_offsets is not None + codes = arr.codes_from_offsets_and_points(offsets, points) + outer_offsets = offsets[outer_offsets] + separate_points += arr.split_points_by_offsets(points, outer_offsets) + separate_codes += arr.split_codes_by_offsets(codes, outer_offsets) + return (separate_points, separate_codes) + elif fill_type_to == FillType.OuterOffset: + separate_points = [] + separate_offsets = [] + for points, offsets, outer_offsets in zip(*filled): + if points is not None: + if TYPE_CHECKING: + assert offsets is not None + assert outer_offsets is not None + if len(outer_offsets) > 2: + separate_offsets += [offsets[s:e+1] - offsets[s] for s, e in + zip(outer_offsets[:-1], outer_offsets[1:])] + else: + separate_offsets.append(offsets) + separate_points += arr.split_points_by_offsets(points, offsets[outer_offsets]) + return (separate_points, separate_offsets) + elif fill_type_to == FillType.ChunkCombinedCode: + chunk_codes: list[cpy.CodeArray | None] = [] + for points, offsets, outer_offsets in zip(*filled): + if points is None: + chunk_codes.append(None) + else: + if TYPE_CHECKING: + assert offsets is not None + assert outer_offsets is not None + chunk_codes.append(arr.codes_from_offsets_and_points(offsets, points)) + ret1: cpy.FillReturn_ChunkCombinedCode = (filled[0], chunk_codes) + return ret1 + elif fill_type_to == FillType.ChunkCombinedOffset: + return (filled[0], filled[1]) + elif fill_type_to == FillType.ChunkCombinedCodeOffset: + chunk_codes = [] + chunk_outer_offsets: list[cpy.OffsetArray | None] = [] + for points, offsets, outer_offsets in zip(*filled): + if points is None: + chunk_codes.append(None) + chunk_outer_offsets.append(None) + else: + if TYPE_CHECKING: + assert offsets is not None + assert outer_offsets is not None + chunk_codes.append(arr.codes_from_offsets_and_points(offsets, points)) + chunk_outer_offsets.append(offsets[outer_offsets]) + ret2: cpy.FillReturn_ChunkCombinedCodeOffset = (filled[0], chunk_codes, chunk_outer_offsets) + return ret2 + elif fill_type_to == FillType.ChunkCombinedOffsetOffset: + return filled + else: + raise ValueError(f"Invalid FillType {fill_type_to}") + + +def convert_filled( + filled: cpy.FillReturn, + fill_type_from: FillType | str, + fill_type_to: FillType | str, +) -> cpy.FillReturn: + """Return the specified filled contours converted to a different :class:`~contourpy.FillType`. + + Args: + filled (sequence of arrays): Filled contour polygons to convert. + fill_type_from (FillType or str): :class:`~contourpy.FillType` to convert from as enum or + string equivalent. + fill_type_to (FillType or str): :class:`~contourpy.FillType` to convert to as enum or string + equivalent. + + Return: + Converted filled contour polygons. + + When converting non-chunked fill types (``FillType.OuterCode`` or ``FillType.OuterOffset``) to + chunked ones, all polygons are placed in the first chunk. When converting in the other + direction, all chunk information is discarded. Converting a fill type that is not aware of the + relationship between outer boundaries and contained holes (``FillType.ChunkCombinedCode`` or) + ``FillType.ChunkCombinedOffset``) to one that is will raise a ``ValueError``. + + .. versionadded:: 1.2.0 + """ + fill_type_from = as_fill_type(fill_type_from) + fill_type_to = as_fill_type(fill_type_to) + + check_filled(filled, fill_type_from) + + if fill_type_from == FillType.OuterCode: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_OuterCode, filled) + return _convert_filled_from_OuterCode(filled, fill_type_to) + elif fill_type_from == FillType.OuterOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_OuterOffset, filled) + return _convert_filled_from_OuterOffset(filled, fill_type_to) + elif fill_type_from == FillType.ChunkCombinedCode: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCode, filled) + return _convert_filled_from_ChunkCombinedCode(filled, fill_type_to) + elif fill_type_from == FillType.ChunkCombinedOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffset, filled) + return _convert_filled_from_ChunkCombinedOffset(filled, fill_type_to) + elif fill_type_from == FillType.ChunkCombinedCodeOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCodeOffset, filled) + return _convert_filled_from_ChunkCombinedCodeOffset(filled, fill_type_to) + elif fill_type_from == FillType.ChunkCombinedOffsetOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffsetOffset, filled) + return _convert_filled_from_ChunkCombinedOffsetOffset(filled, fill_type_to) + else: + raise ValueError(f"Invalid FillType {fill_type_from}") + + +def _convert_lines_from_Separate( + lines: cpy.LineReturn_Separate, + line_type_to: LineType, +) -> cpy.LineReturn: + if line_type_to == LineType.Separate: + return lines + elif line_type_to == LineType.SeparateCode: + separate_codes = [arr.codes_from_points(line) for line in lines] + return (lines, separate_codes) + elif line_type_to == LineType.ChunkCombinedCode: + if not lines: + ret1: cpy.LineReturn_ChunkCombinedCode = ([None], [None]) + else: + points = arr.concat_points(lines) + offsets = arr.offsets_from_lengths(lines) + codes = arr.codes_from_offsets_and_points(offsets, points) + ret1 = ([points], [codes]) + return ret1 + elif line_type_to == LineType.ChunkCombinedOffset: + if not lines: + ret2: cpy.LineReturn_ChunkCombinedOffset = ([None], [None]) + else: + ret2 = ([arr.concat_points(lines)], [arr.offsets_from_lengths(lines)]) + return ret2 + elif line_type_to == LineType.ChunkCombinedNan: + if not lines: + ret3: cpy.LineReturn_ChunkCombinedNan = ([None],) + else: + ret3 = ([arr.concat_points_with_nan(lines)],) + return ret3 + else: + raise ValueError(f"Invalid LineType {line_type_to}") + + +def _convert_lines_from_SeparateCode( + lines: cpy.LineReturn_SeparateCode, + line_type_to: LineType, +) -> cpy.LineReturn: + if line_type_to == LineType.Separate: + # Drop codes. + return lines[0] + elif line_type_to == LineType.SeparateCode: + return lines + elif line_type_to == LineType.ChunkCombinedCode: + if not lines[0]: + ret1: cpy.LineReturn_ChunkCombinedCode = ([None], [None]) + else: + ret1 = ([arr.concat_points(lines[0])], [arr.concat_codes(lines[1])]) + return ret1 + elif line_type_to == LineType.ChunkCombinedOffset: + if not lines[0]: + ret2: cpy.LineReturn_ChunkCombinedOffset = ([None], [None]) + else: + ret2 = ([arr.concat_points(lines[0])], [arr.offsets_from_lengths(lines[0])]) + return ret2 + elif line_type_to == LineType.ChunkCombinedNan: + if not lines[0]: + ret3: cpy.LineReturn_ChunkCombinedNan = ([None],) + else: + ret3 = ([arr.concat_points_with_nan(lines[0])],) + return ret3 + else: + raise ValueError(f"Invalid LineType {line_type_to}") + + +def _convert_lines_from_ChunkCombinedCode( + lines: cpy.LineReturn_ChunkCombinedCode, + line_type_to: LineType, +) -> cpy.LineReturn: + if line_type_to in (LineType.Separate, LineType.SeparateCode): + separate_lines = [] + for points, codes in zip(*lines): + if points is not None: + if TYPE_CHECKING: + assert codes is not None + split_at = np.nonzero(codes == MOVETO)[0] + if len(split_at) > 1: + separate_lines += np.split(points, split_at[1:]) + else: + separate_lines.append(points) + if line_type_to == LineType.Separate: + return separate_lines + else: + separate_codes = [arr.codes_from_points(line) for line in separate_lines] + return (separate_lines, separate_codes) + elif line_type_to == LineType.ChunkCombinedCode: + return lines + elif line_type_to == LineType.ChunkCombinedOffset: + chunk_offsets = [None if codes is None else arr.offsets_from_codes(codes) + for codes in lines[1]] + return (lines[0], chunk_offsets) + elif line_type_to == LineType.ChunkCombinedNan: + points_nan: list[cpy.PointArray | None] = [] + for points, codes in zip(*lines): + if points is None: + points_nan.append(None) + else: + if TYPE_CHECKING: + assert codes is not None + offsets = arr.offsets_from_codes(codes) + points_nan.append(arr.insert_nan_at_offsets(points, offsets)) + return (points_nan,) + else: + raise ValueError(f"Invalid LineType {line_type_to}") + + +def _convert_lines_from_ChunkCombinedOffset( + lines: cpy.LineReturn_ChunkCombinedOffset, + line_type_to: LineType, +) -> cpy.LineReturn: + if line_type_to in (LineType.Separate, LineType.SeparateCode): + separate_lines = [] + for points, offsets in zip(*lines): + if points is not None: + if TYPE_CHECKING: + assert offsets is not None + separate_lines += arr.split_points_by_offsets(points, offsets) + if line_type_to == LineType.Separate: + return separate_lines + else: + separate_codes = [arr.codes_from_points(line) for line in separate_lines] + return (separate_lines, separate_codes) + elif line_type_to == LineType.ChunkCombinedCode: + chunk_codes: list[cpy.CodeArray | None] = [] + for points, offsets in zip(*lines): + if points is None: + chunk_codes.append(None) + else: + if TYPE_CHECKING: + assert offsets is not None + chunk_codes.append(arr.codes_from_offsets_and_points(offsets, points)) + return (lines[0], chunk_codes) + elif line_type_to == LineType.ChunkCombinedOffset: + return lines + elif line_type_to == LineType.ChunkCombinedNan: + points_nan: list[cpy.PointArray | None] = [] + for points, offsets in zip(*lines): + if points is None: + points_nan.append(None) + else: + if TYPE_CHECKING: + assert offsets is not None + points_nan.append(arr.insert_nan_at_offsets(points, offsets)) + return (points_nan,) + else: + raise ValueError(f"Invalid LineType {line_type_to}") + + +def _convert_lines_from_ChunkCombinedNan( + lines: cpy.LineReturn_ChunkCombinedNan, + line_type_to: LineType, +) -> cpy.LineReturn: + if line_type_to in (LineType.Separate, LineType.SeparateCode): + separate_lines = [] + for points in lines[0]: + if points is not None: + separate_lines += arr.split_points_at_nan(points) + if line_type_to == LineType.Separate: + return separate_lines + else: + separate_codes = [arr.codes_from_points(points) for points in separate_lines] + return (separate_lines, separate_codes) + elif line_type_to == LineType.ChunkCombinedCode: + chunk_points: list[cpy.PointArray | None] = [] + chunk_codes: list[cpy.CodeArray | None] = [] + for points in lines[0]: + if points is None: + chunk_points.append(None) + chunk_codes.append(None) + else: + points, offsets = arr.remove_nan(points) + chunk_points.append(points) + chunk_codes.append(arr.codes_from_offsets_and_points(offsets, points)) + return (chunk_points, chunk_codes) + elif line_type_to == LineType.ChunkCombinedOffset: + chunk_points = [] + chunk_offsets: list[cpy.OffsetArray | None] = [] + for points in lines[0]: + if points is None: + chunk_points.append(None) + chunk_offsets.append(None) + else: + points, offsets = arr.remove_nan(points) + chunk_points.append(points) + chunk_offsets.append(offsets) + return (chunk_points, chunk_offsets) + elif line_type_to == LineType.ChunkCombinedNan: + return lines + else: + raise ValueError(f"Invalid LineType {line_type_to}") + + +def convert_lines( + lines: cpy.LineReturn, + line_type_from: LineType | str, + line_type_to: LineType | str, +) -> cpy.LineReturn: + """Return the specified contour lines converted to a different :class:`~contourpy.LineType`. + + Args: + lines (sequence of arrays): Contour lines to convert. + line_type_from (LineType or str): :class:`~contourpy.LineType` to convert from as enum or + string equivalent. + line_type_to (LineType or str): :class:`~contourpy.LineType` to convert to as enum or string + equivalent. + + Return: + Converted contour lines. + + When converting non-chunked line types (``LineType.Separate`` or ``LineType.SeparateCode``) to + chunked ones (``LineType.ChunkCombinedCode``, ``LineType.ChunkCombinedOffset`` or + ``LineType.ChunkCombinedNan``), all lines are placed in the first chunk. When converting in the + other direction, all chunk information is discarded. + + .. versionadded:: 1.2.0 + """ + line_type_from = as_line_type(line_type_from) + line_type_to = as_line_type(line_type_to) + + check_lines(lines, line_type_from) + + if line_type_from == LineType.Separate: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_Separate, lines) + return _convert_lines_from_Separate(lines, line_type_to) + elif line_type_from == LineType.SeparateCode: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_SeparateCode, lines) + return _convert_lines_from_SeparateCode(lines, line_type_to) + elif line_type_from == LineType.ChunkCombinedCode: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedCode, lines) + return _convert_lines_from_ChunkCombinedCode(lines, line_type_to) + elif line_type_from == LineType.ChunkCombinedOffset: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedOffset, lines) + return _convert_lines_from_ChunkCombinedOffset(lines, line_type_to) + elif line_type_from == LineType.ChunkCombinedNan: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedNan, lines) + return _convert_lines_from_ChunkCombinedNan(lines, line_type_to) + else: + raise ValueError(f"Invalid LineType {line_type_from}") diff --git a/contrib/python/contourpy/contourpy/dechunk.py b/contrib/python/contourpy/contourpy/dechunk.py new file mode 100644 index 0000000000..92b61bba29 --- /dev/null +++ b/contrib/python/contourpy/contourpy/dechunk.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from contourpy._contourpy import FillType, LineType +from contourpy.array import ( + concat_codes_or_none, concat_offsets_or_none, concat_points_or_none, + concat_points_or_none_with_nan, +) +from contourpy.enum_util import as_fill_type, as_line_type +from contourpy.typecheck import check_filled, check_lines + +if TYPE_CHECKING: + import contourpy._contourpy as cpy + + +def dechunk_filled(filled: cpy.FillReturn, fill_type: FillType | str) -> cpy.FillReturn: + """Return the specified filled contours with all chunked data moved into the first chunk. + + Filled contours that are not chunked (``FillType.OuterCode`` and ``FillType.OuterOffset``) and + those that are but only contain a single chunk are returned unmodified. Individual polygons are + unchanged, they are not geometrically combined. + + Args: + filled (sequence of arrays): Filled contour data as returned by + :func:`~contourpy.ContourGenerator.filled`. + fill_type (FillType or str): Type of ``filled`` as enum or string equivalent. + + Return: + Filled contours in a single chunk. + + .. versionadded:: 1.2.0 + """ + fill_type = as_fill_type(fill_type) + + if fill_type in (FillType.OuterCode, FillType.OuterOffset): + # No-op if fill_type is not chunked. + return filled + + check_filled(filled, fill_type) + if len(filled[0]) < 2: + # No-op if just one chunk. + return filled + + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_Chunk, filled) + points = concat_points_or_none(filled[0]) + + if fill_type == FillType.ChunkCombinedCode: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCode, filled) + if points is None: + ret1: cpy.FillReturn_ChunkCombinedCode = ([None], [None]) + else: + ret1 = ([points], [concat_codes_or_none(filled[1])]) + return ret1 + elif fill_type == FillType.ChunkCombinedOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffset, filled) + if points is None: + ret2: cpy.FillReturn_ChunkCombinedOffset = ([None], [None]) + else: + ret2 = ([points], [concat_offsets_or_none(filled[1])]) + return ret2 + elif fill_type == FillType.ChunkCombinedCodeOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCodeOffset, filled) + if points is None: + ret3: cpy.FillReturn_ChunkCombinedCodeOffset = ([None], [None], [None]) + else: + outer_offsets = concat_offsets_or_none(filled[2]) + ret3 = ([points], [concat_codes_or_none(filled[1])], [outer_offsets]) + return ret3 + elif fill_type == FillType.ChunkCombinedOffsetOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffsetOffset, filled) + if points is None: + ret4: cpy.FillReturn_ChunkCombinedOffsetOffset = ([None], [None], [None]) + else: + outer_offsets = concat_offsets_or_none(filled[2]) + ret4 = ([points], [concat_offsets_or_none(filled[1])], [outer_offsets]) + return ret4 + else: + raise ValueError(f"Invalid FillType {fill_type}") + + +def dechunk_lines(lines: cpy.LineReturn, line_type: LineType | str) -> cpy.LineReturn: + """Return the specified contour lines with all chunked data moved into the first chunk. + + Contour lines that are not chunked (``LineType.Separate`` and ``LineType.SeparateCode``) and + those that are but only contain a single chunk are returned unmodified. Individual lines are + unchanged, they are not geometrically combined. + + Args: + lines (sequence of arrays): Contour line data as returned by + :func:`~contourpy.ContourGenerator.lines`. + line_type (LineType or str): Type of ``lines`` as enum or string equivalent. + + Return: + Contour lines in a single chunk. + + .. versionadded:: 1.2.0 + """ + line_type = as_line_type(line_type) + if line_type in (LineType.Separate, LineType.SeparateCode): + # No-op if line_type is not chunked. + return lines + + check_lines(lines, line_type) + if len(lines[0]) < 2: + # No-op if just one chunk. + return lines + + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_Chunk, lines) + + if line_type == LineType.ChunkCombinedCode: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedCode, lines) + points = concat_points_or_none(lines[0]) + if points is None: + ret1: cpy.LineReturn_ChunkCombinedCode = ([None], [None]) + else: + ret1 = ([points], [concat_codes_or_none(lines[1])]) + return ret1 + elif line_type == LineType.ChunkCombinedOffset: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedOffset, lines) + points = concat_points_or_none(lines[0]) + if points is None: + ret2: cpy.LineReturn_ChunkCombinedOffset = ([None], [None]) + else: + ret2 = ([points], [concat_offsets_or_none(lines[1])]) + return ret2 + elif line_type == LineType.ChunkCombinedNan: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedNan, lines) + points = concat_points_or_none_with_nan(lines[0]) + ret3: cpy.LineReturn_ChunkCombinedNan = ([points],) + return ret3 + else: + raise ValueError(f"Invalid LineType {line_type}") diff --git a/contrib/python/contourpy/contourpy/enum_util.py b/contrib/python/contourpy/contourpy/enum_util.py index 914d5d8318..14229abebf 100644 --- a/contrib/python/contourpy/contourpy/enum_util.py +++ b/contrib/python/contourpy/contourpy/enum_util.py @@ -13,7 +13,10 @@ def as_fill_type(fill_type: FillType | str) -> FillType: FillType: Converted value. """ if isinstance(fill_type, str): - return FillType.__members__[fill_type] + try: + return FillType.__members__[fill_type] + except KeyError as e: + raise ValueError(f"'{fill_type}' is not a valid FillType") from e else: return fill_type @@ -28,7 +31,10 @@ def as_line_type(line_type: LineType | str) -> LineType: LineType: Converted value. """ if isinstance(line_type, str): - return LineType.__members__[line_type] + try: + return LineType.__members__[line_type] + except KeyError as e: + raise ValueError(f"'{line_type}' is not a valid LineType") from e else: return line_type @@ -43,6 +49,9 @@ def as_z_interp(z_interp: ZInterp | str) -> ZInterp: ZInterp: Converted value. """ if isinstance(z_interp, str): - return ZInterp.__members__[z_interp] + try: + return ZInterp.__members__[z_interp] + except KeyError as e: + raise ValueError(f"'{z_interp}' is not a valid ZInterp") from e else: return z_interp diff --git a/contrib/python/contourpy/contourpy/typecheck.py b/contrib/python/contourpy/contourpy/typecheck.py new file mode 100644 index 0000000000..06a18f6cb0 --- /dev/null +++ b/contrib/python/contourpy/contourpy/typecheck.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +import numpy as np + +from contourpy import FillType, LineType +from contourpy.enum_util import as_fill_type, as_line_type +from contourpy.types import MOVETO, code_dtype, offset_dtype, point_dtype + +if TYPE_CHECKING: + import contourpy._contourpy as cpy + + +# Minimalist array-checking functions that check dtype, ndims and shape only. +# They do not walk the arrays to check the contents for performance reasons. +def check_code_array(codes: Any) -> None: + if not isinstance(codes, np.ndarray): + raise TypeError(f"Expected numpy array not {type(codes)}") + if codes.dtype != code_dtype: + raise ValueError(f"Expected numpy array of dtype {code_dtype} not {codes.dtype}") + if not (codes.ndim == 1 and len(codes) > 1): + raise ValueError(f"Expected numpy array of shape (?,) not {codes.shape}") + if codes[0] != MOVETO: + raise ValueError(f"First element of code array must be {MOVETO}, not {codes[0]}") + + +def check_offset_array(offsets: Any) -> None: + if not isinstance(offsets, np.ndarray): + raise TypeError(f"Expected numpy array not {type(offsets)}") + if offsets.dtype != offset_dtype: + raise ValueError(f"Expected numpy array of dtype {offset_dtype} not {offsets.dtype}") + if not (offsets.ndim == 1 and len(offsets) > 1): + raise ValueError(f"Expected numpy array of shape (?,) not {offsets.shape}") + if offsets[0] != 0: + raise ValueError(f"First element of offset array must be 0, not {offsets[0]}") + + +def check_point_array(points: Any) -> None: + if not isinstance(points, np.ndarray): + raise TypeError(f"Expected numpy array not {type(points)}") + if points.dtype != point_dtype: + raise ValueError(f"Expected numpy array of dtype {point_dtype} not {points.dtype}") + if not (points.ndim == 2 and points.shape[1] ==2 and points.shape[0] > 1): + raise ValueError(f"Expected numpy array of shape (?, 2) not {points.shape}") + + +def _check_tuple_of_lists_with_same_length( + maybe_tuple: Any, + tuple_length: int, + allow_empty_lists: bool = True, +) -> None: + if not isinstance(maybe_tuple, tuple): + raise TypeError(f"Expected tuple not {type(maybe_tuple)}") + if len(maybe_tuple) != tuple_length: + raise ValueError(f"Expected tuple of length {tuple_length} not {len(maybe_tuple)}") + for maybe_list in maybe_tuple: + if not isinstance(maybe_list, list): + msg = f"Expected tuple to contain {tuple_length} lists but found a {type(maybe_list)}" + raise TypeError(msg) + lengths = [len(item) for item in maybe_tuple] + if len(set(lengths)) != 1: + msg = f"Expected {tuple_length} lists with same length but lengths are {lengths}" + raise ValueError(msg) + if not allow_empty_lists and lengths[0] == 0: + raise ValueError(f"Expected {tuple_length} non-empty lists") + + +def check_filled(filled: cpy.FillReturn, fill_type: FillType | str) -> None: + fill_type = as_fill_type(fill_type) + + if fill_type == FillType.OuterCode: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_OuterCode, filled) + _check_tuple_of_lists_with_same_length(filled, 2) + for i, (points, codes) in enumerate(zip(*filled)): + check_point_array(points) + check_code_array(codes) + if len(points) != len(codes): + raise ValueError(f"Points and codes have different lengths in polygon {i}") + elif fill_type == FillType.OuterOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_OuterOffset, filled) + _check_tuple_of_lists_with_same_length(filled, 2) + for i, (points, offsets) in enumerate(zip(*filled)): + check_point_array(points) + check_offset_array(offsets) + if offsets[-1] != len(points): + raise ValueError(f"Inconsistent points and offsets in polygon {i}") + elif fill_type == FillType.ChunkCombinedCode: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCode, filled) + _check_tuple_of_lists_with_same_length(filled, 2, allow_empty_lists=False) + for chunk, (points_or_none, codes_or_none) in enumerate(zip(*filled)): + if points_or_none is not None and codes_or_none is not None: + check_point_array(points_or_none) + check_code_array(codes_or_none) + if len(points_or_none) != len(codes_or_none): + raise ValueError(f"Points and codes have different lengths in chunk {chunk}") + elif not (points_or_none is None and codes_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {chunk}") + elif fill_type == FillType.ChunkCombinedOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffset, filled) + _check_tuple_of_lists_with_same_length(filled, 2, allow_empty_lists=False) + for chunk, (points_or_none, offsets_or_none) in enumerate(zip(*filled)): + if points_or_none is not None and offsets_or_none is not None: + check_point_array(points_or_none) + check_offset_array(offsets_or_none) + if offsets_or_none[-1] != len(points_or_none): + raise ValueError(f"Inconsistent points and offsets in chunk {chunk}") + elif not (points_or_none is None and offsets_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {chunk}") + elif fill_type == FillType.ChunkCombinedCodeOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedCodeOffset, filled) + _check_tuple_of_lists_with_same_length(filled, 3, allow_empty_lists=False) + for i, (points_or_none, codes_or_none, outer_offsets_or_none) in enumerate(zip(*filled)): + if (points_or_none is not None and codes_or_none is not None and + outer_offsets_or_none is not None): + check_point_array(points_or_none) + check_code_array(codes_or_none) + check_offset_array(outer_offsets_or_none) + if len(codes_or_none) != len(points_or_none): + raise ValueError(f"Points and codes have different lengths in chunk {i}") + if outer_offsets_or_none[-1] != len(codes_or_none): + raise ValueError(f"Inconsistent codes and outer_offsets in chunk {i}") + elif not (points_or_none is None and codes_or_none is None and + outer_offsets_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {i}") + elif fill_type == FillType.ChunkCombinedOffsetOffset: + if TYPE_CHECKING: + filled = cast(cpy.FillReturn_ChunkCombinedOffsetOffset, filled) + _check_tuple_of_lists_with_same_length(filled, 3, allow_empty_lists=False) + for i, (points_or_none, offsets_or_none, outer_offsets_or_none) in enumerate(zip(*filled)): + if (points_or_none is not None and offsets_or_none is not None and + outer_offsets_or_none is not None): + check_point_array(points_or_none) + check_offset_array(offsets_or_none) + check_offset_array(outer_offsets_or_none) + if offsets_or_none[-1] != len(points_or_none): + raise ValueError(f"Inconsistent points and offsets in chunk {i}") + if outer_offsets_or_none[-1] != len(offsets_or_none) - 1: + raise ValueError(f"Inconsistent offsets and outer_offsets in chunk {i}") + elif not (points_or_none is None and offsets_or_none is None and + outer_offsets_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {i}") + else: + raise ValueError(f"Invalid FillType {fill_type}") + + +def check_lines(lines: cpy.LineReturn, line_type: LineType | str) -> None: + line_type = as_line_type(line_type) + + if line_type == LineType.Separate: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_Separate, lines) + if not isinstance(lines, list): + raise TypeError(f"Expected list not {type(lines)}") + for points in lines: + check_point_array(points) + elif line_type == LineType.SeparateCode: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_SeparateCode, lines) + _check_tuple_of_lists_with_same_length(lines, 2) + for i, (points, codes) in enumerate(zip(*lines)): + check_point_array(points) + check_code_array(codes) + if len(points) != len(codes): + raise ValueError(f"Points and codes have different lengths in line {i}") + elif line_type == LineType.ChunkCombinedCode: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedCode, lines) + _check_tuple_of_lists_with_same_length(lines, 2, allow_empty_lists=False) + for chunk, (points_or_none, codes_or_none) in enumerate(zip(*lines)): + if points_or_none is not None and codes_or_none is not None: + check_point_array(points_or_none) + check_code_array(codes_or_none) + if len(points_or_none) != len(codes_or_none): + raise ValueError(f"Points and codes have different lengths in chunk {chunk}") + elif not (points_or_none is None and codes_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {chunk}") + elif line_type == LineType.ChunkCombinedOffset: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedOffset, lines) + _check_tuple_of_lists_with_same_length(lines, 2, allow_empty_lists=False) + for chunk, (points_or_none, offsets_or_none) in enumerate(zip(*lines)): + if points_or_none is not None and offsets_or_none is not None: + check_point_array(points_or_none) + check_offset_array(offsets_or_none) + if offsets_or_none[-1] != len(points_or_none): + raise ValueError(f"Inconsistent points and offsets in chunk {chunk}") + elif not (points_or_none is None and offsets_or_none is None): + raise ValueError(f"Inconsistent Nones in chunk {chunk}") + elif line_type == LineType.ChunkCombinedNan: + if TYPE_CHECKING: + lines = cast(cpy.LineReturn_ChunkCombinedNan, lines) + _check_tuple_of_lists_with_same_length(lines, 1, allow_empty_lists=False) + for chunk, points_or_none in enumerate(lines[0]): + if points_or_none is not None: + check_point_array(points_or_none) + else: + raise ValueError(f"Invalid LineType {line_type}") diff --git a/contrib/python/contourpy/contourpy/types.py b/contrib/python/contourpy/contourpy/types.py new file mode 100644 index 0000000000..e704b98eac --- /dev/null +++ b/contrib/python/contourpy/contourpy/types.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import numpy as np + +# dtypes of arrays returned by ContourPy. +point_dtype = np.float64 +code_dtype = np.uint8 +offset_dtype = np.uint32 + +# Kind codes used in Matplotlib Paths. +MOVETO = 1 +LINETO = 2 +CLOSEPOLY = 79 diff --git a/contrib/python/contourpy/contourpy/util/_build_config.py b/contrib/python/contourpy/contourpy/util/_build_config.py index 4097b0b73e..d146b0a011 100644 --- a/contrib/python/contourpy/contourpy/util/_build_config.py +++ b/contrib/python/contourpy/contourpy/util/_build_config.py @@ -9,6 +9,8 @@ def build_config() -> dict[str, str]: All dictionary keys and values are strings, for example ``False`` is returned as ``"False"``. + + .. versionadded:: 1.1.0 """ return dict( #Â Python settings diff --git a/contrib/python/contourpy/contourpy/util/bokeh_renderer.py b/contrib/python/contourpy/contourpy/util/bokeh_renderer.py index 46cea0bd5a..c467fe9f70 100644 --- a/contrib/python/contourpy/contourpy/util/bokeh_renderer.py +++ b/contrib/python/contourpy/contourpy/util/bokeh_renderer.py @@ -12,6 +12,7 @@ from bokeh.plotting import figure import numpy as np from contourpy import FillType, LineType +from contourpy.enum_util import as_fill_type, as_line_type from contourpy.util.bokeh_util import filled_to_bokeh, lines_to_bokeh from contourpy.util.renderer import Renderer @@ -25,11 +26,6 @@ if TYPE_CHECKING: class BokehRenderer(Renderer): - _figures: list[figure] - _layout: GridPlot - _palette: Palette - _want_svg: bool - """Utility renderer using Bokeh to render a grid of plots over the same (x, y) range. Args: @@ -46,6 +42,11 @@ class BokehRenderer(Renderer): :class:`~contourpy.util.mpl_renderer.MplRenderer`, needs to be told in advance if output to SVG format will be required later, otherwise it will assume PNG output. """ + _figures: list[figure] + _layout: GridPlot + _palette: Palette + _want_svg: bool + def __init__( self, nrows: int = 1, @@ -89,7 +90,7 @@ class BokehRenderer(Renderer): def filled( self, filled: FillReturn, - fill_type: FillType, + fill_type: FillType | str, ax: figure | int = 0, color: str = "C0", alpha: float = 0.7, @@ -99,14 +100,15 @@ class BokehRenderer(Renderer): Args: filled (sequence of arrays): Filled contour data as returned by :func:`~contourpy.ContourGenerator.filled`. - fill_type (FillType): Type of ``filled`` data, as returned by - :attr:`~contourpy.ContourGenerator.fill_type`. + fill_type (FillType or str): Type of ``filled`` data as returned by + :attr:`~contourpy.ContourGenerator.fill_type`, or a string equivalent. ax (int or Bokeh Figure, optional): Which plot to use, default ``0``. color (str, optional): Color to plot with. May be a string color or the letter ``"C"`` followed by an integer in the range ``"C0"`` to ``"C9"`` to use a color from the ``Category10`` palette. Default ``"C0"``. alpha (float, optional): Opacity to plot with, default ``0.7``. """ + fill_type = as_fill_type(fill_type) fig = self._get_figure(ax) color = self._convert_color(color) xs, ys = filled_to_bokeh(filled, fill_type) @@ -167,7 +169,7 @@ class BokehRenderer(Renderer): def lines( self, lines: LineReturn, - line_type: LineType, + line_type: LineType | str, ax: figure | int = 0, color: str = "C0", alpha: float = 1.0, @@ -178,8 +180,8 @@ class BokehRenderer(Renderer): Args: lines (sequence of arrays): Contour line data as returned by :func:`~contourpy.ContourGenerator.lines`. - line_type (LineType): Type of ``lines`` data, as returned by - :attr:`~contourpy.ContourGenerator.line_type`. + line_type (LineType or str): Type of ``lines`` data as returned by + :attr:`~contourpy.ContourGenerator.line_type`, or a string equivalent. ax (int or Bokeh Figure, optional): Which plot to use, default ``0``. color (str, optional): Color to plot lines. May be a string color or the letter ``"C"`` followed by an integer in the range ``"C0"`` to ``"C9"`` to use a color from the @@ -190,11 +192,12 @@ class BokehRenderer(Renderer): Note: Assumes all lines are open line strips not closed line loops. """ + line_type = as_line_type(line_type) fig = self._get_figure(ax) color = self._convert_color(color) xs, ys = lines_to_bokeh(lines, line_type) - if len(xs) > 0: - fig.multi_line(xs, ys, line_color=color, line_alpha=alpha, line_width=linewidth) + if xs is not None: + fig.line(xs, ys, line_color=color, line_alpha=alpha, line_width=linewidth) def mask( self, @@ -236,6 +239,8 @@ class BokehRenderer(Renderer): ``False``. webdriver (WebDriver, optional): Selenium WebDriver instance to use to create the image. + .. versionadded:: 1.1.1 + Warning: To output to SVG file, ``want_svg=True`` must have been passed to the constructor. """ @@ -255,6 +260,8 @@ class BokehRenderer(Renderer): Args: webdriver (WebDriver, optional): Selenium WebDriver instance to use to create the image. + .. versionadded:: 1.1.1 + Return: BytesIO: PNG image buffer. """ diff --git a/contrib/python/contourpy/contourpy/util/bokeh_util.py b/contrib/python/contourpy/contourpy/util/bokeh_util.py index e75654d7c3..80b9396ea2 100644 --- a/contrib/python/contourpy/contourpy/util/bokeh_util.py +++ b/contrib/python/contourpy/contourpy/util/bokeh_util.py @@ -3,11 +3,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast from contourpy import FillType, LineType -from contourpy.util.mpl_util import mpl_codes_to_offsets +from contourpy.array import offsets_from_codes +from contourpy.convert import convert_lines +from contourpy.dechunk import dechunk_lines if TYPE_CHECKING: from contourpy._contourpy import ( - CoordinateArray, FillReturn, LineReturn, LineReturn_Separate, LineReturn_SeparateCode, + CoordinateArray, FillReturn, LineReturn, LineReturn_ChunkCombinedNan, ) @@ -25,7 +27,7 @@ def filled_to_bokeh( if points is None: continue if have_codes: - offsets = mpl_codes_to_offsets(offsets) + offsets = offsets_from_codes(offsets) xs.append([]) # New outer with zero or more holes. ys.append([]) for i in range(len(offsets)-1): @@ -39,7 +41,7 @@ def filled_to_bokeh( for j in range(len(outer_offsets)-1): if fill_type == FillType.ChunkCombinedCodeOffset: codes = codes_or_offsets[outer_offsets[j]:outer_offsets[j+1]] - offsets = mpl_codes_to_offsets(codes) + outer_offsets[j] + offsets = offsets_from_codes(codes) + outer_offsets[j] else: offsets = codes_or_offsets[outer_offsets[j]:outer_offsets[j+1]+1] xs.append([]) # New outer with zero or more holes. @@ -57,34 +59,13 @@ def filled_to_bokeh( def lines_to_bokeh( lines: LineReturn, line_type: LineType, -) -> tuple[list[CoordinateArray], list[CoordinateArray]]: - xs: list[CoordinateArray] = [] - ys: list[CoordinateArray] = [] - - if line_type == LineType.Separate: - if TYPE_CHECKING: - lines = cast(LineReturn_Separate, lines) - for line in lines: - xs.append(line[:, 0]) - ys.append(line[:, 1]) - elif line_type == LineType.SeparateCode: - if TYPE_CHECKING: - lines = cast(LineReturn_SeparateCode, lines) - for line in lines[0]: - xs.append(line[:, 0]) - ys.append(line[:, 1]) - elif line_type in (LineType.ChunkCombinedCode, LineType.ChunkCombinedOffset): - for points, offsets in zip(*lines): - if points is None: - continue - if line_type == LineType.ChunkCombinedCode: - offsets = mpl_codes_to_offsets(offsets) - - for i in range(len(offsets)-1): - line = points[offsets[i]:offsets[i+1]] - xs.append(line[:, 0]) - ys.append(line[:, 1]) +) -> tuple[CoordinateArray | None, CoordinateArray | None]: + lines = convert_lines(lines, line_type, LineType.ChunkCombinedNan) + lines = dechunk_lines(lines, LineType.ChunkCombinedNan) + if TYPE_CHECKING: + lines = cast(LineReturn_ChunkCombinedNan, lines) + points = lines[0][0] + if points is None: + return None, None else: - raise RuntimeError(f"Conversion of LineType {line_type} to Bokeh is not implemented") - - return xs, ys + return points[:, 0], points[:, 1] diff --git a/contrib/python/contourpy/contourpy/util/data.py b/contrib/python/contourpy/contourpy/util/data.py index e6ba9a976c..5ab2617205 100644 --- a/contrib/python/contourpy/contourpy/util/data.py +++ b/contrib/python/contourpy/contourpy/util/data.py @@ -51,7 +51,7 @@ def simple( def random( shape: tuple[int, int], seed: int = 2187, mask_fraction: float = 0.0, ) -> tuple[CoordinateArray, CoordinateArray, CoordinateArray | np.ma.MaskedArray[Any, Any]]: - """Return random test data.. + """Return random test data. Args: shape (tuple(int, int)): 2D shape of data to return. diff --git a/contrib/python/contourpy/contourpy/util/mpl_renderer.py b/contrib/python/contourpy/contourpy/util/mpl_renderer.py index dbcb5ca19a..2d8997f2ca 100644 --- a/contrib/python/contourpy/contourpy/util/mpl_renderer.py +++ b/contrib/python/contourpy/contourpy/util/mpl_renderer.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence import io from typing import TYPE_CHECKING, Any, cast @@ -8,7 +9,9 @@ import matplotlib.pyplot as plt import numpy as np from contourpy import FillType, LineType -from contourpy.util.mpl_util import filled_to_mpl_paths, lines_to_mpl_paths, mpl_codes_to_offsets +from contourpy.convert import convert_filled, convert_lines +from contourpy.enum_util import as_fill_type, as_line_type +from contourpy.util.mpl_util import filled_to_mpl_paths, lines_to_mpl_paths from contourpy.util.renderer import Renderer if TYPE_CHECKING: @@ -20,10 +23,6 @@ if TYPE_CHECKING: class MplRenderer(Renderer): - _axes: Axes - _fig: Figure - _want_tight: bool - """Utility renderer using Matplotlib to render a grid of plots over the same (x, y) range. Args: @@ -36,6 +35,10 @@ class MplRenderer(Renderer): gridspec_kw (dict, optional): Gridspec keyword arguments to pass to ``plt.subplots``, default None. """ + _axes: Sequence[Axes] + _fig: Figure + _want_tight: bool + def __init__( self, nrows: int = 1, @@ -49,7 +52,7 @@ class MplRenderer(Renderer): import matplotlib matplotlib.use(backend) - kwargs = dict(figsize=figsize, squeeze=False, sharex=True, sharey=True) + kwargs: dict[str, Any] = dict(figsize=figsize, squeeze=False, sharex=True, sharey=True) if gridspec_kw is not None: kwargs["gridspec_kw"] = gridspec_kw else: @@ -74,7 +77,7 @@ class MplRenderer(Renderer): for ax in self._axes: if getattr(ax, "_need_autoscale", False): ax.autoscale_view(tight=True) - ax._need_autoscale = False + ax._need_autoscale = False # type: ignore[attr-defined] if self._want_tight and len(self._axes) > 1: self._fig.tight_layout() @@ -86,7 +89,7 @@ class MplRenderer(Renderer): def filled( self, filled: cpy.FillReturn, - fill_type: FillType, + fill_type: FillType | str, ax: Axes | int = 0, color: str = "C0", alpha: float = 0.7, @@ -96,20 +99,21 @@ class MplRenderer(Renderer): Args: filled (sequence of arrays): Filled contour data as returned by :func:`~contourpy.ContourGenerator.filled`. - fill_type (FillType): Type of ``filled`` data, as returned by - :attr:`~contourpy.ContourGenerator.fill_type`. + fill_type (FillType or str): Type of ``filled`` data as returned by + :attr:`~contourpy.ContourGenerator.fill_type`, or string equivalent ax (int or Maplotlib Axes, optional): Which axes to plot on, default ``0``. color (str, optional): Color to plot with. May be a string color or the letter ``"C"`` followed by an integer in the range ``"C0"`` to ``"C9"`` to use a color from the ``tab10`` colormap. Default ``"C0"``. alpha (float, optional): Opacity to plot with, default ``0.7``. """ + fill_type = as_fill_type(fill_type) ax = self._get_ax(ax) paths = filled_to_mpl_paths(filled, fill_type) collection = mcollections.PathCollection( paths, facecolors=color, edgecolors="none", lw=0, alpha=alpha) ax.add_collection(collection) - ax._need_autoscale = True + ax._need_autoscale = True # type: ignore[attr-defined] def grid( self, @@ -141,7 +145,7 @@ class MplRenderer(Renderer): """ ax = self._get_ax(ax) x, y = self._grid_as_2d(x, y) - kwargs = dict(color=color, alpha=alpha) + kwargs: dict[str, Any] = dict(color=color, alpha=alpha) ax.plot(x, y, x.T, y.T, **kwargs) if quad_as_tri_alpha > 0: # Assumes no quad mask. @@ -156,12 +160,12 @@ class MplRenderer(Renderer): **kwargs) if point_color is not None: ax.plot(x, y, color=point_color, alpha=alpha, marker="o", lw=0) - ax._need_autoscale = True + ax._need_autoscale = True # type: ignore[attr-defined] def lines( self, lines: cpy.LineReturn, - line_type: LineType, + line_type: LineType | str, ax: Axes | int = 0, color: str = "C0", alpha: float = 1.0, @@ -172,8 +176,8 @@ class MplRenderer(Renderer): Args: lines (sequence of arrays): Contour line data as returned by :func:`~contourpy.ContourGenerator.lines`. - line_type (LineType): Type of ``lines`` data, as returned by - :attr:`~contourpy.ContourGenerator.line_type`. + line_type (LineType or str): Type of ``lines`` data as returned by + :attr:`~contourpy.ContourGenerator.line_type`, or string equivalent. ax (int or Matplotlib Axes, optional): Which Axes to plot on, default ``0``. color (str, optional): Color to plot lines. May be a string color or the letter ``"C"`` followed by an integer in the range ``"C0"`` to ``"C9"`` to use a color from the @@ -181,12 +185,13 @@ class MplRenderer(Renderer): alpha (float, optional): Opacity to plot lines with, default ``1.0``. linewidth (float, optional): Width of lines, default ``1``. """ + line_type = as_line_type(line_type) ax = self._get_ax(ax) paths = lines_to_mpl_paths(lines, line_type) collection = mcollections.PathCollection( paths, facecolors="none", edgecolors=color, lw=linewidth, alpha=alpha) ax.add_collection(collection) - ax._need_autoscale = True + ax._need_autoscale = True # type: ignore[attr-defined] def mask( self, @@ -370,104 +375,10 @@ class MplDebugRenderer(MplRenderer): )) ax.plot(arrow[:, 0], arrow[:, 1], "-", c=color, alpha=alpha) - def _filled_to_lists_of_points_and_offsets( - self, - filled: cpy.FillReturn, - fill_type: FillType, - ) -> tuple[list[cpy.PointArray], list[cpy.OffsetArray]]: - if fill_type == FillType.OuterCode: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_OuterCode, filled) - all_points = filled[0] - all_offsets = [mpl_codes_to_offsets(codes) for codes in filled[1]] - elif fill_type == FillType.ChunkCombinedCode: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_ChunkCombinedCode, filled) - all_points = [points for points in filled[0] if points is not None] - all_offsets = [mpl_codes_to_offsets(codes) for codes in filled[1] if codes is not None] - elif fill_type == FillType.OuterOffset: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_OuterOffset, filled) - all_points = filled[0] - all_offsets = filled[1] - elif fill_type == FillType.ChunkCombinedOffset: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_ChunkCombinedOffset, filled) - all_points = [points for points in filled[0] if points is not None] - all_offsets = [offsets for offsets in filled[1] if offsets is not None] - elif fill_type == FillType.ChunkCombinedCodeOffset: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_ChunkCombinedCodeOffset, filled) - all_points = [] - all_offsets = [] - for points, codes, outer_offsets in zip(*filled): - if points is None: - continue - if TYPE_CHECKING: - assert codes is not None and outer_offsets is not None - all_points += np.split(points, outer_offsets[1:-1]) - all_codes = np.split(codes, outer_offsets[1:-1]) - all_offsets += [mpl_codes_to_offsets(codes) for codes in all_codes] - elif fill_type == FillType.ChunkCombinedOffsetOffset: - if TYPE_CHECKING: - filled = cast(cpy.FillReturn_ChunkCombinedOffsetOffset, filled) - all_points = [] - all_offsets = [] - for points, offsets, outer_offsets in zip(*filled): - if points is None: - continue - if TYPE_CHECKING: - assert offsets is not None and outer_offsets is not None - for i in range(len(outer_offsets)-1): - offs = offsets[outer_offsets[i]:outer_offsets[i+1]+1] - all_points.append(points[offs[0]:offs[-1]]) - all_offsets.append(offs - offs[0]) - else: - raise RuntimeError(f"Rendering FillType {fill_type} not implemented") - - return all_points, all_offsets - - def _lines_to_list_of_points( - self, lines: cpy.LineReturn, line_type: LineType, - ) -> list[cpy.PointArray]: - if line_type == LineType.Separate: - if TYPE_CHECKING: - lines = cast(cpy.LineReturn_Separate, lines) - all_lines = lines - elif line_type == LineType.SeparateCode: - if TYPE_CHECKING: - lines = cast(cpy.LineReturn_SeparateCode, lines) - all_lines = lines[0] - elif line_type == LineType.ChunkCombinedCode: - if TYPE_CHECKING: - lines = cast(cpy.LineReturn_ChunkCombinedCode, lines) - all_lines = [] - for points, codes in zip(*lines): - if points is not None: - if TYPE_CHECKING: - assert codes is not None - offsets = mpl_codes_to_offsets(codes) - for i in range(len(offsets)-1): - all_lines.append(points[offsets[i]:offsets[i+1]]) - elif line_type == LineType.ChunkCombinedOffset: - if TYPE_CHECKING: - lines = cast(cpy.LineReturn_ChunkCombinedOffset, lines) - all_lines = [] - for points, all_offsets in zip(*lines): - if points is not None: - if TYPE_CHECKING: - assert all_offsets is not None - for i in range(len(all_offsets)-1): - all_lines.append(points[all_offsets[i]:all_offsets[i+1]]) - else: - raise RuntimeError(f"Rendering LineType {line_type} not implemented") - - return all_lines - def filled( self, filled: cpy.FillReturn, - fill_type: FillType, + fill_type: FillType | str, ax: Axes | int = 0, color: str = "C1", alpha: float = 0.7, @@ -477,17 +388,20 @@ class MplDebugRenderer(MplRenderer): start_point_color: str = "red", arrow_size: float = 0.1, ) -> None: + fill_type = as_fill_type(fill_type) super().filled(filled, fill_type, ax, color, alpha) if line_color is None and point_color is None: return ax = self._get_ax(ax) - all_points, all_offsets = self._filled_to_lists_of_points_and_offsets(filled, fill_type) + filled = convert_filled(filled, fill_type, FillType.ChunkCombinedOffset) # Lines. if line_color is not None: - for points, offsets in zip(all_points, all_offsets): + for points, offsets in zip(*filled): + if points is None: + continue for start, end in zip(offsets[:-1], offsets[1:]): xys = points[start:end] ax.plot(xys[:, 0], xys[:, 1], c=line_color, alpha=line_alpha) @@ -499,7 +413,9 @@ class MplDebugRenderer(MplRenderer): # Points. if point_color is not None: - for points, offsets in zip(all_points, all_offsets): + for points, offsets in zip(*filled): + if points is None: + continue mask = np.ones(offsets[-1], dtype=bool) mask[offsets[1:]-1] = False # Exclude end points. if start_point_color is not None: @@ -515,7 +431,7 @@ class MplDebugRenderer(MplRenderer): def lines( self, lines: cpy.LineReturn, - line_type: LineType, + line_type: LineType | str, ax: Axes | int = 0, color: str = "C0", alpha: float = 1.0, @@ -524,21 +440,24 @@ class MplDebugRenderer(MplRenderer): start_point_color: str = "red", arrow_size: float = 0.1, ) -> None: + line_type = as_line_type(line_type) super().lines(lines, line_type, ax, color, alpha, linewidth) if arrow_size == 0.0 and point_color is None: return ax = self._get_ax(ax) - all_lines = self._lines_to_list_of_points(lines, line_type) + separate_lines = convert_lines(lines, line_type, LineType.Separate) + if TYPE_CHECKING: + separate_lines = cast(cpy.LineReturn_Separate, separate_lines) if arrow_size > 0.0: - for line in all_lines: + for line in separate_lines: for i in range(len(line)-1): self._arrow(ax, line[i], line[i+1], color, alpha, arrow_size) if point_color is not None: - for line in all_lines: + for line in separate_lines: start_index = 0 end_index = len(line) if start_point_color is not None: @@ -609,5 +528,5 @@ class MplDebugRenderer(MplRenderer): z_level = 1 else: z_level = 0 - ax.text(x[j, i], y[j, i], z_level, ha="left", va="bottom", color=color, + ax.text(x[j, i], y[j, i], str(z_level), ha="left", va="bottom", color=color, clip_on=True) diff --git a/contrib/python/contourpy/contourpy/util/mpl_util.py b/contrib/python/contourpy/contourpy/util/mpl_util.py index 0c970886fa..10a2ccdb23 100644 --- a/contrib/python/contourpy/contourpy/util/mpl_util.py +++ b/contrib/python/contourpy/contourpy/util/mpl_util.py @@ -6,18 +6,17 @@ import matplotlib.path as mpath import numpy as np from contourpy import FillType, LineType +from contourpy.array import codes_from_offsets if TYPE_CHECKING: - from contourpy._contourpy import ( - CodeArray, FillReturn, LineReturn, LineReturn_Separate, OffsetArray, - ) + from contourpy._contourpy import FillReturn, LineReturn, LineReturn_Separate def filled_to_mpl_paths(filled: FillReturn, fill_type: FillType) -> list[mpath.Path]: if fill_type in (FillType.OuterCode, FillType.ChunkCombinedCode): paths = [mpath.Path(points, codes) for points, codes in zip(*filled) if points is not None] elif fill_type in (FillType.OuterOffset, FillType.ChunkCombinedOffset): - paths = [mpath.Path(points, offsets_to_mpl_codes(offsets)) + paths = [mpath.Path(points, codes_from_offsets(offsets)) for points, offsets in zip(*filled) if points is not None] elif fill_type == FillType.ChunkCombinedCodeOffset: paths = [] @@ -35,7 +34,7 @@ def filled_to_mpl_paths(filled: FillReturn, fill_type: FillType) -> list[mpath.P for i in range(len(outer_offsets)-1): offs = offsets[outer_offsets[i]:outer_offsets[i+1]+1] pts = points[offs[0]:offs[-1]] - paths += [mpath.Path(pts, offsets_to_mpl_codes(offs - offs[0]))] + paths += [mpath.Path(pts, codes_from_offsets(offs - offs[0]))] else: raise RuntimeError(f"Conversion of FillType {fill_type} to MPL Paths is not implemented") return paths @@ -61,19 +60,17 @@ def lines_to_mpl_paths(lines: LineReturn, line_type: LineType) -> list[mpath.Pat line = points[offsets[i]:offsets[i+1]] closed = line[0, 0] == line[-1, 0] and line[0, 1] == line[-1, 1] paths.append(mpath.Path(line, closed=closed)) + elif line_type == LineType.ChunkCombinedNan: + paths = [] + for points in lines[0]: + if points is None: + continue + nan_offsets = np.nonzero(np.isnan(points[:, 0]))[0] + nan_offsets = np.concatenate([[-1], nan_offsets, [len(points)]]) + for s, e in zip(nan_offsets[:-1], nan_offsets[1:]): + line = points[s+1:e] + closed = line[0, 0] == line[-1, 0] and line[0, 1] == line[-1, 1] + paths.append(mpath.Path(line, closed=closed)) else: raise RuntimeError(f"Conversion of LineType {line_type} to MPL Paths is not implemented") return paths - - -def mpl_codes_to_offsets(codes: CodeArray) -> OffsetArray: - offsets = np.nonzero(codes == 1)[0].astype(np.uint32) - offsets = np.append(offsets, len(codes)) - return offsets - - -def offsets_to_mpl_codes(offsets: OffsetArray) -> CodeArray: - codes = np.full(offsets[-1]-offsets[0], 2, dtype=np.uint8) # LINETO = 2 - codes[offsets[:-1]] = 1 # MOVETO = 1 - codes[offsets[1:]-1] = 79 # CLOSEPOLY 79 - return codes diff --git a/contrib/python/contourpy/contourpy/util/renderer.py b/contrib/python/contourpy/contourpy/util/renderer.py index ef1d065ee1..f4bdfdff9e 100644 --- a/contrib/python/contourpy/contourpy/util/renderer.py +++ b/contrib/python/contourpy/contourpy/util/renderer.py @@ -33,7 +33,7 @@ class Renderer(ABC): def filled( self, filled: FillReturn, - fill_type: FillType, + fill_type: FillType | str, ax: Any = 0, color: str = "C0", alpha: float = 0.7, @@ -57,7 +57,7 @@ class Renderer(ABC): def lines( self, lines: LineReturn, - line_type: LineType, + line_type: LineType | str, ax: Any = 0, color: str = "C0", alpha: float = 1.0, diff --git a/contrib/python/contourpy/src/base.h b/contrib/python/contourpy/src/base.h index 7faf9335ec..e626e99431 100644 --- a/contrib/python/contourpy/src/base.h +++ b/contrib/python/contourpy/src/base.h @@ -202,6 +202,7 @@ private: bool _direct_line_offsets; // Whether line offsets array is written direct to Python. bool _direct_outer_offsets; // Whether outer offsets array is written direct to Python. bool _outer_offsets_into_points; // Otherwise into line offsets. Only used if _identify_holes. + bool _nan_separated; // Whether adjacent lines' points are separated by nans. unsigned int _return_list_count; }; diff --git a/contrib/python/contourpy/src/base_impl.h b/contrib/python/contourpy/src/base_impl.h index 61b9c46132..6175ad5c56 100644 --- a/contrib/python/contourpy/src/base_impl.h +++ b/contrib/python/contourpy/src/base_impl.h @@ -3,6 +3,7 @@ #include "base.h" #include "converter.h" +#include "util.h" #include <iostream> namespace contourpy { @@ -121,6 +122,7 @@ BaseContourGenerator<Derived>::BaseContourGenerator( _direct_line_offsets(false), _direct_outer_offsets(false), _outer_offsets_into_points(false), + _nan_separated(false), _return_list_count(0) { if (_x.ndim() != 2 || _y.ndim() != 2 || _z.ndim() != 2) @@ -351,8 +353,8 @@ LineType BaseContourGenerator<Derived>::default_line_type() template <typename Derived> py::sequence BaseContourGenerator<Derived>::filled(double lower_level, double upper_level) { - if (lower_level > upper_level) - throw std::invalid_argument("upper and lower levels are the wrong way round"); + if (lower_level >= upper_level) + throw std::invalid_argument("upper_level must be larger than lower_level"); _filled = true; _lower_level = lower_level; @@ -367,6 +369,7 @@ py::sequence BaseContourGenerator<Derived>::filled(double lower_level, double up _direct_outer_offsets = (_fill_type == FillType::ChunkCombinedCodeOffset || _fill_type == FillType::ChunkCombinedOffsetOffset); _outer_offsets_into_points = (_fill_type == FillType::ChunkCombinedCodeOffset); + _nan_separated = false; _return_list_count = (_fill_type == FillType::ChunkCombinedCodeOffset || _fill_type == FillType::ChunkCombinedOffsetOffset) ? 3 : 2; @@ -1911,6 +1914,12 @@ void BaseContourGenerator<Derived>::line(const Location& start_location, ChunkLo Location location = start_location; count_t point_count = 0; + // Insert nan if required before start of new line. + if (_nan_separated and local.pass > 0 && local.line_count > 0) { + *local.points.current++ = Util::nan; + *local.points.current++ = Util::nan; + } + // finished == true indicates closed line loop. bool finished = follow_interior(location, start_location, local, point_count); @@ -1942,7 +1951,12 @@ py::sequence BaseContourGenerator<Derived>::lines(double level) _direct_line_offsets = (_line_type == LineType::ChunkCombinedOffset); _direct_outer_offsets = false; _outer_offsets_into_points = false; - _return_list_count = (_line_type == LineType::Separate) ? 1 : 2; + _return_list_count = (_line_type == LineType::Separate || + _line_type == LineType::ChunkCombinedNan) ? 1 : 2; + _nan_separated = (_line_type == LineType::ChunkCombinedNan); + + if (_nan_separated) + Util::ensure_nan_loaded(); return march_wrapper(); } @@ -2073,6 +2087,14 @@ void BaseContourGenerator<Derived>::march_chunk( if (j_final_start < local.jend) _cache[local.istart + (j_final_start+1)*_nx] |= MASK_NO_MORE_STARTS; + if (_nan_separated && local.line_count > 1) { + // If _nan_separated, each line after the first has an extra nan to separate it from the + // previous line's points. If we were returning line offsets to the caller then this + // would need to occur in line() where the line_count is incremented. But as we are not + // returning line offsets it is faster just to add the extra here all at once. + local.total_point_count += local.line_count - 1; + } + if (local.pass == 0) { if (local.total_point_count == 0) { local.points.clear(); @@ -2169,8 +2191,13 @@ py::sequence BaseContourGenerator<Derived>::march_wrapper() // Return to python objects. if (_return_list_count == 1) { - assert(!_filled && _line_type == LineType::Separate); - return return_lists[0]; + assert(!_filled); + if (_line_type == LineType::Separate) + return return_lists[0]; + else { + assert(_line_type == LineType::ChunkCombinedNan); + return py::make_tuple(return_lists[0]); + } } else if (_return_list_count == 2) return py::make_tuple(return_lists[0], return_lists[1]); @@ -2384,6 +2411,7 @@ bool BaseContourGenerator<Derived>::supports_line_type(LineType line_type) case LineType::SeparateCode: case LineType::ChunkCombinedCode: case LineType::ChunkCombinedOffset: + case LineType::ChunkCombinedNan: return true; default: return false; diff --git a/contrib/python/contourpy/src/chunk_local.h b/contrib/python/contourpy/src/chunk_local.h index f9b68ea6be..0298ea4814 100644 --- a/contrib/python/contourpy/src/chunk_local.h +++ b/contrib/python/contourpy/src/chunk_local.h @@ -21,7 +21,7 @@ struct ChunkLocal int pass; // Data for whole pass. - count_t total_point_count; + count_t total_point_count; // Includes nan separators if used. count_t line_count; // Count of all lines count_t hole_count; // Count of holes only. diff --git a/contrib/python/contourpy/src/fill_type.h b/contrib/python/contourpy/src/fill_type.h index f77c765d28..20d41aeb3b 100644 --- a/contrib/python/contourpy/src/fill_type.h +++ b/contrib/python/contourpy/src/fill_type.h @@ -9,10 +9,10 @@ namespace contourpy { // C++11 scoped enum, must be fully qualified to use. enum class FillType { - OuterCode= 201, + OuterCode = 201, OuterOffset = 202, ChunkCombinedCode = 203, - ChunkCombinedOffset= 204, + ChunkCombinedOffset = 204, ChunkCombinedCodeOffset = 205, ChunkCombinedOffsetOffset = 206, }; diff --git a/contrib/python/contourpy/src/line_type.cpp b/contrib/python/contourpy/src/line_type.cpp index 5ef2d68dac..fbd97ea78c 100644 --- a/contrib/python/contourpy/src/line_type.cpp +++ b/contrib/python/contourpy/src/line_type.cpp @@ -18,6 +18,9 @@ std::ostream &operator<<(std::ostream &os, const LineType& line_type) case LineType::ChunkCombinedOffset: os << "ChunkCombinedOffset"; break; + case LineType::ChunkCombinedNan: + os << "ChunkCombinedNan"; + break; } return os; } diff --git a/contrib/python/contourpy/src/line_type.h b/contrib/python/contourpy/src/line_type.h index afc6edf0c3..c1e5dda702 100644 --- a/contrib/python/contourpy/src/line_type.h +++ b/contrib/python/contourpy/src/line_type.h @@ -12,7 +12,8 @@ enum class LineType Separate = 101, SeparateCode = 102, ChunkCombinedCode = 103, - ChunkCombinedOffset= 104, + ChunkCombinedOffset = 104, + ChunkCombinedNan = 105, }; std::ostream &operator<<(std::ostream &os, const LineType& line_type); diff --git a/contrib/python/contourpy/src/mpl2005.cpp b/contrib/python/contourpy/src/mpl2005.cpp index dfa5c54619..a62d6e86ce 100644 --- a/contrib/python/contourpy/src/mpl2005.cpp +++ b/contrib/python/contourpy/src/mpl2005.cpp @@ -48,8 +48,8 @@ Mpl2005ContourGenerator::~Mpl2005ContourGenerator() py::tuple Mpl2005ContourGenerator::filled(const double& lower_level, const double& upper_level) { - if (lower_level > upper_level) - throw std::invalid_argument("upper and lower levels are the wrong way round"); + if (lower_level >= upper_level) + throw std::invalid_argument("upper_level must be larger than lower_level"); double levels[2] = {lower_level, upper_level}; return cntr_trace(_site, levels, 2); diff --git a/contrib/python/contourpy/src/mpl2014.cpp b/contrib/python/contourpy/src/mpl2014.cpp index 99107ad9ea..d5012de6fc 100644 --- a/contrib/python/contourpy/src/mpl2014.cpp +++ b/contrib/python/contourpy/src/mpl2014.cpp @@ -485,8 +485,8 @@ void Mpl2014ContourGenerator::edge_interp( py::tuple Mpl2014ContourGenerator::filled( const double& lower_level, const double& upper_level) { - if (lower_level > upper_level) - throw std::invalid_argument("upper and lower levels are the wrong way round"); + if (lower_level >= upper_level) + throw std::invalid_argument("upper_level must be larger than lower_level"); init_cache_levels(lower_level, upper_level); diff --git a/contrib/python/contourpy/src/serial.cpp b/contrib/python/contourpy/src/serial.cpp index 08d851ca34..dd69471ef6 100644 --- a/contrib/python/contourpy/src/serial.cpp +++ b/contrib/python/contourpy/src/serial.cpp @@ -115,6 +115,10 @@ void SerialContourGenerator::export_lines( // return_lists[0][local.chunk] already contains points. // return_lists[1][local.chunk] already contains line offsets. break; + case LineType::ChunkCombinedNan: + assert(has_direct_points()); + // return_lists[0][local.chunk] already contains points. + break; } } diff --git a/contrib/python/contourpy/src/threaded.cpp b/contrib/python/contourpy/src/threaded.cpp index e27cd55d51..9f6eec1bed 100644 --- a/contrib/python/contourpy/src/threaded.cpp +++ b/contrib/python/contourpy/src/threaded.cpp @@ -208,6 +208,10 @@ void ThreadedContourGenerator::export_lines( // return_lists[0][local.chunk] already contains points. // return_lists[1][local.chunk] already contains line offsets. break; + case LineType::ChunkCombinedNan: + assert(has_direct_points()); + // return_lists[0][local.chunk] already contains points. + break; } } diff --git a/contrib/python/contourpy/src/util.cpp b/contrib/python/contourpy/src/util.cpp index d41f0edef9..59b6e4da55 100644 --- a/contrib/python/contourpy/src/util.cpp +++ b/contrib/python/contourpy/src/util.cpp @@ -3,6 +3,19 @@ namespace contourpy { +bool Util::_nan_loaded = false; + +double Util::nan = 0.0; + +void Util::ensure_nan_loaded() +{ + if (!_nan_loaded) { + auto numpy = py::module_::import("numpy"); + nan = numpy.attr("nan").cast<double>(); + _nan_loaded = true; + } +} + index_t Util::get_max_threads() { return static_cast<index_t>(std::thread::hardware_concurrency()); diff --git a/contrib/python/contourpy/src/util.h b/contrib/python/contourpy/src/util.h index 22dcaca7fd..f4a1abda79 100644 --- a/contrib/python/contourpy/src/util.h +++ b/contrib/python/contourpy/src/util.h @@ -8,7 +8,20 @@ namespace contourpy { class Util { public: + static void ensure_nan_loaded(); + static index_t get_max_threads(); + + // This is the NaN used internally and returned to calling functions. The value is taken from + // numpy rather than the standard C++ approach so that it is guaranteed to work with + // numpy.isnan(). The value is actually the same for many platforms, but this approach + // guarantees it works for all platforms that numpy supports. + // + // ensure_nan_loaded() must be called before this value is read. + static double nan; + +private: + static bool _nan_loaded; }; } // namespace contourpy diff --git a/contrib/python/contourpy/src/wrap.cpp b/contrib/python/contourpy/src/wrap.cpp index 2c79641990..b784f814a5 100644 --- a/contrib/python/contourpy/src/wrap.cpp +++ b/contrib/python/contourpy/src/wrap.cpp @@ -20,8 +20,7 @@ static contourpy::FillType mpl20xx_fill_type = contourpy::FillType::OuterCode; PYBIND11_MODULE(_contourpy, m) { m.doc() = "C++11 extension module wrapped using `pybind11`_.\n\n" - ".. note::\n" - " It should not be necessary to access classes and functions in this extension module " + "It should not be necessary to access classes and functions in this extension module " "directly. Instead, :func:`contourpy.contour_generator` should be used to create " ":class:`~contourpy.ContourGenerator` objects, and the enums " "(:class:`~contourpy.FillType`, :class:`~contourpy.LineType` and " @@ -53,11 +52,13 @@ PYBIND11_MODULE(_contourpy, m) { py::enum_<contourpy::LineType>(m, "LineType", "Enum used for ``line_type`` keyword argument in :func:`~contourpy.contour_generator`.\n\n" "This controls the format of contour line data returned from " - ":meth:`~contourpy.ContourGenerator.lines`.") + ":meth:`~contourpy.ContourGenerator.lines`.\n\n" + "``LineType.ChunkCombinedNan`` added in version 1.2.0") .value("Separate", contourpy::LineType::Separate) .value("SeparateCode", contourpy::LineType::SeparateCode) .value("ChunkCombinedCode", contourpy::LineType::ChunkCombinedCode) .value("ChunkCombinedOffset", contourpy::LineType::ChunkCombinedOffset) + .value("ChunkCombinedNan", contourpy::LineType::ChunkCombinedNan) .export_values(); py::enum_<contourpy::ZInterp>(m, "ZInterp", @@ -93,7 +94,10 @@ PYBIND11_MODULE(_contourpy, m) { " upper_level (float): Upper z-level of the filled contours.\n" "Return:\n" " Filled contour polygons as one or more sequences of numpy arrays. The exact format is " - "determined by the ``fill_type`` used by the ``ContourGenerator``."; + "determined by the ``fill_type`` used by the ``ContourGenerator``.\n\n" + "Raises a ``ValueError`` if ``lower_level >= upper_level``.\n\n" + "To return filled contours below a ``level`` use ``filled(-np.inf, level)``.\n" + "To return filled contours above a ``level`` use ``filled(level, np.inf)``"; const char* line_type_doc = "Return the ``LineType``."; const char* lines_doc = "Calculate and return contour lines at a particular level.\n\n" diff --git a/contrib/python/contourpy/ya.make b/contrib/python/contourpy/ya.make index fca29aba12..1da5a3ee7c 100644 --- a/contrib/python/contourpy/ya.make +++ b/contrib/python/contourpy/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(1.1.1) +VERSION(1.2.0) LICENSE(BSD-3-Clause) @@ -51,8 +51,13 @@ PY_SRCS( contourpy/__init__.py contourpy/_contourpy.pyi contourpy/_version.py + contourpy/array.py contourpy/chunk.py + contourpy/convert.py + contourpy/dechunk.py contourpy/enum_util.py + contourpy/typecheck.py + contourpy/types.py contourpy/util/__init__.py contourpy/util/_build_config.py contourpy/util/_build_config.pyi |