TimeSeriesTest.java

/**
 * Copyright (c) 2018, RTE (http://www.rte-france.com)
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * SPDX-License-Identifier: MPL-2.0
 */
package com.powsybl.timeseries;

import com.powsybl.commons.report.PowsyblCoreReportResourceBundle;
import com.powsybl.commons.report.ReportNode;
import com.powsybl.commons.test.PowsyblCoreTestReportResourceBundle;
import com.powsybl.timeseries.TimeSeries.TimeFormat;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.net.URISyntaxException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Stream;

import static com.powsybl.timeseries.TimeSeries.writeInstantToString;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * @author Geoffroy Jamgotchian {@literal <geoffroy.jamgotchian at rte-france.com>}
 */
class TimeSeriesTest {

    private void assertOnParsedTimeSeries(Map<Integer, List<TimeSeries>> timeSeriesPerVersion, Class<?> className) {
        assertEquals(2, timeSeriesPerVersion.size());
        assertEquals(2, timeSeriesPerVersion.get(1).size());
        assertEquals(2, timeSeriesPerVersion.get(2).size());

        TimeSeries<?, ?> ts1v1 = timeSeriesPerVersion.get(1).get(0);
        TimeSeries<?, ?> ts2v1 = timeSeriesPerVersion.get(1).get(1);
        TimeSeries<?, ?> ts1v2 = timeSeriesPerVersion.get(2).get(0);
        TimeSeries<?, ?> ts2v2 = timeSeriesPerVersion.get(2).get(1);

        assertEquals(className, ts1v1.getMetadata().getIndex().getClass());

        assertEquals("ts1", ts1v1.getMetadata().getName());
        assertEquals(TimeSeriesDataType.DOUBLE, ts1v1.getMetadata().getDataType());
        assertArrayEquals(new double[] {1, Double.NaN, 3}, ((DoubleTimeSeries) ts1v1).toArray(), 0);

        assertEquals("ts2", ts2v1.getMetadata().getName());
        assertEquals(TimeSeriesDataType.STRING, ts2v1.getMetadata().getDataType());
        assertArrayEquals(new String[] {null, "a", "b"}, ((StringTimeSeries) ts2v1).toArray());

        assertEquals("ts1", ts1v2.getMetadata().getName());
        assertEquals(TimeSeriesDataType.DOUBLE, ts1v2.getMetadata().getDataType());
        assertArrayEquals(new double[] {4, 5, 6}, ((DoubleTimeSeries) ts1v2).toArray(), 0);

        assertEquals("ts2", ts2v2.getMetadata().getName());
        assertEquals(TimeSeriesDataType.STRING, ts2v2.getMetadata().getDataType());
        assertArrayEquals(new String[] {"c", null, "d"}, ((StringTimeSeries) ts2v2).toArray());
    }

    @Test
    void testRegularTimeSeriesIndex() {
        String csv = """
                Time;Version;ts1;ts2
                1970-01-01T01:00:00.000+01:00;1;1.0;
                1970-01-01T02:00:00.000+01:00;1;;a
                1970-01-01T03:00:00.000+01:00;1;3.0;b
                1970-01-01T01:00:00.000+01:00;2;4.0;c
                1970-01-01T02:00:00.000+01:00;2;5.0;
                1970-01-01T03:00:00.000+01:00;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        String csvWithQuotes = """
                "Time";"Version";"ts1";"ts2"
                "1970-01-01T01:00:00.000+01:00";"1";"1.0";
                "1970-01-01T02:00:00.000+01:00";"1";;"a"
                "1970-01-01T03:00:00.000+01:00";"1";"3.0";"b"
                "1970-01-01T01:00:00.000+01:00";"2";"4.0";"c"
                "1970-01-01T02:00:00.000+01:00";"2";"5.0";
                "1970-01-01T03:00:00.000+01:00";"2";"6.0";"d"
                """.replaceAll("\n", System.lineSeparator());

        String csvMicroseconds = """
            Time;Version;ts1;ts2
            1970-01-01T01:00:00.000000+01:00;1;1.0;
            1970-01-01T02:00:00.000000+01:00;1;;a
            1970-01-01T03:00:00.000000+01:00;1;3.0;b
            1970-01-01T01:00:00.000000+01:00;2;4.0;c
            1970-01-01T02:00:00.000000+01:00;2;5.0;
            1970-01-01T03:00:00.000000+01:00;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        String csvNanoseconds = """
            Time;Version;ts1;ts2
            1970-01-01T01:00:00.000000000+01:00;1;1.0;
            1970-01-01T02:00:00.000000000+01:00;1;;a
            1970-01-01T03:00:00.000000000+01:00;1;3.0;b
            1970-01-01T01:00:00.000000000+01:00;2;4.0;c
            1970-01-01T02:00:00.000000000+01:00;2;5.0;
            1970-01-01T03:00:00.000000000+01:00;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        Arrays.asList(csv, csvWithQuotes, csvMicroseconds, csvNanoseconds).forEach(data -> {
            Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(data);

            assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
        });
    }

    @Test
    void testIrregularTimeSeriesIndex() {
        String csv = """
                Time;Version;ts1;ts2
                1970-01-01T01:00:00.000+01:00;1;1.0;
                1970-01-01T02:00:00.000+01:00;1;;a
                1970-01-01T04:00:00.000+01:00;1;3.0;b
                1970-01-01T01:00:00.000+01:00;2;4.0;c
                1970-01-01T02:00:00.000+01:00;2;5.0;
                1970-01-01T04:00:00.000+01:00;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv);

        assertOnParsedTimeSeries(timeSeriesPerVersion, IrregularTimeSeriesIndex.class);
    }

    @Test
    void testTimeSeriesNameMissing() {
        String csv = """
            Time;Version;;ts2
            1970-01-01T01:00:00.000+01:00;1;1.0;
            1970-01-01T02:00:00.000+01:00;1;;a
            1970-01-01T04:00:00.000+01:00;1;3.0;b
            1970-01-01T01:00:00.000+01:00;2;4.0;c
            1970-01-01T02:00:00.000+01:00;2;5.0;
            1970-01-01T04:00:00.000+01:00;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv);

        // Since the name if the first timeseries is missing, only the second is saved
        assertEquals(2, timeSeriesPerVersion.size());
        assertEquals(1, timeSeriesPerVersion.get(1).size());
        assertEquals(1, timeSeriesPerVersion.get(2).size());
    }

    @Test
    void testFractionsOfSecondsRegularTimeSeriesIndex() {
        String csv = """
                Time;Version;ts1;ts2
                0.000;1;1.0;
                0.001;1;;a
                0.002;1;3.0;b
                0.000;2;4.0;c
                0.001;2;5.0;
                0.002;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.FRACTIONS_OF_SECOND, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
    }

    @Test
    void testFractionsOfSecondsRegularTimeSeriesIndexWithDuplicateTime() {
        String csv = """
                Time;Version;ts1;ts2
                0.000000000;1;1.0;
                0.000000001;1;;a
                0.0000000012;1;3.0;b
                0.000000000;2;4.0;c
                0.000000001;2;5.0;
                0.0000000012;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.FRACTIONS_OF_SECOND, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, IrregularTimeSeriesIndex.class);
    }

    @Test
    void testFractionsOfSecondsRegularTimeSeriesIndexWithSkippedDuplicateTime() {
        String csv = """
                Time;Version;ts1;ts2
                0.000000000;1;1.0;
                0.000000001;1;;a
                0.0000000015;1;;b
                0.000000002;1;3.0;b
                0.000000000;2;4.0;c
                0.0000000002;2;4.5;c
                0.000000001;2;5.0;
                0.000000002;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true,
                TimeFormat.FRACTIONS_OF_SECOND, true, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
    }

    @Test
    void testParseCsvBuffered() {
        String csv = """
                Time;Version;ts1;ts2
                0.000;1;1.0;
                0.001;1;;a
                0.002;1;3.0;b
                0.000;2;4.0;c
                0.001;2;5.0;
                0.002;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.FRACTIONS_OF_SECOND, true);
        try (BufferedReader reader = new BufferedReader(new StringReader(csv))) {
            Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(reader, timeSeriesCsvConfig);
            assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
        } catch (IOException e) {
            fail();
        }
    }

    @Test
    void testMillisIrregularTimeSeriesIndex() {
        String csv = """
                Time;Version;ts1;ts2
                0;1;1.0;
                1;1;;a
                4;1;3.0;b
                0;2;4.0;c
                1;2;5.0;
                4;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.MILLIS);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, IrregularTimeSeriesIndex.class);
    }

    @Test
    void testMilliRegularTimeSeriesIndex() {
        String csv = """
            Time;Version;ts1;ts2
            1737377647003;1;1.0;
            1737377647004;1;;a
            1737377647005;1;3.0;b
            1737377647003;2;4.0;c
            1737377647004;2;5.0;
            1737377647005;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.MILLIS, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
        RegularTimeSeriesIndex timeSeriesIndex = RegularTimeSeriesIndex.create(Instant.ofEpochMilli(1737377647003L),
            Instant.ofEpochMilli(1737377647005L),
            Duration.ofMillis(1));
        assertEquals(timeSeriesIndex, timeSeriesPerVersion.get(1).get(0).getMetadata().getIndex());
    }

    @Test
    void testMicroRegularTimeSeriesIndex() {
        String csv = """
            Time;Version;ts1;ts2
            1737377647000004;1;1.0;
            1737377647000005;1;;a
            1737377647000006;1;3.0;b
            1737377647000004;2;4.0;c
            1737377647000005;2;5.0;
            1737377647000006;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.MICROS, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
        RegularTimeSeriesIndex timeSeriesIndex = RegularTimeSeriesIndex.create(Instant.ofEpochSecond(1737377647, 4000),
            Instant.ofEpochSecond(1737377647, 6000),
            Duration.ofNanos(1000));
        assertEquals(timeSeriesIndex, timeSeriesPerVersion.get(1).get(0).getMetadata().getIndex());
    }

    @Test
    void testNanoRegularTimeSeriesIndex() {
        String csv = """
            Time;Version;ts1;ts2
            1737377647000000001;1;1.0;
            1737377647000000002;1;;a
            1737377647000000003;1;3.0;b
            1737377647000000001;2;4.0;c
            1737377647000000002;2;5.0;
            1737377647000000003;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.NANOS, true);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
        RegularTimeSeriesIndex timeSeriesIndex = RegularTimeSeriesIndex.create(Instant.ofEpochSecond(1737377647, 1),
            Instant.ofEpochSecond(1737377647, 3),
            Duration.ofNanos(1));
        assertEquals(timeSeriesIndex, timeSeriesPerVersion.get(1).get(0).getMetadata().getIndex());
    }

    @Test
    void testNoVersion() {
        String csv = """
            Time;ts1;ts2
            1970-01-01T01:00:00.000+01:00;1.0;
            1970-01-01T02:00:00.000+01:00;;a
            1970-01-01T03:00:00.000+01:00;3.0;b
            1970-01-01T04:00:00.000+01:00;4.0;c
            1970-01-01T05:00:00.000+01:00;5.0;
            1970-01-01T06:00:00.000+01:00;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', false, TimeFormat.DATE_TIME);
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(csv, timeSeriesCsvConfig);

        assertEquals(1, timeSeriesPerVersion.size());
        assertEquals(2, timeSeriesPerVersion.get(-1).size());

        TimeSeries ts1 = timeSeriesPerVersion.get(-1).get(0);
        TimeSeries ts2 = timeSeriesPerVersion.get(-1).get(1);

        assertEquals("ts1", ts1.getMetadata().getName());
        assertEquals(TimeSeriesDataType.DOUBLE, ts1.getMetadata().getDataType());
        assertArrayEquals(new double[] {1, Double.NaN, 3, 4, 5, 6}, ((DoubleTimeSeries) ts1).toArray(), 0);

        assertEquals("ts2", ts2.getMetadata().getName());
        assertEquals(TimeSeriesDataType.STRING, ts2.getMetadata().getDataType());
        assertArrayEquals(new String[] {null, "a", "b", "c", null, "d"}, ((StringTimeSeries) ts2).toArray());
    }

    @Test
    void testVersionedAtDefaultNumberNotStrictCSV() {
        String csv = """
            Time;Version;ts1;ts2
            0.000;-1;1.0;
            0.001;-1;;a
            0.002;-1;3.0;b
            0.000;2;4.0;c
            0.001;2;5.0;
            0.002;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());

        // Reporter
        ReportNode reportNode = ReportNode.newRootReportNode()
                .withResourceBundles(PowsyblCoreTestReportResourceBundle.TEST_BASE_NAME, PowsyblCoreReportResourceBundle.BASE_NAME)
                .withMessageTemplate("reportTestVersionedAtDefaultNumberNotStrictCSV")
                .build();

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(ZoneId.of("UTC"), ';', true, TimeFormat.FRACTIONS_OF_SECOND, false);
        TimeSeries.parseCsv(csv, timeSeriesCsvConfig, reportNode);

        assertEquals(4, reportNode.getChildren().size());
        assertEquals("The version number for a versioned TimeSeries should not be equals to the default version number (-1) at line 0.000;-1;1.0;null",
            reportNode.getChildren().get(0).getMessage());
        assertEquals("The version number for a versioned TimeSeries should not be equals to the default version number (-1) at line 0.001;-1;null;a",
            reportNode.getChildren().get(1).getMessage());
        assertEquals("The version number for a versioned TimeSeries should not be equals to the default version number (-1) at line 0.002;-1;3.0;b",
            reportNode.getChildren().get(2).getMessage());
        assertTrue(Pattern.compile("4 time series loaded from CSV in .* ms").matcher(reportNode.getChildren().get(3).getMessage()).find());
    }

    @Test
    void testVersionedAtDefaultNumberStrictCSV() {
        String csv = """
                Time;Version;ts1;ts2
                0.000;-1;1.0;
                0.001;-1;;a
                0.002;-1;3.0;b
                0.000;2;4.0;c
                0.001;2;5.0;
                0.002;2;6.0;d
                """.replaceAll("\n", System.lineSeparator());

        // Reporter
        ReportNode reportNode = ReportNode.newRootReportNode()
                .withResourceBundles(PowsyblCoreTestReportResourceBundle.TEST_BASE_NAME, PowsyblCoreReportResourceBundle.BASE_NAME)
                .withMessageTemplate("reportTestVersionedAtDefaultNumberNotStrictCSV")
                .build();

        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(ZoneId.of("UTC"), ';', true, TimeFormat.FRACTIONS_OF_SECOND, true);
        TimeSeriesException timeSeriesException = assertThrows(TimeSeriesException.class, () -> TimeSeries.parseCsv(csv, timeSeriesCsvConfig, reportNode));
        assertEquals("The version number for a versioned TimeSeries cannot be equals to the default version number (-1) at line \"0.000;-1;1.0;null\"",
            timeSeriesException.getMessage());
    }

    @Test
    void testImportByPath() throws URISyntaxException {
        Path path = Paths.get(Objects.requireNonNull(getClass().getResource("/timeseries.csv")).toURI());

        // Default case
        Map<Integer, List<TimeSeries>> timeSeriesPerVersion = TimeSeries.parseCsv(path);
        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);

        // Case with specific timeSeriesCsvConfig
        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', true, TimeFormat.DATE_TIME, true);
        timeSeriesPerVersion = TimeSeries.parseCsv(path, timeSeriesCsvConfig);
        assertOnParsedTimeSeries(timeSeriesPerVersion, RegularTimeSeriesIndex.class);
    }

    @Test
    void testErrors() {
        TimeSeriesCsvConfig timeSeriesCsvConfig = new TimeSeriesCsvConfig(';', false, TimeFormat.DATE_TIME);

        String emptyCsv = "";
        assertThatCode(() -> TimeSeries.parseCsv(emptyCsv)).hasMessage("CSV header is missing").isInstanceOf(TimeSeriesException.class);

        String badHeaderNoTime = """
            NoTime;ts1;ts2
            1970-01-01T01:00:00.000+01:00;1.0;
            1970-01-01T02:00:00.000+01:00;;a
            1970-01-01T03:00:00.000+01:00;3.0;b
            1970-01-01T04:00:00.000+01:00;4.0;c
            1970-01-01T05:00:00.000+01:00;5.0;
            1970-01-01T06:00:00.000+01:00;6.0;d
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(badHeaderNoTime, timeSeriesCsvConfig)).hasMessage("Bad CSV header, should be \ntime;...").isInstanceOf(TimeSeriesException.class);

        String badHeaderNoVersion = """
            Time;NoVersion;ts1;ts2
            1970-01-01T01:00:00.000+01:00;1;1.0;
            1970-01-01T02:00:00.000+01:00;1;;a
            1970-01-01T03:00:00.000+01:00;1;3.0;b
            1970-01-01T01:00:00.000+01:00;2;4.0;c
            1970-01-01T02:00:00.000+01:00;2;5.0;
            1970-01-01T03:00:00.000+01:00;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(badHeaderNoVersion)).hasMessage("Bad CSV header, should be \ntime;version;...").isInstanceOf(TimeSeriesException.class);

        String duplicates = """
            Time;Version;ts1;ts1
            1970-01-01T01:00:00.000+01:00;1;1.0;
            1970-01-01T02:00:00.000+01:00;1;;a
            1970-01-01T03:00:00.000+01:00;1;3.0;b
            1970-01-01T01:00:00.000+01:00;2;4.0;c
            1970-01-01T02:00:00.000+01:00;2;5.0;
            1970-01-01T03:00:00.000+01:00;2;6.0;d
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(duplicates)).hasMessageContaining("Bad CSV header, there are duplicates in time series names").isInstanceOf(TimeSeriesException.class);

        String noData = """
            Time;Version
            1970-01-01T01:00:00.000+01:00;1
            1970-01-01T02:00:00.000+01:00;1
            1970-01-01T03:00:00.000+01:00;1
            1970-01-01T01:00:00.000+01:00;2
            1970-01-01T02:00:00.000+01:00;2
            1970-01-01T03:00:00.000+01:00;2
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(noData)).hasMessageContaining("Bad CSV header, should be \ntime;version;...").isInstanceOf(TimeSeriesException.class);

        String onlyOneTime = """
            Time;ts1
            1970-01-01T03:00:00.000+01:00;2.0
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(onlyOneTime, timeSeriesCsvConfig)).hasMessageContaining("At least 2 rows are expected").isInstanceOf(TimeSeriesException.class);

        String unexpectedTokens = """
            Time;ts1;ts2
            1970-01-01T01:00:00.000+01:00;1.0;3.2
            1970-01-01T02:00:00.000+01:00;2.0
            1970-01-01T03:00:00.000+01:00;2.0;1.0
            """.replaceAll("\n", System.lineSeparator());
        assertThatCode(() -> TimeSeries.parseCsv(unexpectedTokens, timeSeriesCsvConfig)).hasMessageContaining("Columns of line 1 are inconsistent with header").isInstanceOf(TimeSeriesException.class);

        Path path = Path.of("wrongPath.csv");
        assertThrows(UncheckedIOException.class, () -> TimeSeries.parseCsv(path));
    }

    @Test
    void splitTest() {
        try {
            TimeSeries.split(Collections.<DoubleTimeSeries>emptyList(), 2);
            fail();
        } catch (IllegalArgumentException ignored) {
        }
        TimeSeriesIndex index = new RegularTimeSeriesIndex(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(10002), Duration.ofMillis(1));
        List<DoubleTimeSeries> timeSeriesList = Arrays.asList(TimeSeries.createDouble("ts1", index, 1d, 2d, 3d),
                                                              TimeSeries.createDouble("ts1", index, 4d, 5d, 6d));
        try {
            TimeSeries.split(timeSeriesList, 4);
            fail();
        } catch (IllegalArgumentException ignored) {
        }

        try {
            TimeSeries.split(timeSeriesList, -1);
            fail();
        } catch (IllegalArgumentException ignored) {
        }

        List<List<DoubleTimeSeries>> split = TimeSeries.split(timeSeriesList, 2);
        assertEquals(2, split.size());
        assertEquals(2, split.get(0).size());
        assertEquals(2, split.get(1).size());
        assertArrayEquals(new double[] {1d, 2d, Double.NaN}, split.get(0).get(0).toArray(), 0d);
        assertArrayEquals(new double[] {4d, 5d, Double.NaN}, split.get(0).get(1).toArray(), 0d);
        assertArrayEquals(new double[] {Double.NaN, Double.NaN, 3d}, split.get(1).get(0).toArray(), 0d);
        assertArrayEquals(new double[] {Double.NaN, Double.NaN, 6d}, split.get(1).get(1).toArray(), 0d);
    }

    private static Stream<Arguments> getArgumentsWriteInstantToString() {
        return Stream.of(
            Arguments.of(Instant.ofEpochSecond(123456, 7), "123456000000007", 9),
            Arguments.of(Instant.ofEpochSecond(123456, 7000), "123456000007000", 9),
            Arguments.of(Instant.ofEpochSecond(123456, 700000000), "123456700000000", 9),
            Arguments.of(Instant.ofEpochSecond(0, 7), "7", 9),
            Arguments.of(Instant.ofEpochSecond(0, 7000), "7000", 9),
            Arguments.of(Instant.ofEpochSecond(0, 700000000), "700000000", 9),
            Arguments.of(Instant.ofEpochSecond(123456, 7), "123456000000", 6),
            Arguments.of(Instant.ofEpochSecond(123456, 700), "123456000001", 6), // Case with rounding
            Arguments.of(Instant.ofEpochSecond(123456, 7000), "123456000007", 6),
            Arguments.of(Instant.ofEpochSecond(123456, 700000000), "123456700000", 6),
            Arguments.of(Instant.ofEpochSecond(0, 7), "0", 6),
            Arguments.of(Instant.ofEpochSecond(0, 700), "1", 6), // Case with rounding
            Arguments.of(Instant.ofEpochSecond(0, 7000), "7", 6),
            Arguments.of(Instant.ofEpochSecond(0, 700000000), "700000", 6),
            Arguments.of(Instant.ofEpochSecond(0, 999999999), "1000000", 6) // Rounding to the next second
        );
    }

    @ParameterizedTest
    @MethodSource("getArgumentsWriteInstantToString")
    void testWriteInstantToString(Instant instant, String expected, int precision) {
        assertEquals(expected, writeInstantToString(instant, precision));
    }
}