Skip to content

Commit

Permalink
WIP time series textarea
Browse files Browse the repository at this point in the history
  • Loading branch information
Erikvv committed Dec 18, 2024
1 parent db6758b commit 6665fab
Show file tree
Hide file tree
Showing 13 changed files with 1,001 additions and 1,275 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 23
cache: npm
cache-dependency-path: ./frontend/package-lock.json
- run: npm install
Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ services:
}
npm:
image: node:22
image: node:23
working_dir: /app/frontend
volumes:
- .:/app
Expand Down
2,049 changes: 798 additions & 1,251 deletions frontend/package-lock.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"@emotion/babel-plugin": "^11.11.0",
"@emotion/react": "^11.11.1",
"@geoman-io/leaflet-geoman-free": "^2.17.0",
"@js-joda/core": "^5.6.3",
"@js-joda/timezone": "^2.21.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
Expand Down Expand Up @@ -65,11 +67,11 @@
"@babel/preset-env": "^7.22.10",
"@babel/preset-react": "^7.22.5",
"@types/leaflet": "^1.9.12",
"@vitejs/plugin-react": "^4.3.1",
"@vitejs/plugin-react": "^4.3.4",
"babel-jest": "^29.6.2",
"jest": "^29.6.2",
"process": "^0.11.10",
"react-test-renderer": "^18.2.0",
"vite": "^5.3.1"
"vite": "^6.0.1"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {ProjectConfiguration} from './project'
export enum ConsumptionSpec {
PLACEHOLDER_AUTHORIZATION = "PLACEHOLDER_AUTHORIZATION",
PDF_AUTHORIZATION = "PDF_AUTHORIZATION",
TEXTAREA = "TEXTAREA",
// Option unused as we have not automated this proces
UPLOAD_QUARTER_HOURLY_VALUES = "UPLOAD_QUARTER_HOURLY_VALUES",
// Option removed as per feedback
// ANNUAL_VALUES = "ANNUAL_VALUES",
Expand All @@ -12,6 +14,7 @@ export enum ConsumptionSpec {
const labels = {
[ConsumptionSpec.PLACEHOLDER_AUTHORIZATION]: "Ik wil jullie machtigen voor het ophalen van de meetdata",
[ConsumptionSpec.PDF_AUTHORIZATION]: "Ik wil jullie machtigen voor het ophalen van de meetdata",
[ConsumptionSpec.TEXTAREA]: "Kwartierwaarden plakken",
[ConsumptionSpec.UPLOAD_QUARTER_HOURLY_VALUES]: "Kwartierwaarden uploaden",
// [ConsumptionSpec.ANNUAL_VALUES]: "Jaarverbruik invullen",
}
Expand All @@ -32,9 +35,9 @@ export const ElectricityConsumptionRadios = ({onChange, consumptionSpec, project
Ik wil jullie machtigen voor het ophalen van de meetdata
</Radio>
}
<Radio value={ConsumptionSpec.UPLOAD_QUARTER_HOURLY_VALUES}>
Kwartierwaarden uploaden
<Radio value={ConsumptionSpec.TEXTAREA}>
Ik wil kwartierwaarden kopiëren en plakken
</Radio>
</Radio.Group>
)
}
}
18 changes: 11 additions & 7 deletions frontend/src/components/company-survey-v2/electricity-data.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {useState} from 'react'
import {UseFormReturn} from 'react-hook-form'
import {ConsumptionSpec, ElectricityConsumptionRadios} from './electricity-consumption-radios'
import {LabelRow} from './generic/label-row'
import {Purpose, Upload} from './generic/upload'
import {ProjectConfiguration} from './project'
import {useState} from "react"
import {UseFormReturn} from "react-hook-form"
import {ConsumptionSpec, ElectricityConsumptionRadios} from "./electricity-consumption-radios"
import {LabelRow} from "./generic/label-row"
import {Purpose, Upload} from "./generic/upload"
import {ProjectConfiguration} from "./project"
import {TimeSeriesTextareaAdapter} from "./time-series-textarea"

export const ElectricityData = ({form, prefix, project}: {
form: UseFormReturn,
Expand Down Expand Up @@ -62,6 +63,9 @@ export const ElectricityData = ({form, prefix, project}: {
purpose={Purpose.ELECTRICITY_AUTHORIZATION} />
</LabelRow>
)}
{consumptionSpec === ConsumptionSpec.TEXTAREA && (
<TimeSeriesTextareaAdapter />
)}
{/*{consumptionSpec === ConsumptionSpec.ANNUAL_VALUES && (*/}
{/* <>*/}
{/* <NumberRow*/}
Expand All @@ -80,4 +84,4 @@ export const ElectricityData = ({form, prefix, project}: {
{/*)}*/}
</>
)
}
}
108 changes: 108 additions & 0 deletions frontend/src/components/company-survey-v2/time-series-textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {Instant, ZonedDateTime, ZoneId, ZoneRulesProvider, convert, LocalDateTime} from "@js-joda/core"
import "@js-joda/timezone/dist/js-joda-timezone-2017-2027.esm.js"
import {FunctionComponent, useState} from "react"
import {TimeSeries, timeSeriesFromJson, TimeSeriesType, createEmptyTimeSeriesForYear, toJsJodaInstant} from "zero-zummon"
import { InputTextarea } from 'primereact/inputtextarea';
import {LabelRow} from "./generic/label-row"

const targetYear = 2023
const displayTimeZone = "Europe/Amsterdam"

const placeholder = [0.2, 0.32, 1.2, 1.1, 0.9, 0.65].join("\n").replaceAll(".", ",")

const kwhNumberFormatter = new Intl.NumberFormat("nl-NL", {maximumFractionDigits: 1})

/**
* Has a plain JS object as input and output.
* Converts that objects to a {@link TimeSeries} domain object for use in {@link TimeSeriesTextarea}
*/
export const TimeSeriesTextareaAdapter: FunctionComponent<{
timeSeries?: any,
timeSeriesType?: TimeSeriesType,
setTimeSeries?: () => void
}> = ({
timeSeries,
timeSeriesType = TimeSeriesType.ELECTRICITY_DELIVERY,
setTimeSeries = console.log
}) => {
const timeSeriesDomainObject = timeSeries ? timeSeriesFromJson(timeSeries) : createEmptyTimeSeriesForYear(timeSeriesType, targetYear)

return <TimeSeriesTextarea timeSeries={timeSeriesDomainObject} setTimeSeries={setTimeSeries} />
}

const TimeSeriesTextarea: FunctionComponent<{timeSeries: TimeSeries, setTimeSeries: (t: TimeSeries) => void}> = ({timeSeries}) => {
const [internalTimeSeries, setInternalTimeSeries] = useState(timeSeries)

const localStart = kotlinInstantToJsJodaInstant(internalTimeSeries.start)
.atZone(ZoneId.of(displayTimeZone))
.toLocalDateTime()

const end = kotlinInstantToJsJodaInstant(internalTimeSeries.calculateEnd())

return (
<>
<h2>2. Kwartierwaarden electriciteit</h2>

<p>Richtlijnen</p>

<ul>
<li>Geef waarden op van het gehele jaar {targetYear}.</li>
<li>De eerste waarde betreft het verbruik in het kwartier van 1 januari {targetYear} van 00:00 tot 00:15 CET.</li>
<li>De laatste waarde betreft het verbruik in het kwartier van 31 december {targetYear} 23:45 tot 1 januari {targetYear + 1} om 00:00 CET.</li>
<li>Een langere periode mag.</li>
</ul>

<LabelRow label="Datum en tijd begin kwartierwaarden">
<div style={{display: "flex", gap: "1rem"}}>
<input type="datetime-local" id="start" name="start" defaultValue={localStart.toString()}
onChange={e => {
const local = LocalDateTime.parse(e.target.value)
const zoned = ZonedDateTime.of(local, ZoneId.of(displayTimeZone))
setInternalTimeSeries(
internalTimeSeries.withStartEpochSeconds(zoned.toEpochSecond()),
)
}} />
Nederlandse tijd
</div>
</LabelRow>

<LabelRow label="Plak hier de waarden in kWh">
<InputTextarea
id="values"
name="values"
defaultValue={internalTimeSeries.values.values().map(val => val.toLocaleString()).toArray().join("\n")}
onInput={e => setInternalTimeSeries(internalTimeSeries.withValues(parseTextArea((e.target as HTMLTextAreaElement).value)))}
style={{display: "block", height: "10rem"}}
placeholder={placeholder} />
</LabelRow>

<p>Eind kwartierwaarden: {prettyPrint(end)}</p>
<p>Totaal: <span id="total">{kwhNumberFormatter.format(internalTimeSeries.sum())}</span> kWh</p>
</>
)
}

function parseTextArea(content: String): Float32Array {
const lines = content.split("\n")
const numberArray = lines.map(line => line
.trim()
.replace(",", ".")
).filter(line => line).map(parseFloat)
return new Float32Array(numberArray)
}

// This seems dubious because it mixes different versions of js-joda.
// If this leads to issues we can do it with a conversion through epoch seconds
const kotlinInstantToJsJodaInstant = (kotlinInstant: any): Instant => toJsJodaInstant(kotlinInstant)

// We use native date formatting because js-joda does not support Dutch locale
const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
dateStyle: "full",
timeStyle: "long",
timeZone: displayTimeZone,
})

function prettyPrint(instant: Instant): string {
const jsDate = convert(instant).toDate()
return dateFormatter.format(jsDate)
}
1 change: 1 addition & 0 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import React from 'react'
import ReactDOM from 'react-dom/client'
import {
Expand Down
7 changes: 7 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import {defineConfig} from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
// this is a dev-only option
optimizeDeps: {
esbuildOptions: {
// this is necessary for @js-joda/timezone to work in dev mode
keepNames: true,
},
},
plugins: [react({ jsxImportSource: '@emotion/react' })],
// for dev
server: {
Expand Down
8 changes: 7 additions & 1 deletion zummon/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ kotlin {
compilations["main"].packageJson {
// hack hack hack
types = "kotlin/zero-zummon.d.ts"
dependencies["@js-joda/timezone"] = "2.3.0"
}
browser {
}
Expand All @@ -36,7 +37,7 @@ kotlin {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:${libs.versions.kotlinx.serialization.json.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${libs.versions.kotlinx.serialization.json.get()}")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation("com.benasher44:uuid:0.8.4")
}
}
Expand All @@ -45,5 +46,10 @@ kotlin {
implementation(kotlin("test"))
}
}
jsMain {
dependencies {
implementation(npm("@js-joda/timezone", "2.3.0"))
}
}
}
}
56 changes: 48 additions & 8 deletions zummon/src/commonMain/kotlin/companysurvey/TimeSeries.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.zenmo.zummon.companysurvey

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
Expand All @@ -10,8 +11,9 @@ import com.zenmo.zummon.BenasherUuidSerializer
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.encodeToString
import kotlin.js.JsExport

import kotlin.time.Duration

/**
* This contains values parsed from a CSV/excel or fetched from an API.
Expand All @@ -22,17 +24,24 @@ data class TimeSeries (
@Serializable(with = BenasherUuidSerializer::class)
val id: Uuid = uuid4(),
val type: TimeSeriesType,
// Measurement start time
/** Start of the first measurement interval */
val start: Instant,
val timeStep: kotlin.time.Duration = 15.minutes,
val unit: TimeSeriesUnit = TimeSeriesUnit.KWH,
/** Duration of the measurement interval */
val timeStep: Duration = type.defaultStep(),
val unit: TimeSeriesUnit = type.defaultUnit(),
val values: FloatArray = floatArrayOf(),
) {
@Deprecated("Use .values", ReplaceWith("values"))
fun getFlatDataPoints(): FloatArray = values

fun calculateEnd(): Instant = start + (timeStep * values.size)

fun sum() = values.sum()

fun withStartEpochSeconds(epochSeconds: Double) = copy(
start = Instant.fromEpochSeconds(epochSeconds.toLong())
)

/**
* The number of values needed to fill a year using the specified time step.
*/
Expand All @@ -47,6 +56,10 @@ data class TimeSeries (
}
}

fun withValues(values: FloatArray) = copy(values = values)

fun isEmpty() = values.isEmpty()

fun hasFullYear(year: Int? = null): Boolean {
return try {
val workingYear = year ?: this.start.toLocalDateTime(TimeZone.of("UTC+01:00")).year
Expand Down Expand Up @@ -150,8 +163,13 @@ data class TimeSeries (
result = 31 * result + values.contentHashCode()
return result
}

fun toJson(): String = Json.encodeToString(this)
}

@JsExport
fun timeSeriesFromJson(json: String): TimeSeries = Json.decodeFromString(TimeSeries.serializer(), json)

@JsExport
enum class TimeSeriesUnit {
KWH,
Expand All @@ -161,14 +179,36 @@ enum class TimeSeriesUnit {
@JsExport
enum class TimeSeriesType {
// Delivery from grid to end-user
ELECTRICITY_DELIVERY,
ELECTRICITY_DELIVERY {
override fun defaultUnit() = TimeSeriesUnit.KWH
override fun defaultStep() = 15.minutes
},
// Feed-in of end-user back in to the rid
ELECTRICITY_FEED_IN,
ELECTRICITY_FEED_IN {
override fun defaultUnit() = TimeSeriesUnit.KWH
override fun defaultStep() = 15.minutes
},
// Solar panel production
ELECTRICITY_PRODUCTION,
GAS_DELIVERY,
ELECTRICITY_PRODUCTION {
override fun defaultUnit() = TimeSeriesUnit.KWH
override fun defaultStep() = 15.minutes
},
GAS_DELIVERY {
override fun defaultUnit() = TimeSeriesUnit.M3
override fun defaultStep() = 1.hours
};

abstract fun defaultUnit(): TimeSeriesUnit
abstract fun defaultStep(): Duration
}

@JsExport
fun createEmptyTimeSeriesForYear(type: TimeSeriesType, year: Int) =
TimeSeries(
type = type,
start = Instant.parse("$year-01-01T00:00:00+01:00")
)

/**
* Represents a single point within the time series.
* Improvement: add timestamp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package com.zenmo.zummon.companysurvey

import kotlinx.serialization.Serializable

@Deprecated("unused", ReplaceWith("DataPoint"))
@Serializable
data class TimeSeriesDataPoint (
val timestamp: kotlinx.datetime.Instant,
val value: Float,
)
)
7 changes: 7 additions & 0 deletions zummon/src/jsMain/kotlin/Instant.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import kotlinx.datetime.Instant
import kotlinx.datetime.internal.JSJoda.Instant as jtInstant

@JsExport
fun toJsJodaInstant(instant: Instant) = jtInstant.ofEpochSecond(instant.epochSeconds.toDouble(), 0)

//fun instantToEpochSeconds(instant: Instant) = instant.epochSeconds.toDouble()

0 comments on commit 6665fab

Please sign in to comment.