diff --git a/setup.php b/setup.php index 58c5df08..f2788d5d 100644 --- a/setup.php +++ b/setup.php @@ -213,6 +213,11 @@ function plugin_carbon_check_prerequisites() $prerequisitesSuccess = false; } + if ($DB->use_timezones !== true) { + echo "Enable timezones support
"; + $prerequisitesSuccess = false; + } + if (getenv('CI') === false) { // only when not under test $version_string = $DB->getVersion(); diff --git a/src/CarbonIntensity.php b/src/CarbonIntensity.php index a319562e..731d603b 100644 --- a/src/CarbonIntensity.php +++ b/src/CarbonIntensity.php @@ -213,7 +213,7 @@ public function getFirstKnownDate(string $zone_name, string $source_name): ?Date */ public function downloadOneZone(ClientInterface $data_source, string $zone_name, int $limit = 0, ?ProgressBar $progress_bar = null): int { - $start_date = $this->getDownloadStartDate($zone_name, $data_source); + $start_date = $this->getDownloadStartDate(); $total_count = 0; @@ -279,11 +279,9 @@ public function downloadOneZone(ClientInterface $data_source, string $zone_name, /** * Get the oldest date where data are required * - * @param string $zone_name ignored for now; zone to examine - * @param ClientInterface $data_source ignored for now; data source * @return DateTimeImmutable|null */ - public function getDownloadStartDate(string $zone_name, ClientInterface $data_source): ?DateTimeImmutable + public function getDownloadStartDate(): ?DateTimeImmutable { // Get the default oldest date od data to download $start_date = new DateTime(self::MIN_HISTORY_LENGTH); diff --git a/src/Command/CollectCarbonIntensityCommand.php b/src/Command/CollectCarbonIntensityCommand.php index 2a62da0f..c199dc6d 100644 --- a/src/Command/CollectCarbonIntensityCommand.php +++ b/src/Command/CollectCarbonIntensityCommand.php @@ -46,6 +46,7 @@ use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; @@ -68,6 +69,7 @@ protected function configure() ->setDescription(__('Read carbon dioxyde intensity from external sources', 'carbon')) ->addArgument('source', InputArgument::REQUIRED, '') ->addArgument('zone', InputArgument::REQUIRED, '') + ->addOption('cache', null, InputOption::VALUE_NEGATABLE, 'Use cache. Cache is not read is disabled, but still fed by requests.') ; } @@ -136,6 +138,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->source_id = $data_source->getID(); $zone_code = $input->getArgument('zone'); + $use_cache = $input->getOption('cache'); $zone = new Zone(); $zone->getFromDBByCrit(['name' => $this->zones[$zone_code]]); $carbon_intensity = new CarbonIntensity(); @@ -165,11 +168,14 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($this->client === null) { $this->client = ClientFactory::createByName($source_name); } + if ($use_cache === false) { + $this->client->disableCache(); + } $carbon_intensity->downloadOneZone($this->client, $this->zones[$zone_code], 0, new ProgressBar($this->output)); // Find start and stop dates to cover - $start_date = $carbon_intensity->getDownloadStartDate($this->zones[$zone_code], $this->client); + $start_date = $carbon_intensity->getDownloadStartDate(); $gaps = $carbon_intensity->findGaps($this->source_id, $zone->getID(), $start_date); // Count the hours not covered by any sample diff --git a/src/DataSource/CarbonIntensity/AbstractClient.php b/src/DataSource/CarbonIntensity/AbstractClient.php index 6f6f6956..bf4aceb3 100644 --- a/src/DataSource/CarbonIntensity/AbstractClient.php +++ b/src/DataSource/CarbonIntensity/AbstractClient.php @@ -48,6 +48,8 @@ abstract class AbstractClient implements ClientInterface { protected int $step; + protected bool $use_cache = true; + abstract public function getSourceName(): string; abstract public function getDataInterval(): string; @@ -77,6 +79,11 @@ abstract public function fetchDay(DateTimeImmutable $day, string $zone): array; */ abstract public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone): array; + public function disableCache() + { + $this->use_cache = false; + } + /** * Key of the configuration value that indicates if the full download is complete * @@ -157,8 +164,17 @@ public function getZones(array $crit = []): array public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTimeImmutable $stop_date, CarbonIntensity $intensity, int $limit = 0, ?ProgressBar $progress_bar = null): int { + if ($start_date >= $stop_date) { + return 0; + } + // Round start date to beginning of month + $start_date = $start_date->setTime(0, 0, 0, 0)->setDate((int) $start_date->format('Y'), (int) $start_date->format('m'), 1); + $stop_date = $stop_date->setTime(0, 0, 0, 0)->setDate((int) $stop_date->format('Y'), (int) $stop_date->format('m'), 1); $count = 0; $saved = 0; + if ($start_date == $stop_date) { + $stop_date = $stop_date->add(new DateInterval('P1M')); + } /** * Huge quantity of SQL queries will be executed @@ -171,9 +187,18 @@ public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTi // enpty string in PHPUnit environment $memory_limit = null; } - foreach ($this->sliceDateRangeByMonth($start_date, $stop_date) as $slice) { + + // Traverse each month from start_date to end_date + $current_date = DateTime::createFromImmutable($start_date); + while ($current_date < $stop_date) { + $next_month = clone $current_date; + $next_month->add(new DateInterval('P1M')); try { - $data = $this->fetchRange($slice['start'], $slice['stop'], $zone); + $data = $this->fetchRange( + DateTimeImmutable::createFromMutable($current_date), + DateTimeImmutable::createFromMutable($next_month), + $zone + ); } catch (AbortException $e) { break; } @@ -194,8 +219,8 @@ public function fullDownload(string $zone, DateTimeImmutable $start_date, DateTi // 8 MB memory left, emergency exit return $saved > 0 ? $count : -$count; } + $current_date = $next_month; } - return $saved > 0 ? $count : -$count; } @@ -211,6 +236,9 @@ public function incrementalDownload(string $zone, DateTimeImmutable $start_date, } catch (AbortException $e) { throw $e; } + if (!isset($data[$zone])) { + continue; + } $saved = $intensity->save($zone, $this->getSourceName(), $data[$zone]); $count += abs($saved); if ($limit > 0 && $count >= $limit) { @@ -278,10 +306,9 @@ protected function sliceDateRangeByMonth(DateTimeImmutable $start, DateTimeImmut */ protected function sliceDateRangeByDay(DateTimeImmutable $start, DateTimeImmutable $stop) { - $real_start = $start; $real_stop = $stop->setTime((int) $stop->format('H'), 0, 0); - $current_date = DateTime::createFromImmutable($real_start); + $current_date = DateTime::createFromImmutable($start); while ($current_date <= $real_stop) { yield DateTimeImmutable::createFromMutable($current_date); $current_date->add(new DateInterval('P1D')); diff --git a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php index 3905d21b..b6f366a1 100644 --- a/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php +++ b/src/DataSource/CarbonIntensity/ElectricityMaps/Client.php @@ -41,7 +41,6 @@ use GlpiPlugin\Carbon\Source; use GlpiPlugin\Carbon\Zone; use GlpiPlugin\Carbon\Source_Zone; -use Config as GlpiConfig; use GLPIKey; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\AbortException; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\AbstractClient; @@ -264,16 +263,45 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone): array { - // TODO: get zones from GLPI locations $params = [ 'zone' => $zone, ]; + $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $zone; + $cache_file = $this->getCacheFilename( + $base_path, + $start, + $stop + ); + // If cached file exists, use it + if (file_exists($cache_file)) { + $full_response = json_decode(file_get_contents($cache_file), true); + return $full_response; + } + @mkdir(dirname($cache_file), 0755, true); + + // Set timezone to +00:00 and extend range by 12 hours on each side + $request_start = $start->setTimezone(new DateTimeZone('+0000'))->sub(new DateInterval('PT12H')); + $request_stop = $stop->setTimezone(new DateTimeZone('+0000'))->add(new DateInterval('PT12H')); $this->step = 60; - $response = $this->client->request('GET', $this->base_url . self::PAST_URL, ['query' => $params]); - if (!$response) { + $step = new DateInterval('P10D'); + $full_response = []; + $current_date = DateTime::createFromImmutable($request_start); + while ($current_date < $request_stop) { + $response = $this->client->request('GET', $this->base_url . self::PAST_URL, ['query' => $params]); + if (!$full_response) { + $full_response = $response; + } else { + $full_response['data'] = array_merge($full_response['data'], $response['data']); + } + $current_date->add($step); + if ($current_date > $request_stop) { + $current_date = $request_stop; + } + } + if (!$full_response) { return []; } @@ -286,7 +314,9 @@ public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, st return []; } - return $$response['history']; + $json = json_encode($full_response); + file_put_contents($cache_file, $json); + return $full_response['data']; } protected function formatOutput(array $response, int $step): array @@ -369,4 +399,14 @@ protected function sliceDateRangeByDay(DateTimeImmutable $start, DateTimeImmutab $current_date->setTime(0, 0, 0); } } + + protected function getCacheFilename(string $base_dir, DateTimeImmutable $start, DateTimeImmutable $end): string + { + return sprintf( + '%s/%s_%s.json', + $base_dir, + $start->format('Y-m-d'), + $end->format('Y-m-d') + ); + } } diff --git a/src/DataSource/CarbonIntensity/Rte/Client.php b/src/DataSource/CarbonIntensity/Rte/Client.php index 7db27a72..82265f2b 100644 --- a/src/DataSource/CarbonIntensity/Rte/Client.php +++ b/src/DataSource/CarbonIntensity/Rte/Client.php @@ -52,6 +52,12 @@ * * API documentation * @see https://help.opendatasoft.com/apis/ods-explore-v2/explore_v2.1.html + * + * About DST for this data source + * GLPI must be set up with timezones enabled, set to the same timezone as the host system + * + * If this requirement is not met, then dates here DST change occurs will cause problems + * Searching for gaps will find gaps that the algorithm will try to fill, but fail. */ class Client extends AbstractClient { @@ -59,13 +65,13 @@ class Client extends AbstractClient const EXPORT_URL_REALTIME = '/eco2mix-national-tr/exports/json'; const EXPORT_URL_CONSOLIDATED = '/eco2mix-national-cons-def/exports/json'; + const DATASET_REALTIME = 0; + const DATASET_CONSOLIDATED = 1; + private RestApiClientInterface $client; private string $base_url; - /** @var bool Use consolidated dataset (true) or realtime dataset (false) */ - private bool $use_consolidated = false; - public function __construct(RestApiClientInterface $client, string $url = '') { $this->client = $client; @@ -157,25 +163,22 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array /** @var DBmysql $DB */ global $DB; - $start = DateTime::createFromImmutable($day); - $stop = clone $start; - $stop->setTime(23, 59, 59); + $stop = $day->add(new DateInterval('P1D')); $format = DateTime::ATOM; $timezone = $DB->guessTimezone(); - $from = $start->format($format); + $from = $day->format($format); $to = $stop->format($format); $params = [ - 'select' => 'taux_co2,date_heure', - 'where' => "date_heure IN [date'$from' TO date'$to']", + 'select' => 'date_heure,taux_co2', + 'where' => "date_heure IN [date'$from' TO date'$to'[ AND taux_co2 is not null", 'order_by' => 'date_heure asc', - 'limit' => 4 * 24, // 4 samples per hour = 4 * 24 hours - 'offset' => 0, 'timezone' => $timezone, ]; - $response = $this->client->request('GET', $this->base_url . self::RECORDS_URL, ['timeout' => 8, 'query' => $params]); + $url = $this->base_url . self::EXPORT_URL_REALTIME; + $response = $this->client->request('GET', $url, ['timeout' => 8, 'query' => $params]); if (!$response) { return []; } @@ -185,114 +188,127 @@ public function fetchDay(DateTimeImmutable $day, string $zone): array } // Drop data with no carbon intensity (may be returned by the provider) - $response['results'] = array_filter($response['results'], function ($item) { + $response = array_filter($response, function ($item) { return $item['taux_co2'] != 0; }); // Drop last rows until we reach $safety_count = 0; - while (($last_item = end($response['results'])) !== false) { + while (($last_item = end($response)) !== false) { $time = DateTime::createFromFormat(DateTimeInterface::ATOM, $last_item['date_heure']); if ($time->format('i') === '45') { // We expect 15 minutes steps break; } - array_pop($response['results']); + array_pop($response); $safety_count++; if ($safety_count > 3) { break; } } - return $this->formatOutput($response['results'], 15); + return $this->formatOutput($response, 15); } /** - * Fetch carbon intensities from Opendata Réseaux-Énergies using export dataset. - * - * See https://odre.opendatasoft.com/explore/dataset/eco2mix-national-tr/api/?disjunctive.nature + * Fetch range from cached data or online database. Assume that $start is the beginning of a month (Y-m-1 00:00:00) + * and $stop is the beginning of the next month (Y-m+1-1 00:00:00). * - * The method fetches the intensities for the date range specified in argument. * @param DateTimeImmutable $start * @param DateTimeImmutable $stop * @param string $zone + * @param int $dataset * @return array */ - public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone): array + public function fetchRange(DateTimeImmutable $start, DateTimeImmutable $stop, string $zone, int $dataset = self::DATASET_REALTIME): array { /** @var DBmysql $DB */ global $DB; - $format = DateTime::ATOM; - $from = $start->format($format); - $to = $stop->format($format); + // Build realtime and consolidated paths + $base_path = GLPI_PLUGIN_DOC_DIR . '/carbon/carbon_intensity/' . $this->getSourceName() . '/' . $zone; + $consolidated_dir = $base_path . '/consolidated'; + $realtime_dir = $base_path . '/realtime'; - $timezone = $DB->guessTimezone(); - $interval = $stop->diff($start); - $where = "date_heure IN [date'$from' TO date'$to'"; - if ($interval->y === 0 && $interval->m === 0 && $interval->d === 0 && $interval->h === 1) { - $where .= ']'; - } else { - $where .= '['; - } - $params = [ - 'select' => 'taux_co2,date_heure', - 'where' => $where, - 'order_by' => 'date_heure asc', - 'timezone' => $timezone, - ]; - // convert to 15 minutes interval - if ($this->use_consolidated) { - $this->step = 60; - $url = $this->base_url . self::EXPORT_URL_CONSOLIDATED; - } else { - $this->step = 15; - $url = $this->base_url . self::EXPORT_URL_REALTIME; - } + // Set timezone to +00:00 and extend range by -12/+14 hours + $request_start = $start->setTimezone(new DateTimeZone('+0000'))->sub(new DateInterval('PT12H')); + $request_stop = $stop->setTimezone(new DateTimeZone('+0000'))->add(new DateInterval('PT14H')); + $format = DateTime::ATOM; + $from = $request_start->format($format); + $to = $request_stop->format($format); + $interval = $request_stop->diff($request_start); $expected_samples_count = (int) ($interval->days * 24) + (int) ($interval->h) + (int) ($interval->i / 60); - $alt_response = []; - $response = $this->client->request('GET', $url, ['timeout' => 8, 'query' => $params]); - if ($response) { - // Adjust step as consolidated dataset may return data for each 15 minutes ! - $this->step = $this->detectStep($response); - $expected_samples_count *= (60 / $this->step); + // Choose URL + switch ($dataset) { + case self::DATASET_CONSOLIDATED: + $url = self::EXPORT_URL_CONSOLIDATED; + $cache_file = $this->getCacheFilename( + $consolidated_dir, + $start, + $stop + ); + break; + case self::DATASET_REALTIME: + default: + $url = self::EXPORT_URL_REALTIME; + $cache_file = $this->getCacheFilename( + $realtime_dir, + $start, + $stop + ); + break; } + $url = $this->base_url . $url; - // Tolerate DST switching issues with 15 minutes samples (4 missing samples or too many samples) - if (!$response || abs(count($response) - $expected_samples_count) > 4) { - // Retry with realtime dataset - if (!$this->use_consolidated) { - $this->use_consolidated = true; - $alt_response = $this->fetchRange($start, $stop, $zone); - - if (!isset($alt_response['error_code']) && count($alt_response) > count($response)) { - // Use the alternative response if more samples than the original response - $response = $alt_response; - } - } + // If a cached file exists, use it + if ($this->use_cache && file_exists($cache_file)) { + $response = json_decode(file_get_contents($cache_file), true); + $this->step = $this->detectStep($response); + return $response; } + @mkdir(dirname($cache_file), 0755, true); - if (!$response) { - trigger_error('No response from RTE API for ' . $zone, E_USER_WARNING); - return []; - } - if (count($response) === 0) { - trigger_error('Empty response from RTE API for ' . $zone, E_USER_WARNING); - return []; - } - if (abs(count($response) - $expected_samples_count) > 4) { - trigger_error('Not enough samples from RTE API for ' . $zone . ' (expected: ' . $expected_samples_count . ', got: ' . count($response) . ')', E_USER_WARNING); - } - if (isset($response['error_code'])) { - trigger_error($this->formatError($response)); + // Prepare the HTTP request + $timezone = $DB->guessTimezone(); + $where = "date_heure IN [date'$from' TO date'$to'[ AND taux_co2 is not null"; + $params = [ + 'select' => 'date_heure,taux_co2', + 'where' => $where, + 'order_by' => 'date_heure asc', + 'timezone' => $timezone + ]; + $response = $this->client->request('GET', $url, ['timeout' => 8, 'query' => $params]); + $this->step = $this->detectStep($response); + $expected_samples_count *= (60 / $this->step); + if (($dataset === self::DATASET_REALTIME && abs(count($response) - $expected_samples_count) > 4)) { + $alt_response = $this->fetchRange($start, $stop, $zone, self::DATASET_CONSOLIDATED); + if (!isset($alt_response['error_code']) && count($alt_response) > count($response)) { + // Use the alternative response if more samples than the original response + $response = $alt_response; + } + } else { + if (count($response) > 0 && $stop->format('Y-m') < date('Y-m')) { + // Cache only if the month being processed is older than the month of now + $json = json_encode($response); + file_put_contents($cache_file, $json); + } } - return $response; } + protected function getCacheFilename(string $base_dir, DateTimeImmutable $start, DateTimeImmutable $end): string + { + return sprintf( + '%s/%s_%s.json', + $base_dir, + $start->format('Y-m-d'), + $end->format('Y-m-d') + ); + } + protected function formatOutput(array $response, int $step): array { /** @var DBMysql $DB */ @@ -309,14 +325,15 @@ protected function formatOutput(array $response, int $step): array // Even if we use UTC timezone. $filtered_response = $this->deduplicate($response); - // Convert string dates into datetime objects, restoring timezone as type Continent/City instead of offset + // Convert string dates into datetime objects, + // using timezone expressed as type Continent/City instead of offset // This is needed to detect later the switching to winter time - $timezone = new DateTimeZone($DB->guessTimezone()); - foreach ($filtered_response as &$record) { - $record['date_heure'] = DateTime::createFromFormat('Y-m-d\TH:i:s??????', $record['date_heure'], $timezone); - } + $local_timezone = new DateTimeZone($DB->guessTimezone()); + array_walk($filtered_response, function (&$item, $key) use ($local_timezone) { + $item['date_heure'] = DateTime::createFromFormat('Y-m-d\TH:i:sP', $item['date_heure'])->setTimezone($local_timezone); + }); - // Convert samples from 15 min to 1 hour + // Convert samples from to 1 hour if ($this->step < 60) { $intensities = $this->convertToHourly($filtered_response, $this->step); } else { @@ -354,6 +371,12 @@ protected function deduplicate(array $records): array return $filtered_response; } + /** + * Get the temporal distance between records + * + * @param array $records + * @return integer step in minutes + */ protected function detectStep(array $records): ?int { if (count($records) < 2) { @@ -366,9 +389,8 @@ protected function detectStep(array $records): ?int if ($diff->h === 1) { return 60; // 1 hour step - } else { - return $diff->i; // Return the minutes step } + return $diff->i; // Return the minutes step } /** @@ -387,16 +409,18 @@ protected function convertToHourly(array $records, int $step): array foreach ($records as $record) { $date = $record['date_heure']; - $count++; $intensity += $record['taux_co2']; + if ($record['taux_co2'] === null) { + continue; + } + $count++; $minute = (int) $date->format('i'); if ($previous_record_date !== null) { // Ensure that current date is $step minutes ahead than previous record date $diff = $date->getTimestamp() - $previous_record_date->getTimestamp(); if ($diff !== $step * 60) { - if ($diff == 4500 && $this->switchToWinterTime($previous_record_date, $date)) { - // 4500 = 1h + 15m + if ($this->switchToWinterTime(clone $previous_record_date, clone $date)) { $filled_date = DateTime::createFromFormat('Y-m-d\TH:i:s', end($intensities)['datetime']); $filled_date->add(new DateInterval('PT1H')); $intensities[] = [ @@ -414,6 +438,7 @@ protected function convertToHourly(array $records, int $step): array } if ($minute === (60 - $step)) { + // Finalizing an average of accumulated samples $intensities[] = [ 'datetime' => $date->format('Y-m-d\TH:00:00'), 'intensity' => (float) $intensity / $count, @@ -430,12 +455,15 @@ protected function convertToHourly(array $records, int $step): array } /** - * Detect if the given datetime matches a switching ot winter time (DST) + * Detect if the given datetime matches a switching ot winter time (DST) for France * * @return bool */ private function switchToWinterTime(DateTime $previous, DateTime $date): bool { + $timezone_paris = new DateTimeZone('Europe/Paris'); + $previous->setTimezone($timezone_paris); + $date->setTimezone($timezone_paris); $first_dst = $previous->format('I'); $second_dst = $date->format('I'); return $first_dst === '1' && $second_dst === '0'; diff --git a/src/Impact/Embodied/Boavizta/Computer.php b/src/Impact/Embodied/Boavizta/Computer.php index 85ee6c89..f395664e 100644 --- a/src/Impact/Embodied/Boavizta/Computer.php +++ b/src/Impact/Embodied/Boavizta/Computer.php @@ -195,7 +195,7 @@ protected function analyzeHardware(): array ); if ($device_hard_drive_type !== false && $device_hard_drive_type->fields['name'] === 'removable') { // Ignore removable storage (USB sticks, ...) - continue; + break; } $interface_type = new InterfaceType(); $interface_type->getFromDB($device_hard_drive->fields['interfacetypes_id']); diff --git a/tests/fixtures/RTE/api-sample.json b/tests/fixtures/RTE/api-sample.json index 08943857..f149639a 100644 --- a/tests/fixtures/RTE/api-sample.json +++ b/tests/fixtures/RTE/api-sample.json @@ -1,393 +1,390 @@ -{ - "total_count": 97, - "results": [ - { - "date_heure": "2024-07-03T00:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T00:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T00:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T00:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T01:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T01:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T01:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T01:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T02:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T02:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T02:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T02:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T03:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T03:15:00+00:00", - "taux_co2": 14 - }, - { - "date_heure": "2024-07-03T03:30:00+00:00", - "taux_co2": 14 - }, - { - "date_heure": "2024-07-03T03:45:00+00:00", - "taux_co2": 14 - }, - { - "date_heure": "2024-07-03T04:00:00+00:00", - "taux_co2": 14 - }, - { - "date_heure": "2024-07-03T04:15:00+00:00", - "taux_co2": 14 - }, - { - "date_heure": "2024-07-03T04:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T04:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T05:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T05:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T05:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T05:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T06:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T06:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T06:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T06:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T07:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T07:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T07:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T07:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T08:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T08:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T08:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T08:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T09:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T09:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T09:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T09:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T10:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T10:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T10:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T10:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T11:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T11:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T11:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T11:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T12:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T12:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T12:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T12:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T13:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T13:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T13:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T13:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T14:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T14:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T14:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T14:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T15:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T15:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T15:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T15:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T16:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T16:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T16:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T16:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T17:00:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T17:15:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T17:30:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T17:45:00+00:00", - "taux_co2": 11 - }, - { - "date_heure": "2024-07-03T18:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T18:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T18:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T18:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T19:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T19:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T19:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T19:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T20:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T20:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T20:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T20:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T21:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T21:15:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T21:30:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T21:45:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T22:00:00+00:00", - "taux_co2": 12 - }, - { - "date_heure": "2024-07-03T22:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T22:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T22:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T23:00:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T23:15:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T23:30:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-03T23:45:00+00:00", - "taux_co2": 13 - }, - { - "date_heure": "2024-07-04T00:00:00+00:00", - "taux_co2": 14 - } - ] -} \ No newline at end of file +[ + { + "date_heure": "2024-07-03T00:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T00:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T00:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T00:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T01:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T01:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T01:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T01:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T02:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T02:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T02:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T02:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T03:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T03:15:00+00:00", + "taux_co2": 14 + }, + { + "date_heure": "2024-07-03T03:30:00+00:00", + "taux_co2": 14 + }, + { + "date_heure": "2024-07-03T03:45:00+00:00", + "taux_co2": 14 + }, + { + "date_heure": "2024-07-03T04:00:00+00:00", + "taux_co2": 14 + }, + { + "date_heure": "2024-07-03T04:15:00+00:00", + "taux_co2": 14 + }, + { + "date_heure": "2024-07-03T04:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T04:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T05:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T05:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T05:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T05:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T06:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T06:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T06:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T06:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T07:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T07:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T07:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T07:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T08:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T08:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T08:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T08:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T09:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T09:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T09:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T09:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T10:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T10:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T10:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T10:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T11:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T11:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T11:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T11:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T12:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T12:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T12:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T12:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T13:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T13:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T13:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T13:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T14:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T14:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T14:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T14:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T15:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T15:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T15:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T15:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T16:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T16:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T16:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T16:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T17:00:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T17:15:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T17:30:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T17:45:00+00:00", + "taux_co2": 11 + }, + { + "date_heure": "2024-07-03T18:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T18:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T18:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T18:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T19:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T19:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T19:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T19:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T20:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T20:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T20:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T20:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T21:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T21:15:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T21:30:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T21:45:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T22:00:00+00:00", + "taux_co2": 12 + }, + { + "date_heure": "2024-07-03T22:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T22:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T22:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T23:00:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T23:15:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T23:30:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-03T23:45:00+00:00", + "taux_co2": 13 + }, + { + "date_heure": "2024-07-04T00:00:00+00:00", + "taux_co2": 14 + } +] diff --git a/tests/src/DbTestCase.php b/tests/src/DbTestCase.php index 95380a3c..481068e1 100644 --- a/tests/src/DbTestCase.php +++ b/tests/src/DbTestCase.php @@ -51,9 +51,40 @@ public function tearDown(): void global $DB; $DB->rollback(); + if (!defined('TEST_PLUGIN_NAME')) { + throw new \RuntimeException('TEST_PLUGIN_NAME is not defined'); + } + $this->recursiveRmDir(GLPI_PLUGIN_DOC_DIR . '/' . TEST_PLUGIN_NAME); parent::tearDown(); } + /** + * Recursively remove directory + * @see https://www.php.net/manual/en/function.rmdir.php#117354 + * + * @param string $dir + * @return void + */ + protected function recursiveRmDir(string $src) + { + if (!is_dir($src)) { + return; + } + $dir = opendir($src); + while (false !== ($file = readdir($dir))) { + if ($file != '.' && $file != '..') { + $full = $src . '/' . $file; + if (is_dir($full)) { + $this->recursiveRmDir($full); + } else { + unlink($full); + } + } + } + closedir($dir); + rmdir($src); + } + protected function DBVersionCheck() { /** @var DBmysql $DB */ diff --git a/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php b/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php index f3d99644..c3c18bce 100644 --- a/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php +++ b/tests/units/DataSource/CarbonIntensity/ElectricityMaps/ClientTest.php @@ -32,15 +32,20 @@ namespace GlpiPlugin\Carbon\DataSource\CarbonIntensity\ElectricityMaps; +use DateInterval; +use DateTime; use GlpiPlugin\Carbon\DataSource\CarbonIntensity\ElectricityMaps\Client; use GlpiPlugin\Carbon\DataSource\RestApiClientInterface; use GlpiPlugin\Carbon\Source; use GlpiPlugin\Carbon\Zone; use GlpiPlugin\Carbon\Tests\DbTestCase; use DateTimeImmutable; +use DateTimeInterface; use GlpiPlugin\Carbon\Source_Zone; use PHPUnit\Framework\Attributes\CoversClass; +use function PHPUnit\Framework\assertCount; + #[CoversClass(Client::class)] class ClientTest extends DbTestCase { @@ -87,4 +92,73 @@ public function testFetchDay() $this->assertIsArray($intensities['France']); $this->assertEquals(24, count($intensities['France'])); } + + public function testFetchRange() + { + // Prepare the fake HTTP response + $client = $this->createStub(RestApiClientInterface::class); + $start = DateTime::createFromFormat(DateTimeInterface::ATOM, '2024-04-01T00:00:00Z'); + $stop = DateTime::createFromFormat(DateTimeInterface::ATOM, '2024-05-01T00:00:00Z'); + $responses = []; + $response = [ + 'zone' => 'FR', + 'data' => [], + 'temporalGranularity' => 'hourly', + ]; + $step = new DateInterval('PT1H'); + // The date boundaries are extended 12 hours before and after + $current_date = clone $start; + $current_date->sub(new DateInterval('PT12H')); + $extended_stop = clone $stop; + $extended_stop->add(new DateInterval('PT12H')); + $count = 0; + while ($current_date < $extended_stop) { + $response['data'][] = [ + 'zone' => 'FR', + 'carbonIntensity' => 42, + 'datetime' => $current_date->format('Y-m-d\\TH:i:s.v') . 'Z', + 'updatedAt' => "2024-09-07T15:32:36.348Z", + 'createdAt' => "2024-08-08T06:20:31.772Z", + 'emissionFactorType' => "lifecycle", + 'isEstimated' => false, + 'estimationMethod' => null, + ]; + $count++; + if ($count == 10 * 24) { + // Electricitymaps returns 10 days (240 samples) at max + // @see https://app.electricitymaps.com/developer-hub/api/reference#carbon-intensity-past-range + // The client slices the requets into 7 days + $responses[] = $response; + $response['data'] = []; + $count = 0; + } + $current_date->add($step); + } + if (count($response['data']) > 0) { + $responses[] = $response; + } + $client->method('request')->willReturn(...$responses); + + /** @var RestApiClientInterface $client */ + $data_source = new Client($client); + $source = new Source(); + $source->getFromDBByCrit(['name' => $data_source->getSourceName()]); + $this->assertFalse($source->isNewItem()); + $zone = new Zone(); + $zone->getFromDbByCrit(['name' => 'France']); + $this->assertFalse($zone->isNewItem()); + $source_zone = $this->createItem(Source_Zone::class, [ + Source::getForeignKeyField() => $source->getID(), + Zone::getForeignKeyField() => $zone->getID(), + 'code' => 'FR' + ]); + + $intensities = $data_source->fetchRange( + DateTimeImmutable::createFromMutable($start), + DateTimeImmutable::createFromMutable($stop), + 'France' + ); + + $this->assertCount(24 * 30 + 2 * 12, $intensities); + } } diff --git a/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php b/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php index 4dadb4ba..ab719635 100644 --- a/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php +++ b/tests/units/DataSource/CarbonIntensity/Rte/ClientTest.php @@ -114,7 +114,7 @@ public function testFullDownload() /** @var RestApiClientInterface $client */ $instance = new Client($client); $start_date = new DateTimeImmutable('2024-10-08'); - $stop_date = new DateTimeImmutable('2024-10-08'); + $stop_date = new DateTimeImmutable('2024-10-09'); $carbon_intensity = new CarbonIntensity(); $output = $instance->fullDownload('France', $start_date, $stop_date, $carbon_intensity); $this->assertEquals(1, $output); @@ -138,11 +138,9 @@ public function testIncrementalDownload() // $instance->method('fetchDay')->willReturn(['FR' => []]); $client = $this->createStub(RestApiClientInterface::class); $client->method('request')->willReturn([ - 'results' => [ - [ - 'taux_co2' => 1, - 'date_heure' => '2024-10-08T18:00:00+00:00' - ] + [ + 'taux_co2' => 1, + 'date_heure' => '2024-10-08T18:00:00+00:00' ], ]); $instance = new Client($client);