From aeb6995aa7835cc1cac057bf9d878352255a782e Mon Sep 17 00:00:00 2001 From: EC2 Default User Date: Thu, 14 Mar 2024 01:59:51 +0000 Subject: [PATCH] added nodal hosting capacity analysis feature --- emerge/cli/cli.py | 4 +- emerge/cli/create_sqlite_table.py | 19 ++ emerge/cli/custom_metrics.py | 7 +- emerge/cli/get_num_core.py | 12 ++ emerge/cli/multiscenario_metrics.py | 17 +- emerge/cli/multiscenario_metrics.py.bak | 253 ++++++++++++++++++++++++ emerge/cli/nodal_hosting_capacity.py | 134 +++++++++++++ emerge/cli/timeseries_simulation.py | 5 +- emerge/metrics/system_metrics.py | 11 +- emerge/simulator/opendss.py | 10 + emerge/simulator/simulation_manager.py | 22 +-- pyproject.toml | 1 + 12 files changed, 459 insertions(+), 36 deletions(-) create mode 100644 emerge/cli/create_sqlite_table.py create mode 100644 emerge/cli/get_num_core.py create mode 100644 emerge/cli/multiscenario_metrics.py.bak create mode 100644 emerge/cli/nodal_hosting_capacity.py diff --git a/emerge/cli/cli.py b/emerge/cli/cli.py index 38af4c0..f285b94 100644 --- a/emerge/cli/cli.py +++ b/emerge/cli/cli.py @@ -16,6 +16,7 @@ from emerge.cli.custom_metrics import compute_custom_metrics from emerge.cli.schema_generator import create_schemas from emerge.cli.timeseries_simulation import timeseries_simulation +from emerge.cli.nodal_hosting_capacity import nodal_hosting_analysis @click.command() @@ -67,4 +68,5 @@ def cli(): cli.add_command(generate_scenarios) cli.add_command(multi_timeseries_simulation) cli.add_command(compute_custom_metrics) -cli.add_command(create_schemas) \ No newline at end of file +cli.add_command(create_schemas) +cli.add_command(nodal_hosting_analysis) \ No newline at end of file diff --git a/emerge/cli/create_sqlite_table.py b/emerge/cli/create_sqlite_table.py new file mode 100644 index 0000000..7c1e931 --- /dev/null +++ b/emerge/cli/create_sqlite_table.py @@ -0,0 +1,19 @@ + +from typing import Optional +from pathlib import Path + +from sqlmodel import Field, SQLModel, create_engine + + +class HostingCapacityResult(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + name: str + hoting_capacity_kw: float + + +def create_table(sqlite_file: Path): + """ Function to create sqlite table.""" + + engine = create_engine(f"sqlite:///{str(sqlite_file)}") + SQLModel.metadata.create_all(engine) + return engine \ No newline at end of file diff --git a/emerge/cli/custom_metrics.py b/emerge/cli/custom_metrics.py index 285b06f..0011a02 100644 --- a/emerge/cli/custom_metrics.py +++ b/emerge/cli/custom_metrics.py @@ -1,16 +1,14 @@ """ Module for computing time series metrics for multi scenario simulation.""" -# standard imports import datetime from pathlib import Path from typing import Dict import concurrent.futures -# third-party imports import click +from emerge.simulator.opendss import OpenDSSSimulator import yaml -# internal imports from emerge.metrics import system_metrics from emerge.metrics import observer from emerge.simulator import simulation_manager @@ -61,8 +59,9 @@ def _run_timeseries_sim( scenario_folder_path = None, ): date_format = "%Y-%m-%d %H:%M:%S" + opendss_instance = OpenDSSSimulator(master_dss_file_path) manager = simulation_manager.OpenDSSSimulationManager( - master_dss_file_path, + opendss_instance, datetime.datetime.strptime(start_time, date_format), datetime.datetime.strptime(profile_start_time, date_format), datetime.datetime.strptime(end_time, date_format), diff --git a/emerge/cli/get_num_core.py b/emerge/cli/get_num_core.py new file mode 100644 index 0000000..0c23385 --- /dev/null +++ b/emerge/cli/get_num_core.py @@ -0,0 +1,12 @@ +import os + +def get_num_core(num_core: int, num_inputs: int) -> int: + """ Function to get actual number of core.""" + + if num_core > os.cpu_count(): + num_core = os.cpu_count() - 1 or os.cpu_count() + + if num_inputs < num_core: + num_core = num_inputs + + return num_core \ No newline at end of file diff --git a/emerge/cli/multiscenario_metrics.py b/emerge/cli/multiscenario_metrics.py index cff5625..503832b 100644 --- a/emerge/cli/multiscenario_metrics.py +++ b/emerge/cli/multiscenario_metrics.py @@ -6,11 +6,12 @@ import json import click -from emerge.cli import get_observers +from emerge.cli import get_num_core, get_observers from emerge.cli.timeseries_simulation import TimeseriesSimulationInput from emerge.metrics import observer from emerge.simulator import simulation_manager +from emerge.simulator.opendss import OpenDSSSimulator from pydantic import Field @@ -19,13 +20,15 @@ class ScenarioTimeseriesSimulationInput(TimeseriesSimulationInput): def _run_timeseries_sim(config: ScenarioTimeseriesSimulationInput): + opendss_instance = OpenDSSSimulator(config.master_dss_file) + opendss_instance.post_redirect(config.scenario_file.absolute()) + manager = simulation_manager.OpenDSSSimulationManager( - path_to_master_dss_file=config.master_dss_file, + opendss_instance=opendss_instance, simulation_start_time=config.start_time, profile_start_time=config.profile_start_time, simulation_end_time=config.end_time, simulation_timestep_min=config.resolution_min, - extra_dss_files=[str(config.scenario_file.absolute())] ) subject = observer.MetricsSubject() @@ -83,12 +86,8 @@ def multi_timeseries_simulation( ) ) - if len(timeseries_input) < num_core: - num_core = len(timeseries_input) - - if num_core > os.cpu_count(): - num_core = os.cpu_count() - 1 or os.cpu_count() - + num_core = get_num_core(num_core, len(timeseries_input) ) + if num_core > 0: with multiprocessing.Pool(int(num_core)) as p: p.map(_run_timeseries_sim, timeseries_input) diff --git a/emerge/cli/multiscenario_metrics.py.bak b/emerge/cli/multiscenario_metrics.py.bak new file mode 100644 index 0000000..12824f5 --- /dev/null +++ b/emerge/cli/multiscenario_metrics.py.bak @@ -0,0 +1,253 @@ +""" Module for computing time series metrics for multi scenario simulation.""" +# standard imports +import datetime +from pathlib import Path +import multiprocessing + +# third-party imports +import click + +# internal imports +from emerge.metrics import system_metrics +from emerge.metrics import observer +from emerge.metrics import node_metrics +from emerge.simulator import simulation_manager + + +def _run_timeseries_sim(input): + + date_format = "%Y-%m-%d %H:%M:%S" + manager = simulation_manager.OpenDSSSimulationManager( + input["master_file"], + datetime.datetime.strptime(input["simulation_start"], date_format), + datetime.datetime.strptime(input["profile_start"], date_format), + datetime.datetime.strptime(input["simulation_end"], date_format), + input["simulation_resolution"], + ) + manager.opendss_instance.execute_dss_command(f"Redirect {input['pv_file']}") + manager.opendss_instance.set_max_iteration(200) + subject = observer.MetricsSubject() + + sardi_voltage_observer = system_metrics.SARDI_voltage( + input["overvoltage_threshold"], input["undervoltage_threshold"] + ) + sardi_line_observer = system_metrics.SARDI_line(input["thermal_threshold"]) + sardi_xfmr_observer = system_metrics.SARDI_transformer( + input["thermal_threshold"] + ) + sardi_aggregated_observer = system_metrics.SARDI_aggregated( + loading_limit=input["thermal_threshold"], + voltage_limit={ + "overvoltage_threshold": input["overvoltage_threshold"], + "undervoltage_threshold": input["undervoltage_threshold"], + }, + ) + nvri_observer = node_metrics.NVRI( + input["overvoltage_threshold"], input["undervoltage_threshold"] + ) + llri_observer = node_metrics.LLRI(input["thermal_threshold"]) + tlri_observer = node_metrics.TLRI(input["thermal_threshold"]) + total_energy_observer = system_metrics.TotalEnergy() + total_pv_energy_observer = system_metrics.TotalPVGeneration() + timeseries_total_power_observer = system_metrics.TimeseriesTotalPower() + timeseries_total_pv_power_observer = system_metrics.TimeseriesTotalPVPower() + total_loss_observer = system_metrics.TotalLossEnergy() + timeseries_loss_observer = system_metrics.TimeseriesTotalLoss() + + observers_ = [ + sardi_voltage_observer, + sardi_line_observer, + sardi_xfmr_observer, + sardi_aggregated_observer, + nvri_observer, + llri_observer, + tlri_observer, + total_energy_observer, + total_pv_energy_observer, + timeseries_total_power_observer, + timeseries_total_pv_power_observer, + total_loss_observer, + timeseries_loss_observer + ] + for observer_ in observers_: + subject.attach(observer_) + + manager.opendss_instance.execute_dss_command(f"batchedit pvsystem..* yearly={input['solar_profile']}") + + if input['voltvar']: + num_points = len(input['voltvar_yarray'].split(',')) + curve_command = f"new xycurve.vvar_curve npts={num_points} yarray={input['voltvar_yarray']} xarray={input['voltvar_xarray']}" + manager.opendss_instance.execute_dss_command(curve_command) + + inverter_command = f"new invcontrol.inv_controller_ mode=VOLTVAR voltage_curvex_ref=rated vvc_curve1=vvar_curve eventlog=yes RefReactivePower=VARMAX_VARS" + manager.opendss_instance.execute_dss_command(inverter_command) + + manager.opendss_instance.execute_dss_command("batchedit pvsystem..* wattpriority=yes") + if not input['nighttime']: + manager.opendss_instance.execute_dss_command("batchedit pvsystem..* varfollowinverter=yes") + + manager.simulate(subject) + manager.export_convergence(input["convergence_report"]) + observer.export_tinydb_json(observers_, input["output_json"]) + + +@click.command() +@click.option( + "-m", + "--master-file", + help="Path to master dss file", +) +@click.option( + "-sf", + "--scenario-folder", + help="Path to a scenario folder", +) +@click.option( + "-pn", + "--profile-name", + help="Profile name for solar.", +) +@click.option( + "-nc", + "--number-of-cores", + default=1, + show_default=True, + help="Number of cores to be used in parallel", +) +@click.option( + "-ss", + "--simulation-start", + default="2022-1-1 00:00:00", + show_default=True, + help="Simulation start time.", +) +@click.option( + "-ps", + "--profile-start", + default="2022-1-1 00:00:00", + show_default=True, + help="Time series profile start time.", +) +@click.option( + "-se", + "--simulation-end", + default="2022-1-2 00:00:00", + show_default=True, + help="Simulation end time.", +) +@click.option( + "-r", + "--simulation-resolution", + default=60, + show_default=True, + help="Simulation time resolution in minutes.", +) +@click.option( + "-ot", + "--overvoltage-threshold", + default=1.05, + show_default=True, + help="Overvoltage threshold.", +) +@click.option( + "-ut", + "--undervoltage-threshold", + default=0.95, + show_default=True, + help="Undervoltage threshold.", +) +@click.option( + "-tt", + "--thermal-threshold", + default=0.95, + show_default=True, + help="Thermal laoding threshold.", +) +@click.option( + "-vvar", + "--voltvar", + default=False, + show_default=True, + help="Voltvar smart inverter settings." +) +@click.option( + "-qx", + "--voltvar-xarray", + default="[0.7,0.92,0.967,1.033,1.07,1.3]", + show_default=True, + help="X array for voltvar curve" +) +@click.option( + "-qy", + "--voltvar-yarray", + default="[0.44,0.44,0,0,-0.44, -0.44]", + show_default=True, + help="Y array for voltvar curve" +) +@click.option( + "-nt", + "--nighttime", + default=True, + show_default=True, + help="Inverter night time volt var setting." +) +@click.option( + "-o", + "--output-folder", + help="Path to a scenario folder", +) +def compute_multiscenario_time_series_metrics( + master_file, + scenario_folder, + profile_name, + number_of_cores, + simulation_start, + profile_start, + simulation_end, + simulation_resolution, + overvoltage_threshold, + undervoltage_threshold, + thermal_threshold, + voltvar, + voltvar_xarray, + voltvar_yarray, + nighttime, + output_folder, +): + """Run multiscenario time series simulation and compute + time series metrics.""" + + scenario_folder = Path(scenario_folder) + output_folder = Path(output_folder) + + output_folder.mkdir(exist_ok=True) + + timeseries_input = [] + + for folder_path in scenario_folder.iterdir(): + pvsystem_file = folder_path / "PVSystems.dss" + json_path = output_folder / (folder_path.name + ".json") + convergence_file = output_folder / (folder_path.name + "_convergence.csv") + timeseries_input.append( + { + "master_file": master_file, + "simulation_start": simulation_start, + "solar_profile": profile_name, + "profile_start": profile_start, + "simulation_end": simulation_end, + "simulation_resolution": simulation_resolution, + "pv_file": pvsystem_file, + "overvoltage_threshold": overvoltage_threshold, + "undervoltage_threshold": undervoltage_threshold, + "thermal_threshold": thermal_threshold, + "output_json": json_path, + "convergence_report": convergence_file, + "voltvar": voltvar, + "nighttime": nighttime, + "voltvar_xarray": voltvar_xarray, + "voltvar_yarray": voltvar_yarray + } + ) + + with multiprocessing.Pool(number_of_cores) as p: + p.map(_run_timeseries_sim, timeseries_input) diff --git a/emerge/cli/nodal_hosting_capacity.py b/emerge/cli/nodal_hosting_capacity.py new file mode 100644 index 0000000..ad83ec7 --- /dev/null +++ b/emerge/cli/nodal_hosting_capacity.py @@ -0,0 +1,134 @@ +""" This module contains function that would compute nodal hosting +capacity. + +Idea is to loop through all the nodes. Increase the solar capacity by step kw specified +by user and return max capacity for which the risk would be zero. +""" +from pathlib import Path +from typing import Annotated +from datetime import datetime +import json +import multiprocessing + +import click +from emerge.metrics.system_metrics import SARDI_aggregated +from emerge.simulator.simulation_manager import OpenDSSSimulationManager +import numpy as np +import polars as pl +from pydantic import BaseModel, Field +from loguru import logger +from sqlmodel import Session + +from emerge.cli import get_num_core +from emerge.metrics import observer +from emerge.simulator import opendss +from emerge.cli.create_sqlite_table import create_table, HostingCapacityResult + +class BasicSimulationSettings(BaseModel): + """Interface for basic simulation settings.""" + + master_dss_file: Annotated[Path, Field(..., description="Path to master dss file.")] + start_time: Annotated[datetime, Field(..., description="Start time for simulation.")] + end_time: Annotated[datetime, Field(..., description="End time for simulation.")] + profile_start_time: Annotated[datetime, Field(..., description="Profile start time.")] + resolution_min: Annotated[float, Field(60, gt=0, description="Simulation time resolution in minute.")] + + +class SingleNodeHostingCapacityInput(BasicSimulationSettings): + """Interface for single node hosting capacity.""" + step_kw: Annotated[float, Field(gt=0, description="kW value to increase pv capacity by each time.")] + max_kw: Annotated[float, Field(gt=0, description="Maximum kw to not exceed.")] + + +class MultiNodeHostingCapacityInput(SingleNodeHostingCapacityInput): + """Interface for timeseries simulation input model.""" + + num_core: Annotated[int, Field(ge=1, description="Number of cores to use for parallel simulation.")] + export_sqlite_path: Annotated[Path, Field(..., description="Sqlite file to export nodal hosting capacity.")] + pv_profile: Annotated[str, Field(..., description="Name of the profile for pv system")] + +def _compute_hosting_capacity(input): + """ Wrapper around compute hosting capacity. """ + return compute_hosting_capacity(*input) + +def compute_hosting_capacity(config: SingleNodeHostingCapacityInput, bus: str, pv_profile: str) -> tuple[float, str]: + """ Function to compute node hosting capacity.""" + hosting_capacity = 0 + opendss_instance = opendss.OpenDSSSimulator(config.master_dss_file) + opendss_instance.dss_instance.Circuit.SetActiveBus(bus) + bus_kv = opendss_instance.dss_instance.Bus.kVBase() + pv_name = f"{bus}_pv" + + new_pv = f"new PVSystem.{pv_name} bus1={bus} " + f"kv={round(bus_kv,2 )} phases=3 kVA=1000 Pmpp=1000 " + f"PF=1.0 yearly={pv_profile}" + + opendss_instance.dss_instance.run_command(new_pv) + + for capacity in np.arange(config.step_kw, config.max_kw, config.step_kw): + opendss_instance.dss_instance.run_command( + f"pvsystem.{pv_name}.kva={capacity}") + opendss_instance.dss_instance.run_command( + f"pvsystem.{pv_name}.pmpp={capacity}") + + sim_manager = OpenDSSSimulationManager( + opendss_instance=opendss_instance, + simulation_start_time=config.start_time, + profile_start_time=config.profile_start_time, + simulation_end_time=config.end_time, + simulation_timestep_min=config.resolution_min + ) + + subject = observer.MetricsSubject() + sardi_observer = SARDI_aggregated() + subject.attach(sardi_observer) + + sim_manager.simulate(subject=subject) + logger.info(f"Bus finished {bus}, capacity {capacity}") + sardi_aggregated = pl.from_dict( + sardi_observer.get_metric() + )['sardi_aggregated'].to_list()[0] + + if sardi_aggregated > 0: + break + hosting_capacity = capacity + + return hosting_capacity, bus + + +@click.command() +@click.option( + "-c", + "--config", + help="Path to config file for running timeseries simulation", +) +def nodal_hosting_analysis( + config: str +): + """Run multiscenario time series simulation and compute + time series metrics.""" + + with open(config, "r", encoding="utf-8") as file: + config_dict = json.load(file) + + config: MultiNodeHostingCapacityInput = MultiNodeHostingCapacityInput.model_validate(config_dict) + + opendss_instance = opendss.OpenDSSSimulator(config.master_dss_file) + buses = opendss_instance.dss_instance.Circuit.AllBusNames() + + engine = create_table(config.export_sqlite_path) + + num_core = get_num_core.get_num_core(config.num_core, len(buses)) + with multiprocessing.Pool(int(num_core)) as pool: + data_to_process = [ + [SingleNodeHostingCapacityInput.model_validate(config.model_dump()), + bus, + config.pv_profile] for bus in buses + ] + async_results = [pool.apply_async(_compute_hosting_capacity, (data,)) for data in data_to_process] + + with Session(engine) as session: + for result in async_results: + capacity, bus_ = result.get() + session.add(HostingCapacityResult(name=bus_, hoting_capacity_kw=capacity)) + session.commit() \ No newline at end of file diff --git a/emerge/cli/timeseries_simulation.py b/emerge/cli/timeseries_simulation.py index df9f82a..275ee7e 100644 --- a/emerge/cli/timeseries_simulation.py +++ b/emerge/cli/timeseries_simulation.py @@ -5,6 +5,7 @@ from pathlib import Path from typing import Annotated import json +from emerge.simulator.opendss import OpenDSSSimulator from pydantic import BaseModel, Field import click @@ -29,8 +30,10 @@ class TimeseriesSimulationInput(BaseModel): def compute_timeseries_simulation_metrics(config: TimeseriesSimulationInput): """ Function to compute metrics for timeseries simulation. """ + opendss_instance = OpenDSSSimulator(config.master_dss_file) + manager = OpenDSSSimulationManager( - path_to_master_dss_file=config.master_dss_file, + opendss_instance=opendss_instance, simulation_start_time=config.start_time, profile_start_time=config.profile_start_time, simulation_end_time=config.end_time, diff --git a/emerge/metrics/system_metrics.py b/emerge/metrics/system_metrics.py index 6f7f6fb..c331da5 100644 --- a/emerge/metrics/system_metrics.py +++ b/emerge/metrics/system_metrics.py @@ -185,10 +185,7 @@ class SARDI_aggregated(observer.MetricObserver): def __init__(self, loading_limit: float = 1.0, - voltage_limit: dict = { - 'overvoltage_threshold': 1.05, - 'undervoltage_threshold': 0.95 - } + voltage_limit: dict | None = None ): """ Constructor for `SARDI_line` class. @@ -197,7 +194,11 @@ def __init__(self, used for computing SARDI_line metric. voltage_limit (dict): Voltage threshold """ - + voltage_limit = { + 'overvoltage_threshold': 1.05, + 'undervoltage_threshold': 0.95 + } if voltage_limit is None else voltage_limit + self.loading_limit = data_model.ThermalLoadingLimit(threshold=loading_limit) self.voltage_limit = data_model.VoltageViolationLimit(**voltage_limit) self.sardi_aggregated = 0 diff --git a/emerge/simulator/opendss.py b/emerge/simulator/opendss.py index 7486d2f..fd1ceb9 100644 --- a/emerge/simulator/opendss.py +++ b/emerge/simulator/opendss.py @@ -68,6 +68,16 @@ def set_max_iteration(self, max_iterations): def set_stepsize(self, step_in_min): self.dss_instance.Solution.StepSizeMin(step_in_min) + def post_redirect(self, dss_file_path: Path): + """ Redirect this file.""" + self.dss_instance.run_command(f"Redirect {str(dss_file_path.absolute())}") + self.recalc() + self.solve() + + def recalc(self): + """ Method to recal and solve. """ + self.dss_instance.execute_dss_command("calcv") + def solve(self): self.dss_instance.Solution.Number(1) diff --git a/emerge/simulator/simulation_manager.py b/emerge/simulator/simulation_manager.py index 9e419ba..bb39495 100644 --- a/emerge/simulator/simulation_manager.py +++ b/emerge/simulator/simulation_manager.py @@ -3,22 +3,20 @@ from typing import Union import datetime -import logging import pandas as pd +from loguru import logger from emerge.simulator import opendss from emerge.metrics import observer -logger = logging.getLogger(__name__) -logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.DEBUG) class OpenDSSSimulationManager: """ Class for managing time series simulation using OpenDSS. Attributes: - path_to_master_dss_file (str): Path to Master.dss file + opendss_instance (opendss.OpenDSSSimulator): OpenDSS instance. simulation_start_time (datetime.datetime): Datetime indicating simulation start time profile_start_time (datetime.datetime): Datetime indicating @@ -31,27 +29,18 @@ class OpenDSSSimulationManager: def __init__( self, - path_to_master_dss_file: str, + opendss_instance: opendss.OpenDSSSimulator, simulation_start_time: datetime.datetime, profile_start_time: datetime.datetime, simulation_end_time: datetime.datetime, simulation_timestep_min: float, - extra_dss_files: Union[list[str], None] = None )-> None: - self.path_to_master_dss_file = path_to_master_dss_file self.simulation_start_time = simulation_start_time self.profile_start_time = profile_start_time self.simulation_end_time = simulation_end_time self.simulation_timestep_min = simulation_timestep_min - - self.opendss_instance = opendss.OpenDSSSimulator(self.path_to_master_dss_file) - - if isinstance(extra_dss_files, list): - for file in extra_dss_files: - self.opendss_instance.execute_dss_command(f"Redirect {file}") - self.opendss_instance.execute_dss_command("calcv") - self.opendss_instance.execute_dss_command("solve") + self.opendss_instance = opendss_instance self.opendss_instance.set_mode(2) self.opendss_instance.set_simulation_time( @@ -88,7 +77,8 @@ def simulate(self, subject: Union[observer.MetricsSubject, None] = None): subject.notify(self.opendss_instance.dss_instance) self.current_time += datetime.timedelta(minutes=self.simulation_timestep_min) - logger.info(f"Simulation finished for {self.current_time} >> {convergence}") + if not convergence: + logger.error(f"Simulation finished for {self.current_time} >> {convergence}") diff --git a/pyproject.toml b/pyproject.toml index a67442c..d7eb7c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "pyyaml", "tinydb", "uvicorn[standard]", + "sqlmodel" ] [project.optional-dependencies]