Skip to content

Commit

Permalink
ENH: Add CIM/XML support from zipped archive
Browse files Browse the repository at this point in the history
  • Loading branch information
davidkleiven committed Oct 4, 2023
1 parent 6e4fa62 commit 3e3d52f
Show file tree
Hide file tree
Showing 9 changed files with 194 additions and 90 deletions.
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>9</source>
<target>9</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
2 changes: 0 additions & 2 deletions src/main/ApiDataModels.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down
18 changes: 2 additions & 16 deletions src/main/ApiUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<BusProperties> {
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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/main/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
44 changes: 44 additions & 0 deletions src/main/FileContent.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 19 additions & 11 deletions src/main/Solver.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
70 changes: 10 additions & 60 deletions src/test/AppTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -264,67 +270,11 @@ class ApplicationTest {
}
}

fun formDataFromFile(file: File): List<PartData> {
return formData {
append(
"network",
file.readBytes(),
Headers.build {
append(HttpHeaders.ContentDisposition, "filename=${file.name}")
},
)
}
}

fun formDataWithEmptyNetwork(): List<PartData> {
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
fun isPlausibleSvg(body: String): Boolean {
return body.contains("<svg") && body.contains("<?xml version")
}

fun formDataMinimalNetworkRawx(): List<PartData> {
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()
}
101 changes: 101 additions & 0 deletions src/test/DataFactory.kt
Original file line number Diff line number Diff line change
@@ -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<PartData> {
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<PartData> {
return formData {
append(
"network",
byteArrayOf(),
Headers.build {
append(HttpHeaders.ContentDisposition, "filename=emptyFile.xiidm")
}
)
}
}

fun formDataFromFile(file: File): List<PartData> {
return formData {
append(
"network",
file.readBytes(),
Headers.build {
append(HttpHeaders.ContentDisposition, "filename=${file.name}")
},
)
}
}
9 changes: 9 additions & 0 deletions src/test/SolverTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
}

0 comments on commit 3e3d52f

Please sign in to comment.