001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2025
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.time.Instant;
034import java.time.ZoneOffset;
035import java.time.format.DateTimeFormatter;
036import java.util.ArrayList;
037import java.util.Arrays;
038import java.util.HashMap;
039import java.util.List;
040import java.util.ListIterator;
041import java.util.Map;
042import java.util.regex.Pattern;
043
044import edu.wisc.ssec.mcidasv.McIDASV;
045
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048
049import ucar.unidata.io.RandomAccessFile;
050import ucar.ma2.Array;
051import ucar.ma2.DataType;
052import ucar.ma2.InvalidRangeException;
053import ucar.ma2.Section;
054import ucar.nc2.Attribute;
055import ucar.nc2.Dimension;
056import ucar.nc2.Group;
057import ucar.nc2.NetcdfFile;
058import ucar.nc2.Variable;
059import ucar.nc2.constants.AxisType;
060import ucar.nc2.constants._Coordinate;
061import ucar.nc2.iosp.AbstractIOServiceProvider;
062import ucar.nc2.util.CancelTask;
063
064import javax.swing.JOptionPane;
065
066/**
067 * @author tommyj
068 *
069 */
070
071public class TropomiIOSP extends AbstractIOServiceProvider {
072
073    private static final String LAT = "latitude";
074
075    private static final String LON = "longitude";
076
077    private static final Logger logger = LoggerFactory.getLogger(TropomiIOSP.class);
078    
079    private static final String BASE_GROUP = "PRODUCT";
080
081    private static final String TROPOMI_FIELD_SEPARATOR = "_";
082
083    // This regular expression matches TROPOMI L2 products
084    private static final String TROPOMI_L2_REGEX =
085            // Mission Name (ex: S5P)
086            "\\w\\w\\w" + TROPOMI_FIELD_SEPARATOR +
087            // Type of data: Real-Time, Offline, Reprocessed, or Products Algorithm Laboratory
088            "(NRTI|OFFL|RPRO|PAL_)" + TROPOMI_FIELD_SEPARATOR +
089            // Product Identifier
090            "(L2_|L1B)" + TROPOMI_FIELD_SEPARATOR +
091            // Product (can be up to six characters, separator-padded if less, e.g. CH4___)
092            "\\w\\w\\w\\w\\w\\w" + TROPOMI_FIELD_SEPARATOR +
093            // Start Date and Time (ex: YYYYmmddTHHMMSS)
094            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" + TROPOMI_FIELD_SEPARATOR +
095            // End Date and Time (ex: YYYYmmddTHHMMSS)
096            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" + TROPOMI_FIELD_SEPARATOR +
097            // Orbit Number
098            "\\d\\d\\d\\d\\d" + TROPOMI_FIELD_SEPARATOR +
099            // Collection Number
100            "\\d\\d" + TROPOMI_FIELD_SEPARATOR +
101            // Processor Version Number : MMmmpp (Major - Minor - Patch)
102            "\\d\\d\\d\\d\\d\\d" + TROPOMI_FIELD_SEPARATOR +
103            // Creation Date and Time (ex: YYYYmmddTHHMMSS)
104            "20[0-3]\\d[0-1]\\d[0-3]\\dT[0-2]\\d[0-5]\\d[0-6]\\d" +
105            // NetCDF suffix
106            ".nc";
107
108    /** Compiled representation of {@link #TROPOMI_L2_REGEX}. */
109    public static final Pattern TROPOMI_MATCHER =
110        Pattern.compile(TROPOMI_L2_REGEX);
111    
112    /**
113     * Sometimes {@link #isValidFile(RandomAccessFile)} will need to check
114     * Windows paths that look something like {@code /Z:/Users/bob/foo.txt}.
115     * 
116     * <p>This regular expression is used by {@code isValidFile(...)} to
117     * identity these sorts of paths and fix them. Otherwise we'll generate
118     * an {@link java.nio.file.InvalidPathException}.</p>
119     */
120    private static final Pattern BAD_WIN_PATH =
121        Pattern.compile("^/[A-Za-z]:/.+$");
122
123    private static HashMap<String, String> groupMap = new HashMap<String, String>();
124
125    // Dimensions of a product we can work with, init this early
126    private static int[] dimLen = null;
127    
128    private NetcdfFile hdfFile;
129    private static String filename;
130
131    @Override public boolean isValidFile(RandomAccessFile raf) {
132        // Uses the regex defined near top
133        String filePath = raf.getLocation();
134        // TJJ 2022 - For URLs, just fail the match
135        if (filePath.startsWith("https:")) {
136            return false;
137        }
138        if (McIDASV.isWindows() && BAD_WIN_PATH.matcher(filePath).matches()) {
139            filePath = filePath.substring(1);
140        }
141        // logger.trace("original path: '{}', path used: '{}'", raf, filePath);
142        filename = Paths.get(filePath).getFileName().toString();
143        return TROPOMI_MATCHER.matcher(filename).matches();
144    }
145
146    @Override public void open(RandomAccessFile raf, NetcdfFile ncfile,
147                     CancelTask cancelTask) throws IOException
148    {
149        logger.trace("TropOMI IOSP open()...");
150
151        // TJJ - kick out anything not supported (most) L2 right now
152        if (filename.contains("_L1B_") || filename.contains("_L2__NP")) {
153            JOptionPane.showMessageDialog(null, "McIDAS-V is unable to read your file. " +
154                    "Only TROPOMI Level 2 Products are supported at this time.", "Warning", JOptionPane.OK_OPTION);
155            return;
156        }
157
158        try {
159            
160            hdfFile = NetcdfFile.open(
161               raf.getLocation(), "ucar.nc2.iosp.hdf5.H5iosp", -1, (CancelTask) null, (Object) null
162            );
163            
164            // Get the dimension lengths for product data if we haven't yet
165            dimLen = getDataShape(hdfFile);
166            // Just logging the dimensions here for debugging purposes
167            for (int i = 0; i < dimLen.length; i++) {
168                logger.trace("Product dimension[" + i + "]: " + dimLen[i]);
169            }
170
171            // Populate map pairing group name to any products we deem suitable for visualization
172            Map<String, List<Variable>> newGroups = getDataVars(hdfFile, dimLen);
173
174            ncfile.addDimension(null, new Dimension("line", dimLen[1]));
175            ncfile.addDimension(null, new Dimension("ele", dimLen[2]));
176            populateDataTree(ncfile, newGroups);
177
178            try {
179            // Extract filename without path and extension
180            String filenameOnly = Paths.get(filename).getFileName().toString();
181            if (filenameOnly.endsWith(".nc")) {
182                filenameOnly = filenameOnly.substring(0, filenameOnly.length() - 3);
183            }
184
185            String startStr = null;
186            if (filenameOnly.length() >= 35) {
187                // Start time always begins at char 21 (index 20) and is 15 chars long
188                startStr = filenameOnly.substring(20, 35); // e.g., "20251014T183758"
189            } else {
190                // Fallback: regex search
191                java.util.regex.Matcher m = java.util.regex.Pattern
192                    .compile("(\\d{8}T\\d{6})")
193                    .matcher(filenameOnly);
194                if (m.find()) startStr = m.group(1);
195            }
196
197            if (startStr != null) {
198                // Parse to Instant
199                java.time.format.DateTimeFormatter fmt =
200                    java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss")
201                                                      .withZone(java.time.ZoneOffset.UTC);
202                java.time.Instant start = java.time.Instant.from(fmt.parse(startStr));
203                String isoTime = start.toString(); // ISO 8601 format
204
205                // --- Global attribute ---
206                ncfile.addAttribute(null, new ucar.nc2.Attribute("time_coverage_start", isoTime));
207
208                // --- Time dimension ---
209                ucar.nc2.Dimension timeDim = new ucar.nc2.Dimension("time", 1);
210                ncfile.addDimension(null, timeDim);
211
212                // --- Time variable ---
213                ucar.nc2.Variable timeVar = new ucar.nc2.Variable(ncfile, null, null, "time");
214                timeVar.setDataType(ucar.ma2.DataType.DOUBLE);
215                timeVar.setDimensions("time");
216                timeVar.addAttribute(new ucar.nc2.Attribute("standard_name", "time"));
217                timeVar.addAttribute(new ucar.nc2.Attribute("long_name", "time"));
218                timeVar.addAttribute(new ucar.nc2.Attribute("units", "seconds since 1970-01-01T00:00:00Z"));
219                timeVar.addAttribute(new ucar.nc2.Attribute("calendar", "gregorian"));
220
221                // Value in seconds since epoch
222                ucar.ma2.ArrayDouble.D1 timeData = new ucar.ma2.ArrayDouble.D1(1);
223                timeData.set(0, start.getEpochSecond());
224                timeVar.setCachedData(timeData, false);
225
226                ncfile.addVariable(null, timeVar);
227
228                logger.info("Added time_coverage_start={} and time variable={}", isoTime, start.getEpochSecond());
229            } else {
230                logger.warn("Could not extract start time from filename: {}", filenameOnly);
231            }
232
233        } catch (Exception e) {
234            logger.warn("Error deriving time info from filename: {}", filename, e);
235        }
236
237            ncfile.finish();
238        } catch (ClassNotFoundException e) {
239            logger.error("error loading HDF5 IOSP", e);
240        } catch (IllegalAccessException e) {
241            logger.error("java reflection error", e);
242        } catch (InstantiationException e) {
243            logger.error("error instantiating", e);
244        }
245    }
246
247    /*
248     * Loop over all data looking for the products we can display
249     */
250
251    private static Map<String, List<Variable>> getDataVars(NetcdfFile hdf, int[] dataShape) {
252        List<Variable> variables = hdf.getVariables();
253        Map<String, List<Variable>> groupsToDataVars = new HashMap<>(variables.size());
254        for (Variable v : variables) {
255            if (Arrays.equals(dataShape, v.getShape())) {
256                String groupName = v.getGroup().getFullNameEscaped();
257                if (! groupsToDataVars.containsKey(groupName)) {
258                    groupsToDataVars.put(groupName, new ArrayList<Variable>(variables.size()));
259                }
260                groupsToDataVars.get(groupName).add(v);
261            }
262        }
263        return groupsToDataVars;
264    }
265
266    /*
267     * Create the group structure and data products for our McV output
268     */
269
270    private static void populateDataTree(NetcdfFile ncOut, Map<String, List<Variable>> groupsToVars)
271    {
272        for (Map.Entry<String, List<Variable>> e : groupsToVars.entrySet()) {
273            Group g = new Group(ncOut, null, e.getKey());
274
275            logger.trace("Adding Group: " + g.getFullName());
276            // Newly created groups will have path separators converted to underscores
277            // We'll need to map back to the original group name for file access
278            groupMap.put(g.getFullName(), e.getKey());
279
280            ncOut.addGroup(null, g);
281
282            for (Variable v : e.getValue()) {
283                logger.trace("Adding Variable: " + v.getFullNameEscaped());
284
285                // TJJ Aug 2020
286                // Operational change described in
287                // https://mcidas.ssec.wisc.edu/inquiry-v/?inquiry=2918
288                // This caused invalid units of "milliseconds since ..." in delta_time attribute
289                // to prevent variables from Product group to load
290                if (v.getShortName().equals("delta_time")) {
291                    for (Attribute attribute : v.getAttributes()) {
292                        if (attribute.getShortName().equals("units")) {
293                            if (attribute.getStringValue().startsWith("milliseconds since")) {
294                                logger.warn("Altering invalid units attribute value");
295                                v.addAttribute(new Attribute("units", "milliseconds"));
296                            }
297                        }
298                    }
299                }
300
301                addVar(ncOut, g, v);
302            }
303
304        }
305    }
306
307    /**
308     * Fulfill data requests
309     * @return Array - an array with the requested data subset
310     */
311
312    @Override public Array readData(Variable variable, Section section)
313        throws IOException, InvalidRangeException
314    {
315        String variableName = variable.getShortName();
316        
317        String groupName = groupMap.get(variable.getGroup().getFullName());
318        logger.trace("looking for Group: " + groupName);
319        Group hdfGroup = hdfFile.findGroup(groupName);
320        Array result;
321
322        logger.trace("TropOMI IOSP readData(), var name: " + variableName);
323        Variable hdfVariable = hdfGroup.findVariable(variableName);
324        
325        logger.trace("found var: " + hdfVariable.getFullName() + 
326                " in group: " + hdfVariable.getGroup().getFullName());
327        // Need to knock off 1st dimension for Lat and Lon too...
328        int[] origin = { 0, 0, 0 };
329        int[] size = { 1, dimLen[1], dimLen[2] };
330        logger.trace("reading size: 1, " + dimLen[1] + ", " + dimLen[2]);
331        result = hdfVariable.read(origin, size).reduce();
332        return result;
333    }
334
335    /*
336     * Test whether file in question is a valid product for this IOSP
337     * This method MUST BE LIGHTNING FAST, since it's part of the system
338     * process of attempting to infer the proper handler when the user
339     * is not certain what the best way to handle the data might be.
340     */
341
342    private static boolean validProduct(Variable variable) {
343        int[] varShape = variable.getShape();
344        if (varShape.length != dimLen.length) return false;
345        // Same dimensions, make sure each individual dimension matches
346        for (int i = 0; i < varShape.length; i++) {
347            if (varShape[i] != dimLen[i]) return false;
348        }
349        return true;
350    }
351
352    /*
353     * Get the shape of valid data products. We consider anything that matches
354     * the geolocation bounds to be valid.
355     */
356
357    private static int[] getDataShape(NetcdfFile hdf) {
358        Group productGroup = hdf.findGroup(BASE_GROUP);
359        // Shape of valid data products will match that of geolocation, so base on LAT or LON
360        Variable geoVar = productGroup.findVariable(LAT);
361        return new int[] {
362            geoVar.getDimension(0).getLength(),
363            geoVar.getDimension(1).getLength(),
364            geoVar.getDimension(2).getLength()
365        };
366    }
367
368    /*
369     * Add a variable to the set of available products.
370     */
371
372    private static void addVar(NetcdfFile nc, Group g, Variable vIn) {
373
374        logger.trace("Evaluating: " + vIn.getFullName());
375        if (validProduct(vIn)) {
376            Variable v = new Variable(nc, g, null, vIn.getShortName(), DataType.FLOAT, "line ele");
377            if (vIn.getShortName().equals(LAT)) {
378                v.addAttribute(new Attribute(_Coordinate.AxisType, AxisType.Lat.toString()));
379                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
380                logger.trace("including: " + vIn.getFullName());
381            } else if (vIn.getShortName().equals(LON)) {
382                v.addAttribute(new Attribute(_Coordinate.AxisType, AxisType.Lon.toString()));
383                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
384                logger.trace("including: " + vIn.getFullName());
385            } else {
386                v.addAttribute(new Attribute("coordinates", "latitude longitude"));
387                logger.trace("including: " + vIn.getFullName());
388            }
389            List<Attribute> attList = vIn.getAttributes();
390            for (Attribute a : attList) {
391                v.addAttribute(a);
392            }
393            logger.trace("adding vname: " + vIn.getFullName() + " to group: " + g.getFullName());
394            
395            g.addVariable(v);
396        }
397    }
398    
399    @Override public String getFileTypeId() {
400        return "TropOMI";
401    }
402
403    @Override public String getFileTypeDescription() {
404        return "TROPOspheric Monitoring Instrument";
405    }
406
407    @Override public void close() throws IOException {
408        hdfFile.close();
409    }
410
411    public static void main(String args[]) throws IOException, IllegalAccessException, InstantiationException {
412        NetcdfFile.registerIOProvider(TropomiIOSP.class);
413    }
414    
415}