getMessage() . "\n"; return false; } $intervalData = []; // 1. Extract TARGET MARKET DAY from the filename preg_match('/prices_(\d{8})\.xml/', basename($xmlPath), $matches); $marketDay = $matches[1] ?? null; if (!$marketDay) { echo "Error: Could not extract target market date from filename.\n"; return false; } // Define the start and end of the 24-hour window for the market day in UTC. try { $targetStartTime = new DateTimeImmutable($marketDay . ' 00:00:00', new DateTimeZone('UTC')); $targetEndTime = $targetStartTime->modify('+24 hours'); } catch (Exception $e) { echo "Error: Invalid date format in filename.\n"; return false; } // Helper function for safer XPath access $get_node_value = function ($element, $xpath_query) { $result = $element->xpath($xpath_query); return ($result !== false && isset($result[0])) ? (string)$result[0] : null; }; // 2. Locate TimeSeries and iterate through Periods. $periods = $xml->xpath('//TimeSeries/Period'); foreach ($periods as $period) { $resolution = $get_node_value($period, 'resolution'); $startTime = $get_node_value($period, 'timeInterval/start'); if ($resolution === null || $startTime === null) { continue; } try { $periodStartDateTime = new DateTimeImmutable($startTime, new DateTimeZone('UTC')); } catch (Exception $e) { continue; } // --- Core Logic for Extracting 15-Minute Data from Points --- $position = 0; foreach ($period->xpath('Point') as $point) { $position++; $price_str = $get_node_value($point, 'price.amount'); if ($price_str === null) { continue; } $price = (float) $price_str; $intervalStartDateTime = null; // Calculate the start time of the interval based on resolution and position if ($resolution === 'PT15M') { $minuteOffset = ($position - 1) * 15; $intervalStartDateTime = $periodStartDateTime->modify("+$minuteOffset minutes"); } elseif ($resolution === 'PT60M') { $hourOffset = $position - 1; $intervalStartDateTime = $periodStartDateTime->modify("+$hourOffset hours"); // For PT60M, we must expand it to four 15-minute intervals for continuity for ($j = 0; $j < 4; $j++) { $minuteOffset = $j * 15; $currentIntervalStart = $intervalStartDateTime->modify("+$minuteOffset minutes"); if ($currentIntervalStart >= $targetStartTime && $currentIntervalStart < $targetEndTime) { $intervalData[] = [ 'datetime' => $currentIntervalStart->format('Y-m-d H:i:s'), 'price_eur_mwh' => $price, 'action' => 'idle', 'charged_wh' => 0.0, 'discharged_wh' => 0.0, 'profit_eur' => 0.0, ]; } } continue; // Skip the rest of the loop for PT60M } if ($intervalStartDateTime !== null) { // Check if the interval START TIME is within the 24-hour window if ($intervalStartDateTime >= $targetStartTime && $intervalStartDateTime < $targetEndTime) { $intervalData[] = [ 'datetime' => $intervalStartDateTime->format('Y-m-d H:i:s'), 'price_eur_mwh' => $price, 'action' => 'idle', 'charged_wh' => 0.0, 'discharged_wh' => 0.0, 'profit_eur' => 0.0, ]; } } } } // --- End of Core Logic --- // Sort the interval data by datetime to ensure correct order usort($intervalData, function($a, $b) { return strcmp($a['datetime'], $b['datetime']); }); if (count($intervalData) !== INTERVALS_IN_DAY) { echo "Warning: Expected " . INTERVALS_IN_DAY . " intervals, found " . count($intervalData) . ". Proceeding anyway.\n"; } if (empty($intervalData)) { echo "Error: No price data found in XML for the target day: $marketDay.\n"; return false; } return $intervalData; } /** * Calculates the total cost (for charging) or revenue (for discharging) with tariffs. * * @param float $priceEurMwh Market price in €/MWh. * @param float $energyWh Energy transacted in Wh. * @param bool $isCharging If true, calculates cost. If false, calculates revenue. * @param array $tariffs Array containing tariffs. * @return float Cost (negative) or Revenue (positive) in Euros. */ function calculateTransactionValue(float $priceEurMwh, float $energyWh, bool $isCharging, array $tariffs): float { // Convert energy from Wh to kWh and price from €/MWh to €/kWh $energyKWh = $energyWh / 1000.0; $marketPricePerKWh = $priceEurMwh / 1000.0; // Components of the Price before VAT $priceComponent = $marketPricePerKWh; $buyInComponent = $tariffs['buyin_kwh']; $energyTaxComponent = $tariffs['energy_tax_kwh']; $subtotalPerKWh = $priceComponent + $buyInComponent + $energyTaxComponent; $totalPricePerKWh = $subtotalPerKWh * (1 + $tariffs['vat_rate']); if ($isCharging) { // --- Calculate Cost for Charging (Buying Energy) --- $totalCost = $totalPricePerKWh * $energyKWh; return -$totalCost; // Cost is negative } else { // --- Calculate Revenue for Discharging (Selling Energy) --- // CRITICAL UPDATE: Revenue is calculated based on the full retail price (including taxes) $totalRevenue = $totalPricePerKWh * $energyKWh; return $totalRevenue; // Revenue is positive } } /** * Executes the virtual battery simulation. */ function simulateBattery( string $xmlPath, float $priceDeltaThreshold, float $capacityWh, float $chargeEfficiencyPct, float $dischargeEfficiencyPct, float $chargePowerW, float $dischargePowerW, float $buyInCostKWh, float $energyTaxKWh, float $vatRatePct ): void { $intervalData = loadDataFromXml($xmlPath); if ($intervalData === false) { exit(1); } // Prepare constants and factors $tariffs = [ 'buyin_kwh' => $buyInCostKWh, 'energy_tax_kwh' => $energyTaxKWh, 'vat_rate' => $vatRatePct / 100.0, ]; $chargeEfficiencyFactor = $chargeEfficiencyPct / 100.0; $dischargeEfficiencyFactor = $dischargeEfficiencyPct / 100.0; $intervalDuration = 0.25; // 15 minutes in hours // 1. Determine Cheapest and Most Expensive Intervals $prices = array_column($intervalData, 'price_eur_mwh'); $minPrice = min($prices); $maxPrice = max($prices); echo "\n" . str_repeat('=', 50) . "\n"; echo "šŸ”‹ **Virtual Battery Simulation (15-Minute Resolution)**\n"; echo str_repeat('=', 50) . "\n"; echo "šŸ“Š **Market Analysis**\n"; echo sprintf("Cheapest Interval Price: %.2f €/MWh\n", $minPrice); echo sprintf("Most Expensive Interval Price: %.2f €/MWh\n", $maxPrice); echo sprintf("Target Delta Threshold: %.2f €/MWh\n", $priceDeltaThreshold); echo "**Revenue Rule:** Selling includes all taxes and costs.\n"; echo str_repeat('-', 50) . "\n"; echo "āš™ļø **Battery Parameters**\n"; echo sprintf("Capacity: %.0f Wh | Charge Power: %.0f W | Discharge Power: %.0f W\n", $capacityWh, $chargePowerW, $dischargePowerW); echo sprintf("Charge Efficiency: %.2f%% | Discharge Efficiency: %.2f%%\n", $chargeEfficiencyPct, $dischargeEfficiencyPct); echo str_repeat('-', 50) . "\n"; // 2. Simulation Setup (Identify Optimal Intervals) $batteryStateOfCharge = 0.0; $totalChargedWh = 0.0; $totalDischargedWh = 0.0; $totalLostWh = 0.0; $totalProfitEur = 0.0; $maxChargeWhPerInterval = $chargePowerW * $intervalDuration; $maxDischargeWhPerInterval = $dischargePowerW * $intervalDuration; // --- ARBITRAGE OPTIMIZATION: Identify the 32 cheapest intervals for charging --- $priceIndexMap = []; foreach ($intervalData as $i => $interval) { $priceIndexMap[$i] = $interval['price_eur_mwh']; } // Sort by price in ascending order (cheapest first) asort($priceIndexMap); // Select the indices (0-95) of the 32 cheapest intervals (8 hours * 4 intervals) $optimalChargeIntervals = array_slice(array_keys($priceIndexMap), 0, OPTIMAL_CHARGE_INTERVALS, true); // ------------------------------------------------------------------------- // Determine the Discharge Floor Price for the simple discharge check $dischargeFloorPrice = $minPrice + $priceDeltaThreshold; echo sprintf("Identified %d optimal intervals for charging.\n", count($optimalChargeIntervals)); echo str_repeat('-', 50) . "\n"; // 3. Perform Simulation (Iterate through all 96 intervals) foreach ($intervalData as $i => &$interval) { $priceMwh = $interval['price_eur_mwh']; // --- Discharging Attempt (High Priority) --- // Discharge ONLY if the price is profitable (Price >= Min Price + Delta) if ($priceMwh >= $dischargeFloorPrice) { $dischargeableWh = $batteryStateOfCharge; if ($dischargeableWh > 0.0) { // Discharge logic $dischargeAmount_FromBattery = min($maxDischargeWhPerInterval, $dischargeableWh); $outputToGridWh = $dischargeAmount_FromBattery * $dischargeEfficiencyFactor; $revenue = calculateTransactionValue($priceMwh, $outputToGridWh, false, $tariffs); // Update totals $batteryStateOfCharge -= $dischargeAmount_FromBattery; $totalDischargedWh += $dischargeAmount_FromBattery; $totalLostWh += ($dischargeAmount_FromBattery - $outputToGridWh); $totalProfitEur += $revenue; $interval['action'] = 'discharge'; $interval['discharged_wh'] = $outputToGridWh; $interval['profit_eur'] += $revenue; } } // --- End Discharging Attempt --- // --- Charging Attempt (Lower Priority) --- // Check if the current interval index is one of the optimal charging times if (in_array($i, $optimalChargeIntervals)) { // Only charge if discharge didn't already run in this interval if ($interval['action'] !== 'discharge') { $availableCapacity = $capacityWh - $batteryStateOfCharge; // Only proceed if there is available capacity greater than a small tolerance (1 Wh) if ($availableCapacity > 1.0) { $maxInputByCapacity = $availableCapacity / $chargeEfficiencyFactor; // Energy that is taken from the grid in 15 minutes $inputFromGridWh = min($maxChargeWhPerInterval, $maxInputByCapacity); if ($inputFromGridWh > 0.0) { $storedWh = $inputFromGridWh * $chargeEfficiencyFactor; $cost = calculateTransactionValue($priceMwh, $inputFromGridWh, true, $tariffs); // Update totals $batteryStateOfCharge += $storedWh; $totalChargedWh += $inputFromGridWh; $totalLostWh += ($inputFromGridWh - $storedWh); $totalProfitEur += $cost; $interval['action'] = 'charge'; $interval['charged_wh'] = $storedWh; $interval['profit_eur'] += $cost; } } else { // Battery is full $interval['action'] = 'idle'; } } } // Ensure SOC remains within bounds $batteryStateOfCharge = min($batteryStateOfCharge, $capacityWh); $batteryStateOfCharge = max($batteryStateOfCharge, 0.0); } unset($interval); // 4. Output Results $energyDischargedToGrid = $totalDischargedWh - $totalLostWh; $netRTE = ($totalChargedWh > 0) ? ($energyDischargedToGrid / $totalChargedWh) * 100 : 0.0; echo "\nšŸ“Š **Simulation Results**\n"; echo str_repeat('-', 50) . "\n"; echo sprintf("%-35s: %.2f Wh\n", "Total Energy Charged (Grid Input)", $totalChargedWh); echo sprintf("%-35s: %.2f Wh\n", "Total Energy Discharged (Grid Output)", $energyDischargedToGrid); echo sprintf("%-35s: %.2f Wh\n", "Total Energy Lost (C/D losses)", $totalLostWh); echo sprintf("%-35s: %.2f %%\n", "Net Round-Trip Efficiency (Effective)", $netRTE); echo sprintf("%-35s: **%.2f €**\n", "Total Value Generated (Net Profit)", $totalProfitEur); echo str_repeat('=', 50) . "\n"; echo "\nDetailed 15-Minute Log:\n"; printf("%-20s | %-12s | %-12s | %-12s | %-12s | %s\n", "Datetime", "Price (€/MWh)", "Action", "SOC (Wh)", "Profit (€)", "C/D Energy (Wh)"); echo str_repeat('-', 95) . "\n"; // Print details using the interval data $currentSOC = 0.0; // Reset for accurate logging based on final data foreach ($intervalData as $interval) { $hourlyCDEnergy = 0.0; if ($interval['action'] === 'charge') { $energyStored = $interval['charged_wh']; $currentSOC += $energyStored; $hourlyCDEnergy = $energyStored; } elseif ($interval['action'] === 'discharge') { $energyTakenFromSOC = $interval['discharged_wh'] / $dischargeEfficiencyFactor; $currentSOC -= $energyTakenFromSOC; $hourlyCDEnergy = -$interval['discharged_wh']; // Display output to grid (negative for sell) } $currentSOC = min($currentSOC, $capacityWh); $currentSOC = max($currentSOC, 0.0); printf( "%-20s | %-12.2f | %-12s | %-12.2f | %-12.2f | %.2f\n", $interval['datetime'], $interval['price_eur_mwh'], $interval['action'], $currentSOC, $interval['profit_eur'], $hourlyCDEnergy ); } } // --- Main Execution --- if ($argc < 11) { echo "Usage: php Simulator.php