diff --git a/pom.xml b/pom.xml index 4cb9740..50b3b7f 100644 --- a/pom.xml +++ b/pom.xml @@ -153,6 +153,14 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + 9 + 9 + + diff --git a/src/main/ApiDataModels.kt b/src/main/ApiDataModels.kt index 910b442..578ae9f 100644 --- a/src/main/ApiDataModels.kt +++ b/src/main/ApiDataModels.kt @@ -1,7 +1,5 @@ package com.github.statnett.loadflowservice -import com.powsybl.commons.reporter.Reporter -import com.powsybl.commons.reporter.ReporterModel import com.powsybl.iidm.network.Network import kotlinx.serialization.Serializable diff --git a/src/main/ApiUtil.kt b/src/main/ApiUtil.kt index 7405146..3fd2d9b 100644 --- a/src/main/ApiUtil.kt +++ b/src/main/ApiUtil.kt @@ -8,31 +8,17 @@ import com.powsybl.sld.SingleLineDiagram import com.powsybl.sld.SldParameters import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.content.* -import java.io.ByteArrayInputStream import java.io.StringWriter -import java.security.MessageDigest private val logger = KotlinLogging.logger {} fun busesFromRequest( - type: String, - body: ByteArray, + content: FileContent ): List { - val network = networkFromStream(type, ByteArrayInputStream(body)) + val network = networkFromFileContent(content) return busPropertiesFromNetwork(network) } -class FileContent(val name: String, val bytes: ByteArray) { - fun contentHash(): String { - val md = MessageDigest.getInstance("MD5") - return md.digest(this.bytes).joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } - } - - fun contentAsStream(): ByteArrayInputStream { - return ByteArrayInputStream(this.bytes) - } -} - /** * Convenience class used to deserialize and update a load parameter instance */ diff --git a/src/main/App.kt b/src/main/App.kt index 5a069db..7f56770 100644 --- a/src/main/App.kt +++ b/src/main/App.kt @@ -46,7 +46,7 @@ fun Application.module() { if (files.isEmpty()) { call.response.status(HttpStatusCode.UnprocessableEntity) } else { - val busProps = busesFromRequest(files[0].name, files[0].bytes) + val busProps = busesFromRequest(files[0]) call.respond(busProps) } } diff --git a/src/main/FileContent.kt b/src/main/FileContent.kt new file mode 100644 index 0000000..ee5dc70 --- /dev/null +++ b/src/main/FileContent.kt @@ -0,0 +1,44 @@ +package com.github.statnett.loadflowservice + +import com.powsybl.commons.datasource.DataSourceUtil +import com.powsybl.commons.datasource.ReadOnlyMemDataSource +import java.io.ByteArrayInputStream +import java.security.MessageDigest +import java.util.zip.ZipInputStream + +class FileContent(val name: String, val bytes: ByteArray) { + fun contentHash(): String { + val md = MessageDigest.getInstance("MD5") + return md.digest(this.bytes).joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } + } + + fun contentAsStream(): ByteArrayInputStream { + return ByteArrayInputStream(this.bytes) + } + + fun asReadOnlyMemDataSource(): ReadOnlyMemDataSource { + if (name.endsWith(".zip")) { + return zippedArchiveReadOnlyMemDataSource() + } + return singleFileReadOnlyMemDataSource() + } + + private fun singleFileReadOnlyMemDataSource(): ReadOnlyMemDataSource { + val dataSource = ReadOnlyMemDataSource(DataSourceUtil.getBaseName(name)) + dataSource.putData(name, bytes) + return dataSource + } + + private fun zippedArchiveReadOnlyMemDataSource(): ReadOnlyMemDataSource { + val dataSource = ReadOnlyMemDataSource(DataSourceUtil.getBaseName((name))) + ZipInputStream(contentAsStream()) + .use { stream -> + generateSequence { stream.nextEntry } + .filterNot { entry -> entry.isDirectory } + .forEach { entry -> + dataSource.putData(entry.name, stream.readAllBytes()) + } + } + return dataSource + } +} \ No newline at end of file diff --git a/src/main/Solver.kt b/src/main/Solver.kt index 424e5d5..a501220 100644 --- a/src/main/Solver.kt +++ b/src/main/Solver.kt @@ -1,26 +1,20 @@ package com.github.statnett.loadflowservice +import com.powsybl.commons.PowsyblException +import com.powsybl.commons.reporter.Reporter import com.powsybl.commons.reporter.ReporterModel import com.powsybl.computation.local.LocalComputationManager -import com.powsybl.iidm.network.ImportersServiceLoader -import com.powsybl.iidm.network.Network +import com.powsybl.iidm.network.* import com.powsybl.loadflow.LoadFlow import com.powsybl.loadflow.LoadFlowParameters import com.powsybl.loadflow.json.JsonLoadFlowParameters import io.github.oshai.kotlinlogging.KotlinLogging import java.io.ByteArrayOutputStream -import java.io.InputStream import java.io.StringWriter + private val logger = KotlinLogging.logger {} -fun networkFromStream( - filename: String, - content: InputStream, -): Network { - warnOnFewAvailableImporters() - return Network.read(filename, content) -} fun warnOnFewAvailableImporters() { val numLoaders = ImportersServiceLoader().loadImporters().size @@ -34,9 +28,23 @@ fun warnOnFewAvailableImporters() { } } +// This function follows closely the functionality implemented in Powsybl-core Network.read +// However, here we create the ReadOnlyMemDataSource our self which supports constructing it +// from a zip archive (e.g. CIM/XML files zipped). fun networkFromFileContent(content: FileContent): Network { logger.info { "Loading network from file ${content.name}" } - return networkFromStream(content.name, content.contentAsStream()) + + val importConfig = ImportConfig.CACHE.get() + val loader = ImportersServiceLoader() + val reporter = Reporter.NO_OP + val computationManager = LocalComputationManager.getDefault() + val dataSource = content.asReadOnlyMemDataSource() + val importer = Importer.find(dataSource, loader, computationManager, importConfig) + if (importer != null) { + val networkFactory = NetworkFactory.findDefault() + return importer.importData(dataSource, networkFactory, null, reporter) + } + throw PowsyblException("No importer found") } fun defaultLoadFlowParameters(): String { diff --git a/src/test/AppTest.kt b/src/test/AppTest.kt index 761cec6..003a56a 100644 --- a/src/test/AppTest.kt +++ b/src/test/AppTest.kt @@ -4,13 +4,9 @@ import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.http.content.* import io.ktor.server.testing.* import org.junit.jupiter.api.DynamicTest import org.junit.jupiter.api.TestFactory -import java.io.File -import java.nio.file.Paths -import java.util.* import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertContains @@ -135,6 +131,16 @@ class ApplicationTest { ) } + @Test + fun `test 14 bus ok`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/buses", + formData = formDataFromFile(ieeeCdfNetwork14CgmesFile()) + ) + assertEquals(HttpStatusCode.OK, response.status) + } + @Test fun `test dc solve ok`() = testApplication { @@ -264,38 +270,6 @@ class ApplicationTest { } } -fun formDataFromFile(file: File): List { - return formData { - append( - "network", - file.readBytes(), - Headers.build { - append(HttpHeaders.ContentDisposition, "filename=${file.name}") - }, - ) - } -} - -fun formDataWithEmptyNetwork(): List { - return formData { - append( - "network", - byteArrayOf(), - Headers.build { - append(HttpHeaders.ContentDisposition, "filename=emptyFile.xiidm") - } - ) - } -} - -fun ieeeCdfNetwork14File(): File { - // Initialize temporary file - val file = File.createTempFile("network", ".xiidm") - file.deleteOnExit() - - IeeeCdfNetworkFactory.create14().write("XIIDM", Properties(), Paths.get(file.path)) - return file -} // Function for checking some properties of a body to verify that the returned body // is a valid svg image @@ -303,28 +277,4 @@ fun isPlausibleSvg(body: String): Boolean { return body.contains(" { - return formData { - append( - "network", - minimalRawx(), - Headers.build { - append(HttpHeaders.ContentDisposition, "filename=network.rawx") - } - ) - } -} -fun minimalRawx(): ByteArray { - return ("{\"network\":{\"caseid\":{" + - "\"fields\":[\"ic\",\"sbase\",\"rev\",\"xfrrat\",\"nxfrat\",\"basfrq\",\"title1\"]," + - "\"data\":[0,100.00,35,0,0,60.00,\"PSS(R)EMinimumRAWXCase\"]}," + - "\"bus\":{\"fields\":[\"ibus\",\"name\",\"baskv\",\"ide\"]," + - "\"data\":[[1,\"Slack-Bus\",138.0,3],[2,\"Load-Bus\",138.01]]}," + - "\"load\":{\"fields\":[\"ibus\",\"loadid\",\"stat\",\"pl\",\"ql\"]," + - "\"data\":[[2,\"1\",1,40.0,15.0]]}," + - "\"generator\":{\"fields\":[\"ibus\",\"machid\",\"pg\",\"qg\"]," + - "\"data\":[[1,\"1\",\"40.35\",\"10.87\"]]}," + - "\"acline\":{\"fields\":[\"ibus\",\"jbus\",\"ckt\",\"rpu\",\"xpu\",\"bpu\"]," + - "\"data\":[[1,2,\"1\",0.01938,0.05917,0.05280]]}}}").toByteArray() -} diff --git a/src/test/DataFactory.kt b/src/test/DataFactory.kt new file mode 100644 index 0000000..d192cdc --- /dev/null +++ b/src/test/DataFactory.kt @@ -0,0 +1,101 @@ +import com.powsybl.ieeecdf.converter.IeeeCdfNetworkFactory +import io.ktor.client.request.forms.* +import io.ktor.http.* +import io.ktor.http.content.* +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Paths +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +fun ieeeCdfNetwork14CgmesFile(): File { + val file = File.createTempFile("network_cgmes", ".zip") + file.deleteOnExit() + + IeeeCdfNetworkFactory.create14().write("CGMES", null, Paths.get(file.path.replace(".zip", ""))) + + // Read the produced files in EQ/SSH/SV/TP and create one zip archive + val withOutExtension = file.path.toString().replace(".zip", "") + val cimXmlFiles = listOf("EQ", "TP", "SV", "SSH").map { profile -> "${withOutExtension}_${profile}.xml" } + + val fileOutputStream = FileOutputStream(file) + val zipOutputStream = ZipOutputStream(fileOutputStream) + + + cimXmlFiles.forEach { cimXmlFile -> addToArchiveAndDeleteFile(cimXmlFile, zipOutputStream) } + zipOutputStream.close() + return file +} + +fun addToArchiveAndDeleteFile(filename: String, outStream: ZipOutputStream) { + val pattern = Regex(pattern = """([^/]+$)""") // Extract everything after the last slash + val match = pattern.find(filename)!! + val baseName = match.groupValues[1] + val zipEntry = ZipEntry(baseName) + outStream.putNextEntry(zipEntry) + val cimFile = File(filename) + val bytes = cimFile.readBytes() + outStream.write(bytes, 0, bytes.size) + outStream.closeEntry() + cimFile.delete() +} + +fun formDataMinimalNetworkRawx(): List { + return formData { + append( + "network", + minimalRawx(), + Headers.build { + append(HttpHeaders.ContentDisposition, "filename=network.rawx") + } + ) + } +} + +fun ieeeCdfNetwork14File(): File { + // Initialize temporary file + val file = File.createTempFile("network", ".xiidm") + file.deleteOnExit() + + IeeeCdfNetworkFactory.create14().write("XIIDM", Properties(), Paths.get(file.path)) + return file +} + +fun minimalRawx(): ByteArray { + return ("{\"network\":{\"caseid\":{" + + "\"fields\":[\"ic\",\"sbase\",\"rev\",\"xfrrat\",\"nxfrat\",\"basfrq\",\"title1\"]," + + "\"data\":[0,100.00,35,0,0,60.00,\"PSS(R)EMinimumRAWXCase\"]}," + + "\"bus\":{\"fields\":[\"ibus\",\"name\",\"baskv\",\"ide\"]," + + "\"data\":[[1,\"Slack-Bus\",138.0,3],[2,\"Load-Bus\",138.01]]}," + + "\"load\":{\"fields\":[\"ibus\",\"loadid\",\"stat\",\"pl\",\"ql\"]," + + "\"data\":[[2,\"1\",1,40.0,15.0]]}," + + "\"generator\":{\"fields\":[\"ibus\",\"machid\",\"pg\",\"qg\"]," + + "\"data\":[[1,\"1\",\"40.35\",\"10.87\"]]}," + + "\"acline\":{\"fields\":[\"ibus\",\"jbus\",\"ckt\",\"rpu\",\"xpu\",\"bpu\"]," + + "\"data\":[[1,2,\"1\",0.01938,0.05917,0.05280]]}}}").toByteArray() +} + +fun formDataWithEmptyNetwork(): List { + return formData { + append( + "network", + byteArrayOf(), + Headers.build { + append(HttpHeaders.ContentDisposition, "filename=emptyFile.xiidm") + } + ) + } +} + +fun formDataFromFile(file: File): List { + return formData { + append( + "network", + file.readBytes(), + Headers.build { + append(HttpHeaders.ContentDisposition, "filename=${file.name}") + }, + ) + } +} \ No newline at end of file diff --git a/src/test/SolverTest.kt b/src/test/SolverTest.kt index 4124b22..536afe0 100644 --- a/src/test/SolverTest.kt +++ b/src/test/SolverTest.kt @@ -1,4 +1,6 @@ +import com.github.statnett.loadflowservice.FileContent import com.github.statnett.loadflowservice.defaultLoadFlowParameters +import com.github.statnett.loadflowservice.networkFromFileContent import com.powsybl.loadflow.LoadFlowParameters import com.powsybl.loadflow.json.JsonLoadFlowParameters import org.junit.Test @@ -14,4 +16,11 @@ class SolverTest { assertEquals(parameters.toString(), fromJson.toString()) } + @Test + fun `test load network from zip input stream`() { + val cimXmlFile = ieeeCdfNetwork14CgmesFile() + val fc = FileContent("cgmes_network.zip", cimXmlFile.readBytes()) + val network = networkFromFileContent(fc) + assertEquals(14, network.busView.buses.toList().size) + } } \ No newline at end of file