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}