Skip to content

Commit

Permalink
Time series textarea (#184)
Browse files Browse the repository at this point in the history
New feature: enter quarter-hourly electricity values using a textarea
input.
  • Loading branch information
Erikvv committed Dec 19, 2024
1 parent e743490 commit 29c1a48
Show file tree
Hide file tree
Showing 17 changed files with 2,031 additions and 3,511 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
4 changes: 3 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,11 @@ services:
}
npm:
image: node:22
image: node:23
working_dir: /app/frontend
volumes:
- .:/app
- npm-cache:/root/.npm
entrypoint: npm
environment:
VITE_ZTOR_URL: http://localhost:8082
Expand Down Expand Up @@ -255,4 +256,5 @@ volumes:
gradle-test-cache:
gradle-run-cache:
gradle-cmd-cache:
npm-cache:
postgres:
5,184 changes: 1,729 additions & 3,455 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"
}
}
8 changes: 4 additions & 4 deletions frontend/src/admin/import-excel-button.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import {FunctionComponent} from "react"
import {useNavigate} from "react-router-dom"
import {Button} from "primereact/button"
import { SiMicrosoftexcel } from "react-icons/si";
import {PiMicrosoftExcelLogoFill} from "react-icons/pi"

export const ImportExcelButton: FunctionComponent = () => {
const navigate = useNavigate()

return (
<Button onClick={() => navigate("/admin/import-excel")} aria-label="Importeer Excel">
<SiMicrosoftexcel />
<Button onClick={() => navigate("/admin/import-excel")} aria-label="Importeer Excel" style={{padding: "0 1em"}}>
<PiMicrosoftExcelLogoFill size="1.6rem" />
&nbsp;
Importeer Excel
</Button>
)
}
}
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 {TimeSeriesElectricity} from "./time-series/time-series-electricity"

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 && (
<TimeSeriesElectricity form={form} prefix={prefix} />
)}
{/*{consumptionSpec === ConsumptionSpec.ANNUAL_VALUES && (*/}
{/* <>*/}
{/* <NumberRow*/}
Expand All @@ -80,4 +84,4 @@ export const ElectricityData = ({form, prefix, project}: {
{/*)}*/}
</>
)
}
}
54 changes: 26 additions & 28 deletions frontend/src/components/company-survey-v2/generic/label-row.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
import {css} from '@emotion/react'
import {PropsWithChildren} from 'react'
import {FunctionComponent, PropsWithChildren} from "react"
import {flash} from '../flash'

export const LabelRow = ({label, children}: PropsWithChildren<{label: any}>) => {
return (
<label css={[css`
display: flex;
`, flash]}>
<div css={css`
text-align: right;
width: 50%;
padding: 0.3rem;
export const LabelRow: FunctionComponent<PropsWithChildren<{ label: any }>> = ({label, children}) => (
<label css={[css`
display: flex;
`, flash]}>
<div css={css`
text-align: right;
width: 50%;
padding: 0.3rem;
display: flex;
flex-direction: column;
justify-content: center;
`}>
{label}
</div>
<div css={css`
width: 50%;
padding: 0.3rem;
display: flex;
flex-direction: column;
justify-content: center;
`}>
{label}
</div>
<div css={css`
width: 50%;
padding: 0.3rem;
display: flex;
flex-direction: column;
justify-content: center;
`}>
{children}
</div>
</label>
)
}
display: flex;
flex-direction: column;
justify-content: center;
`}>
{children}
</div>
</label>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {targetYear} from "./time-series-util"
import {TimeSeriesTextareaAdapter} from "./time-series-textarea-adapter"
import {FunctionComponent} from "react"
import {UseFormReturn} from "react-hook-form"
import {TimeSeriesType} from "zero-zummon"

export const TimeSeriesElectricity: FunctionComponent<{form: UseFormReturn, prefix: string}> = ({form, prefix}) => {
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>
<h3>Kwartierwaarden levering</h3>
<TimeSeriesTextareaAdapter
timeSeries={form.watch(`${prefix}.quarterHourlyDelivery_kWh`)}
timeSeriesType={TimeSeriesType.ELECTRICITY_DELIVERY}
setTimeSeries={timeSeries => form.setValue(`${prefix}.quarterHourlyDelivery_kWh`, timeSeries)} />
<h3>Kwartierwaarden teruglevering</h3>
<TimeSeriesTextareaAdapter
timeSeries={form.watch(`${prefix}.quarterHourlyFeedIn_kWh`)}
timeSeriesType={TimeSeriesType.ELECTRICITY_FEED_IN}
setTimeSeries={timeSeries => form.setValue(`${prefix}.quarterHourlyFeedIn_kWh`, timeSeries)} />
<h3>Kwartierwaarden brutoproductiemeter</h3>
<TimeSeriesTextareaAdapter
timeSeries={form.watch(`${prefix}.quarterHourlyProduction_kWh`)}
timeSeriesType={TimeSeriesType.ELECTRICITY_PRODUCTION}
setTimeSeries={timeSeries => form.setValue(`${prefix}.quarterHourlyProduction_kWh`, timeSeries)} />
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {FunctionComponent} from "react"
import {targetYear} from "./time-series-util"
import {TimeSeries, TimeSeriesType, timeSeriesFromJson, createEmptyTimeSeriesForYear} from "zero-zummon"
import {TimeSeriesTextarea} from "./time-series-textarea"

/**
* 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?: (obj: any) => void
}> = ({
timeSeries,
timeSeriesType = TimeSeriesType.ELECTRICITY_DELIVERY,
setTimeSeries = console.log,
}) => {
const timeSeriesDomainObject = timeSeries ? timeSeriesFromJson(JSON.stringify(timeSeries)) : createEmptyTimeSeriesForYear(timeSeriesType, targetYear)

return (
<TimeSeriesTextarea
timeSeries={timeSeriesDomainObject} setTimeSeries={(timeSeries: TimeSeries) => {
setTimeSeries(JSON.parse(timeSeries.toJson()))
}} />
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {LocalDateTime, ZonedDateTime, ZoneId} from "@js-joda/core"
import "@js-joda/timezone/dist/js-joda-timezone-2017-2027.esm.js"
import {FunctionComponent, useState} from "react"
import {TimeSeries} from "zero-zummon"
import {InputTextarea} from "primereact/inputtextarea"
import {LabelRow} from "../generic/label-row"
import {displayTimeZone, kotlinInstantToJsJodaInstant, prettyPrint} from "./time-series-util"
import {Dropdown} from "primereact/dropdown"
import {InputText} from "primereact/inputtext"

const placeholder = `
bijvoorbeeld:
0.23
0,23
.23
23
2.3
`.trim()

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

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

const setTimeSeriesImpl = (timeSeries: TimeSeries) => {
setInternalTimeSeries(timeSeries)
if (timeSeries.isValid()) {
setTimeSeries(timeSeries)
}
}

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

const end = kotlinInstantToJsJodaInstant(internalTimeSeries.calculateEnd())

return (
<>
<LabelRow label="Datum en tijd begin kwartierwaarden">
<InputText 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))
setTimeSeriesImpl(
internalTimeSeries.withStartEpochSeconds(zoned.toEpochSecond()),
)
}} />
</LabelRow>
<LabelRow label="Tijdzone">
<Dropdown options={[{ label: 'Nederlandse tijd', value: displayTimeZone }]} value={displayTimeZone}/>
</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 => {setTimeSeriesImpl(internalTimeSeries.withValues(parseTextArea((e.target as HTMLTextAreaElement).value)))}}
// onChange={e => setTimeSeriesImpl(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)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {convert, Instant} from "@js-joda/core"
import "@js-joda/timezone/dist/js-joda-timezone-2017-2027.esm.js"
import {instantToEpochSeconds, toJsJodaInstant} from "zero-zummon"

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

// 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
// export const kotlinInstantToJsJodaInstant = (kotlinInstant: any): Instant => toJsJodaInstant(kotlinInstant)
export const kotlinInstantToJsJodaInstant = (kotlinInstant: any): Instant => Instant.ofEpochSecond(instantToEpochSeconds(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,
})

export function prettyPrint(instant: Instant): string {
const jsDate = convert(instant).toDate()
return dateFormatter.format(jsDate)
}
2 changes: 2 additions & 0 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
rollupOptions: {
treeshake: false,
output: {
entryFileNames: "[name].js",
assetFileNames: '[name].css',
Expand Down
Loading

0 comments on commit 29c1a48

Please sign in to comment.