<script lang="ts">
import { browser } from '$app/environment'
import { onMount, tick } from 'svelte'
import { dev } from '$app/environment'
import type { Writable } from 'svelte/store'
import { get } from 'svelte/store'
import { getContext } from 'svelte'
import type * as P from 'plotly.js-dist-min'
import * as Grain from '../../../../grain-types'
import {
chooseScale,
relayout,
modeBarButtonsToRemove,
} from '../../../../plot'
import type { Sample } from '../../../../plot'
import type { Derived, PVGIS, PVGISInputs } from '../calcul'
import * as calcul from '../calcul'
let { data, annee = data.annee, pvgis_loading = true } = $props()
console.log(data)
let s: Derived = getContext('simulation')
const soleil: Writable<Sample> = getContext('soleil')
const expert: Writable<boolean> = getContext('expert')
let _communaute: Writable<
(Grain.Communaute & { isMoving?: boolean }) | null
> = getContext('communaute')
const server = dev ? 'http://localhost:5173' : 'https://coturnix.fr'
function addPrm() {
data.prms.push({ prm: null, nom: '', prenom: '', siret: '', pro: true })
data.prms = data.prms
}
const pvgis: Writable<{ pvgis: null | PVGIS; loading: boolean }> =
getContext('pvgis')
let pvgis_: null | PVGISInputs = null
let prm_loading = $state(false)
let initialised = false
let puissance = 0
let communaute: null | (Grain.Communaute & { isMoving?: boolean }) = null
let Plotly: typeof P
let erreur = $state('')
onMount(async () => {
Plotly = await import('plotly.js-dist-min')
s.p.puissance.subscribe((value) => {
puissance = value
if (initialised) {
updatePlot(puissance, communaute)
}
})
_communaute.subscribe((value) => {
communaute = value
if (initialised) {
updatePlot(puissance, communaute)
}
})
pvgis.subscribe((pvgis) => {
console.log('pvgis updated')
if (initialised && pvgis.pvgis?.inputs != pvgis_) {
pvgis_ = pvgis.pvgis?.inputs || null
updatePlot(puissance, communaute)
}
})
for (let p of data.prms) {
p.consentement = false
}
initialised = true
updatePlot(puissance, communaute)
})
let plotStyle = $state('')
let darkMode = false
if (browser) {
darkMode =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (event) => {
darkMode = !!event.matches
redraw()
})
}
let profils_ = $state(Grain.profils($expert))
expert.subscribe((value) => (profils_ = Grain.profils(value)))
function addProfil() {
const prt = Object.keys(profils_)
data.profils.push({ type: prt[0], conso: 0 })
data.profils = data.profils
}
async function delProfil(i: number) {
data.profils.splice(i, 1)
data.profils = data.profils
await updatePlot(puissance, communaute)
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
var lastRequest = performance.now()
async function refresh(event: SubmitEvent) {
event.preventDefault()
if (data.prms) {
prm_loading = true
await tick()
for (let i = 0; i < data.prms.length; i++) {
var file = document.getElementById(
`releve-${i}`
) as HTMLInputElement | null
if (file?.files?.length) {
var reader = new FileReader()
reader.readAsText(file.files[0], 'UTF-8')
let r: string | undefined = await new Promise((resolve) => {
reader.onload = function (evt) {
console.log(typeof evt.target?.result)
let result = evt.target?.result?.toString()
if (result) {
resolve(result)
} else {
resolve(undefined)
}
}
})
data.prms[i].releve = r
}
let siret = document.getElementById(
`siret-${i}`
) as HTMLInputElement | null
if (siret?.value) {
let now = performance.now()
// Rate-limiting: pas plus de 7 requêtes par seconde.
if (now - lastRequest < 150) {
await sleep(150 - (now - lastRequest))
}
lastRequest = now
const resp = await fetch(
`https://recherche-entreprises.api.gouv.fr/search?q=${siret.value}`
)
if (resp.status == 200) {
type Ent = {
results: {
dirigeants: {
nom: string
prenoms: string
}[]
matching_etablissements: {
libelle_voie: string
commune: string
}[]
}[]
}
let ent: Ent = await resp.json()
console.log(
ent.results[0].matching_etablissements[0].commune
)
if (ent.results.length) {
data.prms[i].nom = ent.results[0].dirigeants[0].nom
data.prms[i].prenom =
ent.results[0].dirigeants[0].prenoms
data.prms[i].codeinsee =
ent.results[0].matching_etablissements[0].commune
console.log(data.prms[i])
}
}
}
}
console.log(data.prms)
const resp = await fetch(
`${server}/api/grain/projet/${data.id || 'nouveau'}/prms`,
{
method: 'POST',
body: JSON.stringify(data.prms),
}
)
if (resp.ok) {
annee = await resp.json()
}
prm_loading = false
await tick()
} else {
erreur = 'La liste des PRMs est vide'
}
await updatePlot(puissance, communaute)
}
let auto = 0
let totalProd = $state(0)
let totalConso = $state(0)
let somme: Sample = {
hourly: {
x: [],
y: [],
},
daily: {
x: [],
y: [],
},
weekly: {
x: [],
y: [],
},
x: [],
y: [],
type: 'bar',
line: {
color: '#b81111',
width: 1,
},
marker: {
color: '#b81111',
width: 1,
},
name: 'Consommation totale',
bucket: 1,
}
async function updatePv(
puissance: number,
communaute: (Grain.Communaute & { isMoving?: boolean }) | null
) {
if (communaute?.isMoving) {
return pvgis
}
pvgis_loading = true
await calcul.updatePv(puissance, communaute, pvgis, soleil)
pvgis_loading = false
await tick()
}
async function updatePlot(
puissance: number,
communaute: (Grain.Communaute & { isMoving?: boolean }) | null
) {
console.log('isMoving', communaute?.isMoving)
if (communaute?.isMoving) {
return
}
if (!browser) {
return
}
await updatePv(puissance, communaute)
console.log('updated')
prm_loading = true
let auto_ = await calcul.updatePlot(
annee,
data.profils,
profils_,
somme,
soleil
)
prm_loading = false
auto = auto_.auto
totalProd = auto_.totalProd
totalConso = auto_.totalConso
console.log('updated auto', auto, totalProd, totalConso)
if (!Plotly) Plotly = await import('plotly.js-dist-min')
redraw()
}
let has_plot = { has_plot: false }
let layout = {}
function relayout_(event: Plotly.PlotRelayoutEvent) {
console.log('relayout_')
const plot = document.getElementById('plot')!
relayout(event, plot, Plotly, layout, has_plot, get(soleil), somme)
}
function redraw() {
console.log('redraw')
const plot = document.getElementById('plot')
if (!plot) return
let soleil_ = get(soleil)
chooseScale(soleil_)
chooseScale(somme)
let unite: string
if (soleil_.bucket == 24) {
unite = 'kWh/jour'
} else if (soleil_.bucket == 24 * 7) {
unite = 'kWh/semaine'
} else {
unite = 'kWh'
}
layout = {
title: 'Autoconsommation',
autosize: true,
automargin: true,
plot_bgcolor: '#fff0',
paper_bgcolor: '#fff0',
font: {
color: darkMode ? '#fff' : '#000',
},
xaxis: {
gridcolor: darkMode ? '#444' : '#bbb',
ticklabeloverflow: 'allow',
ticklabelstep: 4,
tickangle: 45,
tickformat: soleil_.bucket < 24 ? '%x %X' : '%x',
type: 'datetime',
},
yaxis: {
title: { text: unite },
gridcolor: darkMode ? '#444' : '#bbb',
color: darkMode ? '#fff' : '#000',
},
showlegend: true,
legend: {
xanchor: 'center',
x: 0.5,
y: -0.4,
orientation: 'h',
},
}
if (has_plot.has_plot) {
Plotly!.update(plot, [soleil_, somme], layout)
} else {
Plotly!.newPlot(plot, [soleil_, somme], layout, {
responsive: true,
modeBarButtonsToRemove: modeBarButtonsToRemove,
})
plot.on('plotly_relayout', relayout_)
has_plot.has_plot = true
}
var dataProd = [
{
values: [Math.round(auto), Math.round(totalProd - auto)],
labels: ['Autoconsommé', 'Surplus'],
marker: { colors: ['#ffbf00dd', '#b81111dd'] },
type: 'pie',
sort: false,
hole: 0.5,
},
]
var dataConso = [
{
values: [Math.round(auto), Math.round(totalConso - auto)],
labels: ['Autoproduit', 'Fournisseur'],
marker: { colors: ['#ffbf00dd', '#b81111dd'] },
type: 'pie',
sort: false,
hole: 0.5,
},
]
if (totalProd && totalConso) {
plotStyle = 'min-height:450px'
Plotly.react(
'autoProd',
dataProd,
{
title: "Autoconsommation sur l'année",
font: {
color: darkMode ? '#fff' : '#000',
},
plot_bgcolor: '#fff0',
paper_bgcolor: '#fff0',
autosize: true,
automargin: true,
legend: {
orientation: 'h',
},
},
{ responsive: true }
)
Plotly.react(
'autoConso',
dataConso,
{
title: "Autoproduction sur l'année",
font: {
color: darkMode ? '#fff' : '#000',
},
plot_bgcolor: '#fff0',
paper_bgcolor: '#fff0',
autosize: true,
automargin: true,
legend: {
orientation: 'h',
},
},
{ responsive: true }
)
} else {
plotStyle = ''
}
}
</script>
<div class="tab-content" id="simuTabContent">
<div
class="tab-pane fade active show p-3"
id="simu-tab-pane"
role="tabpanel"
aria-labelledby="simu-tab"
tabindex="0">
<h3 class="my-3 fs-1 fw-bold">
Production… <span class="text-primary">Consommation</span>
</h3>
<form method="POST" action="/api/grain" id="pdl" onsubmit={refresh}>
</form>
{#if erreur}
<div
id="erreur"
class="alert alert-danger fade d-flex"
class:show={erreur}
role="alert">
{erreur}
<button
type="button"
class="btn-close"
style="padding:20px;margin:-16px -16px -16px auto"
onclick={() => (erreur = '')}
aria-label="Close"></button>
</div>
{/if}
<h2 class="my-5">Profils de consommation</h2>
{#if data.profils.length}
{#if $expert}
<div>
Vous êtes en mode expert. Dans ce mode, vous avez un accès
direct à tous les profils utilisés par les opérateurs du
réseau pour simuler les consommations. Voir <a
href="https://www.enedis.fr/responsable-dequilibre-profilage-et-profils"
>la documentation</a
>.
</div>
{/if}
<table class="table">
<thead
><tr
><th></th><th>Profil</th><th
>Consommation annuelle (kWh)</th
><th></th></tr
></thead>
<tbody>
{#each data.profils as pr, i (i)}
<tr>
<td class="px-3 align-middle">Profil</td>
<td>
<select
class="form-select"
bind:value={pr.type}
onchange={() =>
updatePlot(puissance, communaute)}>
{#each Object.keys(profils_) as p (p)}
<option value={p}>{p}</option>
{/each}
</select>
</td>
<td>
<input
type="number"
class="form-control"
placeholder="Abonnement (kW)"
min="0"
step="500"
bind:value={pr.conso}
onchange={() =>
updatePlot(puissance, communaute)} />
</td>
<td
style="width:1%;white-space:nowrap;text-align:center">
<button
class="btn btn-link"
onclick={() => delProfil(i)}
aria-label="Supprimer ce profil"
><i class="bi bi-x-circle text-primary"></i
></button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div class="text-center">
<button class="btn btn-primary" onclick={addProfil}
><i class="bi bi-plus-circle"></i> Ajouter un profil</button>
</div>
<h2 class="my-5">Relevés de compteurs</h2>
{#if data.active && data.id && data.id != 'nouveau'}
<div class="mb-5">
Vous pouvez ajouter des compteurs d'entreprises ou
d'associations par leur numéro de compteur et leur numéro de
SIRET.
</div>
{#if data.prms.length}
{#each data.prms as prm, i (prm)}
<div class="row my-2">
<div class="col-3 d-flex align-middle">
<label class="ms-auto form-label" for="prm-{i}"
>PRM<button
type="button"
class="btn btn-link align-baseline p-0 ms-2"
data-bs-toggle="popover"
data-bs-trigger="hover focus"
data-bs-content="Un PRM, aussi appelé PDL, est l'identifiant d'un compteur électrique. On peut le trouver sur les factures d'électricité, ou en appuyant sur le bouton + du compteur Linky."
aria-label="Un PRM, aussi appelé PDL, est l'identifiant d'un compteur électrique. On peut le trouver sur les factures d'électricité, ou en appuyant sur le bouton + du compteur Linky."
><i class="bi bi-question-circle"></i
></button>
</label>
</div>
<div class="col d-flex align-items-center">
<input
class="form-control form-control-sm"
id="prm-{i}"
bind:value={prm.prm}
onkeydown={(e) =>
(e.key == 'ArrowUp' ||
e.key == 'ArrowDown') &&
e.preventDefault()}
type="number" />
</div>
<div class="col col-auto d-flex align-items-center">
<button
class="btn btn-outline-primary btn-sm"
onclick={(e) => {
e.preventDefault()
data.prms.splice(i, 1)
data.prms = data.prms
}}
aria-label="Supprimer ce compteur"
><i class="bi bi-trash"></i></button>
</div>
</div>
<div class="row my-2">
<div class="col-3 d-flex align-middle">
<label class="ms-auto form-label" for="prm-{i}"
>Relevé au format CSV</label>
</div>
<div class="col d-flex align-items-center">
<input
class="form-control form-control-sm"
id="releve-{i}"
type="file" />
{#if prm.releve?.length}
<i class="ms-3 bi bi-check-circle"></i>
{/if}
</div>
</div>
<div class="row my-2">
<div class="col-9 ms-auto">
<input
type="radio"
class="btn-check"
id="particulier"
value={false}
bind:group={prm.pro} />
<label class="btn btn-sm" for="particulier"
>Particulier</label>
<input
type="radio"
class="btn-check"
id="pro"
value={true}
bind:group={prm.pro} />
<label class="btn btn-sm" for="pro"
>Professionnel</label>
</div>
</div>
{#if prm.pro}
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="siret-{i}"
>SIRET du titulaire du compteur</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="siret-{i}"
bind:value={prm.siret} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="prenom-{i}"
>Prénom du titulaire du compteur</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="prenom-{i}"
bind:value={prm.prenom} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label class="ms-auto form-label" for="nom-{i}"
>Nom du titulaire du compteur</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="nom-{i}"
bind:value={prm.nom} />
</div>
</div>
{:else}
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="prenom-{i}"
>Prénom du titulaire du compteur</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="prenom-{i}"
bind:value={prm.prenom} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label class="ms-auto form-label" for="nom-{i}"
>Nom du titulaire du compteur</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="nom-{i}"
bind:value={prm.nom} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="numerorue-{i}">Numéro et rue</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="numerorue-{i}"
bind:value={prm.numerorue} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="codepostal-{i}"
>Code INSEE de la commune</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="codepostal-{i}"
bind:value={prm.codeinsee} />
</div>
</div>
<div class="row my-2">
<div
class="col-3 d-flex text-end align-items-center">
<label
class="ms-auto form-label"
for="serie-{i}">Numéro de série</label>
</div>
<div class="col-9 d-flex align-items-center">
<input
class="form-control form-control-sm"
id="serie-{i}"
bind:value={prm.serie} />
</div>
</div>
{/if}
<div class="my-3 form-check d-flex">
<input
class="form-check-input ms-auto me-2"
type="checkbox"
id="consentement-{i}"
bind:checked={prm.consentement} />
<label
class="form-check-label me-auto"
for="consentement-{i}"
>Cet abonné consent à ce que j'accède à ses données.</label>
</div>
<hr class="my-4" />
{/each}
<div class="my-3">
Ce service ne consulte la courbe de charge du compteur
communiquant qu'une seule fois, et ce consentement expire
dès que ces données pour l'année écoulée sont récupérées.
Les données collectées depuis les compteurs communiquants
sont stockées temporairement sur nos serveurs pour des
raisons de performance, ne sont utilisées que pour afficher
cette page et produire le rapport, et ne sont jamais
conservées plus de 30 jours.
</div>
<div class="my-3">
Les données envoyées via un fichier sont conservées jusqu'à
ce que vous supprimiez ce projet.
</div>
{/if}
<div class="d-flex justify-content-center">
<button class="mx-2 btn btn-primary" onclick={addPrm}
><i class="bi bi-plus-circle"></i> Ajouter un compteur</button>
{#if data.prms.length}
<button
disabled={!!data.prms.find(
(p) => !p.releve?.length && !p.consentement
)}
class="mx-2 btn btn-primary"
form="pdl">Mettre à jour</button>
<div
class="mx-2 spinner-border text-primary"
class:d-none={!prm_loading}>
</div>
{/if}
</div>
{:else if data.active}
<div class="my-3">
Sauvegardez ce projet pour avoir accès aux relevés de compteurs
détaillés.
</div>
{:else}
<div class="my-3">
Vous devez être abonné pour avoir accès aux relevés de compteurs
détaillés.
</div>
{/if}
<h2 class="mt-5">Simulation</h2>
<table class="table table-bordered d-inline-block">
<tbody>
<tr
><td>Production totale:</td><td
>{Math.round(totalProd)}kWh</td
></tr>
<tr
><td>Consommation totale:</td><td
>{Math.round(totalConso)}kWh</td
></tr>
</tbody>
</table>
{#if pvgis_loading}
<div class="my-3 d-flex align-items-center text-primary">
<div class="spinner-border text-primary me-3"></div>
Chargement de la production solaire
</div>
{/if}
<div class="my-3">
<div class="mx-auto" style="min-height:450px" id="plot"></div>
</div>
<div class="row">
<div class="col-12 col-sm-6 p-0" style={plotStyle} id="autoProd">
</div>
<div class="col-12 col-sm-6 p-0" style={plotStyle} id="autoConso">
</div>
</div>
</div>
</div>
<style>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
appearance: none;
-moz-appearance: textfield; /* Firefox */
}
</style>