Skip to content

Commit 09647fe

Browse files
committed
Add Topography class
1 parent dd24730 commit 09647fe

File tree

19 files changed

+3784
-926
lines changed

19 files changed

+3784
-926
lines changed

conftest.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,74 @@
88
data files, ensuring that tests can be run consistently from any
99
directory.
1010
"""
11-
import pytest
11+
12+
from __future__ import annotations
13+
1214
from pathlib import Path
15+
import pytest
16+
1317

1418
def get_project_root() -> Path:
15-
"""Find the project root by looking for the 'pycsamt' dir."""
16-
# Starts from the current file and goes up the hierarchy
17-
current_path = Path(__file__).resolve()
18-
for parent in current_path.parents:
19-
if (parent / 'pycsamt').is_dir():
19+
"""Find the repo root by locating the 'pycsamt' dir."""
20+
cur = Path(__file__).resolve()
21+
for parent in cur.parents:
22+
if (parent / "pycsamt").is_dir():
2023
return parent
21-
raise FileNotFoundError("Could not find project root.")
24+
raise FileNotFoundError("Project root not found.")
25+
2226

2327
@pytest.fixture(scope="session")
2428
def project_root() -> Path:
25-
"""Fixture to provide the project's root directory."""
29+
"""Project root directory."""
2630
return get_project_root()
2731

32+
2833
@pytest.fixture(scope="session")
29-
def data_path(project_root) -> Path:
30-
"""Fixture to provide the path to the test data directory."""
34+
def data_path(project_root: Path) -> Path:
35+
"""Base path to bundled test data."""
3136
return project_root / "data" / "avg"
3237

38+
39+
@pytest.fixture(scope="session")
40+
def legacy_data_file(data_path: Path) -> Path:
41+
"""
42+
Path to legacy K1.AVG file; skip if missing.
43+
"""
44+
p = data_path / "K1.AVG"
45+
if not p.exists():
46+
pytest.skip(f"Missing legacy data: {p}")
47+
return p
48+
49+
50+
@pytest.fixture(scope="session")
51+
def modern_data_file(data_path: Path) -> Path:
52+
"""
53+
Path to modern K2.AVG file; skip if missing.
54+
"""
55+
p = data_path / "K2.AVG"
56+
if not p.exists():
57+
pytest.skip(f"Missing modern data: {p}")
58+
return p
59+
60+
3361
@pytest.fixture(scope="session")
34-
def legacy_data_file(data_path):
62+
def stn_file_k1(data_path: Path) -> Path:
3563
"""
36-
Fixture for the path to the legacy K1.AVG data file.
37-
Skips the test if the file is not found.
64+
Path to K1.stn topography file; skip if missing.
3865
"""
39-
file_path = data_path / "K1.AVG"
40-
if not file_path.exists():
41-
pytest.skip(f"Legacy data file not found at: {file_path}")
42-
return file_path
66+
p = data_path / "K1.stn"
67+
if not p.exists():
68+
pytest.skip(f"Missing STN file: {p}")
69+
return p
4370

4471
@pytest.fixture(scope="session")
45-
def modern_data_file(data_path):
72+
def stn_file_k2(data_path: Path) -> Path:
4673
"""
47-
Fixture for the path to the modern K2.AVG data file.
48-
Skips the test if the file is not found.
74+
Path to K2.stn topography file; skip if missing.
4975
"""
50-
file_path = data_path / "K2.AVG"
51-
if not file_path.exists():
52-
pytest.skip(f"Modern data file not found at: {file_path}")
53-
return file_path
76+
p = data_path / "K2.stn"
77+
if not p.exists():
78+
pytest.skip(f"Missing STN file: {p}")
79+
return p
80+
81+

pycsamt/gis/utils.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,188 @@
8686
'dms_to_decimal',
8787
"to_utm",
8888
"to_ll",
89+
"normalize_lat_lon",
8990
'GisError'
9091
]
9192

93+
@overload
94+
def normalize_lat_lon(
95+
a: Union[float, str],
96+
b: Union[float, str],
97+
*,
98+
assume: Literal["lonlat", "latlon", "auto"] = ...,
99+
error: Literal["ignore", "raise"] = ...,
100+
clip: bool = ...,
101+
) -> Tuple[float, float]:
102+
...
103+
104+
105+
@overload
106+
def normalize_lat_lon(
107+
a: Sequence[Union[float, str]],
108+
b: Sequence[Union[float, str]],
109+
*,
110+
assume: Literal["lonlat", "latlon", "auto"] = ...,
111+
error: Literal["ignore", "raise"] = ...,
112+
clip: bool = ...,
113+
) -> Tuple[np.ndarray, np.ndarray]:
114+
...
115+
116+
def normalize_lat_lon(
117+
a: Any,
118+
b: Any,
119+
*,
120+
assume: Literal["lonlat", "latlon", "auto"] = "lonlat",
121+
error: Literal["ignore", "raise"] = "ignore",
122+
clip: bool = False,
123+
):
124+
r"""
125+
Resolve input pair to ``(lat, lon)`` regardless of order.
126+
127+
Accepts values in either legacy order (lat, lon) or the
128+
new expected order (lon, lat). Returns a tuple in the
129+
canonical order ``(lat, lon)`` and avoids common
130+
``|Latitude| > 90`` errors by swapping when clear.
131+
132+
Parameters
133+
----------
134+
a, b : float or str, or 1-D sequences
135+
Coordinate components. Strings may be decimal or DMS
136+
(``"DD:MM:SS"``). Sequences must be same length.
137+
assume : {"lonlat","latlon","auto"}, default "lonlat"
138+
Tie-break for ambiguous pairs (both ``|val| <= 90``).
139+
140+
- ``"lonlat"``: treat input as (lon, lat).
141+
- ``"latlon"``: treat input as (lat, lon).
142+
- ``"auto"``: prefer (lon, lat) when ambiguous.
143+
144+
error : {"ignore","raise"}, default "ignore"
145+
On impossible pairs (both ``|val| > 90`` or both
146+
``|val| > 180``), either raise or return ``nan``s.
147+
clip : bool, default False
148+
If ``True``, clip lat to [-90, 90] and lon to
149+
[-180, 180] when slightly out-of-range.
150+
151+
Returns
152+
-------
153+
lat, lon : float or ndarray
154+
Coordinates in canonical order.
155+
156+
Notes
157+
-----
158+
Heuristics:
159+
160+
- If one value is in (90, 180] and the other in [-90, 90],
161+
the former is lon and the latter is lat.
162+
- If both are ``|val| <= 90``, use ``assume``.
163+
- Values with ``|val| > 180`` are invalid unless ``clip``.
164+
"""
165+
def _to_float(v):
166+
if v is None or v == "None":
167+
return np.nan
168+
if isinstance(v, str):
169+
try:
170+
return float(v)
171+
except Exception:
172+
try:
173+
return convert_position_str2float(v) # type: ignore # noqa: E501
174+
except Exception:
175+
return np.nan
176+
try:
177+
return float(v)
178+
except Exception:
179+
return np.nan
180+
181+
def _coerce_pair(x, y):
182+
xv = _to_float(x)
183+
yv = _to_float(y)
184+
185+
ax = abs(xv)
186+
ay = abs(yv)
187+
188+
# invalid domain checks
189+
if ax > 1e9 or ay > 1e9:
190+
xv = np.nan
191+
yv = np.nan
192+
193+
# quick invalid > 180
194+
if ax > 180 or ay > 180:
195+
if clip:
196+
xv = np.clip(xv, -180.0, 180.0)
197+
yv = np.clip(yv, -180.0, 180.0)
198+
ax = abs(xv)
199+
ay = abs(yv)
200+
else:
201+
if error == "raise":
202+
raise ValueError("Values exceed 180.")
203+
return (np.nan, np.nan)
204+
205+
# decisive cases
206+
if (90 < ax <= 180) and (ay <= 90):
207+
lon = xv
208+
lat = yv
209+
return (lat, lon)
210+
if (90 < ay <= 180) and (ax <= 90):
211+
lon = yv
212+
lat = xv
213+
return (lat, lon)
214+
215+
# both within 90 → ambiguous
216+
if ax <= 90 and ay <= 90:
217+
if assume == "latlon":
218+
lat, lon = xv, yv
219+
else:
220+
# "lonlat" and "auto" prefer lon,lat input
221+
lat, lon = yv, xv
222+
return (lat, lon)
223+
224+
# one slightly out of 90 but not decisive
225+
if clip:
226+
xv = np.clip(xv, -180.0, 180.0)
227+
yv = np.clip(yv, -180.0, 180.0)
228+
ax = abs(xv)
229+
ay = abs(yv)
230+
231+
# fallback: try assume
232+
if assume == "latlon":
233+
lat, lon = xv, yv
234+
else:
235+
lat, lon = yv, xv
236+
237+
# final range checks
238+
if abs(lat) > 90 or abs(lon) > 180:
239+
if error == "raise":
240+
raise ValueError("Unresolvable pair.")
241+
return (np.nan, np.nan)
242+
243+
return (lat, lon)
244+
245+
# scalar vs vector handling
246+
if np.isscalar(a) and np.isscalar(b):
247+
return _coerce_pair(a, b)
248+
249+
a_arr = np.asarray(a, dtype=object)
250+
b_arr = np.asarray(b, dtype=object)
251+
252+
if a_arr.shape != b_arr.shape:
253+
raise ValueError("Shapes of a and b must match.")
254+
255+
lat_out = np.empty_like(a_arr, dtype=float)
256+
lon_out = np.empty_like(b_arr, dtype=float)
257+
258+
it = np.nditer(
259+
[a_arr, b_arr, lat_out, lon_out],
260+
flags=["multi_index", "refs_ok"],
261+
op_flags=[["readonly"], ["readonly"],
262+
["writeonly"], ["writeonly"]],
263+
)
264+
for xa, xb, yl, yo in it:
265+
lat, lon = _coerce_pair(xa.item(), xb.item())
266+
yl[...] = lat
267+
yo[...] = lon
268+
269+
return lat_out, lon_out
270+
92271
def assert_xy_coordinate_system(x, y) -> str:
93272
r"""
94273
Infer the coordinate system of paired ``x`` and ``y`` arrays.
@@ -594,6 +773,7 @@ def convert_position_float2str(position: float) -> str:
594773
)
595774
return position_str
596775

776+
597777
@Deprecated(
598778
"GDAL SpatialReference → UTM string is deprecated; "
599779
"use 'get_utm_zone' for standard UTM formatting."

pycsamt/plot/base.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,13 @@ def __init__(
163163
s: float= 40.,
164164
cmap:str='jet_r',
165165
show_grid: bool = False,
166-
galpha: float = .5,
166+
galpha: float = .7,
167167
gaxis: str = 'both',
168168
gc: str = 'k',
169-
gls: str = '--',
170-
glw: float = 2.,
171-
gwhich: str = 'major',
169+
gls: str = ':',
170+
glw: float = 1.,
171+
gwhich: str = 'major',
172+
grid_props: dict = None,
172173
tp_axis: str = 'both',
173174
tp_labelsize: float = 3.,
174175
tp_bottom: bool =True,
@@ -238,6 +239,7 @@ def __init__(
238239
self.gls=gls
239240
self.glw=glw
240241
self.gwhich=gwhich
242+
self.grid_props= grid_props
241243
self.tp_axis=tp_axis
242244
self.tp_labelsize=tp_labelsize
243245
self.tp_bottom=tp_bottom
@@ -268,6 +270,7 @@ def __init__(
268270
for pname, pvalues in self.__dict__.items()
269271
if pname.startswith('cb_')
270272
}
273+
self.grid_props= grid_props or {"linestyle": self.gls, "alpha": self.galpha}
271274

272275
_PARAMS = OrderedDict(
273276
(p.name, p)

0 commit comments

Comments
 (0)