Home > Blockchain >  Find the closest value by property with Stream API
Find the closest value by property with Stream API

Time:06-16

I am currently working on some Java code that has a goal:

  • Receive list of Collection<ForecastPerDate> (see below);
  • Find items that have date >= today;
  • Get the value of the item with date closest to today (minimum diff);
  • Floor it and round it;
  • If no data has been found, it should fallback to 0 with a log message.
public record ForecastPerDate(String date, Double value) {}

My implementation so far seems pretty efficient and sane to me, but I don't like mutating variables or state (I am becoming more of a Haskell dev lately haha) and always quite liked using the Streams API of Java.

Just FYI the project uses Java 17 so that helps. I assume this probably can be solved with a reduce() function and some accumulator but I am unclear on how to, at least without causing more than one iteration.

Here is the code:

 @Override
    public Long getAvailabilityFromForecastData(final String fuCode,
                                                final String articleCode,
                                                final Collection<ForecastPerDate> forecasts) {
        if (forecasts == null || forecasts.isEmpty()) {
            log.info(
                    "No forecasts received for FU {} articleCode {}, assuming 0!",
                    fuCode,
                    articleCode
            );
            return 0L;
        }

        final long todayEpochDay = LocalDate.now().toEpochDay();
        final Map<String, Double> forecastMap = new HashMap<>();
        long smallestDiff = Integer.MAX_VALUE;
        String smallestDiffDate = null;

        for (final ForecastPerDate forecast : forecasts) {
            final long forecastEpochDay = LocalDate.parse(forecast.date()).toEpochDay();
            final long diff = forecastEpochDay - todayEpochDay;

            if (diff >= 0 && diff < smallestDiff) {
                // we look for values in present or future (>=0)
                smallestDiff = diff;
                smallestDiffDate = forecast.date();
                forecastMap.put(forecast.date(), forecast.value());
            }
        }

        if (smallestDiffDate != null) {
            final Double wantedForecastValue = forecastMap.get(smallestDiffDate);
            if (wantedForecastValue != null) {
                return availabilityAmountFormatter(wantedForecastValue);
            }
        }

        log.info(
                "Resorting to fallback for FU {} articleCode {}, 0 availability for article!  Forecasts: {}",
                fuCode,
                articleCode,
                forecasts
        );
        return 0L;
    }

    private Long availabilityAmountFormatter(final Double raw) {
        return Math.round(Math.floor(raw));
    }

CodePudding user response:

Here is one approach.

  • stream the collection of objects
  • filter out any dates older than and including today.
  • then collect using Collectors.minBy and a special comparator.
  • then use the result with the rest of your code to either return the value or log the result.
public Long getAvailabilityFromForecastData(final String fuCode,
        final String articleCode,
        final Collection<ForecastPerDate> forecasts) {
    
    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("M/d/yyyy");
    
    Comparator<ForecastPerDate> comp =
            Comparator.comparing(f -> LocalDate.parse(f.date(), dtf),
                    (date1, date2) -> date1.compareTo(date2));
    
    Optional<ForecastPerDate> result = forecasts.stream()
            .filter(fpd -> LocalDate.parse(fpd.date(),dtf)
                    .isAfter(LocalDate.now()))
            .collect(Collectors.minBy(comp));
    
    if (result.isPresent()) {
        return availabilityAmountFormatter(result.get().value());
    }
    log.info(
            "Resorting to fallback for FU {} articleCode {}, 0 availability for article!  Forecasts: {}",
            fuCode, articleCode, forecasts);
    return 0L;
}

Demo

Note: for this answer and demo I included in the above method a DateTimeFormatter since I don't know the format of your dates. You will probably need to alter it for your application.

        List<ForecastPerDate> list = List.of(
                new ForecastPerDate("6/14/2022", 112.33),
                new ForecastPerDate("6/19/2022", 122.33),
                new ForecastPerDate("6/16/2022", 132.33),
                new ForecastPerDate("6/20/2022", 142.33));
        long v = getAvailabilityFromForecastData("Foo","Bar", list);
        System.out.println(v);

prints

132 (based on current day of 6/15/2022)

If no dates are present after the current day, 0 will be returned and the issue logged.

Updated

I believe this will be somewhat more efficient since the dates only need to be parsed a single time.

You can put date and the actual ForecastPerDate object in an AbstractMap.SimpleEntry and at the same time parse the LocalDate.

  • Stream as before.
  • create the entry (i.e. map(fpd -> new SimpleEntry<>( LocalDate.parse(fpd.date(), dtf), fpd))
    • this parses the date and stores as the key, the record is the value.
  • Now that the date is parsed you can filter as before by getting the key from the entry.
  • The minBy Comparator is also simpler since there are pre-existing Entry comparators for key and value. So use .collect(Collectors.minBy(Entry.comparingByKey()));

Putting it all together.

Optional<Entry<LocalDate, ForecastPerDate>> result = forecasts
        .stream()
        .map(fpd -> new SimpleEntry<>(
                LocalDate.parse(fpd.date(), dtf), fpd))
        .filter(e -> e.getKey().isAfter(LocalDate.now()))
        .collect(Collectors.minBy(Entry.comparingByKey()));

To get the result there is one extra level of indirection. Get the Optional contents followed by the Entry.value() and then the value from ForecastPerDate object.

if (result.isPresent()) {
    return availabilityAmountFormatter(result.get().getValue().value());
}

CodePudding user response:

Most of the operations you mention are available method-calls on streams.

  • Receive list of Collection -> forecasts.stream()
  • Find items that have date >= today -> .filter()
  • Get the "value" of the item with date closest to today (minimum diff) -> .min(), giving an Optional<ForecastPerDate>
  • Floor it and round it -> optional.map()
  • If no data has been found it should fallback to 0 with a log message -> optional.orElseGet()

Put together, it would be something like this (I haven't compiled it, so it probably won't work on the first try):

@Override
public Long getAvailabilityFromForecastData(final String fuCode,
                                            final String articleCode,
                                            final Collection<ForecastPerDate> forecasts) {

    var today = LocalDate.now();

    return forecasts.stream()
        .filter(forecast -> !today.isBefore(LocalDate.parse(forecast.date())))
        .min(Comparator.comparing(forecast -> 
             Duration.between(today, LocalDate.parse(forecast.date()))
        .map(forecast -> availabilityAmountFormatter(forecast.value()))
        .orElseGet(() -> {
            log.info("No forecasts found");
            return 0L;
        });
}

I would move some of the logic into ForecastPerDate to avoid having to parse forecast.date() multiple times.

CodePudding user response:

I assume this probably can be solved with a reduce()

It's a perfectly valid use case for reduce().

I have one proposal regarding the structure of ForecastPerDate. It would be far more convenient to store the date as LocalDate and not as String. That is the correct way of dealing with dates, because there's a little you can do with a string representing the date without parsing.

And even if you don't consider the idea of changing the ForecastPerDate as a possible option, it would not be difficult to adopt the following approach.

I'll proceed with ForecastPerDate record defined as follows:

public record ForecastPerDate(LocalDate date, Double value) {}

Find items that have date >= today

When we need to compare the two LocalDate objects there's no need to extract day from epoch and manipulate with them manually, basically it's violation of the Information expert principle because LocalDate instances are capable to compare their data. Just let them do that by using ifAfter() and equals().

And there's no need for the intermediate Map. We can perform the reduction on the ForecastPerDate objects by always picking the one with the closest upcoming date.

In the code below, reduce() produces the result as an Optional<ForecastPerDate> (because identity is not provided) which gets transformed into Optional<Long>. If optional is empty, then a default value of 0L will be provided.

That's how it might look like:

public static Long getAvailabilityFromForecastData(final String fuCode,
                                                   final String articleCode,
                                                   final Collection<ForecastPerDate> forecasts) {
    // writing a log message
    
    LocalDate now = LocalDate.now();
    
    Long mostClosestForecastValue = forecasts.stream()
        .filter(forecast -> forecast.date().isAfter(now) || forecast.date.equals(now)) // today in the future
        .filter(forecast -> forecast.value() != null)
        .reduce((result, next) -> result.date().isBefore(next.date()) ? result : next) // Optional<ForecastPerDate>
        .map(forecast -> availabilityAmountFormatter(forecast.value())) // Optional<Long>
        .orElse(0L); // extracting Long value from the optional
    
    // writing a log message
    
    return mostClosestForecastValue;
}

A link to Online Demo

CodePudding user response:

If you want to keep a similar flow to what you have, try something like this:

final LocalDate now = LocalDate.now();
return forecasts.stream()
        .map(ForecastPerDate::date)
        .map(LocalDate::parse)
        .filter(fd -> fd.isEqual(now) || fd.isAfter(now))
        .min()
        .map(LocalDate::toString)
        .map(forecastMap::get)
        .map(this::availabilityAmountFormatter)
        .orElseGet(() -> {
            log.info("Resorting to fallback...");
            return 0L;
        });
  • Related