Skip to content

Commit a58e71f

Browse files
committed
add ctx module
- add catch_isis_error decorator for ISIS exception extraction - add general instrument utils module with func read_image into rioxarrays - ctx module supports 2 main classes: Raw and Calib
1 parent 4ec8b05 commit a58e71f

File tree

5 files changed

+353
-1
lines changed

5 files changed

+353
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,4 @@ dmypy.json
135135
run_migration.py
136136
src/planetarypy/pds/migrate_config.py
137137
src/planetarypy/scripts/migrate_pds_config.py
138+
src/planetarypy/instruments/print.prt

src/planetarypy/instruments/__init__.py

Whitespace-only changes.

src/planetarypy/instruments/ctx.py

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
"""Module for dealing with CTX data."""
2+
3+
# import warnings
4+
import os
5+
import warnings
6+
from functools import cached_property
7+
from pathlib import Path
8+
9+
import pooch
10+
import rasterio
11+
import tomlkit
12+
from loguru import logger
13+
from planetarypy.config import config
14+
from planetarypy.instruments import utils
15+
from planetarypy.pds import get_index
16+
from planetarypy.utils import catch_isis_error, file_variations
17+
from yarl import URL
18+
19+
try:
20+
from kalasiris.pysis import (
21+
cam2map,
22+
ctxcal,
23+
ctxevenodd,
24+
mroctx2isis,
25+
spiceinit,
26+
)
27+
except KeyError:
28+
warnings.warn("kalasiris has a problem initializing ISIS")
29+
import rioxarray as rxr
30+
31+
# idea for later
32+
# from planetarypy.instruments.base import Instrument
33+
# import warnings
34+
35+
storage_root = Path(config["storage_root"])
36+
37+
configpath = Path.home() / ".planetarypy_mro_ctx.toml"
38+
39+
try:
40+
ctxconfig = tomlkit.loads(configpath.read_text())
41+
except tomlkit.exceptions.TOMLKitError as e:
42+
print(f"Error parsing TOML file: {e}")
43+
ctxconfig = None
44+
except FileNotFoundError:
45+
raise FileNotFoundError(f"Configuration file not found at {configpath}")
46+
# warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
47+
48+
baseurl = URL(ctxconfig["raw"]["url"])
49+
50+
# local mirror is a potentially read-only local data server that many groups have.
51+
# usually a user can't write on it, hence extra treatment for it.
52+
# first lookup would be tried here, if it's set in config file.
53+
raw_local_mirror = ctxconfig["raw"]["local_mirror"]
54+
mirror_readable = os.access(raw_local_mirror, os.R_OK)
55+
mirror_writeable = os.access(raw_local_mirror, os.W_OK)
56+
57+
# The next is where we
58+
# 1. lookup data if raw_local_mirror is not set-up or not readable (like currently unmounted drive
59+
# 2. store new data that isn't on the local mirror if it is not writeable.
60+
raw_local_storage = ctxconfig["raw"]["local_storage"]
61+
62+
# consider different cases for raw_local_storage
63+
if not raw_local_storage: # empty string
64+
# this would be the default location for data retrieved by planetarypy
65+
raw_local_storage = storage_root / "missions/mro/ctx"
66+
else:
67+
# if then path given is not absolute, it will be attached to config.storage_root
68+
raw_local_storage = Path(raw_local_storage)
69+
if not raw_local_storage.is_absolute():
70+
raw_local_storage = storage_root / raw_local_storage
71+
72+
# make a cache for the index file to prevent repeated index loading
73+
cache = dict()
74+
75+
76+
def get_edr_index(refresh=False):
77+
"add some useful extra columns to the index."
78+
if "edrindex" in cache and not refresh:
79+
return cache["edrindex"]
80+
else:
81+
edrindex = get_index("mro.ctx.edr", refresh=refresh)
82+
edrindex["short_pid"] = edrindex.PRODUCT_ID.map(lambda x: x[:15])
83+
edrindex["month_col"] = edrindex.PRODUCT_ID.map(lambda x: x[:3])
84+
edrindex.LINE_SAMPLES = edrindex.LINE_SAMPLES.astype(int)
85+
cache["edrindex"] = edrindex
86+
return edrindex
87+
88+
89+
class Raw:
90+
def __init__(self, pid: str, refresh_index=False, prefer_mirror=True):
91+
self.pid = pid # product_id
92+
self.refresh_index = refresh_index
93+
self.with_volume = ctxconfig["raw"]["with_volume"]
94+
self.with_pid = ctxconfig["raw"]["with_pid"]
95+
self.prefer_mirror = prefer_mirror
96+
97+
@property
98+
def pid(self):
99+
return self._pid
100+
101+
@pid.setter
102+
def pid(self, value):
103+
if len(value) < 26:
104+
val = value[:15] # use short_pid
105+
self.edrindex = get_edr_index()
106+
value = self.edrindex.query(f"short_pid=='{val}'").PRODUCT_ID.iloc[0]
107+
self._pid = value
108+
109+
@property
110+
def short_pid(self):
111+
return self.pid[:15]
112+
113+
@cached_property
114+
def meta(self):
115+
"get the metadata from the index table"
116+
edrindex = get_edr_index(refresh=self.refresh_index)
117+
s = edrindex.query("PRODUCT_ID == @self.pid").squeeze()
118+
s.name = f"Metadata for {self.pid}"
119+
s.index = s.index.str.lower()
120+
return s
121+
122+
@property
123+
def is_data_ok(self):
124+
return True if self.meta.data_quality_desc.strip() == "OK" else False
125+
126+
@property
127+
def volume(self):
128+
"get the PDS volume number for the current product id"
129+
return self.meta.volume_id.lower()
130+
131+
def _check_and_add_sub_paths(self, base):
132+
base = Path(base) / self.volume if self.with_volume else base
133+
base = base / self.pid if self.with_pid else base
134+
return base
135+
136+
@property
137+
def fname(self):
138+
return self.pid + ".IMG"
139+
140+
@property
141+
def local_mirror_folder(self):
142+
if raw_local_mirror and mirror_readable:
143+
return self._check_and_add_sub_paths(raw_local_mirror)
144+
else:
145+
return None
146+
147+
@property
148+
def local_storage_folder(self):
149+
return self._check_and_add_sub_paths(raw_local_storage)
150+
151+
def _download(self, folder):
152+
return Path(
153+
pooch.retrieve(
154+
url=str(self.url),
155+
known_hash=None,
156+
fname=self.fname,
157+
path=folder,
158+
progressbar=True,
159+
)
160+
)
161+
162+
@property
163+
def path(self):
164+
# easiest case
165+
if not mirror_readable:
166+
return _download(self.local_storage_folder)
167+
# this checks the mirror always first for reading and writing
168+
# but falls back to local storage if things fail.
169+
if self.prefer_mirror:
170+
try:
171+
return self._download(self.local_mirror_folder)
172+
except Exception as e:
173+
logger.warning(
174+
"You preferred to use local mirror, but I can't access it.\n"
175+
"Using local_storage."
176+
)
177+
return self._download(self.local_storage_folder)
178+
179+
@property
180+
def url(self):
181+
"Calculate URL from input dataframe row."
182+
url = baseurl / self.meta.volume_id.lower() / "data" / (self.pid + ".IMG")
183+
return url
184+
185+
def __repr__(self):
186+
return f"Raw(pid='{self.pid}')"
187+
188+
def __str__(self):
189+
return self.__repr__()
190+
191+
192+
class Calib:
193+
"Manage processing of raw PDS files."
194+
195+
def __init__(
196+
self,
197+
pid, # CTX product_id
198+
destripe_to_calib=True, # if to copy destriped files as calib files or leave extra
199+
):
200+
self.pid = pid
201+
self.destripe_to_calib = destripe_to_calib
202+
self.raw = Raw(pid)
203+
(self.cub_name, self.cal_name, self.destripe_name, self.map_name) = (
204+
file_variations(
205+
self.raw.path.name,
206+
[
207+
".cub",
208+
f"{ctxconfig['calib']['calibrated_ext']}.cub",
209+
".dst.cal.cub",
210+
f"{ctxconfig['calib']['mapped_ext']}.cub",
211+
],
212+
)
213+
)
214+
self.with_volume = ctxconfig["calib"]["with_volume"]
215+
self.with_pid = ctxconfig["calib"]["with_pid"]
216+
self.spice_done = False
217+
218+
def _check_and_add_sub_paths(self, base):
219+
base = Path(base) / self.raw.volume if self.with_volume else base
220+
base = base / self.pid if self.with_pid else base
221+
return base
222+
223+
@property
224+
def storage_folder(self):
225+
if folder := ctxconfig["calib"]["storage"]:
226+
return self._check_and_add_sub_paths(folder)
227+
else:
228+
return self._check_and_add_sub_paths(
229+
Path(config["storage_root"]) / "missions/mro/ctx"
230+
)
231+
232+
@property
233+
def cub_path(self):
234+
return self.storage_folder / self.cub_name
235+
236+
@property
237+
def cal_path(self):
238+
return self.storage_folder / self.cal_name
239+
240+
@property
241+
def destripe_path(self):
242+
return self.storage_folder / self.destripe_name
243+
244+
@property
245+
def map_path(self):
246+
return self.storage_folder / self.map_name
247+
248+
@catch_isis_error
249+
def isis_import(self, refresh=False) -> None:
250+
"Import EDR data into ISIS cube."
251+
if not refresh and self.cub_path.is_file():
252+
return self.cub_path
253+
self.cub_path.parent.mkdir(exist_ok=True, parents=True)
254+
mroctx2isis(from_=self.raw.path, to=self.cub_path, _cwd=self.cub_path.parent)
255+
return self.cub_path
256+
257+
@catch_isis_error
258+
def spice_init(self, web="yes") -> None:
259+
"Perform `spiceinit.`"
260+
if not self.spice_done:
261+
spiceinit(from_=self.cub_path, web=web, _cwd=self.cub_path.parent)
262+
self.spice_done = True
263+
264+
@catch_isis_error
265+
def calibrate(self, refresh=False) -> None:
266+
"Do ISIS `ctxcal`."
267+
if self.cal_path.is_file() and not refresh:
268+
return self.cal_path
269+
ctxcal(from_=self.cub_path, to=self.cal_path, _cwd=self.cub_path.parent)
270+
return self.cal_path
271+
272+
@property
273+
def spatial_summing(self) -> int:
274+
"Get the spatial summing value from the index file."
275+
return int(self.raw.meta["spatial_summing"])
276+
277+
@catch_isis_error
278+
def destripe(self) -> None:
279+
"Do destriping via `ctxevenodd` if allowed by summing status."
280+
if self.spatial_summing != 2:
281+
ctxevenodd(
282+
from_=self.cal_path,
283+
to=self.destripe_path,
284+
_cwd=self.cub_path.parent
285+
)
286+
if self.destripe_to_calib:
287+
self.destripe_path.rename(self.cal_path)
288+
289+
@catch_isis_error
290+
def map_project(self, mpp=6.25) -> None:
291+
"Perform map projection."
292+
cal_path = self.cal_path if self.destripe_to_calib else self.destripe_path
293+
cam2map(
294+
from_=cal_path,
295+
to=self.map_path,
296+
pixres="mpp",
297+
resolution=mpp,
298+
_cwd=self.cub_path.parent
299+
)
300+
301+
def plot_any(self, path):
302+
"returns re-usable holoviews plot object"
303+
da = utils.read_image(path)
304+
return da.hvplot(rasterize=True, aspect="equal", cmap="gray")
305+
306+
def pipeline(self, project=False):
307+
logger.info("Importing...")
308+
self.isis_import()
309+
logger.info("Spiceinit...")
310+
self.spice_init()
311+
logger.info("Calibrating...")
312+
self.calibrate()
313+
logger.info("Destriping (if spatial summing allows...")
314+
self.destripe()
315+
if project:
316+
logger.info("Map projecting..")
317+
self.map_project()
318+
return self.map_path
319+
elif self.destripe_to_calib:
320+
return self.cal_path
321+
else:
322+
return self.destripe_path
323+
324+
325+
class CTXCollection:
326+
def __init__(self, list_of_pids):
327+
self.pids = list_of_pids
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import rioxarray as rxr
2+
3+
def read_image(p):
4+
"""Read all cubes or geotiffs in a standardized way."""
5+
da = rxr.open_rasterio(p, mask_and_scale=True, chunks=True).isel(band=0, drop=True)
6+
# the following is required for hvplot automagically doing the right thing
7+
da.x.attrs["axis"] = "X"
8+
da.y.attrs["axis"] = "Y"
9+
return da

src/planetarypy/utils.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import requests
2424
from requests.auth import HTTPBasicAuth
2525
from tqdm.auto import tqdm
26-
26+
from kalasiris.pysis import ProcessError
2727
from planetarypy.datetime import fromdoyformat
2828

2929
logger = logging.getLogger(__name__)
@@ -156,3 +156,18 @@ def file_variations(filename: Union[str, Path], extensions: list[str]) -> list[P
156156
raise TypeError("extensions must be a list")
157157

158158
return [Path(filename).with_suffix(extension) for extension in extensions]
159+
160+
161+
def catch_isis_error(func):
162+
"""can be used as decorator for any ISIS function"""
163+
164+
def inner(*args, **kwargs):
165+
try:
166+
return func(*args, **kwargs)
167+
except ProcessError as err:
168+
print("Had ISIS error:")
169+
print(" ".join(err.cmd))
170+
print(err.stdout)
171+
print(err.stderr)
172+
173+
return inner

0 commit comments

Comments
 (0)