001/* 002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de) 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package de.cuioss.benchmarking.common.report; 017 018import com.google.gson.Gson; 019import com.google.gson.GsonBuilder; 020import com.google.gson.JsonObject; 021import com.google.gson.JsonSyntaxException; 022import de.cuioss.tools.logging.CuiLogger; 023 024import java.io.IOException; 025import java.nio.file.Files; 026import java.nio.file.Path; 027import java.util.*; 028import java.util.stream.Stream; 029 030import static de.cuioss.benchmarking.common.constants.BenchmarkConstants.Report.Badge.TrendDirection.STABLE; 031import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN; 032 033/** 034 * Processes historical benchmark data for time-series analysis and trend visualization. 035 * <p> 036 * This processor specializes in time-series analysis of benchmark results: 037 * <ul> 038 * <li>Loading and managing historical benchmark data</li> 039 * <li>Detecting performance trends over time</li> 040 * <li>Preparing data for trend visualization charts</li> 041 * <li>Tracking changes between benchmark runs</li> 042 * </ul> 043 * <p> 044 * Use this class when you need to analyze benchmark performance over time. 045 * For pure statistical computations, use {@link StatisticsCalculator}. 046 * For processing individual benchmark results, use {@link MetricsComputer}. 047 * 048 * @see StatisticsCalculator for statistical computations (mean, median, std dev, etc.) 049 * @see MetricsComputer for processing individual benchmark results 050 */ 051public class TrendDataProcessor { 052 053 private static final CuiLogger LOGGER = new CuiLogger(TrendDataProcessor.class); 054 private static final int MAX_HISTORY_ENTRIES = 10; 055 private static final double STABILITY_THRESHOLD = 0.02; // 2% change threshold for "stable" 056 private static final double EWMA_LAMBDA = 0.25; // Decay factor for exponential weighting (standard for performance monitoring) 057 private static final String KEY_THROUGHPUT = "throughput"; 058 private static final String KEY_LATENCY = "latency"; 059 060 private final Gson gson = new GsonBuilder() 061 .setPrettyPrinting() 062 .create(); 063 064 /** 065 * Represents a single historical data point. 066 */ 067 public record HistoricalDataPoint(String timestamp, double throughput, double latency, 068 double performanceScore, String commitSha) { 069 } 070 071 /** 072 * Represents calculated trend metrics. 073 */ 074 public record TrendMetrics(String direction, double changePercentage, double movingAverage, 075 double throughputTrend, double latencyTrend) { 076 } 077 078 /** 079 * Loads historical data files from the history directory. 080 * 081 * @param historyDir path to the history directory 082 * @return list of historical benchmark data points, sorted by timestamp (newest first) 083 */ 084 public List<HistoricalDataPoint> loadHistoricalData(Path historyDir) { 085 if (!Files.exists(historyDir)) { 086 return List.of(); 087 } 088 089 List<HistoricalDataPoint> dataPoints = new ArrayList<>(); 090 091 try (Stream<Path> pathStream = Files.list(historyDir)) { 092 List<Path> historyFiles = pathStream 093 .filter(Files::isRegularFile) 094 .filter(p -> p.toString().endsWith(".json")) 095 .sorted(Comparator.reverseOrder()) // Newest first 096 .limit(MAX_HISTORY_ENTRIES) 097 .toList(); 098 099 for (Path file : historyFiles) { 100 processHistoryFile(file, dataPoints); 101 } 102 } catch (IOException e) { 103 LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "loading historical data"); 104 } 105 106 return dataPoints; 107 } 108 109 /** 110 * Processes a single history file and adds its data point to the list. 111 * 112 * @param file the history file to process 113 * @param dataPoints the list to add the data point to 114 */ 115 private void processHistoryFile(Path file, List<HistoricalDataPoint> dataPoints) { 116 try { 117 String jsonContent = Files.readString(file); 118 JsonObject data = gson.fromJson(jsonContent, JsonObject.class); 119 120 HistoricalDataPoint point = extractDataPoint(data, file.getFileName().toString()); 121 if (point != null) { 122 dataPoints.add(point); 123 } 124 } catch (IOException | JsonSyntaxException e) { 125 LOGGER.warn(e, WARN.ISSUE_PARSING_HISTORY_FILE, file); 126 } 127 } 128 129 /** 130 * Calculates trend metrics from historical data using EWMA (Exponentially Weighted Moving Average). 131 * <p> 132 * EWMA provides a weighted baseline that emphasizes recent performance while still considering 133 * historical context. This prevents false negatives when comparing identical consecutive runs 134 * after a major performance shift. 135 * 136 * @param currentMetrics current benchmark metrics 137 * @param historicalData previous benchmark results (ordered newest first) 138 * @return trend metrics with analysis 139 */ 140 public TrendMetrics calculateTrends(BenchmarkMetrics currentMetrics, 141 List<HistoricalDataPoint> historicalData) { 142 if (historicalData.isEmpty()) { 143 return new TrendMetrics(STABLE, 0.0, currentMetrics.performanceScore(), 0.0, 0.0); 144 } 145 146 // Extract historical performance scores (newest first) 147 List<Double> historicalScores = historicalData.stream() 148 .map(HistoricalDataPoint::performanceScore) 149 .toList(); 150 151 // Calculate EWMA baseline from historical data 152 double ewmaBaseline = StatisticsCalculator.calculateEWMA(historicalScores, EWMA_LAMBDA); 153 154 // Compare current score against EWMA baseline instead of just most recent run 155 double changePercentage = StatisticsCalculator.calculatePercentageChange( 156 ewmaBaseline, 157 currentMetrics.performanceScore()); 158 159 // Determine trend direction 160 String direction = StatisticsCalculator.determineTrendDirection( 161 changePercentage, STABILITY_THRESHOLD * 100); 162 163 // Calculate simple moving average using last 5 runs (industry-standard window) 164 List<Double> recentScores = new ArrayList<>(); 165 recentScores.add(currentMetrics.performanceScore()); 166 historicalData.stream() 167 .limit(4) 168 .map(HistoricalDataPoint::performanceScore) 169 .forEach(recentScores::add); 170 double movingAverage = StatisticsCalculator.calculateMovingAverage(recentScores, 5); 171 172 // Calculate throughput and latency trends using EWMA 173 List<Double> historicalThroughput = historicalData.stream() 174 .map(HistoricalDataPoint::throughput) 175 .toList(); 176 List<Double> historicalLatency = historicalData.stream() 177 .map(HistoricalDataPoint::latency) 178 .toList(); 179 180 double throughputBaseline = StatisticsCalculator.calculateEWMA(historicalThroughput, EWMA_LAMBDA); 181 double latencyBaseline = StatisticsCalculator.calculateEWMA(historicalLatency, EWMA_LAMBDA); 182 183 double throughputTrend = StatisticsCalculator.calculatePercentageChange( 184 throughputBaseline, 185 currentMetrics.throughput() 186 ); 187 double latencyTrend = StatisticsCalculator.calculatePercentageChange( 188 latencyBaseline, 189 currentMetrics.latency() 190 ); 191 192 return new TrendMetrics(direction, changePercentage, movingAverage, 193 throughputTrend, latencyTrend); 194 } 195 196 /** 197 * Generates chart-ready trend data for visualization. 198 * 199 * @param historicalData historical benchmark results 200 * @param currentMetrics current benchmark metrics (optional) 201 * @return map containing chart labels and datasets 202 */ 203 public Map<String, Object> generateTrendChartData(List<HistoricalDataPoint> historicalData, 204 BenchmarkMetrics currentMetrics) { 205 Map<String, Object> chartData = new LinkedHashMap<>(); 206 207 // Prepare data lists 208 List<String> timestamps = new ArrayList<>(); 209 List<Double> throughputValues = new ArrayList<>(); 210 List<Double> latencyValues = new ArrayList<>(); 211 List<Double> performanceScores = new ArrayList<>(); 212 213 // Add historical data (reversed to show oldest first in chart) 214 List<HistoricalDataPoint> reversedData = historicalData.reversed(); 215 216 for (HistoricalDataPoint point : reversedData) { 217 timestamps.add(point.timestamp()); 218 throughputValues.add(point.throughput()); 219 latencyValues.add(point.latency()); 220 performanceScores.add(point.performanceScore()); 221 } 222 223 // Add current data if provided 224 if (currentMetrics != null) { 225 timestamps.add("Current"); 226 throughputValues.add(currentMetrics.throughput()); 227 latencyValues.add(currentMetrics.latency()); 228 performanceScores.add(currentMetrics.performanceScore()); 229 } 230 231 chartData.put("timestamps", timestamps); 232 chartData.put(KEY_THROUGHPUT, throughputValues); 233 chartData.put(KEY_LATENCY, latencyValues); 234 chartData.put("performanceScores", performanceScores); 235 236 // Add statistical analysis 237 Map<String, Object> statistics = new LinkedHashMap<>(); 238 StatisticsCalculator.Statistics throughputStats = StatisticsCalculator.computeStatistics(throughputValues); 239 StatisticsCalculator.Statistics latencyStats = StatisticsCalculator.computeStatistics(latencyValues); 240 241 statistics.put("throughputMin", throughputStats.getMin()); 242 statistics.put("throughputMax", throughputStats.getMax()); 243 statistics.put("throughputAvg", throughputStats.getMean()); 244 statistics.put("latencyMin", latencyStats.getMin()); 245 statistics.put("latencyMax", latencyStats.getMax()); 246 statistics.put("latencyAvg", latencyStats.getMean()); 247 248 chartData.put("statistics", statistics); 249 250 return chartData; 251 } 252 253 /** 254 * Extracts a data point from JSON data. 255 */ 256 private HistoricalDataPoint extractDataPoint(JsonObject data, String filename) { 257 try { 258 JsonObject metadata = data.getAsJsonObject("metadata"); 259 JsonObject overview = data.getAsJsonObject("overview"); 260 261 String timestamp = metadata.has("timestamp") 262 ? metadata.get("timestamp").getAsString() 263 : extractTimestampFromFilename(filename); 264 265 double throughput = overview.has(KEY_THROUGHPUT) 266 ? parseMetricValue(overview.get(KEY_THROUGHPUT).getAsString()) 267 : 0.0; 268 269 double latency = overview.has(KEY_LATENCY) 270 ? parseMetricValue(overview.get(KEY_LATENCY).getAsString()) 271 : 0.0; 272 273 double performanceScore = overview.has("performanceScore") 274 ? overview.get("performanceScore").getAsDouble() 275 : 0.0; 276 277 String commitSha = extractCommitFromFilename(filename); 278 279 return new HistoricalDataPoint(timestamp, throughput, latency, performanceScore, commitSha); 280 } catch (NullPointerException | ClassCastException | IllegalStateException | UnsupportedOperationException e) { 281 LOGGER.warn(e, WARN.ISSUE_DURING_INDEX_GENERATION, "extracting data point"); 282 return null; 283 } 284 } 285 286 /** 287 * Parses a metric value from a formatted string (e.g., "1.5K ops/s" -> 1500.0). 288 */ 289 private double parseMetricValue(String value) { 290 if (value == null || "N/A".equals(value)) { 291 return 0.0; 292 } 293 294 // Remove units and parse 295 String cleanValue = value.replaceAll("[^0-9.KMG]", "").trim(); 296 if (cleanValue.isEmpty()) { 297 return 0.0; 298 } 299 300 double multiplier = 1.0; 301 if (cleanValue.contains("K")) { 302 multiplier = 1000.0; 303 cleanValue = cleanValue.replace("K", ""); 304 } else if (cleanValue.contains("M")) { 305 multiplier = 1_000_000.0; 306 cleanValue = cleanValue.replace("M", ""); 307 } else if (cleanValue.contains("G")) { 308 multiplier = 1_000_000_000.0; 309 cleanValue = cleanValue.replace("G", ""); 310 } 311 312 try { 313 return Double.parseDouble(cleanValue) * multiplier; 314 } catch (NumberFormatException e) { 315 return 0.0; 316 } 317 } 318 319 320 /** 321 * Extracts timestamp from filename. 322 */ 323 private String extractTimestampFromFilename(String filename) { 324 int dashIndex = filename.lastIndexOf('-'); 325 if (dashIndex > 0) { 326 return filename.substring(0, dashIndex); 327 } 328 return filename; 329 } 330 331 /** 332 * Extracts commit SHA from filename. 333 */ 334 private String extractCommitFromFilename(String filename) { 335 int dashIndex = filename.lastIndexOf('-'); 336 int dotIndex = filename.lastIndexOf('.'); 337 if (dashIndex > 0 && dotIndex > dashIndex) { 338 return filename.substring(dashIndex + 1, dotIndex); 339 } 340 return "unknown"; 341 } 342}