diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index fd638841f..8e486014e 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -25,3 +25,10 @@ install( DESTINATION ${CMAKE_INSTALL_BINDIR} RENAME villas-api ) + +install( + PROGRAMS grid2op-timeseries-converter.sh + COMPONENT bin + DESTINATION ${CMAKE_INSTALL_BINDIR} + RENAME grid2op-timeseries-converter +) diff --git a/tools/grid2op-timeseries-converter.sh b/tools/grid2op-timeseries-converter.sh new file mode 100755 index 000000000..576a4d405 --- /dev/null +++ b/tools/grid2op-timeseries-converter.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# +# Create chronics files for OpenDSS. +# This script reads a chronics config, grid mapping, and per-element CSV series, +# then writes the OpenDSS-ready load and generator chronics files. +# Reference: https://grid2op.readthedocs.io/en/latest/user/chronics.html +# +# SPDX-FileCopyrightText: 2026 Institute for Automation of Complex Power Systems, RWTH Aachen University +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +die() { + echo "[error] $1" >&2 + exit 1 +} + +cleanup_tmp_dir() { + [[ -n "${tmp_dir:-}" && -d "$tmp_dir" ]] && rm -rf "$tmp_dir" +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + die "$cmd is not available. Please install it first." + fi +} + +round_dec() { + local value="$1" + local decimals="$2" + + awk -v value="$value" -v decimals="$decimals" 'BEGIN { + scale = 10^decimals + if (value >= 0) { + rounded = int(value * scale + 0.5) / scale + } else { + rounded = -int(-value * scale + 0.5) / scale + } + printf "%.15g\n", rounded + }' +} + +extract_file_number() { + local file="$1" + local stem + + stem=$(basename -- "$file") + stem=${stem%.*} + + if [[ "$stem" =~ ([0-9]+) ]]; then + printf '%s\n' "${BASH_REMATCH[1]}" + else + die "No numeric index in filename: $file" + fi +} + +sorted_files() { + local dir="$1" + local prefix="$2" + + [[ -d "$dir" ]] || die "Directory missing: $dir" + + local -a files=() + local file + shopt -s nullglob + for file in "$dir"/"$prefix"*.csv; do + files+=("$file") + done + shopt -u nullglob + + if (( ${#files[@]} == 0 )); then + return 0 + fi + + for file in "${files[@]}"; do + printf '%s\t%s\n' "$(extract_file_number "$file")" "$file" + done | sort -n -k1,1 | cut -f2- +} + +load_grid_mapping() { + local grid_file="$1" + local table="$2" + + jq -r --arg table "$table" ' + .["_object"][$table]["_object"] | fromjson as $df | + if ($df.columns | index("bus")) == null then + error($table + " dataframe does not contain a bus column") + else + $df + end | + if (.index | length) != (.data | length) then + error($table + " dataframe index and data length mismatch") + else + . + end | + (.columns | index("bus")) as $bus_idx | + range(0; (.index | length)) as $i | + "\(.index[$i])\t\(.data[$i][$bus_idx])" + ' "$grid_file" +} + +parse_series_csv() { + local file="$1" + local p_out="$2" + local q_out="$3" + local decimals="$4" + + : > "$p_out" + : > "$q_out" + + local first_line=1 + local line + local -a columns=() + local -a cells=() + local p_idx=-1 + local q_idx=-1 + local p_value + local q_value + + while IFS= read -r line || [[ -n "$line" ]]; do + if (( first_line )); then + first_line=0 + [[ -n "$line" ]] || die "Empty CSV: $file" + + IFS=, read -r -a columns <<< "$line" + for i in "${!columns[@]}"; do + case "${columns[$i]}" in + P_norm) p_idx=$i ;; + Q_norm) q_idx=$i ;; + esac + done + + (( p_idx >= 0 )) || die "Column P_norm missing in $file" + (( q_idx >= 0 )) || die "Column Q_norm missing in $file" + continue + fi + + [[ -z "$line" ]] && continue + + IFS=, read -r -a cells <<< "$line" + p_value="${cells[$p_idx]:-0}" + q_value="${cells[$q_idx]:-0}" + + round_dec "$p_value" "$decimals" >> "$p_out" + round_dec "$q_value" "$decimals" >> "$q_out" + done < "$file" + + (( first_line == 0 )) || die "Empty CSV: $file" +} + +write_output() { + local compress="$1" + local dest="$2" + local header="$3" + shift 3 + + local -a columns=("$@") + + if [[ "$compress" == true ]]; then + { + printf '%s\n' "$header" + if (( ${#columns[@]} > 0 )); then + paste -d ';' "${columns[@]}" + fi + } | bzip2 -c > "$dest" + else + { + printf '%s\n' "$header" + if (( ${#columns[@]} > 0 )); then + paste -d ';' "${columns[@]}" + fi + } > "$dest" + fi +} + +usage() { + cat >&2 < "$v_out" + while IFS= read -r _; do + printf '%s\n' "$voltage" >> "$v_out" + done < "$p_out" + + prod_p_columns+=("$p_out") + prod_q_columns+=("$q_out") + prod_v_columns+=("$v_out") + + if [[ -n "$sgen_col_names" ]]; then + sgen_col_names+=';' + fi + sgen_col_names+="sgen_${bus_id}_${sgen_idx}" + sgen_idx=$((sgen_idx + 1)) + done + + mkdir -p "$output_dir" + + local output_suffix="" + if [[ "$compress" == true ]]; then + require_cmd bzip2 + output_suffix=".bz2" + fi + + local -a output_names=(load_p load_q prod_p prod_q prod_v) + local -a output_headers=(load_col_names load_col_names sgen_col_names sgen_col_names sgen_col_names) + local -a output_columns=(load_p_columns load_q_columns prod_p_columns prod_q_columns prod_v_columns) + + local i output_name header_name columns_name + for i in "${!output_names[@]}"; do + output_name="${output_names[$i]}" + header_name="${output_headers[$i]}" + columns_name="${output_columns[$i]}" + + local -n output_columns_ref="$columns_name" + local output_path="$output_dir/${output_name}.csv${output_suffix}" + write_output "$compress" "$output_path" "${!header_name}" "${output_columns_ref[@]}" + done +} + +main "$@"