From 5d0ca2081f838a1a7475aac2497baee786931ff3 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 13 Nov 2024 10:57:37 +0100 Subject: [PATCH 01/19] feat(ui-results): add column filters --- .../Results/ResultDetails/ResultFilters.tsx | 293 +++++++++++++++--- .../explore/Results/ResultDetails/index.tsx | 158 ++++++---- .../explore/Results/ResultDetails/utils.ts | 9 + .../components/common/Matrix/shared/types.ts | 11 + .../components/common/Matrix/shared/utils.ts | 93 +++--- 5 files changed, 416 insertions(+), 148 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx index 85909f47e3..dd65f9a12f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx @@ -12,13 +12,41 @@ * This file is part of the Antares project. */ -import { Box } from "@mui/material"; +import { Box, IconButton } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { DataType, Timestep } from "./utils"; +import { DataType, matchesSearchTerm, Timestep } from "./utils"; import BooleanFE from "../../../../../common/fieldEditors/BooleanFE"; import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import NumberFE from "../../../../../common/fieldEditors/NumberFE"; import DownloadMatrixButton from "../../../../../common/buttons/DownloadMatrixButton"; +import CheckBoxFE from "@/components/common/fieldEditors/CheckBoxFE"; +import SearchFE from "@/components/common/fieldEditors/SearchFE"; +import { clamp, equals } from "ramda"; +import { useState, useMemo, useEffect } from "react"; +import { FilterListOff } from "@mui/icons-material"; + +interface ColumnHeader { + variable: string; + unit: string; + stat: string; + original: string[]; +} + +interface Filters { + search: string; + exp: boolean; + min: boolean; + max: boolean; + std: boolean; +} + +const defaultFilters = { + search: "", + exp: true, + min: true, + max: true, + std: true, +} as const; interface Props { year: number; @@ -30,6 +58,8 @@ interface Props { maxYear: number; studyId: string; path: string; + colHeaders: string[][]; + onfilteredColHeadersChange: (colHeaders: string[][]) => void; } function ResultFilters({ @@ -42,33 +72,178 @@ function ResultFilters({ maxYear, studyId, path, + colHeaders, + onfilteredColHeadersChange, }: Props) { const { t } = useTranslation(); + const [filters, setFilters] = useState(defaultFilters); - const handleYearChange = (event: React.ChangeEvent) => { - const newValue = event.target.value; + const filtersApplied = useMemo(() => { + return !equals(filters, defaultFilters); + }, [filters]); - // Allow empty string (when backspacing) - if (newValue === "") { - setYear(1); // Reset to minimum value - return; - } + const parsedHeaders = useMemo(() => { + return colHeaders.map( + (header): ColumnHeader => ({ + variable: String(header[0] || "").trim(), + unit: String(header[1] || "").trim(), + stat: String(header[2] || "").trim(), + original: header, + }), + ); + }, [colHeaders]); + + useEffect(() => { + const filteredHeaders = parsedHeaders.filter((header) => { + // Apply search filters + if (filters.search) { + const matchesVariable = matchesSearchTerm( + header.variable, + filters.search, + ); - const numValue = Number(newValue); + const matchesUnit = matchesSearchTerm(header.unit, filters.search); - // Validate the number is within bounds - if (!isNaN(numValue)) { - if (numValue < 1) { - setYear(1); - } else if (numValue > maxYear) { - setYear(maxYear); - } else { - setYear(numValue); + if (!matchesVariable && !matchesUnit) { + return false; + } } + + // Apply stat filters + if (header.stat) { + const stat = header.stat.toLowerCase(); + + if (!filters.exp && stat.includes("exp")) { + return false; + } + if (!filters.min && stat.includes("min")) { + return false; + } + if (!filters.max && stat.includes("max")) { + return false; + } + if (!filters.std && stat.includes("std")) { + return false; + } + } + + return true; + }); + + onfilteredColHeadersChange(filteredHeaders.map((h) => h.original)); + }, [filters, parsedHeaders, onfilteredColHeadersChange]); + + //////////////////////////////////////////////////////////////// + // Event handlers + //////////////////////////////////////////////////////////////// + + const handleYearChange = (event: React.ChangeEvent) => { + const value = Number(event.target.value); + + if (!isNaN(value)) { + const clampedYear = clamp(1, maxYear, value); + setYear(clampedYear); } }; - const FILTERS = [ + const handleSearchChange = (value: string) => { + setFilters((prev) => ({ ...prev, search: value })); + }; + + const handleStatFilterChange = (stat: keyof Omit) => { + setFilters((prev) => ({ ...prev, [stat]: !prev[stat] })); + }; + + const handleReset = () => { + setFilters(defaultFilters); + }; + + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + // Local filters (immediately applied on columns headers) + const COLUMN_FILTERS = [ + { + id: "search", + label: "", + field: ( + + handleSearchChange(e.target.value)} + size="small" + /> + + ), + }, + { + id: "exp", + label: "Exp", + field: ( + handleStatFilterChange("exp")} + size="small" + /> + ), + }, + { + id: "min", + label: "Min", + field: ( + handleStatFilterChange("min")} + size="small" + /> + ), + }, + { + id: "max", + label: "Max", + field: ( + handleStatFilterChange("max")} + size="small" + /> + ), + }, + { + id: "std", + label: "Std", + field: ( + handleStatFilterChange("std")} + size="small" + /> + ), + }, + { + id: "reset", + label: "", + field: ( + + + + ), + }, + ] as const; + + // Data filters (requiring API calls, refetch new result) + const RESULT_FILTERS = [ { label: `${t("study.results.mc")}:`, field: ( @@ -79,9 +254,7 @@ function ResultFilters({ falseText="Year by year" size="small" variant="outlined" - onChange={(event) => { - setYear(event?.target.value ? -1 : 1); - }} + onChange={(event) => setYear(event?.target.value ? -1 : 1)} /> {year > 0 && ( { - setDataType(event?.target.value as DataType); - }} + onChange={(event) => setDataType(event?.target.value as DataType)} /> ), }, @@ -133,13 +304,11 @@ function ResultFilters({ ]} size="small" variant="outlined" - onChange={(event) => { - setTimestep(event?.target.value as Timestep); - }} + onChange={(event) => setTimestep(event?.target.value as Timestep)} /> ), }, - ]; + ] as const; //////////////////////////////////////////////////////////////// // JSX @@ -150,27 +319,59 @@ function ResultFilters({ sx={{ display: "flex", alignItems: "center", - justifyContent: "flex-end", - gap: 2, + justifyContent: "space-between", + width: "100%", flexWrap: "wrap", - py: 1, }} > - {FILTERS.map(({ label, field }) => ( - - - {label} + {/* Column Filters Group */} + + {COLUMN_FILTERS.map(({ id, label, field }) => ( + + + {label} + + {field} - {field} - - ))} - + ))} + + + {/* Result Filters Group with Download Button */} + + {RESULT_FILTERS.map(({ label, field }) => ( + + + {label} + + {field} + + ))} + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 14cc0df972..aee076b24c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -26,7 +26,6 @@ import GridOffIcon from "@mui/icons-material/GridOff"; import { Area, LinkElement, - MatrixType, StudyMetadata, } from "../../../../../../common/types"; import usePromise from "../../../../../../hooks/usePromise"; @@ -57,6 +56,7 @@ import MatrixGrid from "../../../../../common/Matrix/components/MatrixGrid/index import { generateCustomColumns, generateDateTime, + generateResultColumns, groupResultColumns, } from "../../../../../common/Matrix/shared/utils.ts"; import { Column } from "@/components/common/Matrix/shared/constants.ts"; @@ -65,6 +65,7 @@ import ResultFilters from "./ResultFilters.tsx"; import { toError } from "../../../../../../utils/fnUtils.ts"; import EmptyView from "../../../../../common/page/SimpleContent.tsx"; import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; +import { ResultMatrixDTO } from "@/components/common/Matrix/shared/types.ts"; function ResultDetails() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -82,6 +83,7 @@ function ResultDetails() { const [itemType, setItemType] = useState(OutputItemType.Areas); const [selectedItemId, setSelectedItemId] = useState(""); const [searchValue, setSearchValue] = useState(""); + const [filteredColHeaders, setFilteredColHeaders] = useState([]); const isSynthesis = itemType === OutputItemType.Synthesis; const { t } = useTranslation(); const navigate = useNavigate(); @@ -133,20 +135,23 @@ function ResultDetails() { return ""; }, [output, selectedItem, isSynthesis, dataType, timestep, year]); - const matrixRes = usePromise( + const matrixRes = usePromise( async () => { - if (path) { - const res = await getStudyData(study.id, path); - if (typeof res === "string") { - const fixed = res - .replace(/NaN/g, '"NaN"') - .replace(/Infinity/g, '"Infinity"'); + if (!path) { + return undefined; + } + + const res = await getStudyData(study.id, path); + // TODO add backend parse + if (typeof res === "string") { + const fixed = res + .replace(/NaN/g, '"NaN"') + .replace(/Infinity/g, '"Infinity"'); - return JSON.parse(fixed); - } - return res; + return JSON.parse(fixed); } - return null; + + return res; }, { resetDataOnReload: true, @@ -155,21 +160,13 @@ function ResultDetails() { }, ); - const { data: dateTimeMetadata } = usePromise( - () => getStudyMatrixIndex(study.id, path), - { - deps: [study.id, path], - }, - ); - - const dateTime = dateTimeMetadata && generateDateTime(dateTimeMetadata); - const synthesisRes = usePromise( () => { if (outputId && selectedItem && isSynthesis) { const path = `output/${outputId}/economy/mc-all/grid/${selectedItem.id}`; return getStudyData(study.id, path); } + return Promise.resolve(null); }, { @@ -177,6 +174,36 @@ function ResultDetails() { }, ); + const { data: dateTimeMetadata } = usePromise( + () => getStudyMatrixIndex(study.id, path), + { + deps: [study.id, path], + }, + ); + + const dateTime = dateTimeMetadata && generateDateTime(dateTimeMetadata); + + const resultColumns = useMemo(() => { + if (!matrixRes.data) { + return []; + } + + const columns = + filteredColHeaders?.length > 0 + ? filteredColHeaders + : matrixRes.data.columns; + + return groupResultColumns([ + { + id: "date", + title: "Date", + type: Column.DateTime, + editable: false, + }, + ...generateResultColumns(columns), + ]); + }, [matrixRes.data, filteredColHeaders]); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -241,20 +268,9 @@ function ResultDetails() { sx={{ display: "flex", flexDirection: "column", - p: 2, + p: 1, }} > - {isSynthesis ? ( ) : ( - } - ifFulfilled={([, matrix]) => - matrix && ( - + + ( + + )} + ifFulfilled={([, matrix]) => + matrix && ( + + ) + } + ifRejected={(err) => ( + - ) - } - ifRejected={(err) => ( - - )} - /> + )} + /> + )} diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/utils.ts b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/utils.ts index 042cc80bba..c72001ef14 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/utils.ts @@ -82,3 +82,12 @@ export const SYNTHESIS_ITEMS = [ label: "Thermal synthesis", }, ]; + +// Allow the possibilty to use OR operator on search using pipe +export function matchesSearchTerm(text: string, searchTerm: string): boolean { + const searchTerms = searchTerm + .split("|") + .map((term) => term.trim().toLowerCase()); + + return searchTerms.some((term) => text.toLowerCase().includes(term)); +} diff --git a/webapp/src/components/common/Matrix/shared/types.ts b/webapp/src/components/common/Matrix/shared/types.ts index 915602d0e1..61475a08ef 100644 --- a/webapp/src/components/common/Matrix/shared/types.ts +++ b/webapp/src/components/common/Matrix/shared/types.ts @@ -58,11 +58,16 @@ export interface FormatGridNumberOptions { export interface EnhancedGridColumn extends BaseGridColumn { id: string; + title: string; width?: number; type: ColumnType; editable: boolean; } +export type ResultColumn = Omit & { + title: string[]; +}; + export type AggregateConfig = AggregateType[] | boolean | "stats" | "all"; export interface MatrixAggregates { @@ -79,6 +84,12 @@ export interface MatrixDataDTO { index: number[]; } +export interface ResultMatrixDTO { + data: number[][]; + columns: string[][]; + index: string[]; +} + export type Coordinates = [number, number]; // Shape of updates provided by Glide Data Grid diff --git a/webapp/src/components/common/Matrix/shared/utils.ts b/webapp/src/components/common/Matrix/shared/utils.ts index 0938519418..b51708bd5c 100644 --- a/webapp/src/components/common/Matrix/shared/utils.ts +++ b/webapp/src/components/common/Matrix/shared/utils.ts @@ -21,6 +21,7 @@ import { type AggregateConfig, type DateTimeMetadataDTO, type FormatGridNumberOptions, + type ResultColumn, } from "./types"; import { parseISO, Locale } from "date-fns"; import { fr, enUS } from "date-fns/locale"; @@ -318,46 +319,68 @@ export function calculateMatrixAggregates( * // Both columns will be grouped under "OV. COST (Euro)" * ``` */ + export function groupResultColumns( - columns: EnhancedGridColumn[], + columns: Array, ): EnhancedGridColumn[] { - return columns.map((column) => { - try { - const titles = Array.isArray(column.title) - ? column.title - : [String(column.title)]; - - // Extract and validate components - // [0]: Variable name (e.g., "OV. COST") - // [1]: Unit (e.g., "Euro") - // [2]: Statistic type (e.g., "MIN", "MAX", "STD") - const [variable, unit, stat] = titles.map((t) => String(t).trim()); - - // Create group name: - // - If unit exists and is not empty/whitespace, add it in parentheses - // - If no unit or empty unit, use variable name alone - const hasUnit = unit && unit.trim().length > 0; - const title = hasUnit ? `${variable} (${unit})` : variable; - - // If no stats, it does not make sense to group columns - if (!stat) { - return { - ...column, - title, - }; - } - + return columns.map((column): EnhancedGridColumn => { + const titles = Array.isArray(column.title) + ? column.title + : [String(column.title)]; + + // Extract and validate components + // [0]: Variable name (e.g., "OV. COST") + // [1]: Unit (e.g., "Euro") + // [2]: Statistic type (e.g., "MIN", "MAX", "STD") + const [variable, unit, stat] = titles.map((t) => String(t).trim()); + + // Create group name: + // - If unit exists and is not empty/whitespace, add it in parentheses + // - If no unit or empty unit, use variable name alone + const hasUnit = unit && unit.trim().length > 0; + const title = hasUnit ? `${variable} (${unit})` : variable; + + // If no stats, it does not make sense to group columns + if (!stat) { return { ...column, - group: title, // Group header title - title: stat.toLowerCase(), // Sub columns title - themeOverride: { - bgHeader: "#2D2E40", // Sub columns bg color - }, + title, }; - } catch (error) { - console.error(`Error processing column ${column.id}:`, error); - return column; } + + return { + ...column, + group: title, // Group header title + title: stat.toLowerCase(), // Sub columns title, + + themeOverride: { + bgHeader: "#2D2E40", // Sub columns bg color + }, + }; }); } + +/** + * Generates an array of ResultColumn objects from a 2D array of column titles. + * Each title array should follow the format [variable, unit, stat] as used in result matrices. + + * This function is designed to work in conjunction with groupResultColumns() + * to create properly formatted and grouped result matrix columns. + * + * @param titles - 2D array of string arrays, where each inner array contains: + * - [0]: Variable name (e.g., "OV. COST") + * - [1]: Unit (e.g., "Euro", "MW") + * - [2]: Statistic type (e.g., "MIN", "MAX", "STD") + * + * @returns Array of ResultColumn objects ready for use in result matrices + * + * @see groupResultColumns - Use this function to apply grouping to the generated columns + */ +export function generateResultColumns(titles: string[][]): ResultColumn[] { + return titles.map((title, index) => ({ + id: `custom${index + 1}`, + title: title, + type: Column.Number, + editable: true, + })); +} From c9a8900c758a2847bbcc4854eafd1f30027d016d Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Mon, 18 Nov 2024 10:19:05 +0100 Subject: [PATCH 02/19] feat(ui-results): add debounce on `year_by_year` field updates --- .../Results/ResultDetails/ResultFilters.tsx | 70 +++++++----- .../explore/Results/ResultDetails/index.tsx | 38 ++++--- .../components/common/Matrix/shared/utils.ts | 2 +- webapp/src/hooks/useDebouncedField.tsx | 105 ++++++++++++++++++ 4 files changed, 168 insertions(+), 47 deletions(-) create mode 100644 webapp/src/hooks/useDebouncedField.tsx diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx index dd65f9a12f..fac74da93c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx @@ -22,8 +22,9 @@ import DownloadMatrixButton from "../../../../../common/buttons/DownloadMatrixBu import CheckBoxFE from "@/components/common/fieldEditors/CheckBoxFE"; import SearchFE from "@/components/common/fieldEditors/SearchFE"; import { clamp, equals } from "ramda"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, ChangeEvent } from "react"; import { FilterListOff } from "@mui/icons-material"; +import { useDebouncedField } from "@/hooks/useDebouncedField"; interface ColumnHeader { variable: string; @@ -38,6 +39,7 @@ interface Filters { min: boolean; max: boolean; std: boolean; + values: boolean; } const defaultFilters = { @@ -46,6 +48,7 @@ const defaultFilters = { min: true, max: true, std: true, + values: true, } as const; interface Props { @@ -59,7 +62,7 @@ interface Props { studyId: string; path: string; colHeaders: string[][]; - onfilteredColHeadersChange: (colHeaders: string[][]) => void; + onColHeadersChange: (colHeaders: string[][]) => void; } function ResultFilters({ @@ -73,11 +76,19 @@ function ResultFilters({ studyId, path, colHeaders, - onfilteredColHeadersChange, + onColHeadersChange, }: Props) { const { t } = useTranslation(); const [filters, setFilters] = useState(defaultFilters); + const { localValue: localYear, handleChange: debouncedYearChange } = + useDebouncedField({ + value: year, + onChange: setYear, + delay: 500, + transformValue: (value: number) => clamp(1, maxYear, value), + }); + const filtersApplied = useMemo(() => { return !equals(filters, defaultFilters); }, [filters]); @@ -85,9 +96,9 @@ function ResultFilters({ const parsedHeaders = useMemo(() => { return colHeaders.map( (header): ColumnHeader => ({ - variable: String(header[0] || "").trim(), - unit: String(header[1] || "").trim(), - stat: String(header[2] || "").trim(), + variable: String(header[0]).trim(), + unit: String(header[1]).trim(), + stat: String(header[2]).trim(), original: header, }), ); @@ -125,25 +136,24 @@ function ResultFilters({ if (!filters.std && stat.includes("std")) { return false; } + if (!filters.values && stat.includes("values")) { + return false; + } } return true; }); - onfilteredColHeadersChange(filteredHeaders.map((h) => h.original)); - }, [filters, parsedHeaders, onfilteredColHeadersChange]); + onColHeadersChange(filteredHeaders.map((h) => h.original)); + }, [filters, parsedHeaders, onColHeadersChange]); //////////////////////////////////////////////////////////////// // Event handlers //////////////////////////////////////////////////////////////// - const handleYearChange = (event: React.ChangeEvent) => { + const handleYearChange = (event: ChangeEvent) => { const value = Number(event.target.value); - - if (!isNaN(value)) { - const clampedYear = clamp(1, maxYear, value); - setYear(clampedYear); - } + debouncedYearChange(value); }; const handleSearchChange = (value: string) => { @@ -182,7 +192,6 @@ function ResultFilters({ label: "Exp", field: ( handleStatFilterChange("exp")} size="small" @@ -194,7 +203,6 @@ function ResultFilters({ label: "Min", field: ( handleStatFilterChange("min")} size="small" @@ -206,7 +214,6 @@ function ResultFilters({ label: "Max", field: ( handleStatFilterChange("max")} size="small" @@ -218,13 +225,23 @@ function ResultFilters({ label: "Std", field: ( handleStatFilterChange("std")} size="small" /> ), }, + { + id: "values", + label: "Values", + field: ( + handleStatFilterChange("values")} + size="small" + /> + ), + }, { id: "reset", label: "", @@ -256,11 +273,11 @@ function ResultFilters({ variant="outlined" onChange={(event) => setYear(event?.target.value ? -1 : 1)} /> - {year > 0 && ( + {localYear > 0 && ( {/* Column Filters Group */} @@ -329,7 +346,6 @@ function ResultFilters({ sx={{ display: "flex", alignItems: "center", - gap: 1, }} > {COLUMN_FILTERS.map(({ id, label, field }) => ( @@ -340,9 +356,7 @@ function ResultFilters({ alignItems: "center", }} > - - {label} - + {label} {field} ))} @@ -353,7 +367,6 @@ function ResultFilters({ sx={{ display: "flex", alignItems: "center", - gap: 1, }} > {RESULT_FILTERS.map(({ label, field }) => ( @@ -362,11 +375,10 @@ function ResultFilters({ sx={{ display: "flex", alignItems: "center", + mr: 1, }} > - - {label} - + {label} {field} ))} diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index aee076b24c..071107987c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -83,7 +83,7 @@ function ResultDetails() { const [itemType, setItemType] = useState(OutputItemType.Areas); const [selectedItemId, setSelectedItemId] = useState(""); const [searchValue, setSearchValue] = useState(""); - const [filteredColHeaders, setFilteredColHeaders] = useState([]); + const [resultColHeaders, setResultColHeaders] = useState([]); const isSynthesis = itemType === OutputItemType.Synthesis; const { t } = useTranslation(); const navigate = useNavigate(); @@ -188,11 +188,6 @@ function ResultDetails() { return []; } - const columns = - filteredColHeaders?.length > 0 - ? filteredColHeaders - : matrixRes.data.columns; - return groupResultColumns([ { id: "date", @@ -200,9 +195,9 @@ function ResultDetails() { type: Column.DateTime, editable: false, }, - ...generateResultColumns(columns), + ...generateResultColumns(resultColHeaders), ]); - }, [matrixRes.data, filteredColHeaders]); + }, [matrixRes.data, resultColHeaders]); //////////////////////////////////////////////////////////////// // Event Handlers @@ -301,7 +296,7 @@ function ResultDetails() { studyId={study.id} path={path} colHeaders={matrixRes.data?.columns || []} - onfilteredColHeadersChange={setFilteredColHeaders} + onColHeadersChange={setResultColHeaders} /> matrix && ( - + <> + {resultColHeaders.length === 0 ? ( + + ) : ( + + )} + ) } ifRejected={(err) => ( diff --git a/webapp/src/components/common/Matrix/shared/utils.ts b/webapp/src/components/common/Matrix/shared/utils.ts index b51708bd5c..8d35e7c7a4 100644 --- a/webapp/src/components/common/Matrix/shared/utils.ts +++ b/webapp/src/components/common/Matrix/shared/utils.ts @@ -381,6 +381,6 @@ export function generateResultColumns(titles: string[][]): ResultColumn[] { id: `custom${index + 1}`, title: title, type: Column.Number, - editable: true, + editable: false, })); } diff --git a/webapp/src/hooks/useDebouncedField.tsx b/webapp/src/hooks/useDebouncedField.tsx new file mode 100644 index 0000000000..ea7dce4a1e --- /dev/null +++ b/webapp/src/hooks/useDebouncedField.tsx @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useState, useEffect, useCallback } from "react"; +import useDebounce from "./useDebounce"; + +interface UseDebouncedFieldOptions { + value: T; + onChange: (value: T) => void; + delay: number; + transformValue?: (value: T) => T; +} + +interface UseDebouncedFieldResult { + localValue: T; + handleChange: (value: T) => void; + setLocalValue: (value: T) => void; +} + +/** + * A hook that implements a "locally controlled, parentally debounced" pattern for form fields. + * + * 1. Controlled by Parent: + * - Parent owns the source of truth + * - Hook syncs with parent value changes + * + * 2. Local State Benefits: + * - Immediate UI feedback during typing + * - No input lag + * + * 3. Debounced Updates: + * - Parent updates (e.g. API calls) only trigger after user stops typing + * - Prevents excessive updates during rapid changes + * + * @param options - Configuration object for the debounced field + * @param options.value - The controlled value from parent + * @param options.onChange - Callback to update parent value (debounced) + * @param options.delay - Debounce delay in milliseconds (default: 500) + * @param options.transformValue - Optional value transformation function + + * @returns Object containing: + * - localValue: The immediate local state value + * - handleChange: Function to handle value changes (with debouncing) + * - setLocalValue: Function to directly update local value without debouncing + * + * @example + * ```tsx + * // Example with API calls + * function SearchField({ onSearch }) { + * const { localValue, handleChange } = useDebouncedField({ + * value: searchTerm, // Parent control + * onChange: onSearch, // Debounced API call + * delay: 500 // Wait 500ms after typing stops + * }); + * + * return ( + * + * ); + * } + * ``` + */ +export function useDebouncedField({ + value, + onChange, + delay = 500, + transformValue, +}: UseDebouncedFieldOptions): UseDebouncedFieldResult { + const [localValue, setLocalValue] = useState(value); + + // Sync local value with prop changes + useEffect(() => { + setLocalValue(value); + }, [value]); + + const debouncedOnChange = useDebounce(onChange, delay); + + const handleChange = useCallback( + (newValue: T) => { + const value = transformValue?.(newValue) ?? newValue; + setLocalValue(value); + debouncedOnChange(value); + }, + [debouncedOnChange, transformValue], + ); + + return { + localValue, + handleChange, + setLocalValue, + }; +} From 52af9f73ff862ae177acf75280d99b836c9e4c02 Mon Sep 17 00:00:00 2001 From: Hatim Dinia <33469289+hdinia@users.noreply.github.com> Date: Tue, 19 Nov 2024 15:10:22 +0100 Subject: [PATCH 03/19] feat(ui-results): add `MatrixGridSynthesis` (#2233) --- .../explore/Results/ResultDetails/index.tsx | 5 +- .../Matrix/components/MatrixGrid/index.tsx | 2 +- .../Matrix/components/MatrixGrid/styles.ts | 73 -------------- .../components/MatrixGridSynthesis/index.tsx | 99 +++++++++++++++++++ .../common/Matrix/hooks/useMatrix/index.ts | 2 +- webapp/src/components/common/Matrix/styles.ts | 59 +++++++++++ 6 files changed, 162 insertions(+), 78 deletions(-) delete mode 100644 webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts create mode 100644 webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 071107987c..3aaaf4509b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -65,6 +65,7 @@ import ResultFilters from "./ResultFilters.tsx"; import { toError } from "../../../../../../utils/fnUtils.ts"; import EmptyView from "../../../../../common/page/SimpleContent.tsx"; import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; +import { MatrixGridSynthesis } from "@/components/common/Matrix/components/MatrixGridSynthesis"; import { ResultMatrixDTO } from "@/components/common/Matrix/shared/types.ts"; function ResultDetails() { @@ -272,13 +273,11 @@ function ResultDetails() { ifPending={() => } ifFulfilled={(matrix) => matrix && ( - ) } diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx index 07ffd84d2c..688b647cc1 100644 --- a/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx @@ -31,7 +31,7 @@ import { } from "../../shared/types"; import { useColumnMapping } from "../../hooks/useColumnMapping"; import { useMatrixPortal } from "../../hooks/useMatrixPortal"; -import { darkTheme, readOnlyDarkTheme } from "./styles"; +import { darkTheme, readOnlyDarkTheme } from "../../styles"; export interface MatrixGridProps { data: number[][]; diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts b/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts deleted file mode 100644 index 834af21686..0000000000 --- a/webapp/src/components/common/Matrix/components/MatrixGrid/styles.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { Theme } from "@glideapps/glide-data-grid"; - -export const darkTheme: Theme = { - accentColor: "rgba(255, 184, 0, 0.9)", - accentLight: "rgba(255, 184, 0, 0.2)", - accentFg: "#FFFFFF", - textDark: "#FFFFFF", - textMedium: "#C1C3D9", - textLight: "#A1A5B9", - textBubble: "#FFFFFF", - bgIconHeader: "#1E1F2E", - fgIconHeader: "#FFFFFF", - textHeader: "#FFFFFF", - textGroupHeader: "#C1C3D9", - bgCell: "#262737", // main background color - bgCellMedium: "#2E2F42", - bgHeader: "#1E1F2E", - bgHeaderHasFocus: "#2E2F42", - bgHeaderHovered: "#333447", - bgBubble: "#333447", - bgBubbleSelected: "#3C3E57", - bgSearchResult: "#6366F133", - borderColor: "rgba(255, 255, 255, 0.12)", - drilldownBorder: "rgba(255, 255, 255, 0.35)", - linkColor: "#818CF8", - headerFontStyle: "bold 11px", - baseFontStyle: "13px", - fontFamily: "Inter, sans-serif", - editorFontSize: "13px", - lineHeight: 1.5, - textHeaderSelected: "#FFFFFF", - cellHorizontalPadding: 8, - cellVerticalPadding: 5, - headerIconSize: 16, - markerFontStyle: "normal", -}; - -export const readOnlyDarkTheme: Partial = { - bgCell: "#1A1C2A", - bgCellMedium: "#22243A", - textDark: "#FAF9F6", - textMedium: "#808080", - textLight: "#606060", - accentColor: "#4A4C66", - accentLight: "rgba(74, 76, 102, 0.2)", - borderColor: "rgba(255, 255, 255, 0.08)", - drilldownBorder: "rgba(255, 255, 255, 0.2)", - headerFontStyle: "bold 11px", -}; - -export const aggregatesTheme: Partial = { - bgCell: "#3D3E5F", - bgCellMedium: "#383A5C", - textDark: "#FFFFFF", - fontFamily: "Inter, sans-serif", - baseFontStyle: "13px", - editorFontSize: "13px", - headerFontStyle: "bold 11px", -}; diff --git a/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx b/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx new file mode 100644 index 0000000000..ad56f83b49 --- /dev/null +++ b/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import DataEditor, { + GridCellKind, + GridColumn, + Item, + NumberCell, + TextCell, +} from "@glideapps/glide-data-grid"; +import { useMemo } from "react"; +import { darkTheme, readOnlyDarkTheme } from "../../styles"; +import { formatGridNumber } from "../../shared/utils"; + +type CellValue = number | string; + +interface MatrixGridSynthesisProps { + data: CellValue[][]; + columns: GridColumn[]; + width?: string | number; + height?: string | number; +} + +export function MatrixGridSynthesis({ + data, + columns, + width = "100%", + height = "100%", +}: MatrixGridSynthesisProps) { + const theme = useMemo( + () => ({ + ...darkTheme, + ...readOnlyDarkTheme, + }), + [], + ); + + const getCellContent = useMemo( + () => (cell: Item) => { + const [col, row] = cell; + const value = data[row]?.[col]; + + if (typeof value === "number") { + return { + kind: GridCellKind.Number, + data: value, + displayData: formatGridNumber({ value, maxDecimals: 3 }), + decimalSeparator: ".", + thousandSeparator: " ", + readonly: true, + allowOverlay: false, + contentAlign: "right", + } satisfies NumberCell; + } + + return { + kind: GridCellKind.Text, + data: String(value ?? ""), + displayData: String(value ?? ""), + readonly: true, + allowOverlay: false, + } satisfies TextCell; + }, + [data], + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( +
+ +
+ ); +} diff --git a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts index 27e727b602..d2e80a61ef 100644 --- a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts @@ -43,7 +43,7 @@ import { importFile } from "../../../../../services/api/studies/raw"; import { fetchMatrixFn } from "../../../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; import usePrompt from "../../../../../hooks/usePrompt"; import { Aggregate, Column, Operation } from "../../shared/constants"; -import { aggregatesTheme } from "../../components/MatrixGrid/styles"; +import { aggregatesTheme } from "../../styles"; interface DataState { data: MatrixDataDTO["data"]; diff --git a/webapp/src/components/common/Matrix/styles.ts b/webapp/src/components/common/Matrix/styles.ts index 7711a73c8e..2b6686679f 100644 --- a/webapp/src/components/common/Matrix/styles.ts +++ b/webapp/src/components/common/Matrix/styles.ts @@ -13,6 +13,7 @@ */ import { Box, styled, Typography } from "@mui/material"; +import { Theme } from "@glideapps/glide-data-grid"; export const MatrixContainer = styled(Box)(() => ({ width: "100%", @@ -36,3 +37,61 @@ export const MatrixTitle = styled(Typography)(() => ({ fontWeight: 400, lineHeight: 1, })); + +export const darkTheme: Theme = { + accentColor: "rgba(255, 184, 0, 0.9)", + accentLight: "rgba(255, 184, 0, 0.2)", + accentFg: "#FFFFFF", + textDark: "#FFFFFF", + textMedium: "#C1C3D9", + textLight: "#A1A5B9", + textBubble: "#FFFFFF", + bgIconHeader: "#1E1F2E", + fgIconHeader: "#FFFFFF", + textHeader: "#FFFFFF", + textGroupHeader: "#C1C3D9", + bgCell: "#262737", // main background color + bgCellMedium: "#2E2F42", + bgHeader: "#1E1F2E", + bgHeaderHasFocus: "#2E2F42", + bgHeaderHovered: "#333447", + bgBubble: "#333447", + bgBubbleSelected: "#3C3E57", + bgSearchResult: "#6366F133", + borderColor: "rgba(255, 255, 255, 0.12)", + drilldownBorder: "rgba(255, 255, 255, 0.35)", + linkColor: "#818CF8", + headerFontStyle: "bold 11px", + baseFontStyle: "13px", + fontFamily: "Inter, sans-serif", + editorFontSize: "13px", + lineHeight: 1.5, + textHeaderSelected: "#FFFFFF", + cellHorizontalPadding: 8, + cellVerticalPadding: 5, + headerIconSize: 16, + markerFontStyle: "normal", +}; + +export const readOnlyDarkTheme: Partial = { + bgCell: "#1A1C2A", + bgCellMedium: "#22243A", + textDark: "#FAF9F6", + textMedium: "#808080", + textLight: "#606060", + accentColor: "#4A4C66", + accentLight: "rgba(74, 76, 102, 0.2)", + borderColor: "rgba(255, 255, 255, 0.08)", + drilldownBorder: "rgba(255, 255, 255, 0.2)", + headerFontStyle: "bold 11px", +}; + +export const aggregatesTheme: Partial = { + bgCell: "#3D3E5F", + bgCellMedium: "#383A5C", + textDark: "#FFFFFF", + fontFamily: "Inter, sans-serif", + baseFontStyle: "13px", + editorFontSize: "13px", + headerFontStyle: "bold 11px", +}; From babfb0b1fce0c190b7945b11e24016a78a202f65 Mon Sep 17 00:00:00 2001 From: Sylvain Leclerc Date: Tue, 19 Nov 2024 15:22:02 +0100 Subject: [PATCH 04/19] feat(ts-gen): add failing area and cluster info inside error msg (#2227) (#2231) --- .../generate_thermal_cluster_timeseries.py | 96 ++++++++++--------- ...est_generate_thermal_cluster_timeseries.py | 5 +- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/antarest/study/storage/variantstudy/model/command/generate_thermal_cluster_timeseries.py b/antarest/study/storage/variantstudy/model/command/generate_thermal_cluster_timeseries.py index 43ed95b21d..c6bff64114 100644 --- a/antarest/study/storage/variantstudy/model/command/generate_thermal_cluster_timeseries.py +++ b/antarest/study/storage/variantstudy/model/command/generate_thermal_cluster_timeseries.py @@ -86,53 +86,57 @@ def _build_timeseries( # 5- Loop through thermal clusters in alphabetical order sorted_thermals = sorted(area.thermals, key=lambda x: x.id) for thermal in sorted_thermals: - # 6 - Filters out clusters with no generation - if thermal.gen_ts == LocalTSGenerationBehavior.FORCE_NO_GENERATION: + try: + # 6 - Filters out clusters with no generation + if thermal.gen_ts == LocalTSGenerationBehavior.FORCE_NO_GENERATION: + generation_performed += 1 + continue + # 7- Build the cluster + url = ["input", "thermal", "prepro", area_id, thermal.id.lower(), "modulation"] + matrix = study_data.tree.get_node(url) + matrix_df = matrix.parse(return_dataframe=True) # type: ignore + modulation_capacity = matrix_df[MODULATION_CAPACITY_COLUMN].to_numpy() + url = ["input", "thermal", "prepro", area_id, thermal.id.lower(), "data"] + matrix = study_data.tree.get_node(url) + matrix_df = matrix.parse(return_dataframe=True) # type: ignore + fo_duration, po_duration, fo_rate, po_rate, npo_min, npo_max = [ + np.array(matrix_df[i], dtype=float if i in [FO_RATE_COLUMN, PO_RATE_COLUMN] else int) + for i in matrix_df.columns + ] + generation_params = OutageGenerationParameters( + unit_count=thermal.unit_count, + fo_law=ProbabilityLaw(thermal.law_forced.value.upper()), + fo_volatility=thermal.volatility_forced, + po_law=ProbabilityLaw(thermal.law_planned.value.upper()), + po_volatility=thermal.volatility_planned, + fo_duration=fo_duration, + fo_rate=fo_rate, + po_duration=po_duration, + po_rate=po_rate, + npo_min=npo_min, + npo_max=npo_max, + ) + cluster = ThermalCluster( + outage_gen_params=generation_params, + nominal_power=thermal.nominal_capacity, + modulation=modulation_capacity, + ) + # 8- Generate the time-series + results = generator.generate_time_series_for_clusters(cluster, nb_years) + generated_matrix = results.available_power + # 9- Write the matrix inside the input folder. + df = pd.DataFrame(data=generated_matrix) + df = df[list(df.columns)].astype(int) + target_path = self._build_matrix_path(tmp_path / area_id / thermal.id.lower()) + dump_dataframe(df, target_path, None) + # 10- Notify the progress to the notifier generation_performed += 1 - continue - # 7- Build the cluster - url = ["input", "thermal", "prepro", area_id, thermal.id.lower(), "modulation"] - matrix = study_data.tree.get_node(url) - matrix_df = matrix.parse(return_dataframe=True) # type: ignore - modulation_capacity = matrix_df[MODULATION_CAPACITY_COLUMN].to_numpy() - url = ["input", "thermal", "prepro", area_id, thermal.id.lower(), "data"] - matrix = study_data.tree.get_node(url) - matrix_df = matrix.parse(return_dataframe=True) # type: ignore - fo_duration, po_duration, fo_rate, po_rate, npo_min, npo_max = [ - np.array(matrix_df[i], dtype=float if i in [FO_RATE_COLUMN, PO_RATE_COLUMN] else int) - for i in matrix_df.columns - ] - generation_params = OutageGenerationParameters( - unit_count=thermal.unit_count, - fo_law=ProbabilityLaw(thermal.law_forced.value.upper()), - fo_volatility=thermal.volatility_forced, - po_law=ProbabilityLaw(thermal.law_planned.value.upper()), - po_volatility=thermal.volatility_planned, - fo_duration=fo_duration, - fo_rate=fo_rate, - po_duration=po_duration, - po_rate=po_rate, - npo_min=npo_min, - npo_max=npo_max, - ) - cluster = ThermalCluster( - outage_gen_params=generation_params, - nominal_power=thermal.nominal_capacity, - modulation=modulation_capacity, - ) - # 8- Generate the time-series - results = generator.generate_time_series_for_clusters(cluster, nb_years) - generated_matrix = results.available_power - # 9- Write the matrix inside the input folder. - df = pd.DataFrame(data=generated_matrix) - df = df[list(df.columns)].astype(int) - target_path = self._build_matrix_path(tmp_path / area_id / thermal.id.lower()) - dump_dataframe(df, target_path, None) - # 10- Notify the progress to the notifier - generation_performed += 1 - if listener: - progress = int(100 * generation_performed / total_generations) - listener.notify_progress(progress) + if listener: + progress = int(100 * generation_performed / total_generations) + listener.notify_progress(progress) + except Exception as e: + e.args = (f"Area {area_id}, cluster {thermal.id.lower()}: " + e.args[0],) + raise def to_dto(self) -> CommandDTO: return CommandDTO(action=self.command_name.value, args={}) diff --git a/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py b/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py index 2f69568e4c..2a41307e06 100644 --- a/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py +++ b/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py @@ -112,7 +112,10 @@ def test_errors_and_limit_cases(self, client: TestClient, user_access_token: str # Timeseries generation fails because there's no nominal power task = self._generate_timeseries(client, user_access_token, study_id) assert task.status == TaskStatus.FAILED - assert "Nominal power must be strictly positive, got 0.0" in task.result.message + assert ( + f"Area {area1_id}, cluster {cluster_name.lower()}: Nominal power must be strictly positive, got 0.0" + in task.result.message + ) # Puts the nominal power as a float body = {"nominalCapacity": 4.4} From 6e5d37448c65c091236314debc7abbf43fd310e0 Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Tue, 19 Nov 2024 15:50:36 +0100 Subject: [PATCH 05/19] fix(ts-gen): make variant generation fail when it's supposed to (#2234) --- antarest/study/service.py | 7 ++--- ...est_generate_thermal_cluster_timeseries.py | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 19067b6f9a..9c8667f09f 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -218,7 +218,7 @@ def _generate_timeseries(self, notifier: ITaskNotifier) -> None: file_study = self.storage_service.get_storage(study).get_raw(study) execute_or_add_commands(study, file_study, [command], self.storage_service, listener) - if isinstance(file_study, VariantStudy): + if isinstance(study, VariantStudy): # In this case we only added the command to the list. # It means the generation will really be executed in the next snapshot generation. # We don't want this, we want this task to generate the matrices no matter the study. @@ -228,8 +228,9 @@ def _generate_timeseries(self, notifier: ITaskNotifier) -> None: generation_task_id = variant_service.generate_task(study, True, False, listener) task_service.await_task(generation_task_id) result = task_service.status_task(generation_task_id, RequestParameters(DEFAULT_ADMIN_USER)) - if not result.result or not result.result.success: - raise ValueError(f"Failed to generate variant study {self._study_id}") + assert result.result is not None + if not result.result.success: + raise ValueError(result.result.message) self.event_bus.push( Event( diff --git a/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py b/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py index 2a41307e06..ff8eda61e4 100644 --- a/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py +++ b/tests/integration/study_data_blueprint/test_generate_thermal_cluster_timeseries.py @@ -11,6 +11,7 @@ # This file is part of the Antares project. import numpy as np +import pytest from starlette.testclient import TestClient from antarest.core.tasks.model import TaskDTO, TaskStatus @@ -99,24 +100,18 @@ def test_lifecycle_nominal(self, client: TestClient, user_access_token: str) -> data = res.json()["data"] assert data == [[]] # no generation c.f. gen-ts parameter - def test_errors_and_limit_cases(self, client: TestClient, user_access_token: str) -> None: + @pytest.mark.parametrize("study_type", ["raw", "variant"]) + def test_errors_and_limit_cases(self, client: TestClient, user_access_token: str, study_type: str) -> None: # Study Preparation client.headers = {"Authorization": f"Bearer {user_access_token}"} preparer = PreparerProxy(client, user_access_token) study_id = preparer.create_study("foo", version=860) area1_id = preparer.create_area(study_id, name="Area 1")["id"] + if study_type == "variant": + study_id = preparer.create_variant(study_id, name="Variant 1") - # Create a cluster without nominal power cluster_name = "Cluster 1" preparer.create_thermal(study_id, area1_id, name=cluster_name, group="Lignite") - # Timeseries generation fails because there's no nominal power - task = self._generate_timeseries(client, user_access_token, study_id) - assert task.status == TaskStatus.FAILED - assert ( - f"Area {area1_id}, cluster {cluster_name.lower()}: Nominal power must be strictly positive, got 0.0" - in task.result.message - ) - # Puts the nominal power as a float body = {"nominalCapacity": 4.4} res = client.patch(f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal/{cluster_name}", json=body) @@ -145,6 +140,18 @@ def test_errors_and_limit_cases(self, client: TestClient, user_access_token: str task = self._generate_timeseries(client, user_access_token, study_id) assert task.status == TaskStatus.COMPLETED + # Puts nominal capacity at 0 + body = {"nominalCapacity": 0} + res = client.patch(f"/v1/studies/{study_id}/areas/{area1_id}/clusters/thermal/{cluster_name}", json=body) + assert res.status_code in {200, 201} + # Timeseries generation fails because there's no nominal power + task = self._generate_timeseries(client, user_access_token, study_id) + assert task.status == TaskStatus.FAILED + assert ( + f"Area {area1_id}, cluster {cluster_name.lower()}: Nominal power must be strictly positive, got 0.0" + in task.result.message + ) + def test_advanced_results(self, client: TestClient, user_access_token: str) -> None: # Study Preparation client.headers = {"Authorization": f"Bearer {user_access_token}"} From bcc0219534c42e90e6d26b6f8b6be62efc193373 Mon Sep 17 00:00:00 2001 From: maugde <167874615+maugde@users.noreply.github.com> Date: Fri, 22 Nov 2024 15:31:30 +0100 Subject: [PATCH 06/19] feat: add new build directory structure (#2228) The filesystem for antares desktop version is now isolated, and only contains files and directories that it actually uses. "examples" directory is removed in favor of more adequately named directories "archives", "studies", "internal_studies". Small additional fix: we now kill the server when the systray application process is interrupted, for example by keyboard interrupt. --- antarest/desktop/systray_app.py | 7 ++- .../{deploy => antares-desktop-fs}/README.md | 5 +- .../antares-desktop-fs/archives/.placeholder | 0 resources/antares-desktop-fs/config.yaml | 58 +++++++++++++++++++ .../internal_studies/.placeholder | 0 .../antares-desktop-fs/logs/.placeholder | 0 .../antares-desktop-fs/matrices/.placeholder | 0 .../antares-desktop-fs/studies/.placeholder | 0 .../antares-desktop-fs/studies/README.md | 2 + resources/antares-desktop-fs/tmp/.placeholder | 0 resources/deploy/examples/.placeholder | 0 scripts/package_antares_web.sh | 6 +- 12 files changed, 72 insertions(+), 6 deletions(-) rename resources/{deploy => antares-desktop-fs}/README.md (88%) create mode 100644 resources/antares-desktop-fs/archives/.placeholder create mode 100644 resources/antares-desktop-fs/config.yaml create mode 100644 resources/antares-desktop-fs/internal_studies/.placeholder create mode 100644 resources/antares-desktop-fs/logs/.placeholder create mode 100644 resources/antares-desktop-fs/matrices/.placeholder create mode 100644 resources/antares-desktop-fs/studies/.placeholder create mode 100644 resources/antares-desktop-fs/studies/README.md create mode 100644 resources/antares-desktop-fs/tmp/.placeholder create mode 100644 resources/deploy/examples/.placeholder diff --git a/antarest/desktop/systray_app.py b/antarest/desktop/systray_app.py index b1d2a711fe..8f530d82c9 100644 --- a/antarest/desktop/systray_app.py +++ b/antarest/desktop/systray_app.py @@ -182,5 +182,8 @@ def run_systray_app(config_file: Path) -> None: wait_for_server_start() notification_popup("Antares Web Server started, you can manage the application within the system tray.") open_app() - systray_app.app.exec_() - server.kill() + try: + systray_app.app.exec_() + finally: + # Kill server also on exception, in particular on keyboard interrupt + server.kill() diff --git a/resources/deploy/README.md b/resources/antares-desktop-fs/README.md similarity index 88% rename from resources/deploy/README.md rename to resources/antares-desktop-fs/README.md index 138c2dba73..1562196e02 100644 --- a/resources/deploy/README.md +++ b/resources/antares-desktop-fs/README.md @@ -30,7 +30,8 @@ Run the following command to launch the server: ## Accessing the Web Server -Once the Antares Web server is running, you can access it using a web browser. +Once the Antares Web server is running, the default web browser will automatically open to the application home page. +You can also manually access by following these steps: 1. Open a web browser. 2. Enter the following URL in the address bar: @@ -56,4 +57,4 @@ The tool has the following subcommands: - `generate-script-diff`: Generate variant script commands from two variant script directories - `upgrade-study`: Upgrades study version -Further instructions can be found in the online help. Use the `--help' option. +Further instructions can be found in the online help. Use the `--help` option. diff --git a/resources/antares-desktop-fs/archives/.placeholder b/resources/antares-desktop-fs/archives/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/antares-desktop-fs/config.yaml b/resources/antares-desktop-fs/config.yaml new file mode 100644 index 0000000000..c151490a47 --- /dev/null +++ b/resources/antares-desktop-fs/config.yaml @@ -0,0 +1,58 @@ +# Documentation about this file can be found in this file: `docs/install/1-CONFIG.md` + +security: + disabled: true + jwt: + key: super-secret + +db: + url: "sqlite:///database.db" + +storage: + tmp_dir: ./tmp + matrixstore: ./matrices + archive_dir: ./archives + workspaces: + default: + path: ./internal_studies/ + studies: + path: ./studies/ + +launcher: + local: + binaries: + VER: ANTARES_SOLVER_PATH + +# slurm: +# local_workspace: /path/to/slurm_workspace # Path to the local SLURM workspace +# username: run-antares # SLURM username +# hostname: 10.134.248.111 # SLURM server hostname +# port: 22 # SSH port for SLURM +# private_key_file: /path/to/ssh_private_key # SSH private key file +# default_wait_time: 900 # Default wait time for SLURM jobs +# default_time_limit: 172800 # Default time limit for SLURM jobs +# enable_nb_cores_detection: False # Enable detection of available CPU cores for SLURM +# nb_cores: +# min: 1 # Minimum number of CPU cores +# default: 22 # Default number of CPU cores +# max: 24 # Maximum number of CPU cores +# default_json_db_name: launcher_db.json # Default JSON database name for SLURM +# slurm_script_path: /applis/antares/launchAntares.sh # Path to the SLURM script (on distant server) +# db_primary_key: name # Primary key for the SLURM database +# antares_versions_on_remote_server: #List of Antares versions available on the remote SLURM server +# - "840" +# - "850" + + +debug: false + +# Serve the API at /api +api_prefix: "/api" + +server: + worker_threadpool_size: 12 + services: + - watcher + +logging: + logfile: ./logs/antarest.log diff --git a/resources/antares-desktop-fs/internal_studies/.placeholder b/resources/antares-desktop-fs/internal_studies/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/antares-desktop-fs/logs/.placeholder b/resources/antares-desktop-fs/logs/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/antares-desktop-fs/matrices/.placeholder b/resources/antares-desktop-fs/matrices/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/antares-desktop-fs/studies/.placeholder b/resources/antares-desktop-fs/studies/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/antares-desktop-fs/studies/README.md b/resources/antares-desktop-fs/studies/README.md new file mode 100644 index 0000000000..3af92813f8 --- /dev/null +++ b/resources/antares-desktop-fs/studies/README.md @@ -0,0 +1,2 @@ +Examples can be found at https://github.com/AntaresSimulatorTeam/Antares_Simulator_Examples +These can be copied into the `studies` directory. diff --git a/resources/antares-desktop-fs/tmp/.placeholder b/resources/antares-desktop-fs/tmp/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resources/deploy/examples/.placeholder b/resources/deploy/examples/.placeholder new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/package_antares_web.sh b/scripts/package_antares_web.sh index 9a0efb5e15..9743fbc049 100755 --- a/scripts/package_antares_web.sh +++ b/scripts/package_antares_web.sh @@ -74,7 +74,7 @@ fi echo "INFO: Copying basic configuration files..." rm -rf "${DIST_DIR}/examples" # in case of replay -cp -r "${RESOURCES_DIR}"/deploy/* "${DIST_DIR}" +cp -r "${RESOURCES_DIR}"/antares-desktop-fs/* "${DIST_DIR}" if [[ "$OSTYPE" == "msys"* ]]; then sed -i "s/VER: ANTARES_SOLVER_PATH/$ANTARES_SOLVER_VERSION_INT: .\/AntaresWeb\/antares_solver\/antares-$ANTARES_SOLVER_VERSION-solver.exe/g" "${DIST_DIR}/config.yaml" else @@ -92,7 +92,9 @@ else fi echo "INFO: Unzipping example study..." -cd "${DIST_DIR}/examples/studies" || exit +# Basic study is located in the `deploy` directory +cp -r "${RESOURCES_DIR}/deploy/examples/studies/"* "${DIST_DIR}/studies" +cd "${DIST_DIR}/studies" || exit if [[ "$OSTYPE" == "msys"* ]]; then 7z x example_study.zip else From 58d18c09663611b7928dba91a8979c8bbff0c9db Mon Sep 17 00:00:00 2001 From: Sylvain Leclerc Date: Mon, 25 Nov 2024 09:58:59 +0100 Subject: [PATCH 07/19] feat(installer): update installer for new directory layout (#2242) Update installer version: - it's now adapted to new directory layout - it does not open the browser anymore - it raises an error when updating from a too old version Signed-off-by: Sylvain Leclerc --- installer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer b/installer index 118c9c5d85..574d3962f7 160000 --- a/installer +++ b/installer @@ -1 +1 @@ -Subproject commit 118c9c5d85a0b7b0d47e6b899c7c4ed15caaa7bd +Subproject commit 574d3962f75a47a72f369a8b49b34da70490b47e From 5a79e0c46cee6c7fdfa638170d4a805dff71ffe3 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:40:31 +0100 Subject: [PATCH 08/19] feat(ui-studies): allow to move an archived study (#2241) ANT-2391 --- .../App/Studies/StudyCard/ActionsMenu.tsx | 194 ++++++++++++ .../{StudyCard.tsx => StudyCard/index.tsx} | 283 ++++-------------- .../App/Studies/StudyCard/types.tsx | 15 + 3 files changed, 261 insertions(+), 231 deletions(-) create mode 100644 webapp/src/components/App/Studies/StudyCard/ActionsMenu.tsx rename webapp/src/components/App/Studies/{StudyCard.tsx => StudyCard/index.tsx} (57%) create mode 100644 webapp/src/components/App/Studies/StudyCard/types.tsx diff --git a/webapp/src/components/App/Studies/StudyCard/ActionsMenu.tsx b/webapp/src/components/App/Studies/StudyCard/ActionsMenu.tsx new file mode 100644 index 0000000000..af8733a86f --- /dev/null +++ b/webapp/src/components/App/Studies/StudyCard/ActionsMenu.tsx @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; +import { archiveStudy, copyStudy, unarchiveStudy } from "@/services/api/study"; +import { + ListItemIcon, + ListItemText, + Menu, + MenuItem, + type MenuProps, +} from "@mui/material"; +import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined"; +import UnarchiveOutlinedIcon from "@mui/icons-material/UnarchiveOutlined"; +import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined"; +import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; +import BoltIcon from "@mui/icons-material/Bolt"; +import FileCopyOutlinedIcon from "@mui/icons-material/FileCopyOutlined"; +import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; +import debug from "debug"; +import { useTranslation } from "react-i18next"; +import type { StudyMetadata } from "@/common/types"; +import type { DialogsType } from "./types"; +import type { SvgIconComponent } from "@mui/icons-material"; + +const logError = debug("antares:studieslist:error"); + +interface Props { + anchorEl: MenuProps["anchorEl"]; + onClose: VoidFunction; + study: StudyMetadata; + setStudyToLaunch: (id: StudyMetadata["id"]) => void; + setOpenDialog: (type: DialogsType) => void; +} + +function ActionsMenu(props: Props) { + const { anchorEl, onClose, study, setStudyToLaunch, setOpenDialog } = props; + const { t } = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + + //////////////////////////////////////////////////////////////// + // Events Handlers + //////////////////////////////////////////////////////////////// + + const handleLaunchClick = () => { + setStudyToLaunch(study.id); + onClose(); + }; + + const handleUnarchiveClick = () => { + unarchiveStudy(study.id).catch((err) => { + enqueueErrorSnackbar( + t("studies.error.unarchive", { studyname: study.name }), + err, + ); + logError("Failed to unarchive study", study, err); + }); + }; + + const handleArchiveClick = () => { + archiveStudy(study.id).catch((err) => { + enqueueErrorSnackbar( + t("studies.error.archive", { studyname: study.name }), + err, + ); + logError("Failed to archive study", study, err); + }); + + onClose(); + }; + + const handleCopyClick = () => { + copyStudy( + study.id, + `${study.name} (${t("studies.copySuffix")})`, + false, + ).catch((err) => { + enqueueErrorSnackbar(t("studies.error.copyStudy"), err); + logError("Failed to copy study", study, err); + }); + + onClose(); + }; + + const handleMoveClick = () => { + setOpenDialog("move"); + onClose(); + }; + + const handlePropertiesClick = () => { + setOpenDialog("properties"); + onClose(); + }; + + const handleExportClick = () => { + setOpenDialog("export"); + onClose(); + }; + + const handleDeleteClick = () => { + setOpenDialog("delete"); + onClose(); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + const menuItem = ( + show: boolean, + text: string, + Icon: SvgIconComponent, + onClick: VoidFunction, + color?: string, + ) => + show && ( + + + + + {text} + + ); + + return ( + + {[ + menuItem( + study.archived, + t("global.unarchive"), + UnarchiveOutlinedIcon, + handleUnarchiveClick, + ), + menuItem( + !study.archived, + t("global.launch"), + BoltIcon, + handleLaunchClick, + ), + menuItem( + !study.archived, + t("study.properties"), + EditOutlinedIcon, + handlePropertiesClick, + ), + menuItem( + !study.archived, + t("global.copy"), + FileCopyOutlinedIcon, + handleCopyClick, + ), + menuItem( + study.managed, + t("studies.moveStudy"), + DriveFileMoveIcon, + handleMoveClick, + ), + menuItem( + !study.archived, + t("global.export"), + DownloadOutlinedIcon, + handleExportClick, + ), + menuItem( + study.managed, + t("global.archive"), + ArchiveOutlinedIcon, + handleArchiveClick, + ), + menuItem( + study.managed, + t("global.delete"), + DeleteOutlinedIcon, + handleDeleteClick, + "error.light", + ), + ]} + + ); +} + +export default ActionsMenu; diff --git a/webapp/src/components/App/Studies/StudyCard.tsx b/webapp/src/components/App/Studies/StudyCard/index.tsx similarity index 57% rename from webapp/src/components/App/Studies/StudyCard.tsx rename to webapp/src/components/App/Studies/StudyCard/index.tsx index 79c0c6b991..cb74b58819 100644 --- a/webapp/src/components/App/Studies/StudyCard.tsx +++ b/webapp/src/components/App/Studies/StudyCard/index.tsx @@ -24,10 +24,6 @@ import { CardContent, Button, Typography, - Menu, - MenuItem, - ListItemIcon, - ListItemText, Tooltip, Chip, Divider, @@ -37,37 +33,31 @@ import { indigo } from "@mui/material/colors"; import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; import UpdateOutlinedIcon from "@mui/icons-material/UpdateOutlined"; import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; -import DriveFileMoveIcon from "@mui/icons-material/DriveFileMove"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; -import UnarchiveOutlinedIcon from "@mui/icons-material/UnarchiveOutlined"; -import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined"; import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined"; -import DeleteOutlinedIcon from "@mui/icons-material/DeleteOutlined"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import BoltIcon from "@mui/icons-material/Bolt"; -import FileCopyOutlinedIcon from "@mui/icons-material/FileCopyOutlined"; import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined"; import debug from "debug"; import { areEqual } from "react-window"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; -import { StudyMetadata, StudyType } from "../../../common/types"; +import { StudyMetadata, StudyType } from "../../../../common/types"; import { buildModificationDate, convertUTCToLocalTime, displayVersionName, -} from "../../../services/utils"; -import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import ExportModal from "./ExportModal"; -import StarToggle from "../../common/StarToggle"; -import MoveStudyDialog from "./MoveStudyDialog"; -import ConfirmationDialog from "../../common/dialogs/ConfirmationDialog"; -import useAppSelector from "../../../redux/hooks/useAppSelector"; -import { getStudy, isStudyFavorite } from "../../../redux/selectors"; -import useAppDispatch from "../../../redux/hooks/useAppDispatch"; -import { deleteStudy, toggleFavorite } from "../../../redux/ducks/studies"; -import * as studyApi from "../../../services/api/study"; -import PropertiesDialog from "../Singlestudy/PropertiesDialog"; +} from "../../../../services/utils"; +import useEnqueueErrorSnackbar from "../../../../hooks/useEnqueueErrorSnackbar"; +import ExportModal from "../ExportModal"; +import StarToggle from "../../../common/StarToggle"; +import MoveStudyDialog from "../MoveStudyDialog"; +import ConfirmationDialog from "../../../common/dialogs/ConfirmationDialog"; +import useAppSelector from "../../../../redux/hooks/useAppSelector"; +import { getStudy, isStudyFavorite } from "../../../../redux/selectors"; +import useAppDispatch from "../../../../redux/hooks/useAppDispatch"; +import { deleteStudy, toggleFavorite } from "../../../../redux/ducks/studies"; +import PropertiesDialog from "../../Singlestudy/PropertiesDialog"; +import ActionsMenu from "./ActionsMenu"; +import type { DialogsType } from "./types"; const logError = debug("antares:studieslist:error"); @@ -100,33 +90,30 @@ const StudyCard = memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [anchorEl, setAnchorEl] = useState(null); - const [openMenu, setOpenMenu] = useState(""); - const [openPropertiesDialog, setOpenPropertiesDialog] = useState(false); - const [openConfirmDeleteDialog, setOpenConfirmDeleteDialog] = useState(false); - const [openExportModal, setOpenExportModal] = useState(false); - const [openMoveDialog, setOpenMoveDialog] = useState(false); + const [openDialog, setOpenDialog] = useState(null); const study = useAppSelector((state) => getStudy(state, id)); const isFavorite = useAppSelector((state) => isStudyFavorite(state, id)); const dispatch = useAppDispatch(); const navigate = useNavigate(); + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const closeDialog = () => { + setOpenDialog(null); + }; + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// const handleMenuOpen = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); - setOpenMenu(event.currentTarget.id); }; const handleMenuClose = () => { setAnchorEl(null); - setOpenMenu(""); - }; - - const handleLaunchClick = () => { - setStudyToLaunch(id); - handleMenuClose(); }; const handleFavoriteToggle = () => { @@ -140,28 +127,8 @@ const StudyCard = memo((props: Props) => { enqueueErrorSnackbar(t("studies.error.deleteStudy"), err as AxiosError); logError("Failed to delete study", study, err); }); - setOpenConfirmDeleteDialog(false); - }; - const handleUnarchiveClick = () => { - studyApi.unarchiveStudy(id).catch((err) => { - enqueueErrorSnackbar( - t("studies.error.unarchive", { studyname: study?.name }), - err, - ); - logError("Failed to unarchive study", study, err); - }); - }; - - const handleArchiveClick = () => { - studyApi.archiveStudy(id).catch((err) => { - enqueueErrorSnackbar( - t("studies.error.archive", { studyname: study?.name }), - err, - ); - logError("Failed to archive study", study, err); - }); - handleMenuClose(); + setOpenDialog("delete"); }; const handleCopyId = () => { @@ -178,16 +145,6 @@ const StudyCard = memo((props: Props) => { }); }; - const handleCopyClick = () => { - studyApi - .copyStudy(id, `${study?.name} (${t("studies.copySuffix")})`, false) - .catch((err) => { - enqueueErrorSnackbar(t("studies.error.copyStudy"), err); - logError("Failed to copy study", study, err); - }); - handleMenuClose(); - }; - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -432,8 +389,6 @@ const StudyCard = memo((props: Props) => { - - {study.archived ? ( - - - - - {t("global.unarchive")} - - ) : ( -
- - - - - {t("global.launch")} - - { - setOpenPropertiesDialog(true); - handleMenuClose(); - }} - > - - - - {t("study.properties")} - - - - - - {t("global.copy")} - - {study.managed && ( - { - setOpenMoveDialog(true); - handleMenuClose(); - }} - > - - - - {t("studies.moveStudy")} - - )} - { - setOpenExportModal(true); - handleMenuClose(); - }} - > - - - - {t("global.export")} - - {study.managed && ( - - - - - {t("global.archive")} - - )} -
- )} - {study.managed && ( - { - setOpenConfirmDeleteDialog(true); - handleMenuClose(); - }} - > - - - - - {t("global.delete")} - - - )} -
- - {openPropertiesDialog && study && ( - setOpenPropertiesDialog(false)} - study={study} - /> - )} - {openConfirmDeleteDialog && ( - setOpenConfirmDeleteDialog(false)} - onConfirm={handleDelete} - alert="warning" - open - > - {t("studies.question.delete")} - - )} - {openExportModal && ( - setOpenExportModal(false)} - study={study} - /> - )} - {openMoveDialog && ( - setOpenMoveDialog(false)} study={study} + setStudyToLaunch={setStudyToLaunch} + setOpenDialog={setOpenDialog} /> - )} + + + + {t("studies.question.delete")} + + + ); }, areEqual); diff --git a/webapp/src/components/App/Studies/StudyCard/types.tsx b/webapp/src/components/App/Studies/StudyCard/types.tsx new file mode 100644 index 0000000000..8c7ff608f5 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyCard/types.tsx @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +export type DialogsType = "move" | "properties" | "export" | "delete"; From 35a4f867bc3ae53dfd981d07613c35e733ea8286 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:11:17 +0100 Subject: [PATCH 09/19] feat(ui-i18n): change translations for thermal fields (#2246) ANT-2460 --- webapp/public/locales/en/main.json | 2 +- webapp/public/locales/fr/main.json | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 58404fb446..718b30fc78 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -525,7 +525,7 @@ "study.modelization.clusters.costGeneration": "TS Cost", "study.modelization.clusters.efficiency": "Efficiency (%)", "study.modelization.clusters.timeSeriesGen": "Time-Series generation", - "study.modelization.clusters.genTs": "Generate Time-Series", + "study.modelization.clusters.genTs": "Parameter", "study.modelization.clusters.volatilityForced": "Volatility forced", "study.modelization.clusters.volatilityPlanned": "Volatility planned", "study.modelization.clusters.lawForced": "Law forced", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 783346f988..d119bd8f8a 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -525,11 +525,11 @@ "study.modelization.clusters.costGeneration": "Coût de génération", "study.modelization.clusters.efficiency": "Rendement (%)", "study.modelization.clusters.timeSeriesGen": "Génération des Séries temporelles", - "study.modelization.clusters.genTs": "Générer des Séries temporelles", - "study.modelization.clusters.volatilityForced": "Volatilité forcée", - "study.modelization.clusters.volatilityPlanned": "Volatilité prévue", - "study.modelization.clusters.lawForced": "Loi forcée", - "study.modelization.clusters.lawPlanned": "Loi planifiée", + "study.modelization.clusters.genTs": "Paramètre", + "study.modelization.clusters.volatilityForced": "Arrêts fortuits – Volatilité", + "study.modelization.clusters.volatilityPlanned": "Arrêts planifiés – Volatilité", + "study.modelization.clusters.lawForced": "Loi des arrêts fortuits", + "study.modelization.clusters.lawPlanned": "Loi des arrêts planifiés", "study.modelization.clusters.matrix.common": "Common", "study.modelization.clusters.matrix.tsGen": "TS generator", "study.modelization.clusters.matrix.timeSeries": "Séries temporelles", From 4c4209cf76bb006e9a2671ecaf65351a5ebd4647 Mon Sep 17 00:00:00 2001 From: Sylvain Leclerc Date: Tue, 26 Nov 2024 09:06:02 +0100 Subject: [PATCH 10/19] fix(desktop,windows): wait a few seconds for browser to open (#2247) Signed-off-by: Sylvain Leclerc --- antarest/desktop/systray_app.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/antarest/desktop/systray_app.py b/antarest/desktop/systray_app.py index 8f530d82c9..9d53ebb185 100644 --- a/antarest/desktop/systray_app.py +++ b/antarest/desktop/systray_app.py @@ -50,8 +50,13 @@ def start_server(config_file: Path) -> Process: return server -def open_app() -> None: - webbrowser.open("http://localhost:8080") +def open_app(wait_seconds: int = 0) -> None: + """ + Open antares-web in a new browser tab. + Optionally, waits for some seconds to ensure it does have time for opening. + """ + webbrowser.open_new_tab("http://localhost:8080") + time.sleep(wait_seconds) def monitor_server_process(server: Process, app: QApplication) -> None: @@ -173,7 +178,9 @@ def run_systray_app(config_file: Path) -> None: notification_popup( "Antares Web Server already running, you can manage the application within the system tray.", threaded=False ) - open_app() + # On windows at least, if the current process closes too fast, + # the browser does not have time to open --> waiting an arbitrary 10s + open_app(wait_seconds=10) return notification_popup("Starting Antares Web Server...") systray_app = create_systray_app() From 0054f6c884c27b4da05154f331ca364d0a19d3da Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Wed, 27 Nov 2024 10:47:55 +0100 Subject: [PATCH 11/19] fix(outputs): allow reading inside archive + output with `.` in the name (#2249) --- .../rawstudy/model/filesystem/inode.py | 15 ++++-------- .../rawstudy/model/filesystem/lazy_node.py | 2 +- antarest/study/storage/utils.py | 4 +++- tests/storage/integration/test_STA_mini.py | 4 ++++ tests/storage/test_service.py | 23 ++++++++++++++++++- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/antarest/study/storage/rawstudy/model/filesystem/inode.py b/antarest/study/storage/rawstudy/model/filesystem/inode.py index f88903c729..4b1046162a 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/inode.py +++ b/antarest/study/storage/rawstudy/model/filesystem/inode.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar -from antarest.core.exceptions import ShouldNotHappenException, WritingInsideZippedFileException +from antarest.core.exceptions import WritingInsideZippedFileException from antarest.core.utils.archives import extract_file_to_tmp_dir from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig @@ -137,9 +137,7 @@ def _assert_url_end(self, url: Optional[List[str]] = None) -> None: if len(url) > 0: raise ValueError(f"url should be fully resolved when arrives on {self.__class__.__name__}") - def _extract_file_to_tmp_dir( - self, - ) -> Tuple[Path, Any]: + def _extract_file_to_tmp_dir(self, archived_path: Path) -> Tuple[Path, Any]: """ Happens when the file is inside an archive (aka self.config.zip_file is set) Unzip the file into a temporary directory. @@ -148,13 +146,8 @@ def _extract_file_to_tmp_dir( The actual path of the extracted file the tmp_dir object which MUST be cleared after use of the file """ - if self.config.archive_path is None: - raise ShouldNotHappenException() - inside_archive_path = self.config.path.relative_to(self.config.archive_path.parent / self.config.study_id) - if self.config.archive_path: - return extract_file_to_tmp_dir(self.config.archive_path, inside_archive_path) - else: - raise ShouldNotHappenException() + inside_archive_path = self.config.path.relative_to(archived_path.with_suffix("")) + return extract_file_to_tmp_dir(archived_path, inside_archive_path) def _assert_not_in_zipped_file(self) -> None: """Prevents writing inside a zip file""" diff --git a/antarest/study/storage/rawstudy/model/filesystem/lazy_node.py b/antarest/study/storage/rawstudy/model/filesystem/lazy_node.py index 6520802d90..2662cde82b 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/lazy_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/lazy_node.py @@ -48,7 +48,7 @@ def _get_real_file_path( ) -> t.Tuple[Path, t.Any]: tmp_dir = None if self.config.archive_path: - path, tmp_dir = self._extract_file_to_tmp_dir() + path, tmp_dir = self._extract_file_to_tmp_dir(self.config.archive_path) else: path = self.config.path return path, tmp_dir diff --git a/antarest/study/storage/utils.py b/antarest/study/storage/utils.py index ac4ba9acfb..1b63e4afbe 100644 --- a/antarest/study/storage/utils.py +++ b/antarest/study/storage/utils.py @@ -132,7 +132,9 @@ def find_single_output_path(all_output_path: Path) -> Path: def is_output_archived(path_output: Path) -> bool: # Returns True it the given path is archived or if adding a suffix to the path points to an existing path suffixes = [".zip"] - return path_output.suffix in suffixes or any(path_output.with_suffix(suffix).exists() for suffix in suffixes) + if path_output.suffixes and path_output.suffixes[-1] in suffixes: + return True + return any((path_output.parent / (path_output.name + suffix)).exists() for suffix in suffixes) def extract_output_name(path_output: Path, new_suffix_name: t.Optional[str] = None) -> str: diff --git a/tests/storage/integration/test_STA_mini.py b/tests/storage/integration/test_STA_mini.py index 35aaa4092d..7b1c1ee690 100644 --- a/tests/storage/integration/test_STA_mini.py +++ b/tests/storage/integration/test_STA_mini.py @@ -444,6 +444,10 @@ def test_sta_mini_input(storage_service, url: str, expected_output: dict): f"/v1/studies/{UUID}/raw?path=output/20201014-1422eco-hello/info/general/version", 700, ), + ( + f"/v1/studies/{UUID}/raw?path=output/20201014-1430adq-2/about-the-study/areas", + b"DE\r\nES\r\nFR\r\nIT\r\n", + ), ], ) def test_sta_mini_output(storage_service, url: str, expected_output: dict): diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index d15e8f6d7a..df65098dae 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -74,7 +74,12 @@ from antarest.study.storage.rawstudy.model.filesystem.raw_file_node import RawFileNode from antarest.study.storage.rawstudy.model.filesystem.root.filestudytree import FileStudyTree from antarest.study.storage.rawstudy.raw_study_service import RawStudyService -from antarest.study.storage.utils import assert_permission, assert_permission_on_studies, study_matcher +from antarest.study.storage.utils import ( + assert_permission, + assert_permission_on_studies, + is_output_archived, + study_matcher, +) from antarest.study.storage.variantstudy.business.matrix_constants_generator import GeneratorMatrixConstants from antarest.study.storage.variantstudy.model.command_context import CommandContext from antarest.study.storage.variantstudy.model.dbmodel import VariantStudy @@ -2014,3 +2019,19 @@ def test_upgrade_study__raw_study__failed(tmp_path: Path) -> None: # No event must be emitted event_bus.push.assert_not_called() + + +@pytest.mark.unit_test +def test_is_output_archived(tmp_path) -> None: + assert not is_output_archived(path_output=Path("fake_path")) + assert is_output_archived(path_output=Path("fake_path.zip")) + + zipped_output_path = tmp_path / "output.zip" + zipped_output_path.mkdir(parents=True) + assert is_output_archived(path_output=zipped_output_path) + assert is_output_archived(path_output=tmp_path / "output") + + zipped_with_suffix = tmp_path / "output_1.4.3.zip" + zipped_with_suffix.mkdir(parents=True) + assert is_output_archived(path_output=zipped_with_suffix) + assert is_output_archived(path_output=tmp_path / "output_1.4.3") From 45e356a4fab916eae6ffcc380e36fa195ebd08ad Mon Sep 17 00:00:00 2001 From: MartinBelthle Date: Thu, 28 Nov 2024 17:09:37 +0100 Subject: [PATCH 12/19] fix(export): allow export for zipped outputs (#2253) --- antarest/study/storage/abstract_storage_service.py | 2 +- tests/storage/business/test_export.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/antarest/study/storage/abstract_storage_service.py b/antarest/study/storage/abstract_storage_service.py index 782eb5516f..ccaa477673 100644 --- a/antarest/study/storage/abstract_storage_service.py +++ b/antarest/study/storage/abstract_storage_service.py @@ -314,7 +314,7 @@ def export_output(self, metadata: T, output_id: str, target: Path) -> None: logger.info(f"Exporting output {output_id} from study {metadata.id}") path_output = Path(metadata.path) / "output" / output_id - path_output_zip = Path(metadata.path) / "output" / f"{output_id}.{ArchiveFormat.ZIP}" + path_output_zip = Path(metadata.path) / "output" / f"{output_id}{ArchiveFormat.ZIP}" if path_output_zip.exists(): shutil.copyfile(path_output_zip, target) diff --git a/tests/storage/business/test_export.py b/tests/storage/business/test_export.py index e9e37211e1..2fb4b91411 100644 --- a/tests/storage/business/test_export.py +++ b/tests/storage/business/test_export.py @@ -19,6 +19,7 @@ from py7zr import SevenZipFile from antarest.core.config import Config, StorageConfig +from antarest.core.utils.archives import ArchiveFormat, archive_dir from antarest.study.model import DEFAULT_WORKSPACE_NAME, RawStudy from antarest.study.storage.rawstudy.raw_study_service import RawStudyService @@ -162,3 +163,9 @@ def test_export_output(tmp_path: Path): zipf = ZipFile(export_path) assert "file_output.txt" in zipf.namelist() + + # asserts exporting a zipped output doesn't raise an error + output_path = root / "output" / output_id + target_path = root / "output" / f"{output_id}.zip" + archive_dir(output_path, target_path, True, ArchiveFormat.ZIP) + study_service.export_output(study, output_id, export_path) From 2711e0b4c4050bcd6ed4a905041bc7a2806fb280 Mon Sep 17 00:00:00 2001 From: Deuneuv Makoundou <114937156+makdeuneuv@users.noreply.github.com> Date: Thu, 28 Nov 2024 17:21:21 +0100 Subject: [PATCH 13/19] docs: improve of the documentary tree and make some update (#2243) --- docs/architecture/5-roadmap.md | 1 - .../assets/media/img/installer_screenshot.png | Bin 0 -> 54590 bytes .../media/img/userguide_change_language.png | Bin 0 -> 190196 bytes .../media/img/userguide_token_creation.png | Bin 14601 -> 19644 bytes .../media/img/userguide_token_listing.png | Bin 104305 -> 241032 bytes .../media/img/userguide_token_result.png | Bin 64533 -> 54071 bytes .../01-configuration-general-tab.png | Bin 0 -> 232105 bytes ...figuration-general-themmatic-trimming1.png | Bin 0 -> 128803 bytes ...figuration-general-themmatic-trimming2.png | Bin 0 -> 154779 bytes ...2-configuration-timeseries-management1.png | Bin 0 -> 145105 bytes ...2-configuration-timeseries-management2.png | Bin 0 -> 148146 bytes .../media/user-guide/study/01-map-tab.png | Bin 0 -> 47090 bytes .../media/user-guide/study/01-map.tab.png | Bin 21290 -> 0 bytes .../media/user-guide/study/02-areas-tab.png | Bin 0 -> 47779 bytes .../media/user-guide/study/02-areas.tab.png | Bin 21286 -> 0 bytes .../media/user-guide/study/03-links-tab.png | Bin 0 -> 27209 bytes .../media/user-guide/study/03-links.tab.png | Bin 21189 -> 0 bytes .../study/04-binding-constraints-tab.png | Bin 0 -> 22524 bytes .../study/04-binding-constraints.tab.png | Bin 21271 -> 0 bytes .../media/user-guide/study/05-debug-tab.png | Bin 0 -> 158490 bytes .../media/user-guide/study/05-debug.tab.png | Bin 21302 -> 0 bytes .../user-guide/study/06-table-mode-tab.png | Bin 0 -> 340686 bytes .../user-guide/study/06-table-mode.tab.png | Bin 21251 -> 0 bytes .../study/07-map-layers-districts.png | Bin 0 -> 282350 bytes .../user-guide/study/08-layers-districts.png | Bin 0 -> 93902 bytes .../user-guide/study/09-results-view.png | Bin 0 -> 366444 bytes .../study/areas/01-properties-form.png | Bin 63873 -> 46696 bytes .../study/areas/03-thermals-form.png | Bin 0 -> 190937 bytes .../study/areas/03-thermals-series.png | Bin 0 -> 738495 bytes .../study/areas/03-thermals.form.png | Bin 70510 -> 0 bytes .../study/areas/03-thermals.series.png | Bin 90324 -> 0 bytes .../study/areas/04-renewables-activation.png | Bin 0 -> 183337 bytes .../study/areas/05-hydro-allocation.png | Bin 0 -> 70436 bytes .../study/areas/05-hydro-correlation.png | Bin 0 -> 113036 bytes .../05-hydro-dailypower-energycredits.png | Bin 0 -> 230776 bytes .../study/areas/05-hydro-inflowstructure.png | Bin 0 -> 277143 bytes .../study/areas/06-wind-activation.png | Bin 0 -> 187366 bytes .../study/areas/08-st-storages-form.png | Bin 0 -> 75047 bytes .../areas/08-st-storages-list-enable.png | Bin 0 -> 107203 bytes .../study/areas/08-st-storages-list.png | Bin 0 -> 103238 bytes .../study/areas/08-st-storages-series.png | Bin 0 -> 229615 bytes .../study/areas/08-st-storages.form.png | Bin 40588 -> 0 bytes .../study/areas/08-st-storages.list.png | Bin 61314 -> 0 bytes .../study/areas/08-st-storages.series.png | Bin 143376 -> 0 bytes .../2-add-new-antares-version.md | 0 docs/developer-guide/5-roadmap.md | 2 + .../architecture/0-introduction.md | 0 .../architecture/1-database.md | 0 .../install/0-INSTALL.md | 0 .../{ => developer-guide}/install/1-CONFIG.md | 0 .../{ => developer-guide}/install/2-DEPLOY.md | 17 +-- docs/index.md | 4 +- docs/user-guide/0-introduction.md | 2 +- docs/user-guide/1-interface.md | 40 ++++++- docs/user-guide/2-study.md | 12 +-- .../{ => user-guide}/how-to/studies-create.md | 12 +-- .../{ => user-guide}/how-to/studies-import.md | 0 .../how-to/studies-upgrade.md | 0 .../all-configurations.md | 100 ++++++++++++++++++ docs/user-guide/study/01-map.md | 31 +++++- docs/user-guide/study/02-areas.md | 6 +- docs/user-guide/study/03-links.md | 6 +- .../study/04-binding-constraints.md | 6 +- docs/user-guide/study/05-debug.md | 10 +- docs/user-guide/study/06-table-mode.md | 12 ++- docs/user-guide/study/07-xpansion.md | 14 +++ docs/user-guide/study/08-results.md | 33 ++++++ docs/user-guide/study/areas/01-properties.md | 11 +- docs/user-guide/study/areas/02-load.md | 10 +- docs/user-guide/study/areas/03-thermals.md | 27 +++-- docs/user-guide/study/areas/04-renewables.md | 18 ++-- docs/user-guide/study/areas/05-hydro.md | 47 +++++--- docs/user-guide/study/areas/06-wind.md | 6 ++ docs/user-guide/study/areas/08-st-storages.md | 50 ++++++--- mkdocs.yml | 68 +++++++----- 75 files changed, 408 insertions(+), 137 deletions(-) delete mode 100644 docs/architecture/5-roadmap.md create mode 100644 docs/assets/media/img/installer_screenshot.png create mode 100644 docs/assets/media/img/userguide_change_language.png create mode 100644 docs/assets/media/user-guide/simulation-configuration/01-configuration-general-tab.png create mode 100644 docs/assets/media/user-guide/simulation-configuration/01-configuration-general-themmatic-trimming1.png create mode 100644 docs/assets/media/user-guide/simulation-configuration/01-configuration-general-themmatic-trimming2.png create mode 100644 docs/assets/media/user-guide/simulation-configuration/02-configuration-timeseries-management1.png create mode 100644 docs/assets/media/user-guide/simulation-configuration/02-configuration-timeseries-management2.png create mode 100644 docs/assets/media/user-guide/study/01-map-tab.png delete mode 100644 docs/assets/media/user-guide/study/01-map.tab.png create mode 100644 docs/assets/media/user-guide/study/02-areas-tab.png delete mode 100644 docs/assets/media/user-guide/study/02-areas.tab.png create mode 100644 docs/assets/media/user-guide/study/03-links-tab.png delete mode 100644 docs/assets/media/user-guide/study/03-links.tab.png create mode 100644 docs/assets/media/user-guide/study/04-binding-constraints-tab.png delete mode 100644 docs/assets/media/user-guide/study/04-binding-constraints.tab.png create mode 100644 docs/assets/media/user-guide/study/05-debug-tab.png delete mode 100644 docs/assets/media/user-guide/study/05-debug.tab.png create mode 100644 docs/assets/media/user-guide/study/06-table-mode-tab.png delete mode 100644 docs/assets/media/user-guide/study/06-table-mode.tab.png create mode 100644 docs/assets/media/user-guide/study/07-map-layers-districts.png create mode 100644 docs/assets/media/user-guide/study/08-layers-districts.png create mode 100644 docs/assets/media/user-guide/study/09-results-view.png create mode 100644 docs/assets/media/user-guide/study/areas/03-thermals-form.png create mode 100644 docs/assets/media/user-guide/study/areas/03-thermals-series.png delete mode 100644 docs/assets/media/user-guide/study/areas/03-thermals.form.png delete mode 100644 docs/assets/media/user-guide/study/areas/03-thermals.series.png create mode 100644 docs/assets/media/user-guide/study/areas/04-renewables-activation.png create mode 100644 docs/assets/media/user-guide/study/areas/05-hydro-allocation.png create mode 100644 docs/assets/media/user-guide/study/areas/05-hydro-correlation.png create mode 100644 docs/assets/media/user-guide/study/areas/05-hydro-dailypower-energycredits.png create mode 100644 docs/assets/media/user-guide/study/areas/05-hydro-inflowstructure.png create mode 100644 docs/assets/media/user-guide/study/areas/06-wind-activation.png create mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages-form.png create mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages-list-enable.png create mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages-list.png create mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages-series.png delete mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages.form.png delete mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages.list.png delete mode 100644 docs/assets/media/user-guide/study/areas/08-st-storages.series.png rename docs/{architecture => developer-guide}/2-add-new-antares-version.md (100%) create mode 100644 docs/developer-guide/5-roadmap.md rename docs/{ => developer-guide}/architecture/0-introduction.md (100%) rename docs/{ => developer-guide}/architecture/1-database.md (100%) rename docs/{ => developer-guide}/install/0-INSTALL.md (100%) rename docs/{ => developer-guide}/install/1-CONFIG.md (100%) rename docs/{ => developer-guide}/install/2-DEPLOY.md (85%) rename docs/{ => user-guide}/how-to/studies-create.md (92%) rename docs/{ => user-guide}/how-to/studies-import.md (100%) rename docs/{ => user-guide}/how-to/studies-upgrade.md (100%) create mode 100644 docs/user-guide/simulation-configuration/all-configurations.md create mode 100644 docs/user-guide/study/07-xpansion.md create mode 100644 docs/user-guide/study/08-results.md diff --git a/docs/architecture/5-roadmap.md b/docs/architecture/5-roadmap.md deleted file mode 100644 index 28d9477bae..0000000000 --- a/docs/architecture/5-roadmap.md +++ /dev/null @@ -1 +0,0 @@ -#Roadmap \ No newline at end of file diff --git a/docs/assets/media/img/installer_screenshot.png b/docs/assets/media/img/installer_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb6c138c6d528b26b302a118de3feea0e8cb0cd GIT binary patch literal 54590 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 z zr@}KZQb;>v$&3Wlecl;8=@=*udEhgCzJ2?U9A&Cl6Ou5Nr5`*SJuxIG>=~YzuQPhj zX)*l`uVt8YJls5hoS744K z8i(H37JD|g`=;u-q-wR$WqTO{k!6leEW>BuaOkHXX)5vN*x<0kvEI-4+`>-MWXb1< z&@WslFr!u%d*PY|Ps!|eX`E1`aTlC^fd&!&B5SAGQqkT$sz5iz`DrBdwuQsh{Fs{n^|NZ>RLQo%r*+tGeewX0q4vKDdmnD(NbgGGE`42mNtcymIr_*-YFQzD zfki5fPUB3Kn7gRpLTLm+4q$#L^v-a-)Tk>rt(Qfvn|cEBc7&V(;@YY`a6KSfR@cd+FtY%l$H_6butH ztD55p1owoGka6glyY0!v1oX}ymw4|lnI*P|Bd z?fcA`gx8(e=bxgklRBF$uZg5sY-69K4Zlf5AbHsmPn0pCQ&5nof_iV`Hv(sF++XT_ z4dIJM8Lmhp*YWI9iq^hE-!&WTZ}T0GMZY$X zNo?|O2!Xr1H1qTQL63aXTti9mI(MqN6U*ABSQ(al7UVTI8QYV~EgBgF(p91z7le?l zmmtzknTbk24wG4#P=4PR>aw;$LMGz>RLpmgZlK`-N0-FUeIn@Ktj%)`qOLFbppgn+ zk!KL#S*yclz9)aOUk^KUzw8!Fd~Fzcgk8aG7a?`sYJ%?;z+m6@{ulq)gw#;BiCTtR zd6P|>b?7WlzdsikI17|4Itx5y*TbEMAGs8FOdy5xwC#9VX>NW3ZJUyYbe@oId|Gk)&{%Oy`1j3fvERxt~(eN9=qwilon`(ax9{O3cZ zFyS=lWIfRS%N)fWo66p;({bDYK`BahOG7UCl$aEN|IYNXds;nm&%&U zd}owZh;UTjE3E5&i?fht{A+YB(4y{0(0;kw@ug@vID<1UA6B^gN^RxthZsFP^UOBi ziJRLjXmUi5=ht>7CfW6k=dW6_rrMI^#D3O;;&;rewf-*%cN=|i-t!_awptp6MW8L< zeTi_iH8wzvDqWF`8fNtgX;cA};DskYkUhk|Y7U6ZO$My9(n! zQYw2zg6p9tRr^sc)W8-5hI*2ot@8e+yZgE)g4?z21BEYN@eJ+k9aI2yTQ9LBPX_M7 zBQK08N$Vlo@prZV7EP;4 z>KEe#!>4c5xm!RZPCSIx&ngU6hvL#k%4eMS_N_DZHTqh27RaUUFmR-*+3LT>VeUK> zvg&9TsDYMRjmMlIS}2+Ah{^KGTwo=SwwAtFA!@9_f-$;LdsT|C5AItl%}TO_HsSlz zb&SnMMfEh#050c!uLRB81O*}RCJ%wao7bocjeJz31?QzAZUn2vPnvo6?}m+?@$$~g znFFWGso$ImEMi_MtzGRU1XPgit@ozFdx5$}z91M%-l6g7J9R91_@F1~iv z-m-+y9o4`NjKsY%_>$0Z0?@hjK<}o`^5+n@0;ITcN}4-Dw1VfVn8D4b{wOe-+5-)1 zMLPk}E?u}(Kh)Y-_J7;^#DHoW4+yVfSsa*;`sjbacujqNM&=Fdo zLN6?LF%^V)crciJbK-fohdw*a#ar!28AySD`)LK9*xJATs^ys%=#IW-Pb5 zVKSnh>Z4+RHa(%7vG~yoSJ^bPCdG=y&3SNv;_9RZ&c>mcJp;a}I<{urCMlg~xBRX% zNFt8iI)c*He4K)ZB7≷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 Q;g+kHJbr4vc=P`M0dXV@(f|Me literal 0 HcmV?d00001 diff --git a/docs/assets/media/img/userguide_change_language.png b/docs/assets/media/img/userguide_change_language.png new file mode 100644 index 0000000000000000000000000000000000000000..482f8fa688b9746c6f164809214f67ceae05a514 GIT binary patch literal 190196 zcmb5Wc{tQ>`#;>GNQ*3`GAQd%QG_O2wk+8xDN7M5LdHIpB3o*Nk$vAO6N>C9g~^zZ z>|~jltRrKL8O%J_=yTuq_x?V=-|-yJA00IvQ+Qw3d7iKBoETGM-GiJbICt#WaZq1R z+kD54-C^L@YmU9(Z-mhtlHj+UaC2SF9TnY>S@43*MZ-vA$BxRl{hN38fY?=sFpkq^NRNTJqd-xuYHFErXLPdjS)E?cH)%pVcv5kx;%~>nUG4&&^$F(IM9q3I^g0IrKxv$Rd^eQ+#B+_RQ)C2eJs;@{%FVY|A4oZLU z20xN=R!}R;1i8AiO8?{4i=`^6zqArWgEv*A`l{<5v5S=Xi534)#y{FAdrFrsH-oP6 zZAUJMZ@<<9 zuibtuv-jJbpK%(6{`%Fl+fK7hvKWmMrzQm5{ixat!&Z03t3{2<{}P|k6Bdu-_|VN> zv?-LPo@eI~__#x*Q_4pnv^QDcdb`hHl$b7k2W@)i#ZtG!%R5Ly#N8K&db*lw#~EfL zvmfl@{Ed*_3S3<4FK6_uR-$&wPMYKI7%_JfLrc(R0WQ zbM(shN3;!X*#+*su_u#0j<`2qZG!t12(-|K;A`}f8E;O-Kj3*My*SFJv>nxO z+R{iJPld{V(RN0DYIW}vlCZr+fXoVNI%$!4j4utM%9r%OACJ}D?(dFQYf>@2lyvlx zZz(jQv*HwD=Uu;xp*V) z80NW|-j9v1ctMwo!Wf|j-L4i-zoMR!#P5EJi6pR|)hVh6OmXv>3$K$FWp8Dx)fl(b zsbVx2kF;#zj9zVSSUwFr2OJ0gxmoaT(y{j(Ui&t?ikago)1|U+E!33v-OY2Oh!bTS zr)8R7<`{Ad@V4^Z;{D7g$%lgVU+st=m%27-*kCLjb)Wdrtmtm@2z zVm^swBIkW@%88=EPjfO#StngrAf56MCx$`IAuVebVEtwV(RP|@*5=2(4Fg`j$hlm) zeYe=0UMoooNcU8t*ZxSs%^A0jeiDVxXRSCFC;~mp}t57+ARa(3$8Y_oE6abvEv;JRvNV@I_-+bhPriw<`{-)k`JHuVq$_hxUREp;_vEhR1nf(e9)xTd z<6F%GpW65rNXEvm?sm?qR<_bQpk8l{ky0q=Jp&+#b?(qtE^$T)Oz#4^C^#3084Wh&6JHiRziuFS6 zvXVV#-aR`REC&rf@mof@sZ(X@RC*G%=YacLY+TGf3!-aQ6BaVn*W+iigjZ$#ngR=76lh7c-MQG_^Q z>K*Y=!!l)&GF(iCv9Z&4dl#GpC$FHZ`{(hf43r-SCS^`M!@KO$aWRagu=(-ob{X5? zJKD6X;Kl}CfErq}-gR1INLm6ksi#Hm2a7eW^|D6wx#S?ya4mmDGlHe;DRWKa64ZYC zte;p68_zTOUBi&E(4SODObNB0t6r)E>t$(QK@kz{j z+n^ESsoRoSkS84-J2%$DrBES!^;o_a5JJ_t;QYmU_Gw_ z7dEG>6wt9OkBK7X`qO>PvkoOS$bi;{Xo_j;=n*Wu?|miA>zW~Q)rkWXATo@IW(Os> ztB}1@pSGM^HRy>_!p25-#X&ug!DHq}HRvyo1%CJyf)KnbwDq>7!XWm9Tf#NrXv?F& z2h9T|ME4o}Dk%QYfnb{*PIZ(wbUoYtb^QDc4K%WomWM8|hM8s;l5DOi4pB6+eG!1qh|2y|8C%adc zW=f2mo-}f?itR)j!OADj<~FNs1Ru)v0yPWkjUPoY3Zh~T*3JpWRDJh#`xwwPd1Q7t zvSDzl(k-{j%mC>lBLgwJyZ4lCVAOY%5t+xUAP#kfyI^CtM-$~bx$BL+8)JnL8evZP z9T+xH&wlQUK6``|0crtjoQH)8!FsFBk60c`f`zKiIBDN87t~HQ2c62q*gpH}y|SMt zITPoE0(c}<(;>5P4ouqI_T0O+^C7F%n(3xKQ07G4VvRnLPW`$Q(y>j^pS1rq`!tTv zWAzI`m*qO_Bz;Bt*4e+~FK<)7csfvZN&(i}|4*Au%5ej&v(Vt$x%nK!Ybc>pCWHb> zN&nuFT(9k3Taog20EVq7NR*BaIr6WuiceRa9zWzDPnC=d-;?g&ubhCn5)0l3nN{@~ zfU`Mv$B&<~RNOu*u`r`L(B{F5JA$))`%O4qi+eTOk=DrNz|Ymunz?k)rvwYCCg1e` zb6N+nrF`I8-)?pAGH-rtx^MQIp8rbn$~m)RU&SzclWLC54wuNnOGdS0ur=P7+yjKY z%+)S~`N;a`+@BIZr8<5_gSMe)>2X*fs9!Li$BRxsnY&_cfb_Pk6eB3xigXV zcI16NjX<|HuF!R@>Mzr^ZzMva4Nz~IXAD<_K3bXqKf|1;Y+fwzaw*KKFmPWo z?7|n_E@GPc8jl-;f%i?IuDSI+ z*r)k}TyJ&#S6;0&WQms0cI2*nPu_n}5o;}UGAT+742@p?mp`>-f4xDl6(%1xvlUld z-PikmtMR0z&;@{n_M|&bl&ah?hOQcjlw4yfiv2@}7 z(xaT1+xOqzw^B_6_ub>L+1R7Uqu__&L5nn5l(?CSg`78OJi1L*1tG?`Rw-4xzDG7yE-VpwZx6G(-ga6Y%eShwP*uZXD~dx2=oxr z73tWA+tBVqbo88PeBZ#sk~0s4#5fbZFI5bvl0fI@4)ZDs+{d3*cDqJ35o&Q?q`Nd| z$gy_ZQ)~b{QH_4p-ntTa!AQ=zZD=PSR>ivB(v~MxbyMfC^zq9Qx`CH0m+Jjp@)jr( zJfgaOsG_fzHUKQuHLZBo8Q=U|Beg8WD^6t}N=Qr+|2Ycd@Y!tqf~<&~dR`-J&F9-~ zZbM_XO8x7>C(W&M9Iwu%J{P<2Br$8s7RL>N{(npjaQ%Om;q-|0>(3#x|G&nDT3(y; zjfDNNtKAunf5(ltIG(!0z~OTyK2_WLp0eVv#_{E6-VZ<9y8XFPV>$#CZ@71R1?=$K@0fq&buv6R9YNtlPX@H%erJVOhe9U3bFJUZ4%yLEp`*v#|)i=iUvxa{< z)X>`|iZ#r+4x=i^Gb~vRYKK{oLqS!)Z=0X&sq8bQ2QC#gO6b;~;FIVF)ltubI?KZL zYMqvQst*CAE)Ma`)5XiQvx{PE&$CEQq1_(5tD$)v0CWr7-Lpa(Zd2DtdeQ=CMatMO zUZ$(*ng%MGIX{c(f<%OmC8%bv4u60b_;Dm1WT7XunA;YhC9q!H(50_}wmn?>?t7)< zD^c*k&g7Yvs6k*4*-PF0UEe2zM|E5iUpzU8+L(W@J1Bi!3e@9COHH~QPu6CkiXmU* z`Ist=FQ>26E6&N-Jboo@=?>22)+NE(MlP5X01bZ!5yxcb?Lo602GLcyfvCad4N$$a z^Wjf1SEXRR4@-W{_=7=`75_Z%r-*J~`)Y&kTXtAtBm21{C@I!0I&)Gtu-Gy}e(_T2 zfuhXax~aX5(f{H&N4fVrL&o&7Pea11u7hG=(VP2V@EZ8&O5a+u>1ui!R8=5X0)wh{ zTrthD+=l33azE1(4;{IJ6*MaftVXVSmvrIR?`aR6mY7m5Ud{Sg1m=`C8{U8ds$N#> z-m?KuKDE%MHI8}x+cL1;jVR0=3QZQuM~1}CX)UEOx$Y**Gxy$PFKK$Bmp)$3C1RO|L&PpikYTG?OwAS zOKL~rrP(1cfVE7=EoCQdERJ;75aTh9pUsMfk)LLMrVE{vGx4I=A58je?)5tVGR^fp z3l1F8IBwAdzJ8+KwTmN5(Q<`=Mm!&K6qtoxPi;Pzw^^t+a7Fb5U(p$omN5q4B?>`~ z9SkCsx!tZNOvp4)HH?#aAuEMOUO zqOC|pN5-(Xy6&UtENh6)8u;_1kp3_8;5fEA276I34#TbGlrPJFj8!Xos23N&h=KJ+ zWi*dVSzb5jjCY^rH2uaJhgVMMU|BQ9rD#GmtoOgDhV30+_u}A#ac>pQqh<<%c50-~ zW6i+BtthBE=7W;DBqNeX&*%X9i=|M$_MHUdc1k@l6NojMrrm(N>C$b?k95_z@HiPz z_Hq*~o1KvkI;#ouiKj5RVwtg*bn6d-(*#&RRTO%wj8*Olxw~dz7)vm(p6&*HBobqX z^pn8Yd5E0%^47ic;C1zOaX6JJCBvW=M=X}&C7<+$gKuoz)Y|PLCY_+8Z7CeF%{Jt2 zuqEL$#CE|Gg1S30}*i8dpT1ptilvMBtc=vxU zaD@k50ie;;8VY8s+*8aGf;gm_;{`++);D_oTl`X6r1H0h#QaN1dsfs~b32+ReUfi} zS6>p~iV@~OzmsZVH{}snIs+sRjgiWoM4vCSH$yY~yh#34xmCFVigh16wc}Y8up1EI zLQnuwlOj+F|2jK(ug0Bc;7J^srg8w7+nTd*1jrg%C0*CrtEb8W)blQ%H!A{Wybe5&L;r`_z+KxdB+fc77HNn|bK#AMLC(Kr)xpl~rUaP4 zh!Ci)ePDtQ(3k!NTnB(u7XGP#&XUZw(J8Dq`4np^ox3>X=v7dw^>;R8&Yc%2Q}(Kd zVXPc8!NB=tx%T_wC)k*1v{B=npuSVS4k#1$4$x7b!vF;P3kGKsITZ0_%H}L&=u==K z`zA&t=_pq4ny!D-q_uXgY4Am_VKwCnEkM>Gv)>L&s~>DH8ns8Oe!5<15HA|MGnXeX zV4vt@r#ug%zoSiqF71@m1FIsO9FLAQJ9=gP_M*_ zQ0uOriLHlWy^@Df4FDnf8C<*ASCNRhJq+_iNddlFqQ(Q0!f__)V#BqcBox5Iy^tE# ztVzwPhIj0&HWMBVm$pAkyxla(v2j=5$rKJQ-iv=Qk}?~<{NM5y#aVE6)-?6opPuL{ zE5kNF<5|@wMyxqvU|Pe1M+b7H>^_^VZ&HRu-HbRaz&9)`9i@X@A)p8BH1DdcGzH5S zPY(qSRv$YP_65{K-Zh+P(&vN1jimvfrn8)VhbIpbKE!3ZHLugqDYrs`PKQ)b2%!vO zsM~Ls0)y_d@VP0`r2U4jdHl;KOWgoRXORsS+M>e%#*pSp+phOk!k{1P&*vJB z>1SUqQSpD5Sc8HWh*s;{S~+N`NtB-X^}&lpHh``R3NV_WPzf`J2~tHQ@jzhFvOg-O z+S(U<#qA?NAgCxsKv7s)Ogu}A0cTy=trM9Dy_5-H$K`vRRtCt`Cp>4uYHpnJN@$-! z>JD4@)4g5*!8hVC>6T811iS2F93n*{302U6fS6yuMSH(1@OHJZ3e5+X)atZ<#qA zjVIgk4A9Qzuug|CmO8V04j9u@AmFA9ef!g`T?f!X;A6;`S@SNth$b$Wr!*oBkbXgzPRYNkph^An8T3&25$*aMDb%7f5sjJz+08OGlFu2iVmr`ifH= zROc*cW?gqjMb}e??!JrxR9ZqQWF`@-tG0TcyeQD(dKD79<*+4-os_h{%kUle-1S6w z3q>2k4UA9{WCPKpU#hcBvxx6h0TvHpKE!d4;`=@1gq2s;?;TQKC3O`EY#C8bpz6CJ zKnPthkd6gvOcgL!bpAoy0nd3N^#6@Np)T7)O{SF7c|<e zlPNm3H$tBRr0nriQ2Q_7?C+MG3j}i84Hwx-W+airK=u@i7X1b1m%@M!_YIOZr4Suf z9Qc$Xb}kt*%OXWT{%Ql9X5*G76Y|ipe~rKDMy#a!rZ@cOapL5LLhN`%Hmxk+xBR$! z$h;X^F|-~pMCdJNbY@C1^FsFr$4R<3`dm}d_D^`V!Y4_AXv}&a?9h>zQA2-NmG5xb zJrne^(I+l=%=HTA_I#5S1MDA23%jxT08lQ|qxX%Gt>zR;4S^;jX6W|)8dqWk_Rn!x zue_#H?%xj14(fhCa`pVP?;k4@<*|a*kmJZf&+_riRXZcJLndZdfH1(4K>$m>{STHr z3b5n~VuyHTYxfFL+gi_P_4nT~dV~Bg37lWYneWiCwEFvYpW=CC!WiK{+48QMe5BMmXEO;a3L>NwK~$Gth< z{&3O2{!*0CX8fEFG!Cd{%1<#iJ}Rdx(x{@Ge7peAu#5-;Nl$Cux?C-bcaiz$X!9D> z8zxg;K>LJ*trP_UP6^k)rl(clbz|nQB=O|gwjxnrb1+E@U^F1wrH#C088(7pa)3lj zCb3BLWmKsbAkhGZCciX!Wyye82;Bv0c^PX?KhJRkFd+DN*h)d*z5sK{rq9}O7#k$l z6_hfNh}UPl;VASrdj_He&2>SD=o|NITDT#}8nJZAs;3M+v58}*cmz|~39-yoG-2>G zT0vkbxN69=ohxm3+$LX0mVJHsN=rzLX23Kxj4g?e zu;Vx#7S$j$=Vk0&e723MV;6V2{dph+M+#C?u?SUI}oA)2e z2QU{~u2lLQ@|4HnPD##)xzF)^YeSC&r3C=^v5zzzx_?n|ni06j?R6)C)$(^~;(`2e zGqBzkz>u(Aa*uyMn?eKTH9|1~@Zrb3l z4djKNL&`uW*U)s*VWHd+)5)G7mbfjJUm`vvf1Ju8tzCI~(oxYr&zc-l?RI_|ZND{V z44mS*WEdlsEF62G~FR3nX zzuvk?z>r2CjBoi7Q(PLC!ky!&S$CV)u%x>37hB`2^51dtdO%)ere}$Mm^Vh$S0#6g z^V``q9V(OoT5ouPbk4&8eW&GCYCvQF4Ph*w&DgBPo~?bOG*9woI!&ULD}6`a5F%aG zJ-W9#b`tREeXgVPzFe;3#Yxn12> z%6b!}8rv#zLj|zaTNK^YsBtE`i+$Yh1C53Yo%DUxpB+|lk)Q$}rdbVf#nc^aSXK&J zy8@?9t9vxlveo^pu+5A`f^d59A!TfaKcnBdUwx}#XtF9)0aw2qNPb7IkWbI1;Rxql zD-;AE8&rZ{_VQjt+$70k^CtZFKK_)6%_8Zz)h6}j@y}Q-5_PaFT8t;{yFPPb71FwU zymFVgtlGX++6_KeX7JvFNm0!QoNvKD>JOx)FTG{O5Y9n(6Qgee4@c&W`_ zIJ8_Ar9bcrpy4YeDqs+@#10KIk2AmMEH6hAJ8rQ6dkg0!`xgpT0J;1|@yg{xn0@vy zx3REj+CUrOBA#XKWEmM_bb;Lg@T=%-jNP-rm()`jt+c@$-Hu9~f{&8i(!xz0I+46U zt}JNO`UeRmC~<3-Cc2IBLdWnlTu@ydqu!(5R(!3`k1-5+w4y`!7#GyLNfo>X+a%s1 zol6gy%kENi3B_&_Wz+IgnT#*8*q>-x4lOhe@q(8BhAJ1jo(0D;Z7Fdb3N;tq8wusk zQI->#fyrtSS~;p@e@BL4Oe^kD zY<6!L�QOSwoo3awc$EKh1CtNvmsRhDy{1H)nB_4Uyo4qkg4Zd0xe~x-mvR|u#A{f>{C-hP7Heevhz;9S_?InJZS!})R15jfU7Q7EsaVTNwq2PQICk;J zU;#+EBJjSCrJFsK?PetI(ciL4N|KO<;Z1^4KYZtXO}|N(W`~+UJOZ2C?3-_!kH&3o zJS53KsNbELHF37P<0vSOolB=pW9dOIl%AsG6Cme~?qOjBPyC3mX@y8x$8qzig)kmkI}d1(B_vuF7Y;owMAKe?+UCeT4fp zh~q|$ryVxnAN}%ENH*{No&0AjUiS{A2`PiQT9#H z1vU~;O0uDc!B})#RwV*UsWj-H68e{3*s;eT5LC*3j-~UqK*=y4_;JM@aOq0Wd2SeoaO7rx<>m3d%gKzw4u1YR3w z>xA6I7!h1+$L}B+MBx_0?fP#t^-L4Ft6&BRJBseh7jRqc=#dbvHxCV{o z`-^Esn89^Xr4B1ikKe-!l7Yp-5_~d&#bW6<^BW0dhtIdhGa<7aEFs#4@SP8>!KczM z`dacy9Z*ZuERW_RSCTGa>1Ij(fX@6L5bG6m^2F1|aI@&yyqZfu4@4lZLw zL1HE;4qAEq`j}|uybcI`J;i9+U*V}IngpsnF7V}MzMmYEcwRYmB80Bob;^ES3BF|#;v6E*XtW3ko{dshpsLbh z33dEIpMwM<&J$xpiq<5f^Db{kSE>+fu-^84Mq6dQiZ9$KZ7%5#Ft31*K6W}%MGwZ^ zFnzPy3(i;e)o0-(=6X6>i5Sw}l+|Kdaxd*3&O(EXcHw%N=%qRtlatn={XMei-a*|v zD+A#`Mtrm_BLdY7=)rKK|08NwIc<3~mgThn7ib4EP<)x^UKYFC){g%H?aGM%k#L#A z{^PPANtE>I4L=YCFnin;ARe&{uJe<_0*Ao6XYk3-3l!mR_OB{ z0nAeOLOPxBLMea8lAHR>m%S0djW=WPnFNi+B&@H3S<6^0kWI6wV8%CDont+?hgq9J z?^TkV%W&L5gg(31uPUwYnI}&Nca&}xOsl!a9^HC=1Oaj?`%E@!HcVRRH<^Xk`s;dw zeh(8YUvd`u+LCTt2uHl7 z@@hZ|l&y?Q8+B0n{#mLXF>lEs_G+8tacTK9_L%<)${>$r9f%YZ2w#~}oo@auw^e!HLzttF zo`Hwfk*)qUqnU0|@!06J^7rECUYve}&v!RI&-T7_o#nOiL#`RcRVlvDPTyXgl=CyK zP$~u#_Go6@A8SN~+tmw+@pa?!@LJU3>-H8hVj#^&c(~n7qwr1ECf-w^ zdP~D4wH)2jKf1)EFGOtyjo1Xwao-LaZ@!&SHht?iL0^L&=xG)a+*bpb;jyMvtjWDpD&=OM&_vg3KCA ze9#iI39F84bbh_?OSMIoqJxuGaf}F%NMa9s7#^WxYJ5=CzSiF79z_+J+V}Z7x3={N z`L`?fY_7>oN=r!48<=m@5MmGMxS0Ev8QiIxkbRNbF5kF;=F@>^~h#op~G#CN_5S$5-b?ZbHEgzhh!B=wCrcXp?)S^mxNx(!0Z zkevUbQnO=-ce^asJ-fMev&9j89;iX~*jna1k&xo*p-8>BbfTKsKVig3C0G>{pcENIOlk#$4bZM6;lNd3Ark4abPhaAb@kOCi!xzN0 zYcBqV6JB*4`W^a)+3iHwLH$J^QVr)#;rQvs-;XD7?JI{WV1IceOSw4<7y34*P z@N9gwm@p^rB@xyTAuP_b^=U)+-HWq`wO|L9sK<)4{#Er}-2p+4e=`8_nH#!Ks?;Rd z4{XO8fsdXF1lm%d|4%eY*D!8&NNS^wZ>ZEY27 ze^>QSI~RAx{r9If4q<*-bZ^zyDgB()x~*xy`n9&Hy@9RGW4^r3SGZ1#H>iQ;4|m3X9q&MU(-4`tBC?@8rM^j z%GF&eHbORBZkgcALe$-Q|9A{C-mjjxOZ}GiWv#{)Tj?82n%g`i`-vi}s9cF|tg&7) zy|DORaU*3cE6&StKTlQ5b+$2>>AB& zk5#W*tscrH%rQOr1MO===TE?wxJl(9Yt&5VpSC{vb*gd^1;9amvYLfN*awv>f3si5 z;mfn-i9#D@8_gb?hb~T?f#a<_9wy(am-UwCw^3);VSyKK}fgSY+}}d$~jl{bD`dwL@XhjE=dly zI*cFSVr(Q6N{O~EOYmO)F2R;F*?W7zAhWrl98u)|>m;CZKuimikK2sJc9{voejY(L zey`r46fl$6Pqhe_TW_TbRy12dlWn}~hL-BkW%lasWuJzZ7pM9PDe84YLHK9YU6`$L zx0;Cz`-w9j-{)ssYyTv;xVj{{^kQs)P73XYT%qn5CilNr%ciuhtu7bI+fCG0Q1Rxc4lKqm|l2^ch?#N@)t|bz}#T4^z^Q7U{RJ zNy3*~j|^D%X2(f8r8N)pwmPQ8@mPoE!~Cq!9IPYVBM9#{YUQs9*7i<1CtCWM49Q6Rh9#{ZyvEbb?hJS(r!D~QlBs8 z1VV*;AYl2r@^ARWLK*9tw_JF`gi-I+Xle~2HbA>vFcX=MC+#_)EQtI0xqIOvcU#fL zN#@$v17{7LWePih^uL&6>pleBL`VWH5iq#!t+miW;%>Tq3j@ncv9et=+JLk!%Fy`{ z!nuuvKBcYXp|GY*&++DQE-+nN4jalO~JjGB{> z(0Ne!U5quNTW3jiiZ*lKCH<^vTKRwr%MN=mGk zcJfbC2!pcbFK_l~*=MY$nyg?i#zfW96AiN#N=Fw=$y^w=N}sr2&DhY1#n4db&&oTs zM3(~Bp=|Hh>rB^XU-MwKt}s*B_CQKJp!m7jf;F5RkLFftIko-|b~5-Mc4DgBamavc zTMPp`5@~}6{*x|r0^-<}-mH$?9};clU%WMWB6%csb6lZo5zTQu}S0FBaT zKYHITq*Dp2YI-!=uZKdASDLs!!LC>6i3c(xEfJ`YYK#}c!|w+N(4kwdWQxbwUA2vf zc`R4RW;SK_>?~{838Ew?N)2iA3t<)^$dmXzKwGm0>>KLXZ)yDPIQ0ITfxHF$@s26%}r@rlfV5ruH>CRFsbf3BwnzDb=c-lB4)+P|-^BrkvRk)SRkoCTllU zKHAV6sUjOb<#k>ruPrq4$;gZ%57+AjNfmC(M0xyl36&%7at|!6*;-m> zS(!g&aen^tLoaqr=)}R|KLOoWoK5}|r}wCzqKIBS(|nUxj%|`_-4$hN--ifX9~N_GnsG>KvRcxOqw(eQo~Y1p=J+zKZ{34Hm60ED9f%Og zx^_dAd^p`3G5+W~k)wY8Vrrk3HJeABzX#<~>LSk7_^Jo9Yokp;Io)RhF1g76Y&Ct8 zY19ffupYv zOY%vd0#XAA1G6I39NQ677hT$0cPS8$81YPgg=kMa_%Hp7w~*^ANDBe4aL+Yz}6466L67z6t&)0{me3*3bzN28?D2`s%78h$_EhH8M7^L1Yz?B8_Vr-DaE9Qc!S)5*APj2Vrm@6* z0607Tz9Y1=6&bSui%3KXI(DxUvb0IkWsH{mz5)%qR~9zBrlNL_oQYKvhJ6d7lQD3{ z4>X1F>gpl+TRC@o5T-+AbvM--8SmzH-&(sI6zTOCDy`1Q7)l&Z=CsAGs)j-4gC)aO zPN3SIxeL_yzxi2Ch8ix_57&7fDcU3`?V-hr%7W0D*cGrDWB9KvD8qkmLG83u{S?TPauvA> zLgT=Bgg3=J*F`DyE34@qhTIofhmz-#TbgAaC~f_^PId_;U%Az>RMR=%#a;Z`HRu?a%l8%0o1pfOkggv|xU9*0X*M6w0R6(k z=t0PI%F5TVHBG5$eL)w+Iz<0BUvh=+BhR~B)tssVZv)j2wS*pF4lis65+Sp&fX!~_ z+9RtmD9Zz5p~sWZw`5>Bl;NLI8LL}UBHAQ-c}J1ol5mVmANVOP;1otRycNMFRXq0F z&LX%c+gY*u8;O~@zbmoC?N>%-M9#0BDLmE94M zi85+5hwpv7)TS8*($1AnFt9Z zksaav$<22{7_krLb=CWLg{bf5Tn_Dx!1$p~zpw~G=5)>5(XLo^uQmoy{odI}RTbB3 zE~>(anZc6_bh}bc=_pKQOV<3OyvvpOhS(rca+j)AYAkwya~w;U5_}|R&vnR-4O@#n zA?+tq3KX|-Dn~tBAxXsl^80S$2(L=}4{g)U`)=>vJ>b@`5nr@q;A7Oya`KxhAoLm1_hetT#;m@oVU~cTBcc+#rJJKi_UaruFgC)2~=i< z(f{tA(@tIWxRCzM>~>4uCMNa60FaaG zyEj*?XxW2*$o8APkmX7^c8uw3Up?tdI^Ui_u_h0N0ykhvDdgVukgX`f#g`ds_gA|o zHorENZRx1@UEvB)3Aw_zM_Q`uko6#(P29{*Q)OXG_}bMI#NVQE=n7qD?+9qC%fl@m zsv1Mg^9nsD%|!(tu@)HU!K75PYF8Rx7HJD)5QwuKo#W=#n4f$LMk=HC56~ z>eGDP|Y(g`E#cH}e&ne!i(76wzs>QmxJ;LvNH6EjmBMji}# z%1XY22#+h+`bh0j(Q~UQ?0Y1*2JFY6$J@z2_yH5n&&Y!bJ?YR7 z`1f91#bI$4YPVvMB(DPYg!9~I{E<0Ct|q1hKc}5*WtS3R1ZvESZ{%u3`C*ygr{1+mJm*UXxP6ALt zm!4Ux=->{oR!x2U{DYb}MCnIdM{CTMgP@=uNMWt1n8Oaxj9tIvpIR4EtKW;_Z51%q z?eQXlR4>kfF4VeLlvks$| zE4hW*_u3?}P}g|SWdeib zpB?-r6Y1ndZg&?Sm(1d-xcEtBfe{BM#d9P@HhT||8`Brt85Y0ii{xt_X2^D5m2p*@ zUh1C5R*nZ)vpwvI=T)UiU~a;w~snS!#RO zwWb`}mf0%bjws5p-;8%Dq9*%!41g>h!;6ArSHgK68Vy&00 za=>ctZ8Hv5i1(wX=HHE1qe;5gue%eXdhKm)y7yLmKQ^BRpAT>?-H}f2-cqNR)^nH( zQAW3_FuwqM5U8eym19X;6oIbYwPW8#H?{DyJxj5izi=UI`^l$CCML5kIoUedd$+w0 zkO$+35QJLqungE|Q#$z9XOv0HkwY6f3pA2-!}QmvBHvt|J<1E%@UO4$&fx>b889Xup{P8(bra8#vwY z3&GV8pspul+dj{1Md4kt-1>(Sfc-eId;;nTET0(Y;lByFBHzj$EZu}TE3;Nlw#DXu zt)4U=&`1UQkg5`amW0UVZtYZH%7A<&$_m8wx0A(;--FZlKrmV{;KxMDY1xeo+KUDc z(pS>fcNIkK=LN|Cuj8XLUpCTRo9Wo!s^RMP%&XO;sDZ(GCNAHviVAsP?>A0!C6A?t z+-u(QQ1?9jHG~BIo7Y?L&Y!AJdiN(F;!c{Z8K~+>t3FdFOqy8kzj5ip`;FHuGM@YW z`-WL%5A8=wNDXY``TG|G_)1K(hk8e|2^zFEo9XjYCI?BIHP&Pvzly7O&FFEs^xd1} zjTo|b+{APf*}ApJ#;3g#Adim-Hqv{)jVGH!AZK`G(*}!R6ZSVj)%R7L2TFl6T-hjw z)Y$VnH&2V0AM|u+Hq5!TU}8UiiA(|2Jus;ThjcAwt)j9PU9%${Etb#&LZ|qlvl@Sw zWG={_V((5=H@3>=-lIWz5?uwn13Y){`-6UyyWUymdRb_@HP3#)vmy;oO=+xvJES-3 zT;2{S*qe@=Gh>NXy*FHNAf);?8GY=ADZX_cuzw@~V*j%$&%&um%V+oPv#_|pC zE3Qt6c2wGV#@+t8dKiB`B<>_9s-XhkN#t<9yH?j|UBAG-;!iy7r81$hIV96OqwV=( zePU0Aa(#eWN;oR)d9O3hXA;;f-QWU0i^sfb`q^`-om_Y-Ndw!88US)KupYx$eZ%G7 zxhe$~aA^Wbco3?8$RYe3iU97(wh#W}2G{?VrWzyjmuX-xsw43->&&@?S-N!qdB9cn zl$?;+cva966?FN&1)A2<#xe4m>Vy+xqqv{F-SmF`zF)E9{@S)|Qrpg=34+229|Svm z<;~trAPgIN2Teq0Z!RPg6!ac5 z*_X}4k%?7f@?rXS0cnNfyqB}y6=q0=QSQ2*G2ho?b85?FK<2heYN8M^%_PGKsZOwj z_t28di0L5fir*s94u@QE8@;L}o`-_0t0hO@Kh*5r!q^4B+*9Cwt9loy@AZ?~w9`~K z`w>P6)PS8vm2l`=u(jWq1}a62%L&t%clamW16?T`ULDCZLalT=Gd3Xx}|9zJeRit1^1ozpfK!-y0DNZ1%CzQy;om0ru9g zwhYg|0AEVCo)>JEgAaa~vlQxR4FqBKG!VWzCM{~_P<=+9c!AUfiv*wNAPyi-z_UZ5LPfTI4*~^Ln*%1qz+@22?pKJV`u{-)-^ z5}vwTjq5*cGH#ii_plE@2F$ZxPfu=IgpyHK73WB5D;5O%7WoahDLs#*Gm+R_yOmlM z?toW5axQc%U{y$?b0Nl_30d!^=KGtSN;j6NTlOLkMyYpNCcf`=`{60=#|Z3IQtz^P zGZtwOxBJ7~8TcSlwq{^uUb?DfaDg2Et;BQT=m)n}|*Jv59~tnE3Sv!m`i$nJ~XDKeHagZzr;Pd@b2f+byjp@ZN`Po-1O(> zfC_nc9)z4-zT$IYn=Uj7l>Tq7Ae(ot2Ov<8vsSyz+P^ku<+pZgr%Fq(8#{H`@2B03 zUXcVY8U^APU%~!2kzMicH9S+WYcu(nS6$kV^}!TNz!Ba4U<#MNA56g>`|B~4gFf_2 zBYharw*J}i_dNf6s8a=FP6@kzeEANU$(U1kp}9T+V#mh&NG!IUt#U^Rj+0z+Us_uq z>+(=v@*z+aLo&}kztuDsOn6q_;>_4^!Oorvjtia$$=S-PIr&HIWyq^YWv>EVxjG`p zrO+Q**|%kE5HUA9Ry284unmbqBa{4ZtXYeeFZldazKwog@&eJGdF3TH3iLeMj}&E8 z$7E0gv+HiY`a075utJTr3%+!`LwC(@pl+aZpGfmh<}r14+t>`R@7?|HQ9A0nFM&g| z7%JNA&itw1JXR^JfLLm-9fD6dcBOqoE7fW<;1m+~JN(`tN`Qm3o;`HVz6H_}OI>_8 zu1#v%*yzGo{K@BSMGGvh=?A?@$7ND?KOM|YzaaBZk?Ni4?Zs!x?`K;1^k-F zrnKLzPYSS_Ta3wF{X6LLR#m**9>uJSX|e2?p#N>pyj*+A&B_`Oj1E||?Va!|sO%n( z&p?3>#@RM#Wd0w@&O9FKz5n~?l%hov(qfCU7E;t;NYtS0B*#_>6_MRoPfDS(E9(rh zuSsMVEi{%9F_b0QvW|W1?)P^_opY}1{C@X+|973o)j5xe`FuX_<@I{Ll&QPX^EYm# zVz;q;jAS4TF$fdcl!~>Dd*5;`oSyk$Op!R%JsG#O`TCDXYwu?2OiMqNP5jwcZwl&i z2lBD`GmGx!i*Fm8&pGF<^fj2;lDNy}4D07@4R5YZiLCqZE?%JQYGp4UdeXBLxXQ)k zi5M+7MDpF~-lK%CCtJhEdTdFGn|C~7J%t*5TrikGSr;+fG)_#lYtr@2ACSRWS2Qp1 zgxZ*nRystXt~j`9=v!+Qj^z#1yGW1xQLrKElZRd#Z#*kTDI~GdA{LrX^-fl(5A-v;?me&21RS7sJazzdA!g`p^+%}IZm?@^Si3t>gG*m2X#OPE}gAnJk(+R7dGY>sg7OsEM=lMJ+drMdo_-$qcrwmz8Vr4nH!%kk649 zV{e-rC>IcS`Tle|T@KTvUUkP~sfK88o`p`xZ1fdyy=gCJ)rgw$aG3Ia_Gr$)WF?lf zix|FNm=QL1k?b*>8;iO-dDk6J3{mI17_!-p=(X-~*hQ%fj*!`2MZwuc`dAY z(t*zO`mXYo!7Tb(eN(|9Z`0Ln>2VpYWEOj>0r5v&@^-08*q;a`xS6e*#*_spk6xWyF!&e#hdRbuqtqek~ zcRp%>f9ktbT(_9GVUqfRE+FgeWfjH?>K_0dhJrk?Yu;S&nr>%Clj2}c0mXe>ac+3! z0&ZE=Fm}~S)uI0(putw(_aUaT&t)UYrO|zXa>aHy52>iS9TCN{x%;M1zn1)3c{^gU zpN$lK&}Ql8>YK$6q@_8FRSg>{lCjfWgT=zQ%x#<|7tD{Uyc^1&H!^QCe!gBjyd>jw z%cf+0X0S7e;|gvdhe&AAk95c_Z|&?}`eZ9LB=mCp7Jba> zIa>HQw7sGI65n&*Xcj0$SD)%5!*ppYj1EMfr!tW`R= zX2jgH$sEJVROJ9d=)sg(Xu)vGQbDs2UK?$IxHxI12m_2mz@a&eMRs^xv$$@m%FkZ^ zqLLFvJzZY`bubK&R(Tg*Fa$aVeLLFzsd0t(As0Jv!KKRGT2JlwVb64isl(#^946%8JTh}VKS9lJw(9t-%$Esl?kya~ zFUYH%WlZsh*qIO<+v({3_lv!Xff8S37Z)cyUtnDhuaDNC8^%_jZmu5*Yy0MEFZY< zhRWzkG&2GCSZ!qgEMkt9WZJVuRhpAYl^`|P`Jp`cRE(3@pu^^^=GmtJopFbX&N!~! z@sgB*IHW-_8%jtpBUph|U3}3>0r8{C02B?>wP8Ei&J(q82S&$^)K_4{IfWfia{ILA zp&$Wzz!>h#t_^ese1Rk$rnoXQ~J}* z3G@wLNyXQqKphKMD1A<1fHiv%%a#B{>A4NqLfUfA0OO&+ni4*fr0%wrQdYD$Id3T1 z*$_9A8>WRrFBF7@k4pFepaBttgl7Edd?Z4s6~HtbE->KIJOnO{lvd=ABK2C>hfV{H z0N71`r_xZZ@}__h`TShrDgkkC7;Ph5vK4js$)s^+iYVuRM;4Ki(j!coqu=z}mxDJm z?XCJmW)837H%5REpLi2X_%LpVjmgXxeFeOQxNWY%8t8FqS(Au60ESGa__&w$ZQoVC z>;2&~Zp0#e;QArxj9#GD6#XJN?Wm>Cxh!xWc_q5#$a? zp+Wxfsu_LGTZK2u$bSDYdV>i5p7NWYJe+>O9>h}(BF{Gryni+R{{vqwJ+BcNpD~B0 zgSN6sm!^HE`Mfu@@9-APY1L9THO2;7j^5^8JP<#iyp8W;+#3~cP!@&iN}puf z6Jt7jy3pohcmFWAd`m?6Jo>I&rUp+kX(5*hC{ZspVT`0g7t^;d9bu>KDt}0JP#)oW zvxL2SNE<8Y8CgbgS|+KVkkRUmr)c>zVylN;3%lRLc%FKQ!LWIB*4&vn+GOnTlR!=x zRC65C2D{knroKGW{SY*Y+*;R89R28f&dpL(Tz4=0%5sucfM8|)B40L4kp6bf);JPd zs@1BKdR@6$1K5@Lz$I$tdCBoZ-=7KTv4cm(qRQOqv?`KX?8>>OpaT!19$HYN4#iC{ zbKFckr6lE6+c%E)E1W)cLT+Fi@s)XV1wXJG)H~98btgbW_>37~hPww4iWE>i@fTdd zJ<0!zvsT;2Evf%h_VIcnKKfvzD?Wy9p2}SVTim8E&T&+m!9cEl;HLKg*xDT>fJ#R8 zPybU-3DcfSUzvMo^p_uz)9`_BX+~1305^SMU8LABAEQTV`P@*^rsYJ#jRu4O zMoLvWg3%k88}OSt_*4k7XCDUQE7-GV%@4~iA}a2kW_!%<1?WG9tH`+Mw_|$$!Bs$n zb3eHX<#TQK#m`YsDTVW9R=x{xn}D_xfDB1q_xc=0jP92AQ=Ab1K-Up7-qZLYhBnG? z(nA-Aac8319OzJEN1y{b0)MUDh_ChZjg2+TMVaj^GK6q&-ev?Tsi$WqyiCIe3u^wI z!Uj%8BkCXj|6;J&wuJ!MkY;6k4@TzjZkG00m=r+&p8;=f-M;Yj03mopkvW^T377Ca z04$_n5ys*nLYx^Pim!(+ayl?+`b^DJAtv!XxU6Ik8_?V(K^b#>u>GiE&4-@j{avVQ zG?(9@suWZmXS(bSt|Q-Nz~Z{6#8T6(52w&@!LJ`IM*u(cGC|(f8ZgVU($>yToB3ez zj5c_T;Ey(5NCcAb@qdIQ?Eedr0Mn_lkXa?MbJn%XyUh=&S%2dLC9u{va6g5a^!vQs zhJf_C51mXNt*h>b-Ms(H2;gVT0e>eP_&e_s!La!3=68X6z@LEgl)hc$=iXO5@40LP z2Xq?%8-R!N$Vu~-GJas(ae*^=16>$Hb(T`MXjrh;g*(*{v?6s~lyvbdD9^43gscCy z!c|FvjTm)>8>ahVEN_s?rgYCh*py=Mp%7q<@iqb#m4<)|P2RNK*8q$Fr?5K;*p#4m zcvt(;AzZTcVV}QtXEn>&h`_OA{e@bqK!d`Bf2KwU+E`9pYWt*?rk)P~9i*?)Rq z4o$Yb1?t^FX9>YB?Gkmk%=$h)AUq!IcsCgHa}k2-IL9d>W zLm!3a)DxzgpMAQc5K0w?p4BOhH7H;_s!6eX9b!0c>lAB6@$|cdx zJF(%luGaDxuz(TUhP;Blok@BLC#89FzcR(X$6fC8z|}!7N}_CMC-?h6!RxZM$r@n4XuVeQ!*$s6IX4UQDV5Ve2Uh#d@3 z#@nd7K7@^e&^(L3DRVz(VEoDKfra1ekuDW->#T5FItM}-gU@Zwr)vE`4-6`90F{A8 zx#?NRD*^kGA(n&IAl5Jku|_)@o~OB|003p4Qh~x!%>GkveE{(elqWpYljB%{2@do} zywY+Bour7$<*tM4LF3&Ai>!Ze+Fl`cNcdUF00D}+e?)Gz+7MhsM!bDm=i=xgF+M8P z3LI`q9>APRWSxB-wlm;0Rljk82qDLd^dSre5`%a(luD{;LU4d2OT!7L?%lT z(JPx;25hO9;Fm@i)Gts z==I0i4hFsepu<*4dL5H6pnhz*3nz|#HFa4I0!vxSYpL@W$mCE8zQ;#9zJe9Dg@Rj6n4ME>1Bep{9OGa&vHGlw)% zbOj5Fl>68F`6ReSI^K?=HDyco&B*!~1E327peLFMU>(OI2RMi`$TTGwM%fO#a!