diff --git a/docs/capabilities.md b/docs/capabilities.md index eeddf84a6..c058acc83 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -4,17 +4,18 @@ This page summarizes the Python package surface documented for the current release. GridDyn's C++ application and optional libraries expose additional features that are selected at build time. -| Workflow | Current Python support | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | -| Load a network | Load extension-selected input formats. See the [reader list](python-interface.md#load-and-run-a-network). | -| Power flow | Run the steady-state power-flow solver and inspect bus, generator, and other object collections. | -| Dynamic simulation | Initialize and advance time-domain simulation; inspect recorder samples in memory or write them to files. | -| Network edits | Read and set supported model parameters, including numeric values with unit conversion. | -| File output | Write PYPOWER/MATPOWER static cases and CSV/XML power-flow result summaries through `Simulation.write_file()`. | -| Optimal power flow | Run continuous DC OPF for supported polynomial costs through quadratic order when built with the optimization library. | +| Workflow | Current Python support | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| Load a network | Load extension-selected input formats. See the [reader list](python-interface.md#load-and-run-a-network). | +| Power flow | Run the steady-state power-flow solver and inspect bus, generator, and other object collections. | +| Dynamic simulation | Initialize and advance time-domain simulation; inspect recorder samples in memory or write them to files. | +| Network edits | Read and set supported model parameters, including numeric values with unit conversion. | +| File output | Write PYPOWER/MATPOWER static cases and CSV/XML power-flow result summaries through `Simulation.write_file()`. | +| Optimal power flow | Run DC OPF with convex quadratic and piecewise-linear generator costs when built with the optimization library. | Current Python API boundaries include no AC OPF, integer unit commitment, -piecewise-linear cost solution, or general network-construction API. The +or general network-construction API. Nonconvex piecewise costs need the HiGHS +backend; cubic polynomial costs remain unsupported. The static case exporters omit dynamic models, controls, relays, and events. Unsupported export data may be reported in the warnings returned by `Simulation.write_file()`. diff --git a/docs/python-interface.md b/docs/python-interface.md index 441afd0a8..239eab103 100644 --- a/docs/python-interface.md +++ b/docs/python-interface.md @@ -234,8 +234,10 @@ The result dictionary includes `success`, `status`, `message`, `solver`, `objective_value`, iteration count, feasibility/stationarity diagnostics, and whether the dispatch was applied. This entry point handles DC OPF with polynomial costs through quadratic order. AC OPF and integer commitment are -outside its current scope. Piecewise-linear curves are retained and exported; -the current OPF solvers return an unsupported status for them. +outside its current scope. Convex piecewise-linear generator costs are also +supported by the native DC OPF; nonconvex piecewise curves require HiGHS because +their formulation contains binary segment selectors. A nonzero cubic cost term +remains unsupported by this DC OPF. ## Capability notes diff --git a/src/fileInput/fileInput.h b/src/fileInput/fileInput.h index a3d0f0ee7..58583fdcb 100644 --- a/src/fileInput/fileInput.h +++ b/src/fileInput/fileInput.h @@ -12,7 +12,10 @@ define some functions and operations for configuring file reader operations and #include "../griddyn/gridDynDefinitions.hpp" #include "ReaderInfo.h" #include +#include #include +#include +#include namespace griddyn { class Event; @@ -114,6 +117,44 @@ void loadAux(CoreObject* parentObject, const std::string& fileName, const BasicReaderInfo& readerOptions = defInfo); +/** Named PowerWorld object whose numeric ID can be referenced by other AUX rows. */ +struct AuxNamedRecord { + int mNumber = 0; + std::string mName; +}; + +struct AuxSubstationRecord { + int mNumber = 0; + std::string mName; + std::optional mLatitude; + std::optional mLongitude; +}; + +/** Geographic and organizational references from an AUX Bus row. */ +struct AuxBusMetadata { + int mBusNumber = 0; + int mAreaNumber = 0; + int mZoneNumber = 0; + int mOwnerNumber = 0; + int mSubstationNumber = 0; + int mBalancingAuthorityNumber = 0; + std::optional mLatitude; + std::optional mLongitude; +}; + +/** Metadata that does not have a direct GridDyn simulation object. */ +struct AuxCaseMetadata { + std::unordered_map mOwners; + std::unordered_map mZones; + std::unordered_map mBalancingAuthorities; + std::unordered_map mSubstations; + std::unordered_map mBuses; + std::vector mSuperAreas; +}; + +/** Read PowerWorld case hierarchy and bus assignments without changing a simulation. */ +[[nodiscard]] AuxCaseMetadata readAuxCaseMetadata(const std::string& fileName); + void loadDyr(CoreObject* parentObject, const std::string& fileName, const BasicReaderInfo& readerOptions = defInfo); diff --git a/src/fileInput/gridDynReadAUX.cpp b/src/fileInput/gridDynReadAUX.cpp index ec94c3061..e4aec4e41 100644 --- a/src/fileInput/gridDynReadAUX.cpp +++ b/src/fileInput/gridDynReadAUX.cpp @@ -17,6 +17,9 @@ #include "griddyn/loads/Svd.h" #include "griddyn/loads/ZipLoad.h" #include "griddyn/primary/AcBus.h" +#ifdef GRIDDYN_ENABLE_OPTIMIZATION_LIBRARY +# include "optimization/gridDynOpt.h" +#endif #include #include #include @@ -27,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -142,6 +146,13 @@ namespace { } }; + [[nodiscard]] std::optional + auxOptionalNumber(const AuxRow& row, std::initializer_list names) + { + const auto value = row.number(names, std::numeric_limits::quiet_NaN()); + return std::isfinite(value) ? std::optional{value} : std::nullopt; + } + // AUX has both DATA (Object, [columns], ...) and concise Object (columns) headers. AuxSection parseAuxHeader(std::string_view header) { @@ -243,10 +254,12 @@ namespace { using AuxRowHandler = std::function; using AuxSectionFilter = std::function; + using AuxSubdataHandler = std::function; void scanAuxFile(const std::string& fileName, const AuxSectionFilter& wantsSection, - const AuxRowHandler& handleRow) + const AuxRowHandler& handleRow, + const AuxSubdataHandler& handleSubdata = {}) { std::ifstream input(fileName); if (!input) { @@ -258,6 +271,7 @@ namespace { bool inHeader = false; bool inBody = false; bool inSubdata = false; + std::string subdataName; bool active = false; size_t lineNumber = 0; while (std::getline(input, line)) { @@ -282,14 +296,40 @@ namespace { header += trimmed; } else { if (inSubdata) { - // Embedded SUBDATA belongs to its parent PowerWorld object, - // not to the tabular network row that precedes it. if (lowerAux(trimmed).starts_with("'); + if (end == std::string::npos) { + throw std::runtime_error(fileName + ':' + std::to_string(lineNumber) + + ": unterminated AUX SUBDATA tag"); + } + subdataName = lowerAux(trimAux( + std::string_view(trimmed).substr(std::string_view("getName() + "_Gen_" + identifier; auto* generator = dynamic_cast(bus->find(name)); if (generator == nullptr) { - if (!row.has({"genmwsetpoint", "mwsetpoint", "genmwmax", "mwmax"})) { + if (!row.has({"genmwsetpoint", "genmw", "mwsetpoint", "genmwmax", "mwmax"})) { return; // An AUX supplement may only amend an existing generator. } generator = network.mOptions.checkFlag(ASSUME_POWERFLOW_ONLY) ? @@ -529,11 +569,13 @@ namespace { if (row.has({"genstatus", "status"}) && !auxClosed(row.value({"genstatus", "status"}))) { generator->disable(); } - if (row.has({"genmwsetpoint", "mwsetpoint"})) { - generator->set("p", row.number({"genmwsetpoint", "mwsetpoint"}), units::MW); + if (row.has({"genmwsetpoint", "genmw", "mwsetpoint"})) { + generator->set("p", row.number({"genmwsetpoint", "genmw", "mwsetpoint"}), units::MW); } - if (row.has({"genmvrsetpoint", "mvarsetpoint"})) { - generator->set("q", row.number({"genmvrsetpoint", "mvarsetpoint"}), units::MVAR); + if (row.has({"genmvrsetpoint", "genmvr", "mvarsetpoint"})) { + generator->set("q", + row.number({"genmvrsetpoint", "genmvr", "mvarsetpoint"}), + units::MVAR); } if (row.has({"genmwmax", "mwmax"})) { generator->set("pmax", row.number({"genmwmax", "mwmax"}), units::MW); @@ -1033,6 +1075,172 @@ namespace { } } +#ifdef GRIDDYN_ENABLE_OPTIMIZATION_LIBRARY + struct AuxGeneratorCost { + int mBusNumber = 0; + std::string mGeneratorId; + std::string mModel; + double mFixed = 0.0; + double mFuel = 0.0; + double mVariableOm = 0.0; + double mIoA = 0.0; + double mIoB = 0.0; + double mIoC = 0.0; + double mIoD = 0.0; + double mMultiplier = 1.0; + bool mHasUnsupportedExtras = false; + std::vector> mBids; + }; + + void loadAuxOpfCosts(AuxNetwork& network, + const std::string& fileName, + GridDynOptimization& optimization) + { + std::optional pending; + size_t unsupportedCubic = 0; + size_t unsupportedExtras = 0; + size_t missingBids = 0; + size_t unmatchedGenerators = 0; + size_t invalidBids = 0; + const auto finish = [&]() { + if (!pending.has_value() || + (pending->mModel != "cubic" && pending->mModel != "piecewise linear")) { + return; + } + if (pending->mHasUnsupportedExtras) { + ++unsupportedExtras; + return; + } + auto* bus = findAuxBus(network, pending->mBusNumber); + if (bus == nullptr) { + ++unmatchedGenerators; + return; + } + auto* generator = dynamic_cast( + bus->find(bus->getName() + "_Gen_" + pending->mGeneratorId)); + if (generator == nullptr) { + ++unmatchedGenerators; + return; + } + MatPowerCostCurve curve; + const auto multiplier = pending->mMultiplier; + if (pending->mModel == "cubic") { + // GridDyn's DC OPF evaluates costs through quadratic order. + // Never silently discard a nonzero cubic term. + if (pending->mIoD != 0.0) { + ++unsupportedCubic; + return; + } + curve.model = 2; + curve.coefficients = {multiplier * pending->mFuel * pending->mIoC, + multiplier * + ((pending->mFuel * pending->mIoB) + pending->mVariableOm), + multiplier * + (pending->mFixed + (pending->mFuel * pending->mIoA))}; + } else if (pending->mModel == "piecewise linear" && !pending->mBids.empty()) { + // PowerWorld stores the marginal price from each MW break to + // the next. MATPOWER stores total cost at each break instead. + curve.model = 1; + double previousMw = 0.0; + double cost = pending->mFixed * multiplier; + double price = pending->mBids.front().second * multiplier; + curve.coefficients = {0.0, cost}; + for (const auto& [breakpointMw, nextPrice] : pending->mBids) { + if (breakpointMw < previousMw) { + ++invalidBids; + return; + } + // Repeated MW breaks have no width. Keep the last price + // as the slope of the segment to their right. + if (breakpointMw > previousMw) { + cost += (breakpointMw - previousMw) * price; + curve.coefficients.insert(curve.coefficients.end(), {breakpointMw, cost}); + } + previousMw = breakpointMw; + price = nextPrice * multiplier; + } + const double maximumMw = generator->get("pmax", units::MW); + if (maximumMw > previousMw) { + cost += (maximumMw - previousMw) * price; + curve.coefficients.insert(curve.coefficients.end(), {maximumMw, cost}); + } + } else { + ++missingBids; + return; + } + if (curve.valid()) { + optimization.setGeneratorCostCurve(generator, curve); + } else { + ++invalidBids; + } + }; + scanAuxFile( + fileName, + [](std::string_view type) { return type == "gen"; }, + [&](const AuxRow& row) { + finish(); + pending.reset(); + if (!row.has({"gencostmodel"})) { + return; + } + AuxGeneratorCost cost; + cost.mBusNumber = row.integer({"busnum"}); + cost.mGeneratorId = trimAux(row.value({"genid"})); + if (cost.mGeneratorId.empty()) { + cost.mGeneratorId = "1"; + } + cost.mModel = lowerAux(trimAux(row.value({"gencostmodel"}))); + cost.mFixed = row.number({"genfixedcost"}); + cost.mFuel = row.number({"genfuelcost"}); + cost.mVariableOm = row.number({"genvariableom"}); + cost.mIoA = row.number({"genioa"}); + cost.mIoB = row.number({"geniob"}); + cost.mIoC = row.number({"genioc"}); + cost.mIoD = row.number({"geniod"}); + cost.mMultiplier = row.number({"gencostmultiplier"}, 1.0); + // These PowerWorld adjustments need a separate, verified + // mapping; omitting them would change the OPF objective. + cost.mHasUnsupportedExtras = row.number({"genextrafuelcost"}) != 0.0 || + row.number({"genextraomcost"}) != 0.0 || row.number({"gencostshift"}) != 0.0; + pending = std::move(cost); + }, + [&](std::string_view name, std::string_view line, size_t /*lineNumber*/) { + if (!pending.has_value() || name != "bidcurve") { + return; + } + const auto values = tokenizeAuxRow(line); + if (values.size() != 2U) { + throw std::runtime_error( + "AUX generator BidCurve requires MW and marginal price"); + } + std::size_t used = 0; + const double breakpointMw = std::stod(values[0], &used); + if (used != values[0].size() || !std::isfinite(breakpointMw) || + breakpointMw < 0.0) { + throw std::runtime_error("invalid AUX generator BidCurve MW break"); + } + const double price = std::stod(values[1], &used); + if (used != values[1].size() || !std::isfinite(price)) { + throw std::runtime_error("invalid AUX generator BidCurve marginal price"); + } + pending->mBids.emplace_back(breakpointMw, price); + }); + finish(); + if (unsupportedCubic + unsupportedExtras + missingBids + unmatchedGenerators + + invalidBids != + 0U) { + optimization.log(&optimization, + PrintLevel::WARNING, + "AUX OPF costs skipped: " + std::to_string(unsupportedCubic) + + " nonzero cubic terms, " + std::to_string(unsupportedExtras) + + " unmodeled cost adjustments, " + std::to_string(missingBids) + + " missing BidCurve data, " + std::to_string(unmatchedGenerators) + + " unmatched generators, " + std::to_string(invalidBids) + + " invalid curves"); + } + } +#endif + } // namespace void loadAux(CoreObject* parentObject, @@ -1055,8 +1263,9 @@ void loadAux(CoreObject* parentObject, if (!network.mExistingNetwork) { GridSimulation::resetObjectCounters(); } - // Stream the file three times rather than buffering it. Areas and base - // power must precede buses, which must precede equipment references. + // Stream the network in three passes rather than buffering it. Areas and + // base power precede buses and equipment; optimization costs need a fourth + // pass after their generator objects exist. scanAuxFile( fileName, [](std::string_view type) { @@ -1075,7 +1284,9 @@ void loadAux(CoreObject* parentObject, }, [&](const AuxRow& row) { if (row.mSection.mType == "gen") { - readAuxGenerator(network, row); + if (row.has({"genmwsetpoint", "genmw", "genmwmax", "genmwmin", "genstatus"})) { + readAuxGenerator(network, row); + } } else if (row.mSection.mType == "load") { readAuxLoad(network, row); } else if (row.mSection.mType == "shunt") { @@ -1089,6 +1300,113 @@ void loadAux(CoreObject* parentObject, } }); finishAuxGeneratorControls(network); +#ifdef GRIDDYN_ENABLE_OPTIMIZATION_LIBRARY + auto* optimization = dynamic_cast(parentObject->getRoot()); + if (optimization != nullptr) { + loadAuxOpfCosts(network, fileName, *optimization); + } +#endif +} + +AuxCaseMetadata readAuxCaseMetadata(const std::string& fileName) +{ + AuxCaseMetadata metadata; + scanAuxFile( + fileName, + [](std::string_view type) { + return type == "owner" || type == "zone" || type == "substation" || + type == "superarea" || type == "balancingauthority" || type == "bus"; + }, + [&](const AuxRow& row) { + const auto& type = row.mSection.mType; + if (type == "owner") { + const int number = row.integer({"ownernum"}); + if (number <= 0) { + return; + } + auto& owner = metadata.mOwners[number]; + owner.mNumber = number; + if (auto name = trimAux(row.value({"ownername"})); !name.empty()) { + owner.mName = std::move(name); + } + } else if (type == "zone") { + const int number = row.integer({"zonenum"}); + if (number <= 0) { + return; + } + auto& zone = metadata.mZones[number]; + zone.mNumber = number; + if (auto name = trimAux(row.value({"zonename"})); !name.empty()) { + zone.mName = std::move(name); + } + } else if (type == "balancingauthority") { + const int number = row.integer({"banumber"}); + if (number <= 0) { + return; + } + auto& authority = metadata.mBalancingAuthorities[number]; + authority.mNumber = number; + if (auto name = trimAux(row.value({"baname"})); !name.empty()) { + authority.mName = std::move(name); + } + } else if (type == "substation") { + const int number = row.integer({"subnum"}); + if (number <= 0) { + return; + } + auto& substation = metadata.mSubstations[number]; + substation.mNumber = number; + if (auto name = trimAux(row.value({"subname"})); !name.empty()) { + substation.mName = std::move(name); + } + if (auto latitude = auxOptionalNumber(row, {"latitude"}); latitude.has_value()) { + substation.mLatitude = latitude; + } + if (auto longitude = auxOptionalNumber(row, {"longitude"}); longitude.has_value()) { + substation.mLongitude = longitude; + } + } else if (type == "superarea") { + auto name = trimAux(row.value({"saname"})); + if (!name.empty() && + std::find(metadata.mSuperAreas.begin(), metadata.mSuperAreas.end(), name) == + metadata.mSuperAreas.end()) { + metadata.mSuperAreas.push_back(std::move(name)); + } + } else if (type == "bus") { + const int number = row.integer({"busnum"}); + if (number <= 0) { + return; + } + auto& bus = metadata.mBuses[number]; + bus.mBusNumber = number; + // A later Bus section may supply only a few fields. Retain + // assignments obtained from earlier sections of the same file. + if (row.has({"areanum"})) { + bus.mAreaNumber = row.integer({"areanum"}); + } + if (row.has({"zonenum"})) { + bus.mZoneNumber = row.integer({"zonenum"}); + } + if (row.has({"ownernum"})) { + bus.mOwnerNumber = row.integer({"ownernum"}); + } + if (row.has({"subnum"})) { + bus.mSubstationNumber = row.integer({"subnum"}); + } + if (row.has({"banumber"})) { + bus.mBalancingAuthorityNumber = row.integer({"banumber"}); + } + if (auto latitude = auxOptionalNumber(row, {"latitude:1", "latitude"}); + latitude.has_value()) { + bus.mLatitude = latitude; + } + if (auto longitude = auxOptionalNumber(row, {"longitude:1", "longitude"}); + longitude.has_value()) { + bus.mLongitude = longitude; + } + } + }); + return metadata; } } // namespace griddyn diff --git a/test/libraryTests/testSolverReader.cpp b/test/libraryTests/testSolverReader.cpp index 52a8d9904..bbb69c982 100644 --- a/test/libraryTests/testSolverReader.cpp +++ b/test/libraryTests/testSolverReader.cpp @@ -5,6 +5,7 @@ */ #include "../gtestHelper.h" +#include "fileInput/fileInput.h" #include "fileInput/readerHelper.h" #include "griddyn/Generator.h" #include "griddyn/GridArea.h" @@ -228,6 +229,35 @@ namespace { EXPECT_EQ(gds->getInt("totallinkcount"), 2); } + TEST_F(SolverReaderTests, AuxCaseMetadataKeepsNamedReferencesAndBusAssignments) + { + const auto metadata = + readAuxCaseMetadata(std::string{inputTestDirectory} + "aux_case_metadata.aux"); + ASSERT_EQ(metadata.mOwners.size(), 1); + EXPECT_EQ(metadata.mOwners.at(7).mName, "First Owner"); + ASSERT_EQ(metadata.mZones.size(), 2); + EXPECT_EQ(metadata.mZones.at(4).mName, "Inland"); + ASSERT_EQ(metadata.mSubstations.size(), 1); + const auto& substation = metadata.mSubstations.at(20); + EXPECT_EQ(substation.mName, "North Yard"); + EXPECT_EQ(substation.mLatitude, std::optional{35.1}); + EXPECT_EQ(substation.mLongitude, std::optional{-118.1}); + ASSERT_EQ(metadata.mSuperAreas.size(), 1); + EXPECT_EQ(metadata.mSuperAreas.front(), "Western System"); + ASSERT_EQ(metadata.mBalancingAuthorities.size(), 1); + EXPECT_EQ(metadata.mBalancingAuthorities.at(9).mName, "Test BA"); + ASSERT_EQ(metadata.mBuses.size(), 2); + const auto& firstBus = metadata.mBuses.at(101); + EXPECT_EQ(firstBus.mAreaNumber, 5); + EXPECT_EQ(firstBus.mZoneNumber, 4); + EXPECT_EQ(firstBus.mOwnerNumber, 7); + EXPECT_EQ(firstBus.mSubstationNumber, 20); + EXPECT_EQ(firstBus.mBalancingAuthorityNumber, 9); + EXPECT_EQ(firstBus.mLatitude, std::optional{35.2}); + EXPECT_EQ(firstBus.mLongitude, std::optional{-118.2}); + EXPECT_FALSE(metadata.mBuses.at(102).mLatitude.has_value()); + } + TEST_F(SolverReaderTests, AuxRemoteGeneratorRegulation) { gds = std::make_unique(); @@ -319,5 +349,22 @@ namespace { ASSERT_EQ(gds->powerflow(), 0); EXPECT_EQ(gds->currentProcessState(), GridDynSimulation::GridState::POWERFLOW_COMPLETE); } + + TEST_F(SolverReaderTests, AuxActivsg10kMetadataWhenCaseIsAvailable) + { + const char* path = std::getenv("GRIDDYN_ACTIVSG10K_AUX"); + if (path == nullptr || *path == '\0') { + GTEST_SKIP() << "Set GRIDDYN_ACTIVSG10K_AUX to run the full-case metadata regression"; + } + const auto metadata = readAuxCaseMetadata(path); + EXPECT_EQ(metadata.mBuses.size(), 10000); + EXPECT_FALSE(metadata.mOwners.empty()); + EXPECT_FALSE(metadata.mZones.empty()); + EXPECT_FALSE(metadata.mSubstations.empty()); + const auto& firstBus = metadata.mBuses.at(10001); + EXPECT_EQ(firstBus.mSubstationNumber, 10000); + ASSERT_TRUE(firstBus.mLatitude.has_value()); + ASSERT_TRUE(firstBus.mLongitude.has_value()); + } } // namespace } // namespace griddyn diff --git a/test/optimizationTests/testDcFormulation.cpp b/test/optimizationTests/testDcFormulation.cpp index 3e2949b5d..c193cf810 100644 --- a/test/optimizationTests/testDcFormulation.cpp +++ b/test/optimizationTests/testDcFormulation.cpp @@ -938,6 +938,100 @@ TEST(OptimizationDcFormulationTests, NativeOptimizerMaterializesConvexPiecewiseL static_cast(root->constraintSize(mode))); } +TEST(OptimizationDcFormulationTests, AuxImportsCubicAndMarginalBidCosts) +{ + auto gds = std::make_unique(); + const auto filePath = + std::filesystem::path{GRIDDYN_TEST_DIRECTORY} / "input_tests" / "aux_opf_costs.aux"; + griddyn::loadFile(gds.get(), filePath.string()); + + auto* bus1 = dynamic_cast(gds->findByUserID("bus", 1)); + auto* bus2 = dynamic_cast(gds->findByUserID("bus", 2)); + ASSERT_NE(bus1, nullptr); + ASSERT_NE(bus2, nullptr); + auto* generator1 = bus1->getGen(0); + auto* generator2 = bus2->getGen(0); + ASSERT_NE(generator1, nullptr); + ASSERT_NE(generator2, nullptr); + + const auto* cubic = gds->matPowerCostCurve(generator1); + ASSERT_NE(cubic, nullptr); + EXPECT_EQ(cubic->model, 2); + EXPECT_EQ(cubic->coefficients, (std::vector{0.2, 10.0, 7.0})); + + const auto* bid = gds->matPowerCostCurve(generator2); + ASSERT_NE(bid, nullptr); + EXPECT_EQ(bid->model, 1); + EXPECT_EQ(bid->coefficients, + (std::vector{0.0, 5.0, 20.0, 205.0, 40.0, 445.0, 60.0, 725.0})); + + const auto mode = makeDcMode(griddyn::LinearityMode::QUADRATIC); + gds->initializeOptimizationModel(mode); + auto* root = gds->getOptimizationObject(); + ASSERT_NE(root, nullptr); + griddyn::NativeOptimizer optimizer(gds.get(), mode); + ASSERT_EQ(optimizer.allocate(root->objSize(mode), root->constraintSize(mode)), + FUNCTION_EXECUTION_SUCCESS); + optimizer.initialize(0.0); + ASSERT_EQ(optimizer.prepareProblemData(0.0), FUNCTION_EXECUTION_SUCCESS) + << optimizer.getLastErrorString(); +#ifdef GRIDDYN_ENABLE_HIGHS + griddyn::HighsOptimizer highs(gds.get(), mode); + ASSERT_EQ(highs.allocate(root->objSize(mode), root->constraintSize(mode)), + FUNCTION_EXECUTION_SUCCESS); + highs.initialize(0.0); + double returnTime = -1.0; + ASSERT_EQ(highs.solve(0.0, returnTime), FUNCTION_EXECUTION_SUCCESS) + << highs.lastSolveResult().message; + EXPECT_EQ(highs.lastSolveResult().status, griddyn::NativeSolveStatus::OPTIMAL); +#endif +} + +TEST(OptimizationDcFormulationTests, AuxActivsg2000CostWhenCaseIsAvailable) +{ + const char* caseFile = std::getenv("GRIDDYN_ACTIVSG2000_AUX"); + if (caseFile == nullptr || *caseFile == '\0') { + GTEST_SKIP() << "Set GRIDDYN_ACTIVSG2000_AUX to run the full-case cost check"; + } + auto gds = std::make_unique(); + griddyn::loadFile(gds.get(), caseFile); + auto* bus = dynamic_cast(gds->findByUserID("bus", 2057)); + ASSERT_NE(bus, nullptr); + auto* generator = bus->getGen(0); + ASSERT_NE(generator, nullptr); + const auto* curve = gds->matPowerCostCurve(generator); + ASSERT_NE(curve, nullptr); + EXPECT_EQ(curve->model, 2); + ASSERT_EQ(curve->coefficients.size(), 3U); + EXPECT_GT(curve->coefficients[0], 0.0); + EXPECT_GT(curve->coefficients[1], 0.0); +} + +TEST(OptimizationDcFormulationTests, AuxTexas7kBidWhenCaseIsAvailable) +{ + const char* caseFile = std::getenv("GRIDDYN_TEXAS7K_AUX"); + if (caseFile == nullptr || *caseFile == '\0') { + GTEST_SKIP() << "Set GRIDDYN_TEXAS7K_AUX to run the full-case bid check"; + } + auto gds = std::make_unique(); + griddyn::loadFile(gds.get(), caseFile); + auto* bus = dynamic_cast(gds->findByUserID("bus", 111180)); + ASSERT_NE(bus, nullptr); + auto* generator = bus->getGen(0); + ASSERT_NE(generator, nullptr); + const auto* curve = gds->matPowerCostCurve(generator); + ASSERT_NE(curve, nullptr); + EXPECT_EQ(curve->model, 1); + EXPECT_GE(curve->coefficients.size(), 4U); + if (curve->coefficients.size() >= 4U) { + EXPECT_NEAR(curve->coefficients[2], 94.0, 1e-8); + EXPECT_NEAR((curve->coefficients[3] - curve->coefficients[1]) / + (curve->coefficients[2] - curve->coefficients[0]), + 45.83, + 1e-8); + } +} + #ifdef GRIDDYN_ENABLE_HIGHS TEST(OptimizationDcFormulationTests, HighsOptimizerSolvesNonconvexPiecewiseLinearCost) { diff --git a/test/test_files/input_tests/aux_case_metadata.aux b/test/test_files/input_tests/aux_case_metadata.aux new file mode 100644 index 000000000..f6f78ecd0 --- /dev/null +++ b/test/test_files/input_tests/aux_case_metadata.aux @@ -0,0 +1,35 @@ +// Case hierarchy can be read independently of network creation. +DATA (Owner, [OwnerName,OwnerNum]) +{ + "First Owner" 7 +} +DATA (Owner, [OwnerNum,DataMaintainerAssign]) +{ + 7 "" +} +DATA (Zone, [ZoneNum,ZoneName]) +{ + 3 "Coastal" + 4 "Inland" +} +DATA (Substation, [Latitude,SubName,SubNum,Longitude]) +{ + 35.1 "North Yard" 20 -118.1 +} +DATA (SuperArea, [SAName]) +{ + "Western System" +} +DATA (BalancingAuthority, [BAName,BANumber]) +{ + "Test BA" 9 +} +DATA (Bus, [BusNum,SubNum,ZoneNum,OwnerNum,AreaNum,BANumber,Latitude:1,Longitude:1]) +{ + 101 20 3 7 5 9 35.2 -118.2 +} +DATA (Bus, [BusNum,ZoneNum]) +{ + 101 4 + 102 3 +} diff --git a/test/test_files/input_tests/aux_opf_costs.aux b/test/test_files/input_tests/aux_opf_costs.aux new file mode 100644 index 000000000..d8168b68a --- /dev/null +++ b/test/test_files/input_tests/aux_opf_costs.aux @@ -0,0 +1,38 @@ +// Cubic I/O data and a separate PowerWorld marginal BidCurve section. +DATA (Sim_Solution_Options_Value, [VariableName, ValueField]) +{ + "MvaBase" 100 +} +DATA (Bus, [BusNum, BusName, BusNomVolt, BusPUVolt, BusSlack]) +{ + 1 "ONE" 230 1 YES + 2 "TWO" 230 1 NO +} +DATA (Gen, [GenID, BusNum, GenStatus, GenMWSetPoint, GenMWMin, GenMWMax, + GenCostModel, GenFuelCost, GenIOA, GenIOB, GenIOC, GenIOD, + GenFixedCost, GenVariableOM]) +{ + "A" 1 Closed 20 0 80 "Cubic" 2 0 3 0.1 0 7 4 + "B" 2 Closed 30 0 60 "None" 0 0 0 0 0 0 0 +} +DATA (Gen, [BusNum, GenID, GenCostModel, GenFixedCost, GenCostMultiplier]) +{ + 2 "B" "Piecewise Linear" 5 1 + + // MW Price[$/MWhr], used as the slope beginning at this MW + 0 10 + 20 12 + // PowerWorld exports can repeat an MW break with no intervening segment. + 20 12 + 40 14 + +} +DATA (Load, [BusNum, LoadID, LoadStatus, LoadSMW, LoadSMVR]) +{ + 2 "1" Closed 50 0 +} +DATA (Branch, [BusNum, BusNum:1, LineCircuit, BranchDeviceType, + LineStatus, LineR, LineX, LineAMVA]) +{ + 1 2 "1" Line Closed 0 0.1 100 +}