View Javadoc
1   /*
2    * This file is part of Indicators.
3    *
4    * Indicators is free software: you can redistribute it and/or modify
5    * it under the terms of the GNU General Public License as published by
6    * the Free Software Foundation, either version 3 of the License, or
7    * (at your option) any later version.
8    *
9    * Indicators is distributed in the hope that it will be useful,
10   * but WITHOUT ANY WARRANTY; without even the implied warranty of
11   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12   * GNU General Public License for more details.
13   *
14   * You should have received a copy of the GNU General Public License
15   * along with Indicators. If not, see <https://www.gnu.org/licenses/>.
16   */
17  package fr.inrae.agroclim.indicators.model.data.climate;
18  
19  import java.text.DateFormat;
20  import java.util.ArrayList;
21  import java.util.Arrays;
22  import java.util.Collection;
23  import java.util.Date;
24  import java.util.EnumMap;
25  import java.util.HashMap;
26  import java.util.HashSet;
27  import java.util.List;
28  import java.util.Locale;
29  import java.util.Map;
30  import java.util.Objects;
31  import java.util.Set;
32  
33  import fr.inrae.agroclim.indicators.model.TimeScale;
34  import fr.inrae.agroclim.indicators.model.data.DataLoadingListener;
35  import fr.inrae.agroclim.indicators.model.data.FileLoader;
36  import fr.inrae.agroclim.indicators.model.data.Resource;
37  import fr.inrae.agroclim.indicators.model.data.Variable;
38  import fr.inrae.agroclim.indicators.resources.I18n;
39  import fr.inrae.agroclim.indicators.resources.Messages;
40  import fr.inrae.agroclim.indicators.util.DateUtils;
41  import fr.inrae.agroclim.indicators.util.StringUtils;
42  import jakarta.xml.bind.annotation.XmlAccessType;
43  import jakarta.xml.bind.annotation.XmlAccessorType;
44  import jakarta.xml.bind.annotation.XmlElement;
45  import jakarta.xml.bind.annotation.XmlTransient;
46  import jakarta.xml.bind.annotation.XmlType;
47  import lombok.Getter;
48  import lombok.Setter;
49  import lombok.extern.log4j.Log4j2;
50  import tools.jackson.databind.MappingIterator;
51  import tools.jackson.databind.ObjectReader;
52  import tools.jackson.dataformat.csv.CsvMapper;
53  import tools.jackson.dataformat.csv.CsvReadFeature;
54  import tools.jackson.dataformat.csv.CsvSchema;
55  
56  /**
57   * Load climate data from file.
58   *
59   * Last changed : $Date$
60   *
61   * @author $Author$
62   * @version $Revision$
63   */
64  @XmlAccessorType(XmlAccessType.FIELD)
65  @XmlType(propOrder = {"separator", "headers", "midnight", "endYear", "startYear"})
66  @Log4j2
67  public final class ClimateFileLoader extends FileLoader implements ClimateLoader {
68      /**
69       * UUID for Serializable.
70       */
71      private static final long serialVersionUID = 1913730755957817418L;
72  
73      /**
74       * Localized date format for log message.
75       */
76      @XmlTransient
77      private DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
78  
79      /**
80       * Localized datetime format for log message.
81       */
82      @XmlTransient
83      private DateFormat dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT);
84  
85      /**
86       * Hour of midnight (0 for 0-23 or 24 for 1-24).
87       */
88      @Getter
89      @Setter
90      @XmlElement
91      private Integer midnight = 0;
92  
93      /**
94       * Headers of CSV file.
95       */
96      @Getter
97      @Setter
98      @XmlElement(name = "header")
99      private String[] headers;
100 
101     /**
102      * CSV separator.
103      */
104     @Getter
105     @Setter
106     @XmlElement
107     private String separator = Resource.DEFAULT_SEP;
108 
109     /**
110      * Calculator to compute ETP from climatic daily data.
111      */
112     @Setter
113     @XmlTransient
114     private EtpCalculator etpCalculator;
115 
116     /**
117      * End year of data filtering (included).
118      */
119     @Getter
120     @Setter
121     private Integer endYear;
122 
123     /**
124      * Start year of data filtering (included).
125      */
126     @Getter
127     @Setter
128     private Integer startYear;
129 
130     /**
131      * Related time scales.
132      */
133     @Getter
134     @Setter
135     @XmlTransient
136     private TimeScale timeScale = TimeScale.DAILY;
137 
138     /**
139      * Constructor.
140      */
141     public ClimateFileLoader() {
142         setDataFile(DataLoadingListener.DataFile.CLIMATIC);
143     }
144 
145     /**
146      * Constructor.
147      *
148      * @param csvFile
149      *            relative path of CSV file
150      * @param csvHeaders
151      *            CSV headers
152      * @param csvSeparator
153      *            CSV separator
154      */
155     public ClimateFileLoader(final String csvFile, final String[] csvHeaders,
156             final String csvSeparator) {
157         this();
158         setPath(csvFile);
159         this.headers = csvHeaders;
160         this.separator = csvSeparator;
161     }
162 
163     /**
164      * Ensure climatic data are ordered and there is not any missing.
165      *
166      * @param previous previous data
167      * @param current current data to check
168      * @param line line number
169      * @param path file path
170      */
171     void checkDate(final ClimaticDailyData previous, final ClimaticDailyData current, final int line,
172             final String path) {
173         if (previous == null || current == null) {
174             return;
175         }
176         final DateFormat df;
177         if (timeScale == null) {
178             throw new IllegalStateException("timeScale must not be null!");
179         }
180         final long delta;
181         switch (timeScale) {
182         case DAILY -> {
183             df = dateFormat;
184             delta = DateUtils.NB_OF_MS_IN_DAY;
185         }
186         case HOURLY -> {
187             df = dateTimeFormat;
188             delta = DateUtils.NB_OF_MS_IN_HOUR;
189         }
190         default -> throw new IllegalStateException("TimeScale not handled: " + timeScale);
191         }
192         if (previous.getDate() != null && current.getDate() != null) {
193             final long previousTime = previous.getDate().getTime();
194             final long currentTime = current.getDate().getTime();
195             final long interval = currentTime - previousTime;
196             if (interval < 0) {
197                 current.getErrors().add(
198                         Messages.format("error.day.succession", path, line,
199                                 df.format(current.getDate()),
200                                 df.format(previous.getDate())
201                                 )
202                         );
203                 return;
204             }
205             if (interval == 0) {
206                 current.getErrors().add(Messages.format("error.day.duplicate", path, line,
207                         df.format(previous.getDate())));
208                 return;
209             }
210             if (interval > delta) {
211                 current.getErrors().add(Messages.format("error.day.missing", path, line, df.format(current.getDate())));
212             }
213         } else {
214             current.getErrors().add(Messages.format("error.date.notread"));
215         }
216     }
217 
218     @Override
219     public ClimateFileLoader clone() {
220         final ClimateFileLoader clone = new ClimateFileLoader();
221         clone.etpCalculator = etpCalculator.clone();
222         clone.setPath(getPath());
223         clone.headers = headers;
224         clone.separator = separator;
225         return clone;
226     }
227 
228     @Override
229     public boolean equals(final Object obj) {
230         if (this == obj) {
231             return true;
232         }
233         if (obj == null) {
234             return false;
235         }
236         if (getClass() != obj.getClass()) {
237             return false;
238         }
239         final ClimateFileLoader other = (ClimateFileLoader) obj;
240         if (!Objects.equals(this.separator, other.separator)) {
241             return false;
242         }
243         if (!Objects.equals(this.getPath(), other.getPath())) {
244             return false;
245         }
246         if (!Arrays.deepEquals(this.headers, other.headers)) {
247             return false;
248         }
249         if (!Objects.equals(this.etpCalculator, other.etpCalculator)) {
250             return false;
251         }
252         if (!Objects.equals(this.endYear, other.endYear)) {
253             return false;
254         }
255         return Objects.equals(this.startYear, other.startYear);
256     }
257 
258     @Override
259     public Map<String, String> getConfigurationErrors() {
260         final Map<String, String> errors = new HashMap<>();
261         if (getPath() == null) {
262             errors.put("climate.file", "error.evaluation.climate.file.missing");
263         }
264         if (!getFile().exists()) {
265             errors.put("climate.file", "error.evaluation.climate.file.doesnotexist");
266         } else if (getFile().length() == 0) {
267             errors.put("climate.file", "error.evaluation.climate.file.empty");
268         }
269         if (separator == null) {
270             errors.put("climate.separator", "error.evaluation.climate.separator.missing");
271         } else if (separator.isEmpty()) {
272             errors.put("climate.separator", "error.evaluation.climate.separator.empty");
273         }
274         if (headers == null) {
275             errors.put("climate.header", "error.evaluation.climate.header.missing");
276         }
277         if (timeScale == TimeScale.DAILY && etpCalculator == null) {
278             errors.put("climate.etpCalculator", "error.evaluation.climate.etpCalculator.missing");
279         }
280         if (errors.isEmpty()) {
281             return null;
282         }
283         return errors;
284     }
285 
286     /**
287      * @return Calculator to compute ETP from climatic daily data.
288      */
289     private EtpCalculator getEtpCalculator() {
290         if (timeScale != TimeScale.DAILY) {
291             throw new UnsupportedOperationException("Only daily data should have ETP!");
292         }
293         if (etpCalculator == null) {
294             throw new RuntimeException("EtpCalculator not set!");
295         }
296         return etpCalculator;
297     }
298 
299     /**
300      * @return Missing climatic variables, to check in aggregation indicators.
301      */
302     @Override
303     public Collection<String> getMissingVariables() {
304         final List<String> all = new ArrayList<>(ClimaticDailyData.getAllColumnNames(timeScale));
305         if (headers != null) {
306             for (final String header : headers) {
307                 all.remove(header.toLowerCase());
308             }
309         }
310         return all;
311     }
312 
313     @Override
314     public Set<Variable> getProvidedVariables() {
315         return super.getProvidedVariables(headers);
316     }
317 
318     @Override
319     public Set<Variable> getVariables() {
320         if (etpCalculator == null) {
321             return new HashSet<>();
322         }
323         return etpCalculator.getVariables();
324     }
325 
326     @Override
327     public int hashCode() {
328         final int prime1 = 7;
329         final int prime = 71;
330         int hash = prime1;
331         hash = prime * hash + Objects.hashCode(this.getPath());
332         hash = prime * hash + Arrays.deepHashCode(this.headers);
333         hash = prime * hash + Objects.hashCode(this.separator);
334         hash = prime * hash + Objects.hashCode(this.etpCalculator);
335         hash = prime * hash + Objects.hashCode(this.endYear);
336         hash = prime * hash + Objects.hashCode(this.startYear);
337         return hash;
338     }
339 
340     @Override
341     public List<ClimaticDailyData> load() {
342         LOGGER.trace("start");
343         if (getPath() == null || getFile() == null) {
344             throw new RuntimeException("no file defined for climate.");
345         }
346         if (separator == null) {
347             throw new RuntimeException("no separator defined for climate.");
348         }
349         LOGGER.trace("headers: {}", StringUtils.join(headers, ","));
350         final List<ClimaticDailyData> data = new ArrayList<>();
351         final List<String> headerFiltered = new ArrayList<>();
352         final Map<Variable, Integer> valuesCol = new EnumMap<>(Variable.class);
353         int yearCol = -1;
354         int monthCol = -1;
355         int dayCol = -1;
356         int hourCol = -1;
357         final String[] headersFromFile = getHeaders(getFile(), separator.charAt(0));
358         String[] usedHeaders;
359 
360         if (headers == null) {
361             usedHeaders = headersFromFile;
362         } else {
363             usedHeaders = headers;
364         }
365         for (int i = 0; i < usedHeaders.length; i++) {
366             final String header = usedHeaders[i];
367             final String lcHeader = header.toLowerCase();
368             final int index = ClimaticDailyData.getAllColumnNames(timeScale).indexOf(lcHeader);
369             if (index != -1) {
370                 headerFiltered.add(header.substring(0, 1).toUpperCase() + lcHeader.substring(1));
371                 if (header.equals("year")) {
372                     yearCol = i;
373                     continue;
374                 }
375                 if (header.equals("month")) {
376                     monthCol = i;
377                     continue;
378                 }
379                 if (header.equals("day")) {
380                     dayCol = i;
381                     continue;
382                 }
383                 if (header.equals("hour")) {
384                     hourCol = i;
385                     continue;
386                 }
387                 valuesCol.put(Variable.getByName(header), i);
388             } else {
389                 headerFiltered.add(null);
390             }
391         }
392 
393         LOGGER.trace("userHeadersArray: {}", StringUtils.join(headerFiltered, ","));
394 
395         LOGGER.trace("year: {}, month: {}, day: {}", yearCol, monthCol, dayCol);
396         LOGGER.trace("variables: {}", valuesCol);
397         if (usedHeaders.length != headersFromFile.length) {
398             final I18n i18n = new I18n("fr.inrae.agroclim.indicators.resources.messages", Locale.getDefault());
399             final String msg = i18n.format("error.climate.wrong.headers", headersFromFile.length,
400                     StringUtils.join(headersFromFile, ", "), usedHeaders.length, StringUtils.join(usedHeaders, ", "));
401             throw new RuntimeException(msg);
402         }
403         fireDataLoadingStartEvent("Start of reading file: " + getFile().getName());
404 
405         final CsvSchema schema = CsvSchema.emptySchema().withSkipFirstDataRow(true)//
406                 .withColumnSeparator(separator.charAt(0));
407         final CsvMapper mapper = CsvMapper.builder() //
408                 .configure(CsvReadFeature.WRAP_AS_ARRAY, true) //
409                 .build();
410 
411         final ObjectReader objReader = mapper.readerFor(String[].class).with(schema);
412         try (MappingIterator<String[]> it = objReader.readValues(getFile())) {
413             ClimaticDailyData previous = null;
414             while (it.hasNext()) {
415                 final int lineNumber = it.currentLocation().getLineNr();
416                 final String[] row = it.next();
417                 final Integer year = this.parseInt(row[yearCol], null);
418                 if (startYear != null && year < startYear || endYear != null && year > endYear) {
419                     continue;
420                 }
421                 final ClimaticDailyData dailyData = new ClimaticDailyData();
422                 dailyData.setTimescale(timeScale);
423                 dailyData.setYear(year);
424                 dailyData.setMonth(this.parseInt(row[monthCol], null));
425                 dailyData.setDay(this.parseInt(row[dayCol], null));
426                 if (timeScale == TimeScale.HOURLY) {
427                     final int hour = Integer.parseInt(row[hourCol]);
428                     if (midnight == DateUtils.NB_OF_HOURS_IN_DAY && hour == DateUtils.NB_OF_HOURS_IN_DAY) {
429                         final long newTime = dailyData.getDate().getTime() + DateUtils.NB_OF_MS_IN_DAY;
430                         final Date newDate = new Date(newTime);
431                         dailyData.setYear(DateUtils.getYear(newDate));
432                         dailyData.setMonth(DateUtils.getMonth(newDate));
433                         dailyData.setDay(DateUtils.getDom(newDate));
434                         dailyData.setHour(0);
435                     } else {
436                         dailyData.setHour(hour);
437                     }
438                 }
439                 valuesCol.forEach((variable, index) -> {
440                     if (row[index] != null && !row[index].isEmpty()) {
441                         dailyData.setValue(variable, Double.valueOf(row[index]));
442                     }
443                 });
444                 if (timeScale == TimeScale.DAILY) {
445                     dailyData.setEtpCalculator(getEtpCalculator());
446                 }
447                 dailyData.check(lineNumber, getFile().getName());
448                 checkDate(previous, dailyData, lineNumber, getFile().getName());
449                 fireDataLoadingAddEvent(dailyData);
450                 data.add(dailyData);
451                 previous = dailyData;
452             }
453             fireDataLoadingEndEvent("End of reading " + getFile().getName());
454         }
455         return data;
456     }
457 
458     /**
459      * Parse String to Integer, like {@code Integer.parseInt} method.
460      * @param value the string value to parsing
461      * @param defaultValue if not possible to parse, this value will be returned
462      * @return value parsed or defaultValue
463      */
464     private Integer parseInt(final String value, final Integer defaultValue) {
465         Integer ret;
466         try {
467             ret = Integer.valueOf(value);
468         } catch (final NumberFormatException e) {
469             ret = defaultValue;
470         }
471         return ret;
472     }
473     /**
474      * @param locale locale for date formatter
475      */
476     public void setLocale(final Locale locale) {
477         dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, locale);
478         dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT, locale);
479     }
480 }