1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
58
59
60
61
62
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
70
71 private static final long serialVersionUID = 1913730755957817418L;
72
73
74
75
76 @XmlTransient
77 private DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT);
78
79
80
81
82 @XmlTransient
83 private DateFormat dateTimeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT);
84
85
86
87
88 @Getter
89 @Setter
90 @XmlElement
91 private Integer midnight = 0;
92
93
94
95
96 @Getter
97 @Setter
98 @XmlElement(name = "header")
99 private String[] headers;
100
101
102
103
104 @Getter
105 @Setter
106 @XmlElement
107 private String separator = Resource.DEFAULT_SEP;
108
109
110
111
112 @Setter
113 @XmlTransient
114 private EtpCalculator etpCalculator;
115
116
117
118
119 @Getter
120 @Setter
121 private Integer endYear;
122
123
124
125
126 @Getter
127 @Setter
128 private Integer startYear;
129
130
131
132
133 @Getter
134 @Setter
135 @XmlTransient
136 private TimeScale timeScale = TimeScale.DAILY;
137
138
139
140
141 public ClimateFileLoader() {
142 setDataFile(DataLoadingListener.DataFile.CLIMATIC);
143 }
144
145
146
147
148
149
150
151
152
153
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
165
166
167
168
169
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
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
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
460
461
462
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
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 }