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
0 commit comments