diff --git a/src/main/ApiUtil.kt b/src/main/ApiUtil.kt index 550ac76..7fd4391 100644 --- a/src/main/ApiUtil.kt +++ b/src/main/ApiUtil.kt @@ -1,9 +1,13 @@ package com.github.statnett.loadflowservice +import com.powsybl.iidm.network.Network import com.powsybl.loadflow.LoadFlowParameters import com.powsybl.loadflow.json.JsonLoadFlowParameters +import com.powsybl.nad.NetworkAreaDiagram +import com.powsybl.sld.SingleLineDiagram import io.ktor.http.content.* import java.io.ByteArrayInputStream +import java.io.StringWriter fun busesFromRequest( type: String, @@ -61,3 +65,50 @@ suspend fun multiPartDataHandler( fun undoPrettyPrintJson(jsonString: String): String { return jsonString.replace("\n", "").replace(" ", "") } + +enum class DiagramType { Generic, Substation, VoltageLevel } + +// Case insensitive enum value matching +fun getDiagramType(value: String): DiagramType { + return try { + DiagramType.entries.first { item -> item.toString().lowercase() == value.lowercase() } + } catch (e: NoSuchElementException) { + DiagramType.Generic + } + +} + +fun singleLineDiagram(type: DiagramType, name: String, network: Network): String { + val svgWriter = StringWriter() + + // Declare a writer for metadata + val metaDataWriter = StringWriter() + when (type) { + DiagramType.VoltageLevel -> { + SingleLineDiagram.drawVoltageLevel(network, name, svgWriter, metaDataWriter) + } + + DiagramType.Substation -> { + SingleLineDiagram.drawSubstation(network, name, svgWriter, metaDataWriter) + } + + DiagramType.Generic -> { + SingleLineDiagram.draw(network, name, svgWriter, metaDataWriter) + } + } + return svgWriter.toString() +} + +fun networkDiagram(network: Network): String { + val svgWriter = StringWriter() + NetworkAreaDiagram(network).draw(svgWriter) + return svgWriter.toString() +} + +fun substationNames(network: Network): List { + return network.substations.map { substation -> substation.nameOrId }.toList() +} + +fun voltageLevelNames(network: Network): List { + return network.voltageLevels.map { voltageLevel -> voltageLevel.nameOrId }.toList() +} \ No newline at end of file diff --git a/src/main/App.kt b/src/main/App.kt index 4bd8579..cbe63af 100644 --- a/src/main/App.kt +++ b/src/main/App.kt @@ -1,13 +1,14 @@ package com.github.statnett.loadflowservice +import com.powsybl.commons.PowsyblException import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.swagger.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.server.plugins.swagger.* fun main(args: Array): Unit = io.ktor.server.netty.EngineMain.main(args) @@ -21,7 +22,7 @@ fun Application.module() { call.respondText("Hello, world!") } - post("/get-buses") { + post("/buses") { val files = multiPartDataHandler(call.receiveMultipart()) if (files.isEmpty()) { @@ -36,6 +37,26 @@ fun Application.module() { call.respondText(defaultLoadFlowParameters(), ContentType.Application.Json, HttpStatusCode.OK) } + post("/substation-names") { + val files = multiPartDataHandler(call.receiveMultipart()) + if (files.isEmpty()) { + call.response.status(HttpStatusCode.UnprocessableEntity) + } else { + val network = networkFromFileContent(files[0]) + call.respond(substationNames(network)) + } + } + + post("/voltage-levels") { + val files = multiPartDataHandler(call.receiveMultipart()) + if (files.isEmpty()) { + call.response.status(HttpStatusCode.UnprocessableEntity) + } else { + val network = networkFromFileContent(files[0]) + call.respond(voltageLevelNames(network)) + } + } + post("/run-load-flow") { val paramContainer = LoadParameterContainer() val files = multiPartDataHandler(call.receiveMultipart(), paramContainer::formItemHandler) @@ -48,6 +69,35 @@ fun Application.module() { call.respond(result) } } - swaggerUI(path="openapi", swaggerFile = "openapi/documentation.yaml") + + post("/diagram/{type}/{name}") { + val diagramType = getDiagramType(call.parameters["type"] ?: DiagramType.Generic.toString()) + val name = call.parameters["name"] ?: "" + val files = multiPartDataHandler((call.receiveMultipart())) + if (files.isEmpty()) { + call.response.status(HttpStatusCode.UnprocessableEntity) + } else { + try { + val network = networkFromFileContent(files[0]) + val diagram = singleLineDiagram(diagramType, name, network) + call.respondText(diagram, ContentType.Image.SVG, HttpStatusCode.OK) + } catch (e: PowsyblException) { + call.respondText(e.toString(), ContentType.Text.Plain, HttpStatusCode.BadRequest) + } + + } + } + + post("/diagram") { + val files = multiPartDataHandler((call.receiveMultipart())) + if (files.isEmpty()) { + call.response.status(HttpStatusCode.UnprocessableEntity) + } else { + val network = networkFromFileContent(files[0]) + val diagram = networkDiagram(network) + call.respondText(diagram, ContentType.Image.SVG, HttpStatusCode.OK) + } + } + swaggerUI(path = "openapi", swaggerFile = "openapi/documentation.yaml") } } diff --git a/src/test/AppTest.kt b/src/test/AppTest.kt index c2dafb3..40ab0d1 100644 --- a/src/test/AppTest.kt +++ b/src/test/AppTest.kt @@ -29,7 +29,7 @@ class ApplicationTest { testApplication { val response = client.submitFormWithBinaryData( - url = "/get-buses", + url = "/buses", formData = formData { append("network", "not file content") @@ -43,7 +43,7 @@ class ApplicationTest { testApplication { val response = client.submitFormWithBinaryData( - url = "/get-buses", + url = "/buses", formData = formDataFromFile(ieeeCdfNetwork14File()), ) assertEquals(HttpStatusCode.OK, response.status) @@ -95,6 +95,77 @@ class ApplicationTest { ) } + @Test + fun `test response ok network`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/diagram", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + val body = response.bodyAsText() + assertEquals(ContentType.Image.SVG.toString(), response.headers["Content-Type"]) + assertTrue(isPlausibleSvg(body)) + assertEquals(response.status, HttpStatusCode.OK) + } + + @Test + fun `test bad request when substation does not exist`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/diagram/substation/non-existent-station", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + assertEquals(response.status, HttpStatusCode.BadRequest) + } + + @Test + fun `test response OK and svg produced substation`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/diagram/substation/S1", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + assertEquals(response.status, HttpStatusCode.OK) + val body = response.bodyAsText() + assertTrue(isPlausibleSvg(body)) + } + + @Test + fun `test 11 substation names extracted`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/substation-names", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + assertEquals(response.status, HttpStatusCode.OK) + val substationNames = response.bodyAsText().split(",") + assertEquals(substationNames.size, 11) + } + + @Test + fun `test 2 voltage levels extracted`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/voltage-levels", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + assertEquals(response.status, HttpStatusCode.OK) + val voltageLevels = response.bodyAsText().split(",") + assertEquals(14, voltageLevels.size) + } + + @Test + fun `test svg produced for voltage level`() = + testApplication { + val response = client.submitFormWithBinaryData( + url = "/diagram/voltageLevel/VL1", + formData = formDataFromFile(ieeeCdfNetwork14File()) + ) + assertEquals(response.status, HttpStatusCode.OK) + val body = response.bodyAsText() + assertTrue(isPlausibleSvg(body)) + assertEquals(response.headers["Content-Type"].toString(), "image/svg+xml") + } } fun formDataFromFile(file: File): List { @@ -116,4 +187,10 @@ fun ieeeCdfNetwork14File(): File { 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("