001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas/
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see https://www.gnu.org/licenses/.
027 */
028
029package edu.wisc.ssec.mcidasv.data;
030
031import java.io.IOException;
032import java.nio.file.Paths;
033import java.util.ArrayList;
034import java.util.Arrays;
035import java.util.HashMap;
036import java.util.List;
037import java.util.ListIterator;
038import java.util.Map;
039import java.util.regex.Pattern;
040
041import edu.wisc.ssec.mcidasv.McIDASV;
042
043import org.slf4j.Logger;
044import org.slf4j.LoggerFactory;
045
046import ucar.unidata.io.RandomAccessFile;
047import ucar.ma2.Array;
048import ucar.ma2.DataType;
049import ucar.ma2.InvalidRangeException;
050import ucar.ma2.Section;
051import ucar.nc2.Attribute;
052import ucar.nc2.Dimension;
053import ucar.nc2.Group;
054import ucar.nc2.NetcdfFile;
055import ucar.nc2.Variable;
056import ucar.nc2.constants.AxisType;
057import ucar.nc2.constants._Coordinate;
058import ucar.nc2.iosp.AbstractIOServiceProvider;
059import ucar.nc2.util.CancelTask;
060
061import javax.swing.JOptionPane;
062
063/**
064 * @author tommyj
065 *
066 */
067
068public class TropomiIOSP extends AbstractIOServiceProvider {
069
070    private static final String LAT = "latitude";
071
072    private static final String LON = "longitude";
073
074    private static final Logger logger = LoggerFactory.getLogger(TropomiIOSP.class);
075    
076    private static final String BASE_GROUP = "PRODUCT";
077
078    private static final String TROPOMI_FIELD_SEPARATOR = "_";
079
080    // This regular expression matches TROPOMI L2 products
081    private static final String TROPOMI_L2_REGEX =
082            // Mission Name (ex: S5P)
083            "\\w\\w\\w" + TROPOMI_FIELD_SEPARATOR +
084            // Type of data: Real-Time, Offline, Reprocessed, or Products Algorithm Laboratory
085            "(NRTI|OFFL|RPRO|PAL_)" + TROPOMI_FIELD_SEPARATOR +
086            // Product Identifier
087            "(L2_|L1B)" + TROPOMI_FIELD_SEPARATOR +
088            // Product (can be up to six characters, separator-padded if less, e.g. CH4___)
089            "\\w\\w\\w\\w\\w\\w" + TROPOMI_FIELD_SEPARATOR +
090            // Start Date and Time (ex: YYYYmmddTHHMMSS)
091            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" + TROPOMI_FIELD_SEPARATOR +
092            // End Date and Time (ex: YYYYmmddTHHMMSS)
093            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" + TROPOMI_FIELD_SEPARATOR +
094            // Orbit Number
095            "\\d\\d\\d\\d\\d" + TROPOMI_FIELD_SEPARATOR +
096            // Collection Number
097            "\\d\\d" + TROPOMI_FIELD_SEPARATOR +
098            // Processor Version Number : MMmmpp (Major - Minor - Patch)
099            "\\d\\d\\d\\d\\d\\d" + TROPOMI_FIELD_SEPARATOR +
100            // Creation Date and Time (ex: YYYYmmddTHHMMSS)
101            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" +
102            // NetCDF suffix
103            ".nc";
104
105    /** Compiled representation of {@link #TROPOMI_L2_REGEX}. */
106    public static final Pattern TROPOMI_MATCHER =
107        Pattern.compile(TROPOMI_L2_REGEX);
108    
109    /**
110     * Sometimes {@link #isValidFile(RandomAccessFile)} will need to check
111     * Windows paths that look something like {@code /Z:/Users/bob/foo.txt}.
112     * 
113     * <p>This regular expression is used by {@code isValidFile(...)} to
114     * identity these sorts of paths and fix them. Otherwise we'll generate
115     * an {@link java.nio.file.InvalidPathException}.</p>
116     */
117    private static final Pattern BAD_WIN_PATH =
118        Pattern.compile("^/[A-Za-z]:/.+$");
119
120    private static HashMap<String, String> groupMap = new HashMap<String, String>();
121
122    // Dimensions of a product we can work with, init this early
123    private static int[] dimLen = null;
124    
125    private NetcdfFile hdfFile;
126    private static String filename;
127
128    @Override public boolean isValidFile(RandomAccessFile raf) {
129        // Uses the regex defined near top
130        String filePath = raf.getLocation();
131        // TJJ 2022 - For URLs, just fail the match
132        if (filePath.startsWith("https:")) {
133            return false;
134        }
135        if (McIDASV.isWindows() && BAD_WIN_PATH.matcher(filePath).matches()) {
136            filePath = filePath.substring(1);
137        }
138        logger.trace("original path: '{}', path used: '{}'", raf, filePath);
139        filename = Paths.get(filePath).getFileName().toString();
140        return TROPOMI_MATCHER.matcher(filename).matches();
141    }
142
143    @Override public void open(RandomAccessFile raf, NetcdfFile ncfile,
144                     CancelTask cancelTask) throws IOException
145    {
146        logger.trace("TropOMI IOSP open()...");
147
148        // TJJ - kick out anything not supported (most) L2 right now
149        if (filename.contains("_L1B_") || filename.contains("_L2__NP")) {
150            JOptionPane.showMessageDialog(null, "McIDAS-V is unable to read your file. " +
151                    "Only TROPOMI Level 2 Products are supported at this time.", "Warning", JOptionPane.OK_OPTION);
152            return;
153        }
154
155        try {
156            
157            hdfFile = NetcdfFile.open(
158               raf.getLocation(), "ucar.nc2.iosp.hdf5.H5iosp", -1, (CancelTask) null, (Object) null
159            );
160            
161            // Get the dimension lengths for product data if we haven't yet
162            dimLen = getDataShape(hdfFile);
163            // Just logging the dimensions here for debugging purposes
164            for (int i = 0; i < dimLen.length; i++) {
165                logger.trace("Product dimension[" + i + "]: " + dimLen[i]);
166            }
167
168            // Populate map pairing group name to any products we deem suitable for visualization
169            Map<String, List<Variable>> newGroups = getDataVars(hdfFile, dimLen);
170
171            ncfile.addDimension(null, new Dimension("line", dimLen[1]));
172            ncfile.addDimension(null, new Dimension("ele", dimLen[2]));
173            populateDataTree(ncfile, newGroups);
174
175            ncfile.finish();
176        } catch (ClassNotFoundException e) {
177            logger.error("error loading HDF5 IOSP", e);
178        } catch (IllegalAccessException e) {
179            logger.error("java reflection error", e);
180        } catch (InstantiationException e) {
181            logger.error("error instantiating", e);
182        }
183    }
184
185    /*
186     * Loop over all data looking for the products we can display
187     */
188
189    private static Map<String, List<Variable>> getDataVars(NetcdfFile hdf, int[] dataShape) {
190        List<Variable> variables = hdf.getVariables();
191        Map<String, List<Variable>> groupsToDataVars = new HashMap<>(variables.size());
192        for (Variable v : variables) {
193            if (Arrays.equals(dataShape, v.getShape())) {
194                String groupName = v.getGroup().getFullNameEscaped();
195                if (! groupsToDataVars.containsKey(groupName)) {
196                    groupsToDataVars.put(groupName, new ArrayList<Variable>(variables.size()));
197                }
198                groupsToDataVars.get(groupName).add(v);
199            }
200        }
201        return groupsToDataVars;
202    }
203
204    /*
205     * Create the group structure and data products for our McV output
206     */
207
208    private static void populateDataTree(NetcdfFile ncOut, Map<String, List<Variable>> groupsToVars)
209    {
210        for (Map.Entry<String, List<Variable>> e : groupsToVars.entrySet()) {
211            Group g = new Group(ncOut, null, e.getKey());
212
213            logger.trace("Adding Group: " + g.getFullName());
214            // Newly created groups will have path separators converted to underscores
215            // We'll need to map back to the original group name for file access
216            groupMap.put(g.getFullName(), e.getKey());
217
218            ncOut.addGroup(null, g);
219
220            for (Variable v : e.getValue()) {
221                logger.trace("Adding Variable: " + v.getFullNameEscaped());
222
223                // TJJ Aug 2020
224                // Operational change described in
225                // https://mcidas.ssec.wisc.edu/inquiry-v/?inquiry=2918
226                // This caused invalid units of "milliseconds since ..." in delta_time attribute
227                // to prevent variables from Product group to load
228                if (v.getShortName().equals("delta_time")) {
229                    for (Attribute attribute : v.getAttributes()) {
230                        if (attribute.getShortName().equals("units")) {
231                            if (attribute.getStringValue().startsWith("milliseconds since")) {
232                                logger.warn("Altering invalid units attribute value");
233                                v.addAttribute(new Attribute("units", "milliseconds"));
234                            }
235                        }
236                    }
237                }
238
239                addVar(ncOut, g, v);
240            }
241
242        }
243    }
244
245    /**
246     * Fulfill data requests
247     * @return Array - an array with the requested data subset
248     */
249
250    @Override public Array readData(Variable variable, Section section)
251        throws IOException, InvalidRangeException
252    {
253        String variableName = variable.getShortName();
254        
255        String groupName = groupMap.get(variable.getGroup().getFullName());
256        logger.trace("looking for Group: " + groupName);
257        Group hdfGroup = hdfFile.findGroup(groupName);
258        Array result;
259
260        logger.trace("TropOMI IOSP readData(), var name: " + variableName);
261        Variable hdfVariable = hdfGroup.findVariable(variableName);
262        
263        logger.trace("found var: " + hdfVariable.getFullName() + 
264                " in group: " + hdfVariable.getGroup().getFullName());
265        // Need to knock off 1st dimension for Lat and Lon too...
266        int[] origin = { 0, 0, 0 };
267        int[] size = { 1, dimLen[1], dimLen[2] };
268        logger.trace("reading size: 1, " + dimLen[1] + ", " + dimLen[2]);
269        result = hdfVariable.read(origin, size).reduce();
270        return result;
271    }
272
273    /*
274     * Test whether file in question is a valid product for this IOSP
275     * This method MUST BE LIGHTNING FAST, since it's part of the system
276     * process of attempting to infer the proper handler when the user
277     * is not certain what the best way to handle the data might be.
278     */
279
280    private static boolean validProduct(Variable variable) {
281        int[] varShape = variable.getShape();
282        if (varShape.length != dimLen.length) return false;
283        // Same dimensions, make sure each individual dimension matches
284        for (int i = 0; i < varShape.length; i++) {
285            if (varShape[i] != dimLen[i]) return false;
286        }
287        return true;
288    }
289
290    /*
291     * Get the shape of valid data products. We consider anything that matches
292     * the geolocation bounds to be valid.
293     */
294
295    private static int[] getDataShape(NetcdfFile hdf) {
296        Group productGroup = hdf.findGroup(BASE_GROUP);
297        // Shape of valid data products will match that of geolocation, so base on LAT or LON
298        Variable geoVar = productGroup.findVariable(LAT);
299        return new int[] {
300            geoVar.getDimension(0).getLength(),
301            geoVar.getDimension(1).getLength(),
302            geoVar.getDimension(2).getLength()
303        };
304    }
305
306    /*
307     * Add a variable to the set of available products.
308     */
309
310    private static void addVar(NetcdfFile nc, Group g, Variable vIn) {
311
312        logger.trace("Evaluating: " + vIn.getFullName());
313        if (validProduct(vIn)) {
314            Variable v = new Variable(nc, g, null, vIn.getShortName(), DataType.FLOAT, "line ele");
315            if (vIn.getShortName().equals(LAT)) {
316                v.addAttribute(new Attribute(_Coordinate.AxisType, AxisType.Lat.toString()));
317                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
318                logger.trace("including: " + vIn.getFullName());
319            } else if (vIn.getShortName().equals(LON)) {
320                v.addAttribute(new Attribute(_Coordinate.AxisType, AxisType.Lon.toString()));
321                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
322                logger.trace("including: " + vIn.getFullName());
323            } else {
324                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
325                logger.trace("including: " + vIn.getFullName());
326            }
327            List<Attribute> attList = vIn.getAttributes();
328            for (Attribute a : attList) {
329                v.addAttribute(a);
330            }
331            logger.trace("adding vname: " + vIn.getFullName() + " to group: " + g.getFullName());
332            
333            g.addVariable(v);
334        }
335    }
336    
337    @Override public String getFileTypeId() {
338        return "TropOMI";
339    }
340
341    @Override public String getFileTypeDescription() {
342        return "TROPOspheric Monitoring Instrument";
343    }
344
345    @Override public void close() throws IOException {
346        hdfFile.close();
347    }
348
349    public static void main(String args[]) throws IOException, IllegalAccessException, InstantiationException {
350        NetcdfFile.registerIOProvider(TropomiIOSP.class);
351    }
352    
353}