From b3dc13e2b5af159fb8ce9b9f8f884da913ec9c81 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Wed, 12 Feb 2025 23:34:41 +0800 Subject: [PATCH 01/16] warp coupe --- doc/api/index.rst | 1 + pygmt/figure.py | 1 + pygmt/src/__init__.py | 1 + pygmt/src/coupe.py | 303 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 pygmt/src/coupe.py diff --git a/doc/api/index.rst b/doc/api/index.rst index 25de6d44adf..888f866b9ae 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -45,6 +45,7 @@ Plotting tabular data :toctree: generated Figure.contour + Figure.coupe Figure.histogram Figure.meca Figure.plot diff --git a/pygmt/figure.py b/pygmt/figure.py index 5c5d4734ce6..54e37ddc4ea 100644 --- a/pygmt/figure.py +++ b/pygmt/figure.py @@ -414,6 +414,7 @@ def _repr_html_(self) -> str: coast, colorbar, contour, + coupe, grdcontour, grdimage, grdview, diff --git a/pygmt/src/__init__.py b/pygmt/src/__init__.py index 8905124f917..fc08b0fd340 100644 --- a/pygmt/src/__init__.py +++ b/pygmt/src/__init__.py @@ -9,6 +9,7 @@ from pygmt.src.colorbar import colorbar from pygmt.src.config import config from pygmt.src.contour import contour +from pygmt.src.coupe import coupe from pygmt.src.dimfilter import dimfilter from pygmt.src.filter1d import filter1d from pygmt.src.grd2cpt import grd2cpt diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py new file mode 100644 index 00000000000..4886c708f1a --- /dev/null +++ b/pygmt/src/coupe.py @@ -0,0 +1,303 @@ +""" +coupe - Plot cross-sections of focal mechanisms. +""" + +import numpy as np +import pandas as pd +from pygmt.clib import Session +from pygmt.exceptions import GMTError, GMTInvalidInput +from pygmt.helpers import build_arg_string, fmt_docstring, kwargs_to_strings, use_alias +from pygmt.src.meca import convention_params, convention_code + +def section_convention_code(section_format): + + codes = { + "lonlat_lonlat": "a", + "lonlat_strlen": "b", + "xy_xy": "c", + "xy_strlen": "d" + } + + if section_format in codes: + return codes[section_format] + else: + raise GMTInvalidInput(f"Invalid section format '{section_format}'.") + +@fmt_docstring +@use_alias( + A="section", + B="frame", + C="cmap", + E="extensionfill", + Fr="labelbox", + G="compressionfill", + J="projection", + L="outline", + N="no_clip", + Q="no_file", + R="region", + T="nodal", + V="verbose", + W="pen", + c="panel", + p="perspective", + t="transparency", +) +@kwargs_to_strings(A="sequence", R="sequence", c="sequence_comma", p="sequence") +def coupe( + self, + spec, + scale, + convention=None, + component="full", + longitude=None, + latitude=None, + depth=None, + section_format='lonlat_lonlat', + event_name=None, + **kwargs +): + r""" + Plot focal mechanisms in a vertical cross section. + This function/method is copied from `pygmt.src.meca.meca()` + Full option list at :gmt-docs:`supplements/seis/coupe.html` + {aliases} + Parameters + ---------- + spec : str, 1-D array, 2-D array, dict, or pd.DataFrame + Data that contains focal mechanism parameters. + ``spec`` can be specified in either of the following types: + - *str*: a file name containing focal mechanism parameters as + columns. The meaning of each column is: + - Columns 1 and 2: event longitude and latitude + - Column 3: event depth (in km) + - Columns 4 to 3+n: focal mechanism parameters. The number of columns + *n* depends on the choice of ``convention``, which will be + described below. + - Columns 4+n and 5+n: longitude, latitude at which to place + beachball. Using ``0 0`` will plot the beachball at the longitude, + latitude given in columns 1 and 2. [optional and requires + ``offset=True`` to take effect]. + - Text string to appear near the beachball [optional]. + - *1-D array*: focal mechanism parameters of a single event. + The meanings of columns are the same as above. + - *2-D array*: focal mechanism parameters of multiple events. + The meanings of columns are the same as above. + - *dictionary or pd.DataFrame*: The dictionary keys or pd.DataFrame + column names determine the focal mechanism convention. For + different conventions, the following combination of keys are allowed: + - ``"aki"``: *strike, dip, rake, magnitude* + - ``"gcmt"``: *strike1, dip1, rake1, strike2, dip2, rake2, mantissa,* + *exponent* + - ``"mt"``: *mrr, mtt, mff, mrt, mrf, mtf, exponent* + - ``"partial"``: *strike1, dip1, strike2, fault_type, magnitude* + - ``"principal_axis"``: *t_value, t_azimuth, t_plunge, n_value, + n_azimuth, n_plunge, p_value, p_azimuth, p_plunge, exponent* + A dictionary may contain values for a single focal mechanism or + lists of values for multiple focal mechanisms. + Both dictionary and pd.DataFrame may optionally contain + keys/column names: ``latitude``, ``longitude``, ``depth``, + ``plot_longitude``, ``plot_latitude``, and/or ``event_name``. + If ``spec`` is either a str, a 1-D array or a 2-D array, the + ``convention`` parameter is required so we know how to interpret the + columns. If ``spec`` is a dictionary or a pd.DataFrame, + ``convention`` is not needed and is ignored if specified. + scale : int, float, or str + *scale*\ [**+a**\ *angle*][**+f**\ *font*][**+j**\ *justify*]\ + [**+l**][**+m**][**+o**\ *dx*\ [/\ *dy*]][**+s**\ *reference*]. + Adjust scaling of the radius of the beachball, which is + proportional to the magnitude. By default, *scale* defines the + size for magnitude = 5 (i.e., scalar seismic moment + M0 = 4.0E23 dynes-cm). If **+l** is used the radius will be + proportional to the seismic moment instead. Use **+s** and give + a *reference* to change the reference magnitude (or moment), and + use **+m** to plot all beachballs with the same size. A text + string can be specified to appear near the beachball + (corresponding to column or parameter ``event_name``). + Append **+a**\ *angle* to change the angle of the text string; + append **+f**\ *font* to change its font (size,fontname,color); + append **+j**\ *justify* to change the text location relative + to the beachball [Default is ``"TC"``, i.e., Top Center]; + append **+o** to offset the text string by *dx*\ /*dy*. + section : list, or str + Cross-section parameters. + *section*\ a|b|c|dparams[+c[n|t]][+ddip][+r[a|e|dx]][+wwidth]\ + [+z[s]a|e|dz|min/max]. + a, b, c, and d are specified by *section_format*. + a: List of four float values of the longitude and latitude of points 1 and 2 + limiting the length of the cross-section. + b: List of four float values of the longitude and latitude of + the beginning of the cross-section, strike is the azimuth of + the direction of the cross-section, and length is the length + along which the cross-section is made (in km). + c: List of four float values the same as `a` option + with x and y given as Cartesian coordinates. + d: List of four float values the same as `b` option + with x and y given as Cartesian coordinates. + section_format : str, `"lonlat_lonlat"` + `"lonlat_lonlat"`: a + `"lonlat_strlen"`: b + `"xy_xy"`: c + `"xy_strlen"`: d + no_file : bool, default to False + If True, creates no output files in the current path. + convention : str + Focal mechanism convention. Choose from: + - ``"aki"`` (Aki & Richards) + - ``"gcmt"`` (global CMT) + - ``"mt"`` (seismic moment tensor) + - ``"partial"`` (partial focal mechanism) + - ``"principal_axis"`` (principal axis) + Ignored if ``spec`` is a dictionary or pd.DataFrame. + component : str + The component of the seismic moment tensor to plot. + - ``"full"``: the full seismic moment tensor + - ``"dc"``: the closest double couple defined from the moment tensor + (zero trace and zero determinant) + - ``"deviatoric"``: deviatoric part of the moment tensor (zero trace) + longitude : int, float, list, or 1-D numpy array + Longitude(s) of event location(s). Must be the same length as the + number of events. Will override the ``longitude`` values + in ``spec`` if ``spec`` is a dictionary or pd.DataFrame. + latitude : int, float, list, or 1-D numpy array + Latitude(s) of event location(s). Must be the same length as the + number of events. Will override the ``latitude`` values + in ``spec`` if ``spec`` is a dictionary or pd.DataFrame. + depth : int, float, list, or 1-D numpy array + Depth(s) of event location(s) in kilometers. Must be the same length + as the number of events. Will override the ``depth`` values in ``spec`` + if ``spec`` is a dictionary or pd.DataFrame. + event_name : str or list of str, or 1-D numpy array + Text string(s), e.g., event name(s) to appear near the beachball(s). + List must be the same length as the number of events. Will override + the ``event_name`` labels in ``spec`` if ``spec`` is a dictionary + or pd.DataFrame. + labelbox : bool or str + [*fill*]. + Draw a box behind the label if given. Use *fill* to give a fill color + [Default is ``"white"``]. + offset : bool or str + [**+p**\ *pen*][**+s**\ *size*]. + Offset beachball(s) to longitude(s) and latitude(s) specified in the + the last two columns of the input file or array, or by + ``plot_longitude`` and ``plot_latitude`` if provided. A small circle + is plotted at the initial location and a line connects the beachball + to the circle. Use **+s**\ *size* to set the diameter of the circle + [Default is no circle]. Use **+p**\ *pen* to set the pen attributes + for this feature [Default is set via ``pen``]. The fill of the + circle is set via ``compressionfill`` or ``cmap``, i.e., + corresponds to the fill of the compressive quadrants. + compressionfill : str + Set color or pattern for filling compressive quadrants + [Default is ``"black"``]. This setting also applies to the fill of + the circle defined via ``offset``. + extensionfill : str + Set color or pattern for filling extensive quadrants + [Default is ``"white"``]. + pen : str + Set pen attributes for all lines related to beachball [Default is + ``"0.25p,black,solid"``]. This setting applies to ``outline``, + ``nodal``, and ``offset``, unless overruled by arguments passed to + those parameters. Draws circumference of beachball. + outline : bool or str + [*pen*]. + Draw circumference and nodal planes of beachball. Use *pen* to set + the pen attributes for this feature [Default is set via ``pen``]. + nodal : bool, int, or str + [*nplane*][/*pen*]. + Plot the nodal planes and outline the bubble which is transparent. + If *nplane* is + - ``0`` or ``True``: both nodal planes are plotted [Default]. + - ``1``: only the first nodal plane is plotted. + - ``2``: only the second nodal plane is plotted. + Use /*pen* to set the pen attributes for this feature [Default is + set via ``pen``]. + For double couple mechanisms, ``nodal`` renders the beachball + transparent by drawing only the nodal planes and the circumference. + For non-double couple mechanisms, ``nodal=0`` overlays best + double couple transparently. + cmap : str + File name of a CPT file or a series of comma-separated colors (e.g., + *color1,color2,color3*) to build a linear continuous CPT from those + colors automatically. The color of the compressive quadrants is + determined by the z-value (i.e., event depth or the third column for + an input file). This setting also applies to the fill of the circle + defined via ``offset``. + no_clip : bool + Do **not** skip symbols that fall outside the frame boundaries + [Default is ``False``, i.e., plot symbols inside the frame + boundaries only]. + {projection} + {region} + {frame} + {verbose} + {panel} + {perspective} + {transparency} + """ + kwargs = self._preprocess(**kwargs) + + ## The cross-sectional profile + if kwargs.get("A") is None: + raise GMTInvalidInput("The `section` parameter must be specified.") + kwargs["A"] = section_convention_code(section_format) + kwargs["A"] + + # Convert spec to pandas.DataFrame unless it's a file + if isinstance(spec, (dict, pd.DataFrame)): # spec is a dict or pd.DataFrame + # determine convention from dict keys or pd.DataFrame column names + for conv in ["aki", "gcmt", "mt", "partial", "pricipal_axis"]: + if set(convention_params(conv)).issubset(set(spec.keys())): + convention = conv + break + else: + if isinstance(spec, dict): + msg = "Keys in dict 'spec' do not match known conventions." + else: + msg = "Column names in pd.DataFrame 'spec' do not match known conventions." + raise GMTError(msg) + + # convert dict to pd.DataFrame so columns can be reordered + if isinstance(spec, dict): + # convert values to ndarray so pandas doesn't complain about "all + # scalar values". See + # https://github.com/GenericMappingTools/pygmt/pull/2174 + spec = pd.DataFrame( + {key: np.atleast_1d(value) for key, value in spec.items()} + ) + + # Now spec is a pd.DataFrame or a file + if isinstance(spec, pd.DataFrame): + # override the values in pd.DataFrame if parameters are given + if longitude is not None: + spec["longitude"] = np.atleast_1d(longitude) + if latitude is not None: + spec["latitude"] = np.atleast_1d(latitude) + if depth is not None: + spec["depth"] = np.atleast_1d(depth) + if event_name is not None: + spec["event_name"] = np.atleast_1d(event_name) + + # Due to the internal implementation of the meca module, we need to + # convert the following columns to strings if they exist + if "event_name" in spec.columns: + spec["event_name"] = spec["event_name"].astype(str) + + # Reorder columns in DataFrame to match convention if necessary + # expected columns are: + # longitude, latitude, depth, focal_parameters, + # [event_name] + newcols = ["longitude", "latitude", "depth"] + convention_params(convention) + if "event_name" in spec.columns: + newcols += ["event_name"] + # reorder columns in DataFrame + if spec.columns.tolist() != newcols: + spec = spec.reindex(newcols, axis=1) + + data_format = convention_code(convention=convention, component=component) + kwargs["S"] = f"{data_format}{scale}" + + with Session() as lib: + # Choose how data will be passed into the module + file_context = lib.virtualfile_from_data(check_kind="vector", data=spec) + with file_context as fname: + lib.call_module(module="coupe", args=build_arg_string(kwargs, infile=fname)) \ No newline at end of file From 3071d4c189a3bb544853aaa8402a23e635e0396b Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 13 Feb 2025 22:45:45 +0800 Subject: [PATCH 02/16] add test_coupe.py --- pygmt/tests/test_coupe.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pygmt/tests/test_coupe.py diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py new file mode 100644 index 00000000000..651e4cc07f2 --- /dev/null +++ b/pygmt/tests/test_coupe.py @@ -0,0 +1,64 @@ +""" +Test Figure.coupe. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from packaging.version import Version +from pygmt import Figure +from pygmt.clib import __gmt_version__ +from pygmt.exceptions import GMTInvalidInput +from pygmt.helpers import GMTTempFile + +@pytest.mark.mpl_image_compare(filename="test_coupe_one_type.png") +@pytest.mark.parametrize("inputtype", ["dict_mecha", "dict_full", "array1d", "pandas"]) +def test_coupe_one_type(inputtype): + """ + Test passing a single focal mechanism to the spec parameter. + """ + if inputtype == "dict_mecha": + args = { + "spec": {"strike": 30, "dip": 90, "rake": 0, "magnitude": 4}, + "longitude": 0, + "latitude": 5, + "depth": 10, + } + elif inputtype == "dict_full": + args = { + "spec": { + "longitude": 0, + "latitude": 5, + "depth": 10, + "strike": 30, + "dip": 90, + "rake": 0, + "magnitude": 4, + } + } + elif inputtype == "array1d": + args = { + "spec": np.array([0, 5, 10, 0, 30, 0, 4]), + "convention": "a", + } + elif inputtype == "pandas": + args = { + "spec": pd.DataFrame( + { + "longitude": 0, + "latitude": 5, + "depth": 10, + "strike": 0, + "dip": 30, + "rake": 0, + "magnitude": 4, + }, + index=[0], + ) + } + fig = Figure() + fig.basemap(region=[-1, 1, 4, 6], projection="M8c", frame=2) + fig.meca(scale="2.5c", **args) + return fig \ No newline at end of file From d652b4c44c350c99e77122684f82f0776b124609 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Fri, 14 Feb 2025 23:35:42 +0800 Subject: [PATCH 03/16] add one focal mech and three focal mech --- pygmt/src/coupe.py | 63 ++++++++++++------- pygmt/tests/test_coupe.py | 124 +++++++++++++++++++++++++++++++++----- 2 files changed, 150 insertions(+), 37 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 4886c708f1a..d1503c9f13c 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -6,8 +6,8 @@ import pandas as pd from pygmt.clib import Session from pygmt.exceptions import GMTError, GMTInvalidInput -from pygmt.helpers import build_arg_string, fmt_docstring, kwargs_to_strings, use_alias -from pygmt.src.meca import convention_params, convention_code +from pygmt.helpers import build_arg_list, fmt_docstring, kwargs_to_strings, use_alias +from pygmt.src.meca import convention_params, convention_code, convention_name def section_convention_code(section_format): @@ -86,6 +86,7 @@ def coupe( - *dictionary or pd.DataFrame*: The dictionary keys or pd.DataFrame column names determine the focal mechanism convention. For different conventions, the following combination of keys are allowed: + - ``"aki"``: *strike, dip, rake, magnitude* - ``"gcmt"``: *strike1, dip1, rake1, strike2, dip2, rake2, mantissa,* *exponent* @@ -93,6 +94,7 @@ def coupe( - ``"partial"``: *strike1, dip1, strike2, fault_type, magnitude* - ``"principal_axis"``: *t_value, t_azimuth, t_plunge, n_value, n_azimuth, n_plunge, p_value, p_azimuth, p_plunge, exponent* + A dictionary may contain values for a single focal mechanism or lists of values for multiple focal mechanisms. Both dictionary and pd.DataFrame may optionally contain @@ -100,8 +102,9 @@ def coupe( ``plot_longitude``, ``plot_latitude``, and/or ``event_name``. If ``spec`` is either a str, a 1-D array or a 2-D array, the ``convention`` parameter is required so we know how to interpret the - columns. If ``spec`` is a dictionary or a pd.DataFrame, - ``convention`` is not needed and is ignored if specified. + columns. If ``spec`` is a dict or a :class:`pandas.DataFrame`, + ``convention`` is not needed and ignored if specified. + scale : int, float, or str *scale*\ [**+a**\ *angle*][**+f**\ *font*][**+j**\ *justify*]\ [**+l**][**+m**][**+o**\ *dx*\ [/\ *dy*]][**+s**\ *reference*]. @@ -148,30 +151,21 @@ def coupe( - ``"mt"`` (seismic moment tensor) - ``"partial"`` (partial focal mechanism) - ``"principal_axis"`` (principal axis) - Ignored if ``spec`` is a dictionary or pd.DataFrame. + Ignored if ``spec`` is a dict or :class:`pandas.DataFrame`. component : str The component of the seismic moment tensor to plot. - ``"full"``: the full seismic moment tensor - ``"dc"``: the closest double couple defined from the moment tensor (zero trace and zero determinant) - ``"deviatoric"``: deviatoric part of the moment tensor (zero trace) - longitude : int, float, list, or 1-D numpy array - Longitude(s) of event location(s). Must be the same length as the - number of events. Will override the ``longitude`` values - in ``spec`` if ``spec`` is a dictionary or pd.DataFrame. - latitude : int, float, list, or 1-D numpy array - Latitude(s) of event location(s). Must be the same length as the - number of events. Will override the ``latitude`` values - in ``spec`` if ``spec`` is a dictionary or pd.DataFrame. - depth : int, float, list, or 1-D numpy array - Depth(s) of event location(s) in kilometers. Must be the same length - as the number of events. Will override the ``depth`` values in ``spec`` - if ``spec`` is a dictionary or pd.DataFrame. + longitude/latitude/depth : float, list, or 1-D numpy array + Longitude(s) / latitude(s) / depth(s) of the event(s). Length must match the + number of events. Overrides the ``longitude`` / ``latitude`` / ``depth`` values + in ``spec`` if ``spec`` is a dict or :class:`pandas.DataFrame`. event_name : str or list of str, or 1-D numpy array Text string(s), e.g., event name(s) to appear near the beachball(s). List must be the same length as the number of events. Will override - the ``event_name`` labels in ``spec`` if ``spec`` is a dictionary - or pd.DataFrame. + the ``event_name`` labels in ``spec`` if ``spec`` is a dict or :class:`pandas.DataFrame`. labelbox : bool or str [*fill*]. Draw a box behind the label if given. Use *fill* to give a fill color @@ -264,6 +258,32 @@ def coupe( spec = pd.DataFrame( {key: np.atleast_1d(value) for key, value in spec.items()} ) + elif isinstance(spec, np.ndarray): # spec is a numpy array + if convention is None: + msg = "'convention' must be specified for an array input." + raise GMTInvalidInput(msg) + # make sure convention is a name, not a code + convention = convention_name(convention) + + # Convert array to pd.DataFrame and assign column names + spec = pd.DataFrame(np.atleast_2d(spec)) + colnames = ["longitude", "latitude", "depth", *convention_params(convention)] + # check if spec has the expected number of columns + ncolsdiff = len(spec.columns) - len(colnames) + if ncolsdiff == 0: + pass + elif ncolsdiff == 1: + colnames += ["event_name"] + elif ncolsdiff == 2: + colnames += ["plot_longitude", "plot_latitude"] + elif ncolsdiff == 3: + colnames += ["plot_longitude", "plot_latitude", "event_name"] + else: + msg = ( + f"Input array must have {len(colnames)} to {len(colnames) + 3} columns." + ) + raise GMTInvalidInput(msg) + spec.columns = colnames # Now spec is a pd.DataFrame or a file if isinstance(spec, pd.DataFrame): @@ -298,6 +318,7 @@ def coupe( with Session() as lib: # Choose how data will be passed into the module - file_context = lib.virtualfile_from_data(check_kind="vector", data=spec) + file_context = lib.virtualfile_in(check_kind="vector", data=spec) with file_context as fname: - lib.call_module(module="coupe", args=build_arg_string(kwargs, infile=fname)) \ No newline at end of file + lib.call_module(module="coupe", args=build_arg_list(kwargs, infile=fname)) + \ No newline at end of file diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index 651e4cc07f2..b2058ecad3e 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -15,23 +15,24 @@ @pytest.mark.mpl_image_compare(filename="test_coupe_one_type.png") @pytest.mark.parametrize("inputtype", ["dict_mecha", "dict_full", "array1d", "pandas"]) -def test_coupe_one_type(inputtype): +def test_coupe_spec_single_focalmecha(inputtype): """ Test passing a single focal mechanism to the spec parameter. """ if inputtype == "dict_mecha": args = { "spec": {"strike": 30, "dip": 90, "rake": 0, "magnitude": 4}, - "longitude": 0, - "latitude": 5, - "depth": 10, + "longitude": 112, + "latitude": 32, + "depth": 25, } + elif inputtype == "dict_full": args = { "spec": { - "longitude": 0, - "latitude": 5, - "depth": 10, + "longitude": 112, + "latitude": 32, + "depth": 25, "strike": 30, "dip": 90, "rake": 0, @@ -40,18 +41,18 @@ def test_coupe_one_type(inputtype): } elif inputtype == "array1d": args = { - "spec": np.array([0, 5, 10, 0, 30, 0, 4]), - "convention": "a", + "spec": np.array([112, 32, 25, 30, 90, 0, 4]), + "convention": "aki", } elif inputtype == "pandas": args = { "spec": pd.DataFrame( { - "longitude": 0, - "latitude": 5, - "depth": 10, - "strike": 0, - "dip": 30, + "longitude": 112, + "latitude": 32, + "depth": 25, + "strike": 30, + "dip": 90, "rake": 0, "magnitude": 4, }, @@ -59,6 +60,97 @@ def test_coupe_one_type(inputtype): ) } fig = Figure() - fig.basemap(region=[-1, 1, 4, 6], projection="M8c", frame=2) + fig.basemap(region=[111, 113, 31.5, 32.5], projection="M8c", frame=True) fig.meca(scale="2.5c", **args) - return fig \ No newline at end of file + fig.shift_origin(yshift="5.5c") + fig.basemap(region=[0, 1000, 0, 30], projection="X8c/-4c", frame=True) + fig.coupe(scale="2.5c", section=[110, 33, 120, 33], section_format='lonlat_lonlat', **args) + + return fig + +@pytest.mark.mpl_image_compare(filename="test_coupe_one_type.png") +def test_coupe_spec_single_focalmecha_file(): + """ + Test supplying a file containing focal mechanisms and locations to the spec + parameter. + """ + fig = Figure() + fig.basemap(region=[111, 113, 31.5, 32.5], projection="M8c", frame=True) + with GMTTempFile() as temp: + Path(temp.name).write_text("112 32 25 30 90 0 4", encoding="utf-8") + fig.meca(spec=temp.name, convention="aki", scale="2.5c") + fig.shift_origin(yshift="5.5c") + fig.basemap(region=[0, 1000, 0, 30], projection="X8c", frame=True) + with GMTTempFile() as temp: + Path(temp.name).write_text("112 32 25 30 90 0 4", encoding="utf-8") + fig.coupe(spec=temp.name, convention="aki", scale="2.5c", + section=[110, 33, 120, 33], section_format='lonlat_lonlat') + return fig + +@pytest.mark.benchmark +@pytest.mark.mpl_image_compare(filename="test_coupe_spec_multiple_focalmecha.png") +@pytest.mark.parametrize( + "inputtype", ["dict_mecha", "dict_mecha_mixed", "dataframe", "array2d"] +) +def test_coupe_spec_multiple_focalmecha(inputtype): + """ + Test passing multiple focal mechanisms to the spec parameter. + """ + + if inputtype == "dict_mecha": + args = { + "spec": { + "strike": [30, 30, 30], + "dip": [90, 60, 60], + "rake": [0, 90, -90], + "magnitude": [4, 5, 6], + }, + "longitude": [112, 115, 118], + "latitude": [32, 34, 32], + "depth": [25, 15, 45], + } + elif inputtype == "dict_mecha_mixed": + args = { + "spec": { + "strike": [30, 30, 30], + "dip": [90, 60, 60], + "rake": [0, 90, -90], + "magnitude": [4, 5, 6], + }, + "longitude": np.array([112, 115, 118]), + "latitude": [32, 34, 32], + "depth": [25, 15, 45], + } + elif inputtype == "dataframe": + args = { + "spec": pd.DataFrame( + data={ + "strike": [30, 30, 30], + "dip": [90, 60, 60], + "rake": [0, 90, -90], + "magnitude": [4, 5, 6], + "longitude": [112, 115, 118], + "latitude": [32, 34, 32], + "depth": [25, 15, 45], + }, + ) + } + elif inputtype == "array2d": + args = { + "spec": np.array( + [ + [112, 32, 25, 30, 90, 0, 4], + [115, 34, 15, 30, 60, 90, 5], + [118, 32, 45, 30, 60, -90, 6], + ] + ), + "convention": "aki", + } + + fig = Figure() + fig.basemap(region=[110, 120, 31, 35], projection="M8c", frame=True) + fig.meca(scale="1.5c", **args) + fig.shift_origin(yshift="5.5c") + fig.basemap(region=[0, 1000, 0, 60], projection="X8c/-4c", frame=True) + fig.coupe(scale="1.5c", section=[110, 33, 120, 33], section_format='lonlat_lonlat', **args) + return fig From e98007dfb802c0b646e072be0a8203e1b6871839 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Fri, 14 Feb 2025 23:42:04 +0800 Subject: [PATCH 04/16] revise figure filename --- pygmt/tests/test_coupe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index b2058ecad3e..7aa8486abbd 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -13,7 +13,7 @@ from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import GMTTempFile -@pytest.mark.mpl_image_compare(filename="test_coupe_one_type.png") +@pytest.mark.mpl_image_compare(filename="test_coupe_spec_single_focalmech.png") @pytest.mark.parametrize("inputtype", ["dict_mecha", "dict_full", "array1d", "pandas"]) def test_coupe_spec_single_focalmecha(inputtype): """ @@ -68,7 +68,7 @@ def test_coupe_spec_single_focalmecha(inputtype): return fig -@pytest.mark.mpl_image_compare(filename="test_coupe_one_type.png") +@pytest.mark.mpl_image_compare(filename="test_coupe_spec_single_focalmech.png") def test_coupe_spec_single_focalmecha_file(): """ Test supplying a file containing focal mechanisms and locations to the spec From 7c8a2abf2c9ffe37e0473536a03e4c6ede570dbc Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 22:18:38 +0800 Subject: [PATCH 05/16] add test_coupe.py and five test figure --- pygmt/tests/test_coupe.py | 124 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 4 deletions(-) diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index 7aa8486abbd..1a150228dae 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -64,7 +64,13 @@ def test_coupe_spec_single_focalmecha(inputtype): fig.meca(scale="2.5c", **args) fig.shift_origin(yshift="5.5c") fig.basemap(region=[0, 1000, 0, 30], projection="X8c/-4c", frame=True) - fig.coupe(scale="2.5c", section=[110, 33, 120, 33], section_format='lonlat_lonlat', **args) + fig.coupe( + scale="2.5c", + section=[110, 33, 120, 33], + section_format="lonlat_lonlat", + no_file=True, + **args + ) return fig @@ -83,8 +89,8 @@ def test_coupe_spec_single_focalmecha_file(): fig.basemap(region=[0, 1000, 0, 30], projection="X8c", frame=True) with GMTTempFile() as temp: Path(temp.name).write_text("112 32 25 30 90 0 4", encoding="utf-8") - fig.coupe(spec=temp.name, convention="aki", scale="2.5c", - section=[110, 33, 120, 33], section_format='lonlat_lonlat') + fig.coupe(spec=temp.name, convention="aki", scale="2.5c", + section=[110, 33, 120, 33], section_format="lonlat_lonlat") return fig @pytest.mark.benchmark @@ -152,5 +158,115 @@ def test_coupe_spec_multiple_focalmecha(inputtype): fig.meca(scale="1.5c", **args) fig.shift_origin(yshift="5.5c") fig.basemap(region=[0, 1000, 0, 60], projection="X8c/-4c", frame=True) - fig.coupe(scale="1.5c", section=[110, 33, 120, 33], section_format='lonlat_lonlat', **args) + fig.coupe( + scale="1.5c", + section=[110, 33, 120, 33], + section_format="lonlat_lonlat", + no_file=True, + **args + ) + return fig + +# TODO(GMT>=6.5.0): Remove the skipif marker for GMT>=6.5.0. +# Passing event names via pandas doesn't work for GMT<=6.4. +# See https://github.com/GenericMappingTools/pygmt/issues/2524. +@pytest.mark.mpl_image_compare(filename="test_coupe_eventname.png") +@pytest.mark.parametrize( + "inputtype", + [ + "args", + pytest.param( + "dataframe", + marks=pytest.mark.skipif( + condition=Version(__gmt_version__) < Version("6.5.0"), + reason="Upstream bug fixed in https://github.com/GenericMappingTools/gmt/pull/7557", + ), + ), + ], +) +def test_coupe_eventname(inputtype): + """ + Test passing event names. + """ + if inputtype == "args": + args = { + "spec": {"strike": 30, "dip": 90, "rake": 0, "magnitude": 4}, + "longitude": 112, + "latitude": 32, + "depth": 25, + "event_name": "Strike-slip" + } + elif inputtype == "dataframe": + # Test pandas.DataFrame input. Requires GMT>=6.5. + # See https://github.com/GenericMappingTools/pygmt/issues/2524. + # The numeric columns must be in float type to trigger the bug. + args = { + "spec": pd.DataFrame( + { + "longitude": [112], + "latitude": [32], + "depth": [25], + "strike": [30], + "dip": [90], + "rake": [0], + "magnitude": [4], + "event_name": ["Strike-slip"] + }, + index=[0], + ) + } + fig = Figure() + fig.basemap(region=[111, 113, 31.5, 32.5], projection="M8c", frame=True) + fig.meca(scale="1.5c", **args) + fig.shift_origin(yshift="5.5c") + fig.basemap(region=[0, 1000, 0, 30], projection="X8c/-4c", frame=True) + fig.coupe( + scale="1.5c", + section=[110, 33, 120, 33], + section_format="lonlat_lonlat", + no_file=True, + **args + ) + return fig + + +@pytest.mark.benchmark +@pytest.mark.mpl_image_compare(filename="test_coupe_vertical_profile.png") +@pytest.mark.parametrize( + "inputtype", ["dict_mecha",] +) +def test_coupe_vertical_profile(inputtype): + """ + Test passing vertical profile. + See example of https://docs.gmt-china.org/6.1/module/coupe/ + """ + + if inputtype == "dict_mecha": + args = { + "spec": { + "mrr": [1.14, 6.19, 0.95, -2.49], + "mtt": [-0.10, -1.14, 0.11, 3.40], + "mff": [-1.04, -5.05, -1.06, -0.91], + "mrt": [-0.51, -0.72, -0.20, 3.09], + "mrf": [-2.21, -9.03, -2.32, 0.83], + "mtf": [-0.99, -4.24, 0.90, -3.64], + "exponent": [26, 25, 25, 25] + }, + "longitude": [131.55, 133.74, 135.52, 138.37], + "latitude": [41.48, 41.97, 37.64, 42.85], + "depth": [579, 604, 432, 248], + } + + fig = Figure() + fig.coupe(projection="X15c/-6c", + scale="0.8", + section=[130, 43, 140, 36, 90, 100, 0, 700, "+f"], + section_format="lonlat_lonlat", + component="dc", + no_clip=True, + no_file=True, + **args + ) + fig.basemap(frame=True) + return fig From fcc9784dfe60cdb9fa06d65c8fbe0a490e50a4ab Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 22:20:52 +0800 Subject: [PATCH 06/16] coupe add PT --- pygmt/src/coupe.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index d1503c9f13c..609f552242b 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -29,6 +29,7 @@ def section_convention_code(section_format): B="frame", C="cmap", E="extensionfill", + Fa="PTaxis", Fr="labelbox", G="compressionfill", J="projection", @@ -54,6 +55,8 @@ def coupe( latitude=None, depth=None, section_format='lonlat_lonlat', + plot_longitude=None, + plot_latitude=None, event_name=None, **kwargs ): From e0e6870c0c069d0407be491d6b90b620ced22d86 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 23:05:22 +0800 Subject: [PATCH 07/16] follwing latest version meca.py --- pygmt/src/coupe.py | 142 ++++++++++++++++++++++++++++++--------------- 1 file changed, 96 insertions(+), 46 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 609f552242b..ad32f05c2ef 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -7,7 +7,7 @@ from pygmt.clib import Session from pygmt.exceptions import GMTError, GMTInvalidInput from pygmt.helpers import build_arg_list, fmt_docstring, kwargs_to_strings, use_alias -from pygmt.src.meca import convention_params, convention_code, convention_name +from pygmt.src._common import _FocalMechanismConvention def section_convention_code(section_format): @@ -29,7 +29,7 @@ def section_convention_code(section_format): B="frame", C="cmap", E="extensionfill", - Fa="PTaxis", + Fa="pt_axis", Fr="labelbox", G="compressionfill", J="projection", @@ -61,10 +61,59 @@ def coupe( **kwargs ): r""" - Plot focal mechanisms in a vertical cross section. + Plot focal mechanisms in a cross section. This function/method is copied from `pygmt.src.meca.meca()` + + The following focal mechanism conventions are supported: + + .. list-table:: Supported focal mechanism conventions. + :widths: 15 15 40 30 + :header-rows: 1 + + * - Convention + - Description + - Focal parameters + - Remark + * - ``"aki"`` + - Aki and Richard + - *strike*, *dip*, *rake*, *magnitude* + - angles in degrees + * - ``"gcmt"`` + - global centroid moment tensor + - | *strike1*, *dip1*, *rake1*, + | *strike2*, *dip2*, *rake2*, + | *mantissa*, *exponent* + - | angles in degrees; + | seismic moment is + | :math:`mantissa * 10 ^ {{exponent}}` + | in dyn cm + * - ``"mt"`` + - seismic moment tensor + - | *mrr*, *mtt*, *mff*, + | *mrt*, *mrf*, *mtf*, + | *exponent* + - | moment components + | in :math:`10 ^ {{exponent}}` dyn cm + * - ``"partial"`` + - partial focal mechanism + - | *strike1*, *dip1*, *strike2*, + | *fault_type*, *magnitude* + - | angles in degrees; + | *fault_type* means +1/-1 for + | normal/reverse fault + * - ``"principal_axis"`` + - principal axis + - | *t_value*, *t_azimuth*, *t_plunge*, + | *n_value*, *n_azimuth*, *n_plunge*, + | *p_value*, *p_azimuth*, *p_plunge*, + | *exponent* + - | values in :math:`10 ^ {{exponent}}` dyn cm; + | azimuths and plunges in degrees + Full option list at :gmt-docs:`supplements/seis/coupe.html` + {aliases} + Parameters ---------- spec : str, 1-D array, 2-D array, dict, or pd.DataFrame @@ -86,23 +135,18 @@ def coupe( The meanings of columns are the same as above. - *2-D array*: focal mechanism parameters of multiple events. The meanings of columns are the same as above. - - *dictionary or pd.DataFrame*: The dictionary keys or pd.DataFrame - column names determine the focal mechanism convention. For - different conventions, the following combination of keys are allowed: + - *dict* or :class:`pandas.DataFrame`: The dict keys or + :class:`pandas.DataFrame` column names determine the focal mechanism + convention. For the different conventions, the combination of keys / + column names as given in the table above are required. + + A dict may contain values for a single focal mechanism or lists of + values for multiple focal mechanisms. - - ``"aki"``: *strike, dip, rake, magnitude* - - ``"gcmt"``: *strike1, dip1, rake1, strike2, dip2, rake2, mantissa,* - *exponent* - - ``"mt"``: *mrr, mtt, mff, mrt, mrf, mtf, exponent* - - ``"partial"``: *strike1, dip1, strike2, fault_type, magnitude* - - ``"principal_axis"``: *t_value, t_azimuth, t_plunge, n_value, - n_azimuth, n_plunge, p_value, p_azimuth, p_plunge, exponent* + Both dict and :class:`pandas.DataFrame` may optionally contain the keys / + column names: ``latitude``, ``longitude``, ``depth``, ``plot_longitude``, + ``plot_latitude``, and/or ``event_name``. - A dictionary may contain values for a single focal mechanism or - lists of values for multiple focal mechanisms. - Both dictionary and pd.DataFrame may optionally contain - keys/column names: ``latitude``, ``longitude``, ``depth``, - ``plot_longitude``, ``plot_latitude``, and/or ``event_name``. If ``spec`` is either a str, a 1-D array or a 2-D array, the ``convention`` parameter is required so we know how to interpret the columns. If ``spec`` is a dict or a :class:`pandas.DataFrame`, @@ -239,19 +283,11 @@ def coupe( raise GMTInvalidInput("The `section` parameter must be specified.") kwargs["A"] = section_convention_code(section_format) + kwargs["A"] - # Convert spec to pandas.DataFrame unless it's a file - if isinstance(spec, (dict, pd.DataFrame)): # spec is a dict or pd.DataFrame - # determine convention from dict keys or pd.DataFrame column names - for conv in ["aki", "gcmt", "mt", "partial", "pricipal_axis"]: - if set(convention_params(conv)).issubset(set(spec.keys())): - convention = conv - break - else: - if isinstance(spec, dict): - msg = "Keys in dict 'spec' do not match known conventions." - else: - msg = "Column names in pd.DataFrame 'spec' do not match known conventions." - raise GMTError(msg) + if isinstance(spec, dict | pd.DataFrame): # spec is a dict or pd.DataFrame + # Determine convention from dict keys or pd.DataFrame column names + _convention = _FocalMechanismConvention.from_params( + spec.keys(), component=component + ) # convert dict to pd.DataFrame so columns can be reordered if isinstance(spec, dict): @@ -265,12 +301,14 @@ def coupe( if convention is None: msg = "'convention' must be specified for an array input." raise GMTInvalidInput(msg) - # make sure convention is a name, not a code - convention = convention_name(convention) + + _convention = _FocalMechanismConvention( + convention=convention, component=component + ) # Convert array to pd.DataFrame and assign column names spec = pd.DataFrame(np.atleast_2d(spec)) - colnames = ["longitude", "latitude", "depth", *convention_params(convention)] + colnames = ["longitude", "latitude", "depth", *_convention.params] # check if spec has the expected number of columns ncolsdiff = len(spec.columns) - len(colnames) if ncolsdiff == 0: @@ -287,37 +325,49 @@ def coupe( ) raise GMTInvalidInput(msg) spec.columns = colnames + else: + _convention = _FocalMechanismConvention( + convention=convention, component=component + ) # Now spec is a pd.DataFrame or a file if isinstance(spec, pd.DataFrame): # override the values in pd.DataFrame if parameters are given - if longitude is not None: - spec["longitude"] = np.atleast_1d(longitude) - if latitude is not None: - spec["latitude"] = np.atleast_1d(latitude) - if depth is not None: - spec["depth"] = np.atleast_1d(depth) - if event_name is not None: - spec["event_name"] = np.atleast_1d(event_name) + for arg, name in [ + (longitude, "longitude"), + (latitude, "latitude"), + (depth, "depth"), + (plot_longitude, "plot_longitude"), + (plot_latitude, "plot_latitude"), + (event_name, "event_name"), + ]: + if arg is not None: + spec[name] = np.atleast_1d(arg) # Due to the internal implementation of the meca module, we need to # convert the following columns to strings if they exist + if "plot_longitude" in spec.columns and "plot_latitude" in spec.columns: + spec["plot_longitude"] = spec["plot_longitude"].astype(str) + spec["plot_latitude"] = spec["plot_latitude"].astype(str) if "event_name" in spec.columns: spec["event_name"] = spec["event_name"].astype(str) # Reorder columns in DataFrame to match convention if necessary # expected columns are: # longitude, latitude, depth, focal_parameters, - # [event_name] - newcols = ["longitude", "latitude", "depth"] + convention_params(convention) + # [plot_longitude, plot_latitude] [event_name] + newcols = ["longitude", "latitude", "depth", *_convention.params] + if "plot_longitude" in spec.columns and "plot_latitude" in spec.columns: + newcols += ["plot_longitude", "plot_latitude"] + if kwargs.get("A") is None: + kwargs["A"] = True if "event_name" in spec.columns: newcols += ["event_name"] # reorder columns in DataFrame if spec.columns.tolist() != newcols: spec = spec.reindex(newcols, axis=1) - data_format = convention_code(convention=convention, component=component) - kwargs["S"] = f"{data_format}{scale}" + kwargs["S"] = f"{_convention.code}{scale}" with Session() as lib: # Choose how data will be passed into the module From 86fbc4cbf2ea5c351045fa2f418300319298f46a Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 23:05:53 +0800 Subject: [PATCH 08/16] adding test for P T axis --- pygmt/tests/test_coupe.py | 44 ++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index 1a150228dae..3f56520d891 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -89,7 +89,7 @@ def test_coupe_spec_single_focalmecha_file(): fig.basemap(region=[0, 1000, 0, 30], projection="X8c", frame=True) with GMTTempFile() as temp: Path(temp.name).write_text("112 32 25 30 90 0 4", encoding="utf-8") - fig.coupe(spec=temp.name, convention="aki", scale="2.5c", + fig.coupe(spec=temp.name, convention="aki", scale="2.5c", no_file=True, section=[110, 33, 120, 33], section_format="lonlat_lonlat") return fig @@ -232,9 +232,7 @@ def test_coupe_eventname(inputtype): @pytest.mark.benchmark @pytest.mark.mpl_image_compare(filename="test_coupe_vertical_profile.png") -@pytest.mark.parametrize( - "inputtype", ["dict_mecha",] -) +@pytest.mark.parametrize("inputtype", ["dict_mecha"]) def test_coupe_vertical_profile(inputtype): """ Test passing vertical profile. @@ -258,7 +256,8 @@ def test_coupe_vertical_profile(inputtype): } fig = Figure() - fig.coupe(projection="X15c/-6c", + fig.coupe( + projection="X15c/-6c", scale="0.8", section=[130, 43, 140, 36, 90, 100, 0, 700, "+f"], section_format="lonlat_lonlat", @@ -270,3 +269,38 @@ def test_coupe_vertical_profile(inputtype): fig.basemap(frame=True) return fig + +@pytest.mark.benchmark +@pytest.mark.mpl_image_compare(filename="test_coupe_PT_axis.png") +@pytest.mark.parametrize("inputtype", ["dict_mecha"]) +def test_coupe_PT_axis(inputtype): + """ + Test plotting P and T axis with W-E cross-section + See example of https://docs.gmt-china.org/5.4/module/pscoupe/ + """ + + if inputtype == "dict_mecha": + args = { + "spec": {"strike1": [0], "dip1": [90], "rake1": [0], + "strike2": [90], "dip2": [90], "rake2": [180], + "mantissa": [1], "exponent": [24]}, + "longitude": [129.5], + "latitude": [10.5], + "depth": [10] + } + fig = Figure() + fig.coupe( + projection="X1.5c/-1.5c", + scale="0.4c", + section=[128, 11, 130, 11, 10, 60, 0, 100, "+f"], + section_format="lonlat_lonlat", + pt_axis="0.1c/cd", + no_clip=True, + no_file=True, + verbose=True, + **args + ) + fig.basemap(frame=True) + + return fig + From 03ff235a45a668004c4142556d5040785ee36fd6 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 23:15:44 +0800 Subject: [PATCH 09/16] modify docuemnts (following meca.py) --- pygmt/src/coupe.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index ad32f05c2ef..5320502b236 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -192,15 +192,11 @@ def coupe( no_file : bool, default to False If True, creates no output files in the current path. convention : str - Focal mechanism convention. Choose from: - - ``"aki"`` (Aki & Richards) - - ``"gcmt"`` (global CMT) - - ``"mt"`` (seismic moment tensor) - - ``"partial"`` (partial focal mechanism) - - ``"principal_axis"`` (principal axis) + Focal mechanism convention. See the table above for the supported conventions. Ignored if ``spec`` is a dict or :class:`pandas.DataFrame`. component : str The component of the seismic moment tensor to plot. + - ``"full"``: the full seismic moment tensor - ``"dc"``: the closest double couple defined from the moment tensor (zero trace and zero determinant) From 1ce87b0b4e47d9e62a481e7cb30c84afcdf68ee4 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Thu, 20 Feb 2025 23:17:51 +0800 Subject: [PATCH 10/16] add Description --- pygmt/tests/test_coupe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index 3f56520d891..af0fb19c86d 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -18,6 +18,7 @@ def test_coupe_spec_single_focalmecha(inputtype): """ Test passing a single focal mechanism to the spec parameter. + Original script: https://github.com/GenericMappingTools/gmt-for-geodesy/blob/main/05_seismology/beachball-cross-section.sh """ if inputtype == "dict_mecha": args = { @@ -297,7 +298,6 @@ def test_coupe_PT_axis(inputtype): pt_axis="0.1c/cd", no_clip=True, no_file=True, - verbose=True, **args ) fig.basemap(frame=True) From eb3b8bf74b7cfbe10ca71daf4a194c133017d757 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Fri, 21 Feb 2025 22:42:04 +0800 Subject: [PATCH 11/16] change to pt_axes --- pygmt/src/coupe.py | 16 +++++++++++----- pygmt/tests/test_coupe.py | 4 +--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 5320502b236..1a9eb7e3fb5 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -29,7 +29,7 @@ def section_convention_code(section_format): B="frame", C="cmap", E="extensionfill", - Fa="pt_axis", + Fa="pt_axes", Fr="labelbox", G="compressionfill", J="projection", @@ -174,15 +174,15 @@ def coupe( *section*\ a|b|c|dparams[+c[n|t]][+ddip][+r[a|e|dx]][+wwidth]\ [+z[s]a|e|dz|min/max]. a, b, c, and d are specified by *section_format*. - a: List of four float values of the longitude and latitude of points 1 and 2 + - a: List of four float values of the longitude and latitude of points 1 and 2 limiting the length of the cross-section. - b: List of four float values of the longitude and latitude of + - b: List of four float values of the longitude and latitude of the beginning of the cross-section, strike is the azimuth of the direction of the cross-section, and length is the length along which the cross-section is made (in km). - c: List of four float values the same as `a` option + - c: List of four float values the same as `a` option with x and y given as Cartesian coordinates. - d: List of four float values the same as `b` option + - d: List of four float values the same as `b` option with x and y given as Cartesian coordinates. section_format : str, `"lonlat_lonlat"` `"lonlat_lonlat"`: a @@ -209,6 +209,12 @@ def coupe( Text string(s), e.g., event name(s) to appear near the beachball(s). List must be the same length as the number of events. Will override the ``event_name`` labels in ``spec`` if ``spec`` is a dict or :class:`pandas.DataFrame`. + pt_axes : bool or str + [*size*[/*Psymbol*[*Tsymbol*]]] + Compute and plot P and T axes with symbols. Optionally specify size and + (separate) P and T axis symbols from the following: (c) circle, + (d) diamond, (h) hexagon, (i) inverse triangle, (p) point, (s) square, + (t) triangle, (x) cross. [Default is ``"6p/cc"``] labelbox : bool or str [*fill*]. Draw a box behind the label if given. Use *fill* to give a fill color diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index af0fb19c86d..08cfa8b5df0 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -231,7 +231,6 @@ def test_coupe_eventname(inputtype): return fig -@pytest.mark.benchmark @pytest.mark.mpl_image_compare(filename="test_coupe_vertical_profile.png") @pytest.mark.parametrize("inputtype", ["dict_mecha"]) def test_coupe_vertical_profile(inputtype): @@ -271,7 +270,6 @@ def test_coupe_vertical_profile(inputtype): return fig -@pytest.mark.benchmark @pytest.mark.mpl_image_compare(filename="test_coupe_PT_axis.png") @pytest.mark.parametrize("inputtype", ["dict_mecha"]) def test_coupe_PT_axis(inputtype): @@ -295,7 +293,7 @@ def test_coupe_PT_axis(inputtype): scale="0.4c", section=[128, 11, 130, 11, 10, 60, 0, 100, "+f"], section_format="lonlat_lonlat", - pt_axis="0.1c/cd", + pt_axes=True, no_clip=True, no_file=True, **args From 1e6fd460ee432abda1ccb6f45c282b178ec65e2f Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Tue, 25 Feb 2025 23:10:51 +0800 Subject: [PATCH 12/16] update document --- pygmt/src/coupe.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 1a9eb7e3fb5..3827b152817 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -5,7 +5,7 @@ import numpy as np import pandas as pd from pygmt.clib import Session -from pygmt.exceptions import GMTError, GMTInvalidInput +from pygmt.exceptions import GMTInvalidInput from pygmt.helpers import build_arg_list, fmt_docstring, kwargs_to_strings, use_alias from pygmt.src._common import _FocalMechanismConvention @@ -62,6 +62,7 @@ def coupe( ): r""" Plot focal mechanisms in a cross section. + This function/method is copied from `pygmt.src.meca.meca()` The following focal mechanism conventions are supported: @@ -121,6 +122,7 @@ def coupe( ``spec`` can be specified in either of the following types: - *str*: a file name containing focal mechanism parameters as columns. The meaning of each column is: + - Columns 1 and 2: event longitude and latitude - Column 3: event depth (in km) - Columns 4 to 3+n: focal mechanism parameters. The number of columns @@ -169,28 +171,30 @@ def coupe( append **+j**\ *justify* to change the text location relative to the beachball [Default is ``"TC"``, i.e., Top Center]; append **+o** to offset the text string by *dx*\ /*dy*. - section : list, or str + section : list or str Cross-section parameters. *section*\ a|b|c|dparams[+c[n|t]][+ddip][+r[a|e|dx]][+wwidth]\ [+z[s]a|e|dz|min/max]. a, b, c, and d are specified by *section_format*. + - a: List of four float values of the longitude and latitude of points 1 and 2 limiting the length of the cross-section. - - b: List of four float values of the longitude and latitude of - the beginning of the cross-section, strike is the azimuth of + - b: List of four float values of the longitude and latitude of + the beginning of the cross-section, strike is the azimuth of the direction of the cross-section, and length is the length along which the cross-section is made (in km). - - c: List of four float values the same as `a` option + - c: List of four float values the same as `a` option with x and y given as Cartesian coordinates. - d: List of four float values the same as `b` option with x and y given as Cartesian coordinates. - section_format : str, `"lonlat_lonlat"` - `"lonlat_lonlat"`: a - `"lonlat_strlen"`: b - `"xy_xy"`: c - `"xy_strlen"`: d - no_file : bool, default to False - If True, creates no output files in the current path. + section_format : str + + - ``"lonlat_lonlat"``: a + - ``"lonlat_strlen"``: b + - ``"xy_xy"``: c + - ``"xy_strlen"``: d + no_file : bool + If True, creates no output files in the current path. [Default is ``False``]. convention : str Focal mechanism convention. See the table above for the supported conventions. Ignored if ``spec`` is a dict or :class:`pandas.DataFrame`. From e2b977237e1558985eb23d45e5b3040bd7431e10 Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Tue, 25 Feb 2025 23:19:21 +0800 Subject: [PATCH 13/16] remove space --- pygmt/tests/test_coupe.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/pygmt/tests/test_coupe.py b/pygmt/tests/test_coupe.py index 08cfa8b5df0..89999e4a957 100644 --- a/pygmt/tests/test_coupe.py +++ b/pygmt/tests/test_coupe.py @@ -66,10 +66,10 @@ def test_coupe_spec_single_focalmecha(inputtype): fig.shift_origin(yshift="5.5c") fig.basemap(region=[0, 1000, 0, 30], projection="X8c/-4c", frame=True) fig.coupe( - scale="2.5c", - section=[110, 33, 120, 33], + scale="2.5c", + section=[110, 33, 120, 33], section_format="lonlat_lonlat", - no_file=True, + no_file=True, **args ) @@ -160,10 +160,10 @@ def test_coupe_spec_multiple_focalmecha(inputtype): fig.shift_origin(yshift="5.5c") fig.basemap(region=[0, 1000, 0, 60], projection="X8c/-4c", frame=True) fig.coupe( - scale="1.5c", - section=[110, 33, 120, 33], - section_format="lonlat_lonlat", - no_file=True, + scale="1.5c", + section=[110, 33, 120, 33], + section_format="lonlat_lonlat", + no_file=True, **args ) return fig @@ -223,9 +223,9 @@ def test_coupe_eventname(inputtype): fig.basemap(region=[0, 1000, 0, 30], projection="X8c/-4c", frame=True) fig.coupe( scale="1.5c", - section=[110, 33, 120, 33], - section_format="lonlat_lonlat", - no_file=True, + section=[110, 33, 120, 33], + section_format="lonlat_lonlat", + no_file=True, **args ) return fig @@ -258,9 +258,9 @@ def test_coupe_vertical_profile(inputtype): fig = Figure() fig.coupe( projection="X15c/-6c", - scale="0.8", - section=[130, 43, 140, 36, 90, 100, 0, 700, "+f"], - section_format="lonlat_lonlat", + scale="0.8", + section=[130, 43, 140, 36, 90, 100, 0, 700, "+f"], + section_format="lonlat_lonlat", component="dc", no_clip=True, no_file=True, @@ -271,10 +271,10 @@ def test_coupe_vertical_profile(inputtype): return fig @pytest.mark.mpl_image_compare(filename="test_coupe_PT_axis.png") -@pytest.mark.parametrize("inputtype", ["dict_mecha"]) +@pytest.mark.parametrize("inputtype", ["dict_mecha"]) def test_coupe_PT_axis(inputtype): """ - Test plotting P and T axis with W-E cross-section + Test plotting P and T axis with W-E cross-section. See example of https://docs.gmt-china.org/5.4/module/pscoupe/ """ @@ -290,9 +290,9 @@ def test_coupe_PT_axis(inputtype): fig = Figure() fig.coupe( projection="X1.5c/-1.5c", - scale="0.4c", - section=[128, 11, 130, 11, 10, 60, 0, 100, "+f"], - section_format="lonlat_lonlat", + scale="0.4c", + section=[128, 11, 130, 11, 10, 60, 0, 100, "+f"], + section_format="lonlat_lonlat", pt_axes=True, no_clip=True, no_file=True, From d095a2d0ac57e142801be34dcedf7547777baf6e Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Fri, 7 Mar 2025 22:24:48 +0800 Subject: [PATCH 14/16] updated coupe with latest meca --- pygmt/src/coupe.py | 205 ++++++++++++++++++++++++++------------------- 1 file changed, 119 insertions(+), 86 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 3827b152817..09c492bdf9f 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -6,7 +6,13 @@ import pandas as pd from pygmt.clib import Session from pygmt.exceptions import GMTInvalidInput -from pygmt.helpers import build_arg_list, fmt_docstring, kwargs_to_strings, use_alias +from pygmt.helpers import ( + build_arg_list, + data_kind, + fmt_docstring, + kwargs_to_strings, + use_alias, +) from pygmt.src._common import _FocalMechanismConvention def section_convention_code(section_format): @@ -22,6 +28,97 @@ def section_convention_code(section_format): return codes[section_format] else: raise GMTInvalidInput(f"Invalid section format '{section_format}'.") + +def _get_focal_convention(spec, convention, component) -> _FocalMechanismConvention: + """ + Determine the focal mechanism convention from the input data or parameters. + """ + # Determine the convention from dictionary keys or pandas.DataFrame column names. + if hasattr(spec, "keys"): # Dictionary or pandas.DataFrame + return _FocalMechanismConvention.from_params(spec.keys(), component=component) + + # Determine the convention from the 'convention' parameter. + if convention is None: + msg = "Parameter 'convention' must be specified." + raise GMTInvalidInput(msg) + return _FocalMechanismConvention(convention=convention, component=component) + + +def _preprocess_spec(spec, colnames, override_cols): + """ + Preprocess the input data. + + Parameters + ---------- + spec + The input data to be preprocessed. + colnames + The minimum required column names of the input data. + override_cols + Dictionary of column names and values to override in the input data. Only makes + sense if ``spec`` is a dict or :class:`pandas.DataFrame`. + """ + kind = data_kind(spec) # Determine the kind of the input data. + + # Convert pandas.DataFrame and numpy.ndarray to dict. + if isinstance(spec, pd.DataFrame): + spec = {k: v.to_numpy() for k, v in spec.items()} + elif isinstance(spec, np.ndarray): + spec = np.atleast_2d(spec) + # Optional columns that are not required by the convention. The key is the + # number of extra columns, and the value is a list of optional column names. + extra_cols = { + 0: [], + 1: ["event_name"], + 2: ["plot_longitude", "plot_latitude"], + 3: ["plot_longitude", "plot_latitude", "event_name"], + } + ndiff = spec.shape[1] - len(colnames) + if ndiff not in extra_cols: + msg = f"Input array must have {len(colnames)} or two/three more columns." + raise GMTInvalidInput(msg) + spec = dict(zip([*colnames, *extra_cols[ndiff]], spec.T, strict=False)) + + # Now, the input data is a dict or an ASCII file. + if isinstance(spec, dict): + # The columns can be overridden by the parameters given in the function + # arguments. Only makes sense for dict/pandas.DataFrame input. + if kind != "matrix" and override_cols is not None: + spec.update({k: v for k, v in override_cols.items() if v is not None}) + # Due to the internal implementation of the meca module, we need to convert the + # ``plot_longitude``, ``plot_latitude``, and ``event_name`` columns into strings + # if they exist. + for key in ["plot_longitude", "plot_latitude", "event_name"]: + if key in spec: + spec[key] = np.array(spec[key], dtype=str) + + # Reorder columns to match convention if necessary. The expected columns are: + # longitude, latitude, depth, focal_parameters, [plot_longitude, plot_latitude], + # [event_name]. + extra_cols = [] + if "plot_longitude" in spec and "plot_latitude" in spec: + extra_cols.extend(["plot_longitude", "plot_latitude"]) + if "event_name" in spec: + extra_cols.append("event_name") + cols = [*colnames, *extra_cols] + if list(spec.keys()) != cols: + spec = {k: spec[k] for k in cols} + return spec + + +def _auto_offset(spec) -> bool: + """ + Determine if offset should be set based on the input data. + + If the input data contains ``plot_longitude`` and ``plot_latitude``, then we set the + ``offset`` parameter to ``True`` automatically. + """ + return ( + isinstance(spec, dict | pd.DataFrame) + and "plot_longitude" in spec + and "plot_latitude" in spec + ) + @fmt_docstring @use_alias( @@ -289,92 +386,28 @@ def coupe( raise GMTInvalidInput("The `section` parameter must be specified.") kwargs["A"] = section_convention_code(section_format) + kwargs["A"] - if isinstance(spec, dict | pd.DataFrame): # spec is a dict or pd.DataFrame - # Determine convention from dict keys or pd.DataFrame column names - _convention = _FocalMechanismConvention.from_params( - spec.keys(), component=component - ) - - # convert dict to pd.DataFrame so columns can be reordered - if isinstance(spec, dict): - # convert values to ndarray so pandas doesn't complain about "all - # scalar values". See - # https://github.com/GenericMappingTools/pygmt/pull/2174 - spec = pd.DataFrame( - {key: np.atleast_1d(value) for key, value in spec.items()} - ) - elif isinstance(spec, np.ndarray): # spec is a numpy array - if convention is None: - msg = "'convention' must be specified for an array input." - raise GMTInvalidInput(msg) - - _convention = _FocalMechanismConvention( - convention=convention, component=component - ) - - # Convert array to pd.DataFrame and assign column names - spec = pd.DataFrame(np.atleast_2d(spec)) - colnames = ["longitude", "latitude", "depth", *_convention.params] - # check if spec has the expected number of columns - ncolsdiff = len(spec.columns) - len(colnames) - if ncolsdiff == 0: - pass - elif ncolsdiff == 1: - colnames += ["event_name"] - elif ncolsdiff == 2: - colnames += ["plot_longitude", "plot_latitude"] - elif ncolsdiff == 3: - colnames += ["plot_longitude", "plot_latitude", "event_name"] - else: - msg = ( - f"Input array must have {len(colnames)} to {len(colnames) + 3} columns." - ) - raise GMTInvalidInput(msg) - spec.columns = colnames - else: - _convention = _FocalMechanismConvention( - convention=convention, component=component - ) - - # Now spec is a pd.DataFrame or a file - if isinstance(spec, pd.DataFrame): - # override the values in pd.DataFrame if parameters are given - for arg, name in [ - (longitude, "longitude"), - (latitude, "latitude"), - (depth, "depth"), - (plot_longitude, "plot_longitude"), - (plot_latitude, "plot_latitude"), - (event_name, "event_name"), - ]: - if arg is not None: - spec[name] = np.atleast_1d(arg) - - # Due to the internal implementation of the meca module, we need to - # convert the following columns to strings if they exist - if "plot_longitude" in spec.columns and "plot_latitude" in spec.columns: - spec["plot_longitude"] = spec["plot_longitude"].astype(str) - spec["plot_latitude"] = spec["plot_latitude"].astype(str) - if "event_name" in spec.columns: - spec["event_name"] = spec["event_name"].astype(str) - - # Reorder columns in DataFrame to match convention if necessary - # expected columns are: - # longitude, latitude, depth, focal_parameters, - # [plot_longitude, plot_latitude] [event_name] - newcols = ["longitude", "latitude", "depth", *_convention.params] - if "plot_longitude" in spec.columns and "plot_latitude" in spec.columns: - newcols += ["plot_longitude", "plot_latitude"] - if kwargs.get("A") is None: - kwargs["A"] = True - if "event_name" in spec.columns: - newcols += ["event_name"] - # reorder columns in DataFrame - if spec.columns.tolist() != newcols: - spec = spec.reindex(newcols, axis=1) - + # Determine the focal mechanism convention from the input data or parameters. + _convention = _get_focal_convention(spec, convention, component) + # Preprocess the input data. + spec = _preprocess_spec( + spec, + # The minimum expected columns for the input data. + colnames=["longitude", "latitude", "depth", *_convention.params], + override_cols={ + "longitude": longitude, + "latitude": latitude, + "depth": depth, + "plot_longitude": plot_longitude, + "plot_latitude": plot_latitude, + "event_name": event_name, + }, + ) + + # Determine the offset parameter if not provided. + if kwargs.get("A") is None: + kwargs["A"] = _auto_offset(spec) kwargs["S"] = f"{_convention.code}{scale}" - + with Session() as lib: # Choose how data will be passed into the module file_context = lib.virtualfile_in(check_kind="vector", data=spec) From bbf40f4bbdac2aacc84accdf8baceb3ce7509a6a Mon Sep 17 00:00:00 2001 From: jhtong33 Date: Fri, 7 Mar 2025 23:00:03 +0800 Subject: [PATCH 15/16] simpfied code --- pygmt/src/coupe.py | 92 +--------------------------------------------- 1 file changed, 1 insertion(+), 91 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 09c492bdf9f..8ca63fbbd7c 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -13,7 +13,7 @@ kwargs_to_strings, use_alias, ) -from pygmt.src._common import _FocalMechanismConvention +from pygmt.src.meca import _get_focal_convention, _preprocess_spec, _auto_offset def section_convention_code(section_format): @@ -29,96 +29,6 @@ def section_convention_code(section_format): else: raise GMTInvalidInput(f"Invalid section format '{section_format}'.") -def _get_focal_convention(spec, convention, component) -> _FocalMechanismConvention: - """ - Determine the focal mechanism convention from the input data or parameters. - """ - # Determine the convention from dictionary keys or pandas.DataFrame column names. - if hasattr(spec, "keys"): # Dictionary or pandas.DataFrame - return _FocalMechanismConvention.from_params(spec.keys(), component=component) - - # Determine the convention from the 'convention' parameter. - if convention is None: - msg = "Parameter 'convention' must be specified." - raise GMTInvalidInput(msg) - return _FocalMechanismConvention(convention=convention, component=component) - - -def _preprocess_spec(spec, colnames, override_cols): - """ - Preprocess the input data. - - Parameters - ---------- - spec - The input data to be preprocessed. - colnames - The minimum required column names of the input data. - override_cols - Dictionary of column names and values to override in the input data. Only makes - sense if ``spec`` is a dict or :class:`pandas.DataFrame`. - """ - kind = data_kind(spec) # Determine the kind of the input data. - - # Convert pandas.DataFrame and numpy.ndarray to dict. - if isinstance(spec, pd.DataFrame): - spec = {k: v.to_numpy() for k, v in spec.items()} - elif isinstance(spec, np.ndarray): - spec = np.atleast_2d(spec) - # Optional columns that are not required by the convention. The key is the - # number of extra columns, and the value is a list of optional column names. - extra_cols = { - 0: [], - 1: ["event_name"], - 2: ["plot_longitude", "plot_latitude"], - 3: ["plot_longitude", "plot_latitude", "event_name"], - } - ndiff = spec.shape[1] - len(colnames) - if ndiff not in extra_cols: - msg = f"Input array must have {len(colnames)} or two/three more columns." - raise GMTInvalidInput(msg) - spec = dict(zip([*colnames, *extra_cols[ndiff]], spec.T, strict=False)) - - # Now, the input data is a dict or an ASCII file. - if isinstance(spec, dict): - # The columns can be overridden by the parameters given in the function - # arguments. Only makes sense for dict/pandas.DataFrame input. - if kind != "matrix" and override_cols is not None: - spec.update({k: v for k, v in override_cols.items() if v is not None}) - # Due to the internal implementation of the meca module, we need to convert the - # ``plot_longitude``, ``plot_latitude``, and ``event_name`` columns into strings - # if they exist. - for key in ["plot_longitude", "plot_latitude", "event_name"]: - if key in spec: - spec[key] = np.array(spec[key], dtype=str) - - # Reorder columns to match convention if necessary. The expected columns are: - # longitude, latitude, depth, focal_parameters, [plot_longitude, plot_latitude], - # [event_name]. - extra_cols = [] - if "plot_longitude" in spec and "plot_latitude" in spec: - extra_cols.extend(["plot_longitude", "plot_latitude"]) - if "event_name" in spec: - extra_cols.append("event_name") - cols = [*colnames, *extra_cols] - if list(spec.keys()) != cols: - spec = {k: spec[k] for k in cols} - return spec - - -def _auto_offset(spec) -> bool: - """ - Determine if offset should be set based on the input data. - - If the input data contains ``plot_longitude`` and ``plot_latitude``, then we set the - ``offset`` parameter to ``True`` automatically. - """ - return ( - isinstance(spec, dict | pd.DataFrame) - and "plot_longitude" in spec - and "plot_latitude" in spec - ) - @fmt_docstring @use_alias( From ec57ad9d7d3cb183c4ce2ac535e6610dc53557ed Mon Sep 17 00:00:00 2001 From: Tong Date: Sat, 22 Mar 2025 20:52:33 +0800 Subject: [PATCH 16/16] Update pygmt/src/coupe.py Co-authored-by: Dongdong Tian --- pygmt/src/coupe.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pygmt/src/coupe.py b/pygmt/src/coupe.py index 8ca63fbbd7c..95012d49827 100644 --- a/pygmt/src/coupe.py +++ b/pygmt/src/coupe.py @@ -24,10 +24,10 @@ def section_convention_code(section_format): "xy_strlen": "d" } - if section_format in codes: - return codes[section_format] - else: - raise GMTInvalidInput(f"Invalid section format '{section_format}'.") + if section_format not in codes: + msg = f"Invalid section format '{section_format}'." + raise GMTInvalidInput(msg) + return codes[section_format] @fmt_docstring