diff --git a/VERSION.txt b/VERSION.txt index 56a2b781e..b1659d5c4 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -1.20261009.5 +1.20261009.6 diff --git a/src/api/libopencor/sedinstance.h b/src/api/libopencor/sedinstance.h index ae581bc9b..9bdef29ad 100644 --- a/src/api/libopencor/sedinstance.h +++ b/src/api/libopencor/sedinstance.h @@ -72,7 +72,8 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger /** * @brief Run all the tasks associated with this instance. * - * Run all the tasks associated with this instance. + * Run all the tasks associated with this instance. If a run is already in progress (see @ref startRun), then wait + * for it to complete before running all the tasks associated with this instance. * * @return The elapsed time in milliseconds. */ @@ -82,7 +83,9 @@ class LIBOPENCOR_EXPORT SedInstance: public Logger /** * @brief Start running, in a background thread, all the tasks associated with this instance. * - * Start running, in a background thread, all the tasks associated with this instance. + * Start running, in a background thread, all the tasks associated with this instance. The results of the tasks + * are (re)allocated before this method returns, so they can be retrieved while the tasks are being run (e.g., to + * plot them progressively). The simulation settings used are those in effect when this method is called. * * @return @c true if a new run was started, @c false if a run is already in progress. */ diff --git a/src/bindings/python/sed.cpp b/src/bindings/python/sed.cpp index 71dd9d6d1..4c76b2469 100644 --- a/src/bindings/python/sed.cpp +++ b/src/bindings/python/sed.cpp @@ -103,7 +103,7 @@ void sedApi(nb::module_ &m) sedInstance.def_prop_ro("status", &libOpenCOR::SedInstance::status, "Return the status of this instance.") .def("run", &libOpenCOR::SedInstance::run, "Run all the tasks associated with this instance.", nb::call_guard()) - .def("start_run", &libOpenCOR::SedInstance::startRun, "Start running, in a background thread, all the tasks associated with this instance.") + .def("start_run", &libOpenCOR::SedInstance::startRun, "Start running, in a background thread, all the tasks associated with this instance.", nb::call_guard()) .def("wait_for_run", &libOpenCOR::SedInstance::waitForRun, "Wait for any currently-running instance to complete.", nb::call_guard()) .def("pause_run", &libOpenCOR::SedInstance::pauseRun, "Pause a currently-running instance.") .def("resume_run", &libOpenCOR::SedInstance::resumeRun, "Resume a currently-paused instance.") diff --git a/src/sed/sedinstance.cpp b/src/sed/sedinstance.cpp index 1306a617d..bd00b2a01 100644 --- a/src/sed/sedinstance.cpp +++ b/src/sed/sedinstance.cpp @@ -21,18 +21,11 @@ limitations under the License. #include "libopencor/seddocument.h" #include -#include #include #include namespace libOpenCOR { -namespace { - -constexpr auto ZERO_WAIT {std::chrono::milliseconds {0}}; - -} // namespace - SedInstancePtr SedInstance::Impl::create(const SedDocumentPtr &pDocument) { return SedInstancePtr {new SedInstance(pDocument)}; @@ -105,8 +98,12 @@ SedInstance::Status SedInstance::Impl::status() const return Status::RUNNING; } -double SedInstance::Impl::run() +void SedInstance::Impl::prepareRun() { + // Note: we are called from the thread that runs us or that starts running us (see run() and startRun()), i.e. + // before our tasks actually get run. This means that our issues are reset and the results of our tasks + // (re)allocated before our tasks get run, so that they can be safely retrieved while our tasks are being run. + // Reset ourselves by restoring the issues of all the tasks. // Note: the clearing of mIssues, mErrors, and mWarnings could be done using removeAllIssues(), but this would // result in transiently-empty issues, which could be seen by a reader. So, instead, we just clear and restore @@ -131,10 +128,9 @@ double SedInstance::Impl::run() } // Make sure that our control flags are passed to each task so that they can be used by them. - // Note: our control flags are reset by our callers (see SedInstance::run() and startRun()) rather than here. - // Indeed, when called from startRun(), we are run on a separate thread, i.e. some time after startRun() has - // returned. So, if we were to reset our control flags here, a stop or pause requested in between would be - // lost. + // Note: our control flags are reset by our callers (see run() and startRun()) rather than here. Indeed, they are + // reset before a run is started and they must not be reset afterwards, or a stop or pause requested in + // between would be lost. for (const auto &task : mTasks) { task->pimpl()->mRunControl = &mRunControl; @@ -143,23 +139,23 @@ double SedInstance::Impl::run() task->pimpl()->mPauseConditionVariable = &mPauseConditionVariable; } - // Run all the tasks associated with this instance unless they have some issues. + // Prepare all the tasks associated with this instance to be run unless they have some issues. // Note: a task may throw an exception (e.g., std::bad_alloc if its results cannot be allocated), in which case we - // report it as an error rather than let it escape. Indeed, run() may be called from startRun(), i.e. on a - // separate thread, and an escaping exception would leave us in a state from which we cannot recover. Also, - // we have no way to trigger such an exception in our tests, hence we ignore our try...catch statement during - // code coverage. + // report it as an error rather than let it escape and we don't run any of our tasks. We have no way to + // trigger such an exception in our tests, hence we ignore our try...catch statement during code coverage. - auto res {0.0}; + mTasksToRun.clear(); #ifndef CODE_COVERAGE_ENABLED try { #endif + mTasksToRun.reserve(mTasks.size()); + for (const auto &task : mTasks) { if (!task->hasIssues()) { - res += task->pimpl()->run(); - - if (task->hasIssues()) { + if (task->pimpl()->prepareRun()) { + mTasksToRun.push_back(task); + } else { addIssues(task, "Task"); // Reset the issues of the task so that they are not reported again should the instance be run @@ -169,6 +165,43 @@ double SedInstance::Impl::run() } } } +#ifndef CODE_COVERAGE_ENABLED + } catch (const std::exception &exception) { + mTasksToRun.clear(); + + addError(std::string("The simulation failed: ") + exception.what() + "."); + } catch (...) { + mTasksToRun.clear(); + + addError("The simulation failed."); + } +#endif +} + +double SedInstance::Impl::executeRun() +{ + // Run all the tasks that were prepared to be run (see prepareRun()). + // Note: a task may throw an exception, in which case we report it as an error rather than let it escape. Indeed, we + // may be called from startRun(), i.e. on a separate thread, and an escaping exception would leave us in a + // state from which we cannot recover. Also, we have no way to trigger such an exception in our tests, hence + // we ignore our try...catch statement during code coverage. + + auto res {0.0}; + +#ifndef CODE_COVERAGE_ENABLED + try { +#endif + for (const auto &task : mTasksToRun) { + res += task->pimpl()->run(); + + if (task->hasIssues()) { + addIssues(task, "Task"); + + // Reset the issues of the task so that they are not reported again should the instance be run again. + + task->pimpl()->removeAllIssues(); + } + } #ifndef CODE_COVERAGE_ENABLED } catch (const std::exception &exception) { addError(std::string("The simulation failed: ") + exception.what() + "."); @@ -179,6 +212,8 @@ double SedInstance::Impl::run() // Reset and make sure that our control flags are no longer passed to each task. + mTasksToRun.clear(); + for (const auto &task : mTasks) { task->pimpl()->mRunControl = nullptr; @@ -189,28 +224,70 @@ double SedInstance::Impl::run() return res; } +double SedInstance::Impl::run() +{ + const std::scoped_lock runLock(mRunMutex); + + // Wait for any asynchronous run to complete since our tasks cannot be run while they are already being run. + + if (mRunFuture.valid()) { + mLastRunElapsedTime.store(mRunFuture.get(), std::memory_order_relaxed); + } + + // Reset our control flags (see the note in prepareRun()). + + mRunControl.store(INSTANCE_RUN_CONTROL_NONE, std::memory_order_relaxed); + + // Prepare and execute our run, making sure that we are flagged as running while doing so, and as not running + // anymore once done, even if an exception is thrown. + + prepareRun(); + + mRunning.store(true, std::memory_order_release); + + auto resetRunning = [](std::atomic *pRunning) { + pRunning->store(false, std::memory_order_release); + }; + const std::unique_ptr, decltype(resetRunning)> runningGuard {&mRunning, resetRunning}; + + return executeRun(); +} + bool SedInstance::Impl::startRun() { const std::scoped_lock runLock(mRunMutex); + // Make sure that no run is in progress and, if a previous run is done, retrieve its elapsed time. + // Note: our previous run is flagged as not running anymore just before its future becomes ready (see below), so we + // check whether we are running rather than whether our future is ready. Otherwise, a caller that waited for + // status() to be IDLE before calling us might be told that a run is still in progress. This means that + // retrieving the elapsed time of our previous run may require waiting for its future to become ready, but + // only for the very short time that it takes for our previous run to complete. + if (mRunFuture.valid()) { - if (mRunFuture.wait_for(ZERO_WAIT) != std::future_status::ready) { + if (mRunning.load(std::memory_order_acquire)) { return false; } mLastRunElapsedTime.store(mRunFuture.get(), std::memory_order_relaxed); } - // Reset our control flags (see the note in run()). + // Reset our control flags (see the note in prepareRun()). mRunControl.store(INSTANCE_RUN_CONTROL_NONE, std::memory_order_relaxed); + // Prepare our run. + // Note: this must be done here rather than on the separate thread below so that the results of our tasks are + // (re)allocated before we return, i.e. so that they can be safely retrieved as soon as we return. + + prepareRun(); + mRunning.store(true, std::memory_order_release); - // Start our run in a separate thread. - // Note #1: we must be flagged as not running anymore once our run is done, even if run() throws an exception (it - // reports a failure as an issue, but it might still throw, e.g., std::bad_alloc when restoring our - // issues), hence we use a guard to do so. Otherwise, we would be stuck in RUNNING. + // Execute our run in a separate thread. + // Note #1: we must be flagged as not running anymore once our run is done, even if executeRun() throws an exception + // (it reports a failure as an issue, but it might still throw, e.g., std::bad_alloc when adding an issue), + // hence we use a guard to do so. Otherwise, we would be stuck in RUNNING. // Note #2: std::async() may throw an exception (e.g., std::system_error if no thread could be created), in which // case we must also be flagged as not running anymore. We have no way to trigger such an exception in our // tests, hence we ignore our try...catch statement during code coverage. @@ -224,7 +301,7 @@ bool SedInstance::Impl::startRun() }; const std::unique_ptr, decltype(resetRunning)> runningGuard {&mRunning, resetRunning}; - return run(); + return executeRun(); }); #ifndef CODE_COVERAGE_ENABLED } catch (...) { @@ -368,10 +445,6 @@ SedInstance::Status SedInstance::status() const noexcept double SedInstance::run() { - // Reset our control flags (see the note in SedInstance::Impl::run()). - - pimpl()->mRunControl.store(INSTANCE_RUN_CONTROL_NONE, std::memory_order_relaxed); - return pimpl()->run(); } diff --git a/src/sed/sedinstance_p.h b/src/sed/sedinstance_p.h index 5321e933a..d0f146619 100644 --- a/src/sed/sedinstance_p.h +++ b/src/sed/sedinstance_p.h @@ -31,6 +31,7 @@ class SedInstance::Impl: public Logger::Impl { public: SedInstanceTaskPtrs mTasks; + SedInstanceTaskPtrs mTasksToRun; IssuePtrs mTasksIssues; IssuePtrs mTasksErrors; @@ -52,6 +53,9 @@ class SedInstance::Impl: public Logger::Impl Status status() const; + void prepareRun(); + double executeRun(); + double run(); bool startRun(); double waitForRun(); diff --git a/src/sed/sedinstancetask.cpp b/src/sed/sedinstancetask.cpp index 97f284661..cddcb86fc 100644 --- a/src/sed/sedinstancetask.cpp +++ b/src/sed/sedinstancetask.cpp @@ -98,6 +98,13 @@ SedInstanceTask::Impl::Impl(const SedAbstractTaskPtr &pTask) ASSERT_EQ(mDifferentialModel, (mSedUniformTimeCourse != nullptr)); + // Keep track of our initial time since it is needed to initialise ourselves (see initialise()). + // Note: our other times and our number of steps are only needed when running ourselves (see prepareRun()). + + if (mDifferentialModel) { + mInitialTime = mSedUniformTimeCourse->pimpl()->mInitialTime; + } + const auto &odeSolver {mSimulation->odeSolver()}; const auto &nlaSolver {mSimulation->nlaSolver()}; @@ -261,6 +268,54 @@ SedInstanceTask::Impl::Impl(const SedAbstractTaskPtr &pTask) } } +void SedInstanceTask::Impl::allocateResults(size_t pResultsSize) +{ + // Allocate our results, but only if their size has changed. + // Note #1: we only (re)allocate our results if their size has changed. This avoids needless allocations, but it + // also means that our results remain where they are in memory, which matters since our Python bindings + // return zero-copy NumPy arrays. + // Note #2: when (re)allocating our results, we release our previous results first (to limit our peak memory usage) + // and only then allocate our new results, which we do into a local structure that we move into ours once + // all of its arrays have been allocated. This means that should an allocation fail (e.g., std::bad_alloc), + // our results structure would be left empty rather than with an inconsistent results size and arrays of + // mismatched sizes (which would result in out-of-bounds accesses when retrieving our results). + // Note #3: on a 32-bit platform (e.g., WebAssembly), the size of an array may overflow (e.g., with many constants + // and a large number of steps), in which case we would allocate an array that is too small for our + // results, hence we check for it. We cannot trigger such an overflow on a 64-bit platform, so we ignore + // our check during code coverage. + + if (mResults.resultsSize == pResultsSize) { + return; + } + + mResults = {}; + + auto arraySize = [pResultsSize](size_t pCount) { +#ifndef CODE_COVERAGE_ENABLED + if ((pCount != 0) && (pResultsSize > (std::numeric_limits::max() / pCount))) { + throw std::length_error("the results are too large to be allocated"); + } +#endif + + return pCount * pResultsSize; + }; + SedInstanceTaskResults results; + + results.resultsSize = pResultsSize; + + if (mDifferentialModel) { + results.voi.resize(pResultsSize); + } + + results.states.resize(arraySize(mStateCount)); + results.rates.resize(arraySize(mStateCount)); + results.constants.resize(arraySize(mConstantCount)); + results.computedConstants.resize(arraySize(mComputedConstantCount)); + results.algebraicVariables.resize(arraySize(mAlgebraicVariableCount)); + + mResults = std::move(results); +} + void SedInstanceTask::Impl::trackResults(size_t pIndex) { mResults.voi[pIndex] = mVoi; @@ -283,6 +338,31 @@ void SedInstanceTask::Impl::trackResults(size_t pIndex) } } +void SedInstanceTask::Impl::nanFillResults(size_t pFromIndex) +{ + // Fill our results with NaN values from the given index onwards. + + auto nanFillRowTails = [pFromIndex, this](Doubles &pResults, size_t pCount) { + for (size_t i {0}; i < pCount; ++i) { + const auto rowStart {i * mResults.resultsSize}; + + std::fill(pResults.begin() + static_cast(rowStart + pFromIndex), + pResults.begin() + static_cast(rowStart + mResults.resultsSize), + NAN); + } + }; + + std::fill(mResults.voi.begin() + static_cast(pFromIndex), + mResults.voi.end(), + NAN); + + nanFillRowTails(mResults.states, mStateCount); + nanFillRowTails(mResults.rates, mStateCount); + nanFillRowTails(mResults.constants, mConstantCount); + nanFillRowTails(mResults.computedConstants, mComputedConstantCount); + nanFillRowTails(mResults.algebraicVariables, mAlgebraicVariableCount); +} + void SedInstanceTask::Impl::applyChanges() { // Retrieve our changes (see SedChangeAttribute::Impl::apply()). @@ -358,7 +438,7 @@ void SedInstanceTask::Impl::initialise() // initialise our variables and compute computed constants and variables. if (mSedUniformTimeCourse != nullptr) { - mVoi = mSedUniformTimeCourse->pimpl()->mInitialTime; + mVoi = mInitialTime; mRuntime->initialiseArraysForDifferentialModel()(mStates, mRates, mConstants, mComputedConstants, mAlgebraicVariables); } else { @@ -410,29 +490,9 @@ void SedInstanceTask::Impl::run(double pVoiStart, double pVoiEnd, double pVoiInt // reaching the end of our simulation. auto guard = [this, &index, pTrackResults]() { - if (!pTrackResults) { - return; + if (pTrackResults) { + nanFillResults(index + 1); } - - auto nanFillRowTails = [index, this](Doubles &pResults, size_t pCount) { - for (size_t i {0}; i < pCount; ++i) { - const auto rowStart {i * mResults.resultsSize}; - - std::fill(pResults.begin() + static_cast(rowStart + index + 1), - pResults.begin() + static_cast(std::min(rowStart + mResults.resultsSize, pResults.size())), - NAN); - } - }; - - std::fill(mResults.voi.begin() + static_cast(index + 1), - mResults.voi.end(), - NAN); - - nanFillRowTails(mResults.states, mStateCount); - nanFillRowTails(mResults.rates, mStateCount); - nanFillRowTails(mResults.constants, mConstantCount); - nanFillRowTails(mResults.computedConstants, mComputedConstantCount); - nanFillRowTails(mResults.algebraicVariables, mAlgebraicVariableCount); }; // Compute the differential model. @@ -534,24 +594,26 @@ void SedInstanceTask::Impl::run(double pVoiStart, double pVoiEnd, double pVoiInt } } -double SedInstanceTask::Impl::run() +bool SedInstanceTask::Impl::prepareRun() { - // Start our timer. - - auto startTime {std::chrono::high_resolution_clock::now()}; + // Prepare ourselves to be run. + // Note: we are called from the thread that runs us or that starts running us (see SedInstance::Impl::prepareRun()), + // i.e. before we actually get run. This means that our results are (re)allocated before we get run, so that + // they can be safely retrieved while we are being run (e.g., to plot them progressively). This also means + // that our simulation settings are those that were in effect when we were asked to be run, even if they get + // changed while we are being run. // Reset our progress counters. mCompletedSteps.store(0, std::memory_order_relaxed); mTotalSteps.store(0, std::memory_order_relaxed); - // Make sure that our simulation is valid. + // Make sure that our simulation is valid, if needed, and keep track of its settings. // Note: our simulation was validated when we were instantiated (see SedSimulation::Impl::isValid()), but its times // and/or number of steps may have been changed since then. - const auto *sedUniformTimeCoursePimpl {mDifferentialModel ? mSedUniformTimeCourse->pimpl() : nullptr}; - if (mDifferentialModel) { + const auto *sedUniformTimeCoursePimpl {mSedUniformTimeCourse->pimpl()}; const auto errors {sedUniformTimeCoursePimpl->validationErrors(mModel)}; if (!errors.empty()) { @@ -559,98 +621,89 @@ double SedInstanceTask::Impl::run() addIssue(Issue::Type::ERROR, error, "Simulation"); } - return 0.0; + return false; } + + mInitialTime = sedUniformTimeCoursePimpl->mInitialTime; + mOutputStartTime = sedUniformTimeCoursePimpl->mOutputStartTime; + mOutputEndTime = sedUniformTimeCoursePimpl->mOutputEndTime; + mNumberOfSteps = static_cast(sedUniformTimeCoursePimpl->mNumberOfSteps); + } else { + mNumberOfSteps = 1; } + // (Re)allocate our results, if needed. + // Note: our results include our initial point, hence a differential model has one more result than its number of + // steps. + + allocateResults(mDifferentialModel ? mNumberOfSteps + 1 : 1); + // Set our total number of steps. - // Note: we retrieve our number of steps only once since it is used in several places below. - const auto numberOfSteps {mDifferentialModel ? sedUniformTimeCoursePimpl->mNumberOfSteps : 1}; - const auto totalSteps {static_cast(numberOfSteps)}; + mTotalSteps.store(mNumberOfSteps, std::memory_order_relaxed); - mTotalSteps.store(totalSteps, std::memory_order_relaxed); + return true; +} + +double SedInstanceTask::Impl::run() +{ + // Note: we must have been prepared to be run (see prepareRun()). + + // Start our timer. + + auto startTime {std::chrono::high_resolution_clock::now()}; // (Re)initialise our model. // Note: reinitialise our model because we initialised it when we created the instance task. initialise(); + // Make sure that our model could be (re)initialised. It may not be possible, e.g., if an NLA system cannot be + // solved at our initial time (which may have been changed since we were created), in which case we have no results + // to report. Also, we must not go any further since our ODE solver, if any, may not have been (re)initialised. + + if (hasIssues()) { + nanFillResults(0); + + return 0.0; + } + // Compute our model, unless it's an algebraic/NLA model in which case we are already done. if (mDifferentialModel) { // Run our simulation from the initial time to the output start time, without tracking our results, but only if // the output start time is after the initial time. + // Note: should our simulation fail or be stopped before reaching the output start time, we have no results to + // report, hence we fill all of them with NaN values (rather than leave our previous results, if any, or + // our newly allocated ones, which are all zeros). - const auto voiInterval {(sedUniformTimeCoursePimpl->mOutputEndTime - sedUniformTimeCoursePimpl->mOutputStartTime) / numberOfSteps}; + const auto voiInterval {(mOutputEndTime - mOutputStartTime) / static_cast(mNumberOfSteps)}; - if (!fuzzyCompare(sedUniformTimeCoursePimpl->mInitialTime, sedUniformTimeCoursePimpl->mOutputStartTime)) { - run(sedUniformTimeCoursePimpl->mInitialTime, sedUniformTimeCoursePimpl->mOutputStartTime, voiInterval, false); + if (!fuzzyCompare(mInitialTime, mOutputStartTime)) { + run(mInitialTime, mOutputStartTime, voiInterval, false); if (hasIssues()) { + nanFillResults(0); + return 0.0; } } - // Initialise our results structure, if needed. - // Note #1: we only (re)allocate our results if their size has changed. This avoids needless allocations, but - // it also means that our results remain where they are in memory, which matters since our Python - // bindings return zero-copy NumPy arrays. - // Note #2: when (re)allocating our results, we release our previous results first (to limit our peak memory - // usage) and only then allocate our new results, which we do into a local structure that we move into - // ours once all of its arrays have been allocated. This means that should an allocation fail (e.g., - // std::bad_alloc), our results structure would be left empty rather than with an inconsistent results - // size and arrays of mismatched sizes (which would result in out-of-bounds accesses when retrieving - // our results). - // Note #3: on a 32-bit platform (e.g., WebAssembly), the size of an array may overflow (e.g., with many - // constants and a large number of steps), in which case we would allocate an array that is too small - // for our results, hence we check for it. We cannot trigger such an overflow on a 64-bit platform, so - // we ignore our check during code coverage. - - const auto resultsSize {totalSteps + 1}; - - if (mResults.resultsSize != resultsSize) { - mResults = {}; - - auto arraySize = [resultsSize](size_t pCount) { -#ifndef CODE_COVERAGE_ENABLED - if ((pCount != 0) && (resultsSize > (std::numeric_limits::max() / pCount))) { - throw std::length_error("the results are too large to be allocated"); - } -#endif - - return pCount * resultsSize; - }; - SedInstanceTaskResults results; - - results.resultsSize = resultsSize; - - results.voi.resize(resultsSize); - results.states.resize(arraySize(mStateCount)); - results.rates.resize(arraySize(mStateCount)); - results.constants.resize(arraySize(mConstantCount)); - results.computedConstants.resize(arraySize(mComputedConstantCount)); - results.algebraicVariables.resize(arraySize(mAlgebraicVariableCount)); - - mResults = std::move(results); - } - - // Run our simulation from the output start time to the output end time, tracking our results. + // Run our simulation from the output start time to the output end time, tracking our results, unless we have + // already been asked to stop. - run(sedUniformTimeCoursePimpl->mOutputStartTime, sedUniformTimeCoursePimpl->mOutputEndTime, voiInterval, true); + if ((mRunControl->load(std::memory_order_relaxed) & INSTANCE_RUN_CONTROL_STOP) != 0) { + nanFillResults(0); + } else { + run(mOutputStartTime, mOutputEndTime, voiInterval, true); - if (hasIssues()) { - return 0.0; + if (hasIssues()) { + return 0.0; + } } } else { // Track our results. - mResults.resultsSize = 1; - - mResults.constants.resize(mConstantCount); - mResults.computedConstants.resize(mComputedConstantCount); - mResults.algebraicVariables.resize(mAlgebraicVariableCount); - for (size_t i {0}; i < mConstantCount; ++i) { mResults.constants[i] = mConstants[i]; // NOLINT } diff --git a/src/sed/sedinstancetask_p.h b/src/sed/sedinstancetask_p.h index 2aaf83041..82b3c5a96 100644 --- a/src/sed/sedinstancetask_p.h +++ b/src/sed/sedinstancetask_p.h @@ -100,6 +100,11 @@ class SedInstanceTask::Impl: public Logger::Impl SedInstanceTaskInitialisations mInitialisations; SedInstanceTaskChanges mChanges; + double mInitialTime {0.0}; + double mOutputStartTime {0.0}; + double mOutputEndTime {0.0}; + size_t mNumberOfSteps {0}; + SedInstanceTaskResults mResults; std::atomic mCompletedSteps {0}; @@ -127,10 +132,13 @@ class SedInstanceTask::Impl: public Logger::Impl explicit Impl(const SedAbstractTaskPtr &pTask); + void allocateResults(size_t pResultsSize); void trackResults(size_t pIndex); + void nanFillResults(size_t pFromIndex); void applyChanges(); void initialise(); + bool prepareRun(); void run(double pVoiStart, double pVoiEnd, double pVoiInterval, bool pTrackResults); double run(); diff --git a/src/sed/seduniformtimecourse.cpp b/src/sed/seduniformtimecourse.cpp index 2d3cc2d1a..9e834e8db 100644 --- a/src/sed/seduniformtimecourse.cpp +++ b/src/sed/seduniformtimecourse.cpp @@ -34,9 +34,9 @@ Strings SedUniformTimeCourse::Impl::validationErrors(const SedModelPtr &pModel) { // Make sure that our times are finite and such that initialTime <= outputStartTime < outputEndTime, and that our // number of steps is strictly positive. - // Note: we are validated both when instantiating a document (see SedSimulation::Impl::isValid()) and when running - // an instance task (see SedInstanceTask::Impl::run()) since our times and number of steps may have been - // changed in between. + // Note: we are validated both when instantiating a document (see SedSimulation::Impl::isValid()) and when + // preparing an instance task to be run (see SedInstanceTask::Impl::prepareRun()) since our times and number + // of steps may have been changed in between. const auto &modelId = pModel->id(); Strings res; diff --git a/tests/api/file/childtests.cpp b/tests/api/file/childtests.cpp index 6d69cc701..e8d1aa4da 100644 --- a/tests/api/file/childtests.cpp +++ b/tests/api/file/childtests.cpp @@ -89,8 +89,8 @@ TEST(ChildFileTest, remoteVirtualCombineArchives) auto simulationFile135 {file135->childFile("simulation.json")}; auto simulationFile157 {file157->childFile("simulation.json")}; - ASSERT_NE(simulationFile135, nullptr); - ASSERT_NE(simulationFile157, nullptr); + GTEST_ASSERT_NE(simulationFile135, nullptr); + GTEST_ASSERT_NE(simulationFile157, nullptr); EXPECT_NE(simulationFile135, simulationFile157); EXPECT_EQ(libOpenCOR::toString(simulationFile135->contents()), libOpenCOR::textFileContents(libOpenCOR::resourcePath("api/file/dataset_135.json"))); EXPECT_EQ(libOpenCOR::toString(simulationFile157->contents()), libOpenCOR::textFileContents(libOpenCOR::resourcePath("api/file/dataset_157.json"))); @@ -106,7 +106,7 @@ TEST(ChildFileTest, remoteCombineArchive) auto simulationFile {file->childFile("simulation.json")}; EXPECT_EQ(file->type(), libOpenCOR::File::Type::COMBINE_ARCHIVE); - ASSERT_NE(simulationFile, nullptr); + GTEST_ASSERT_NE(simulationFile, nullptr); EXPECT_EQ(libOpenCOR::toString(simulationFile->contents()), libOpenCOR::textFileContents(libOpenCOR::resourcePath("api/file/dataset_135.json"))); } diff --git a/tests/api/sed/coveragetests.cpp b/tests/api/sed/coveragetests.cpp index 481a2e8ed..0f8250f38 100644 --- a/tests/api/sed/coveragetests.cpp +++ b/tests/api/sed/coveragetests.cpp @@ -20,6 +20,7 @@ limitations under the License. #include +#include #include TEST(CoverageSedTest, initialise) @@ -732,13 +733,62 @@ TEST(CoverageSedTest, KinsolWithNoSolutionOverTime) instance->run(); - ASSERT_EQ(instance->issueCount(), 2U); + GTEST_ASSERT_EQ(instance->issueCount(), 2U); EXPECT_EQ(instance->issue(0)->description(), CVODE_KINSOL_ERROR); EXPECT_EQ(instance->issue(1)->description().substr(0, CVODE_ERROR_START.size()), CVODE_ERROR_START); EXPECT_FALSE(std::isnan(instance->tasks()[0]->voi()[LAST_VALID_INDEX])); EXPECT_TRUE(std::isnan(instance->tasks()[0]->voi()[LAST_VALID_INDEX + 1])); } +TEST(CoverageSedTest, KinsolWithNoSolutionWhenInitialising) +{ + // The first NLA system of our model (a^2+x = 1) has no solution for x > 1, so if the initial value of x gets set to + // 2 after our instance has been created, then our model cannot be (re)initialised when running our instance, + // whether we use a fixed-step ODE solver or CVODE. Our simulation should therefore stop there and we should have no + // results to report. + + static const libOpenCOR::ExpectedIssues EXPECTED_ISSUES = { + {libOpenCOR::Issue::Type::ERROR, "Task | KINSOL: the linear solver's setup function failed in an unrecoverable manner."}, + }; + static constexpr auto OUTPUT_END_TIME {2.0}; + static constexpr auto NUMBER_OF_STEPS {20}; + static constexpr auto STEP {0.01}; + + auto file = libOpenCOR::File::create(libOpenCOR::resourcePath("api/sed/kinsol_with_no_solution_over_time.cellml")); + auto forwardEuler {libOpenCOR::SolverForwardEuler::create()}; + + forwardEuler->setStep(STEP); + + auto isNan = [](double pValue) { + return std::isnan(pValue); + }; + + for (const libOpenCOR::SolverOdePtr &odeSolver : {libOpenCOR::SolverOdePtr {forwardEuler}, libOpenCOR::SolverOdePtr {libOpenCOR::SolverCvode::create()}}) { + auto document = libOpenCOR::SedDocument::create(file); + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + + simulation->setOutputEndTime(OUTPUT_END_TIME); + simulation->setNumberOfSteps(NUMBER_OF_STEPS); + simulation->setOdeSolver(odeSolver); + + auto instance {document->instantiate()}; + + EXPECT_FALSE(instance->hasIssues()); + + document->models()[0]->addChange(libOpenCOR::SedChangeAttribute::create("my_component", "x", "2.0")); + + EXPECT_EQ(instance->run(), 0.0); + EXPECT_EQ_ISSUES(instance, EXPECTED_ISSUES); + EXPECT_EQ(instance->progress(), 0.0); + + const auto &instanceTask {instance->tasks()[0]}; + + EXPECT_EQ(instanceTask->voi().size(), NUMBER_OF_STEPS + 1); + EXPECT_TRUE(std::ranges::all_of(instanceTask->voi(), isNan)); + EXPECT_TRUE(std::ranges::all_of(instanceTask->state(0), isNan)); + } +} + TEST(CoverageSedTest, sedmlFileNlaAlgorithmAndNlaAlgorithm) { static const libOpenCOR::ExpectedIssues EXPECTED_ISSUES = { diff --git a/tests/api/sed/instancetests.cpp b/tests/api/sed/instancetests.cpp index 52ea76784..7406020d2 100644 --- a/tests/api/sed/instancetests.cpp +++ b/tests/api/sed/instancetests.cpp @@ -18,9 +18,11 @@ limitations under the License. #include +#include #include #include #include +#include #include TEST(InstanceSedTest, noFile) @@ -399,6 +401,71 @@ TEST(InstanceSedTest, stopRunResultsHaveNans) EXPECT_LT(nanIndex, voi.size() - 1); } +TEST(InstanceSedTest, stopRunBeforeOutputStartTime) +{ + // Note: our output start time is such that it would take our simulation a very long time to reach it, so we are + // guaranteed to stop our run before it gets reached. + + static const auto OUTPUT_START_TIME {1.0e9}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + auto instance {document->instantiate()}; + + // Run our instance so that we have some results. + + EXPECT_GT(instance->run(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto voi {instanceTask->voi()}; + + EXPECT_FALSE(std::isnan(voi[0])); + + // Start our run and stop it before it reaches the output start time, which means that we have no results to + // report. Our results should therefore not have been reallocated (since their size has not changed), but they + // should all be NaN values. + + simulation->setOutputStartTime(OUTPUT_START_TIME); + simulation->setOutputEndTime(OUTPUT_START_TIME + static_cast(simulation->numberOfSteps())); + + EXPECT_TRUE(instance->startRun()); + + instance->stopRun(); + instance->waitForRun(); + + EXPECT_EQ(instance->status(), libOpenCOR::SedInstance::Status::IDLE); + EXPECT_EQ(instance->progress(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + EXPECT_EQ(instanceTask->voi().data(), voi.data()); + + auto allNan = [](std::span pValues) { + return !pValues.empty() && std::ranges::all_of(pValues, [](double pValue) { + return std::isnan(pValue); + }); + }; + + EXPECT_TRUE(allNan(instanceTask->voi())); + + for (size_t i {0}; i < instanceTask->stateCount(); ++i) { + EXPECT_TRUE(allNan(instanceTask->state(i))); + EXPECT_TRUE(allNan(instanceTask->rate(i))); + } + + for (size_t i {0}; i < instanceTask->constantCount(); ++i) { + EXPECT_TRUE(allNan(instanceTask->constant(i))); + } + + for (size_t i {0}; i < instanceTask->computedConstantCount(); ++i) { + EXPECT_TRUE(allNan(instanceTask->computedConstant(i))); + } + + for (size_t i {0}; i < instanceTask->algebraicVariableCount(); ++i) { + EXPECT_TRUE(allNan(instanceTask->algebraicVariable(i))); + } +} + TEST(InstanceSedTest, pauseRunAndResumeRun) { static const auto SIMULATION_PROPERTY {1000000}; @@ -622,6 +689,34 @@ TEST(InstanceSedTest, startRunWhileAlreadyRunning) EXPECT_FALSE(instance->hasIssues()); } +TEST(InstanceSedTest, startRunRightAfterStatusIsIdle) +{ + // Note: a run is flagged as not running anymore just before it completes, so make sure that a new run can be + // started as soon as our instance is reported as idle. + + static const auto NUMBER_OF_STEPS {10}; + static const auto NUMBER_OF_RUNS {1000}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + + simulation->setNumberOfSteps(NUMBER_OF_STEPS); + simulation->setOutputEndTime(static_cast(NUMBER_OF_STEPS)); + + auto instance {document->instantiate()}; + + for (auto i {0}; i < NUMBER_OF_RUNS; ++i) { + ASSERT_TRUE(instance->startRun()); + + while (instance->status() != libOpenCOR::SedInstance::Status::IDLE) { + } + } + + EXPECT_GT(instance->waitForRun(), 0.0); + EXPECT_FALSE(instance->hasIssues()); +} + TEST(InstanceSedTest, startRunAfterPreviousRunCompleted) { static const auto WAIT_ITERATIONS = 60000; @@ -657,6 +752,129 @@ TEST(InstanceSedTest, startRunAfterPreviousRunCompleted) EXPECT_FALSE(instance->hasIssues()); } +TEST(InstanceSedTest, resultsAllocatedWhenStartingRun) +{ + // Note: the results of a task must be (re)allocated before startRun() returns, so that they can be safely retrieved + // while the task is being run (e.g., to plot them progressively). + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + auto instance {document->instantiate()}; + + // Run our instance so that our results get allocated. + + EXPECT_GT(instance->run(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto numberOfSteps {static_cast(simulation->numberOfSteps())}; + + EXPECT_EQ(instanceTask->voi().size(), numberOfSteps + 1); + + // Change the size of our results and start running our instance, which means that our results must have been + // reallocated by the time startRun() returns and that they must remain valid for the whole run. + + simulation->setNumberOfSteps(static_cast(2 * numberOfSteps)); + + EXPECT_TRUE(instance->startRun()); + + const auto voi {instanceTask->voi()}; + const auto state {instanceTask->state(0)}; + + EXPECT_EQ(voi.size(), (2 * numberOfSteps) + 1); + EXPECT_EQ(state.size(), (2 * numberOfSteps) + 1); + + // Retrieve our results while our instance is running. They should always be the same arrays and they should never + // contain any NaN values (our results are either not yet computed, i.e. zeros, or computed). + + auto isNan = [](double pValue) { + return std::isnan(pValue); + }; + + while (instance->status() != libOpenCOR::SedInstance::Status::IDLE) { + EXPECT_EQ(instanceTask->voi().data(), voi.data()); + EXPECT_EQ(instanceTask->state(0).data(), state.data()); + EXPECT_FALSE(std::ranges::any_of(voi, isNan)); + EXPECT_FALSE(std::ranges::any_of(state, isNan)); + } + + EXPECT_GT(instance->waitForRun(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + EXPECT_DOUBLE_EQ(instance->progress(), 1.0); + EXPECT_EQ(instanceTask->voi().data(), voi.data()); + EXPECT_EQ(instanceTask->state(0).data(), state.data()); + EXPECT_EQ(voi[voi.size() - 1], simulation->outputEndTime()); + EXPECT_FALSE(std::isnan(state[state.size() - 1])); +} + +TEST(InstanceSedTest, simulationSettingsUsedWhenStartingRun) +{ + // Note: the simulation settings used by a run are those in effect when the run is started, even if they get changed + // while the run is in progress. + + static const auto FACTOR {2}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + auto instance {document->instantiate()}; + const auto outputEndTime {simulation->outputEndTime()}; + const auto numberOfSteps {simulation->numberOfSteps()}; + + EXPECT_TRUE(instance->startRun()); + + simulation->setOutputEndTime(FACTOR * outputEndTime); + simulation->setNumberOfSteps(FACTOR * numberOfSteps); + + EXPECT_GT(instance->waitForRun(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto voi {instanceTask->voi()}; + + EXPECT_EQ(voi.size(), static_cast(numberOfSteps) + 1); + EXPECT_EQ(voi[voi.size() - 1], outputEndTime); + + // Running our instance again should use our new simulation settings. + + EXPECT_GT(instance->run(), 0.0); + EXPECT_FALSE(instance->hasIssues()); + + EXPECT_EQ(instanceTask->voi().size(), static_cast(FACTOR * numberOfSteps) + 1); + EXPECT_EQ(instanceTask->voi()[instanceTask->voi().size() - 1], FACTOR * outputEndTime); +} + +TEST(InstanceSedTest, runWhileAsynchronousRunInProgress) +{ + // Note: running an instance while it is already being run asynchronously should wait for the asynchronous run to + // complete before running the instance again. + + static const auto MODERATE_STEP_COUNT {10000}; + + auto file {libOpenCOR::File::create(libOpenCOR::resourcePath("cellml_2.cellml"))}; + auto document {libOpenCOR::SedDocument::create(file)}; + const auto &simulation {std::dynamic_pointer_cast(document->simulations()[0])}; + + simulation->setNumberOfSteps(MODERATE_STEP_COUNT); + simulation->setOutputEndTime(static_cast(MODERATE_STEP_COUNT)); + + auto instance {document->instantiate()}; + + EXPECT_TRUE(instance->startRun()); + EXPECT_GT(instance->run(), 0.0); + EXPECT_EQ(instance->status(), libOpenCOR::SedInstance::Status::IDLE); + EXPECT_DOUBLE_EQ(instance->progress(), 1.0); + EXPECT_FALSE(instance->hasIssues()); + + const auto &instanceTask {instance->tasks()[0]}; + const auto voi {instanceTask->voi()}; + + EXPECT_EQ(voi.size(), MODERATE_STEP_COUNT + 1); + EXPECT_EQ(voi[voi.size() - 1], static_cast(MODERATE_STEP_COUNT)); + EXPECT_FALSE(std::isnan(instanceTask->state(0)[voi.size() - 1])); +} + TEST(InstanceSedTest, odeModel) { const libOpenCOR::ExpectedIssues EXPECTED_ISSUES {{ @@ -1296,10 +1514,23 @@ TEST(InstanceSedTest, simulationWithInitialTimeFailing) auto instance {document->instantiate()}; EXPECT_FALSE(instance->hasIssues()); + EXPECT_EQ(instance->run(), 0.0); + EXPECT_TRUE(instance->hasIssues()); - instance->run(); + // Our simulation failed before reaching its output start time, so we have no results to report, i.e. our results + // should all be NaN values. - EXPECT_TRUE(instance->hasIssues()); + const auto &instanceTask {instance->tasks()[0]}; + const auto voi {instanceTask->voi()}; + const auto state {instanceTask->state(0)}; + + EXPECT_FALSE(voi.empty()); + EXPECT_TRUE(std::ranges::all_of(voi, [](double pValue) { + return std::isnan(pValue); + })); + EXPECT_TRUE(std::ranges::all_of(state, [](double pValue) { + return std::isnan(pValue); + })); } TEST(InstanceSedTest, changesToVariablesUsedToInitialiseOtherVariables) diff --git a/tests/bindings/javascript/sed.coverage.test.js b/tests/bindings/javascript/sed.coverage.test.js index 0168fd747..c3f1653c0 100644 --- a/tests/bindings/javascript/sed.coverage.test.js +++ b/tests/bindings/javascript/sed.coverage.test.js @@ -739,6 +739,57 @@ test.describe('Sed coverage tests', () => { assert.strictEqual(Number.isNaN(instance.tasks[0].voi[lastValidIndex + 1]), true); }); + test('KINSOL with no solution when initialising', () => { + // The first NLA system of our model (a^2+x = 1) has no solution for x > 1, so if the initial value of x gets set to + // 2 after our instance has been created, then our model cannot be (re)initialised when running our instance, + // whether we use a fixed-step ODE solver or CVODE. Our simulation should therefore stop there and we should have + // no results to report. + + const expectedIssues = [ + [loc.Issue.Type.ERROR, "Task | KINSOL: the linear solver's setup function failed in an unrecoverable manner."] + ]; + const numberOfSteps = 20; + + const file = new loc.File(utils.resourcePath('api/sed/kinsol_with_no_solution_over_time.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const forwardEuler = new loc.SolverForwardEuler(); + + forwardEuler.step = 0.01; + + for (const odeSolver of [forwardEuler, new loc.SolverCvode()]) { + const document = new loc.SedDocument(file); + const simulation = document.simulations[0]; + + simulation.outputEndTime = 2.0; + simulation.numberOfSteps = numberOfSteps; + simulation.odeSolver = odeSolver; + + const instance = document.instantiate(); + + assert.strictEqual(instance.hasIssues, false); + + document.model(0).addChange(new loc.SedChangeAttribute('my_component', 'x', '2.0')); + + assert.strictEqual(instance.run(), 0.0); + assertIssues(loc, instance, expectedIssues); + assert.strictEqual(instance.progress, 0.0); + + const instanceTask = instance.tasks[0]; + + assert.strictEqual(instanceTask.voi.length, numberOfSteps + 1); + assert.strictEqual( + instanceTask.voi.every((value) => Number.isNaN(value)), + true + ); + assert.strictEqual( + instanceTask.state(0).every((value) => Number.isNaN(value)), + true + ); + } + }); + test('SED-ML file with nlaAlgorithm and NLA algorithm', () => { const cellmlFile = new loc.File(utils.resourcePath('api/sed/dae/model.cellml')); diff --git a/tests/bindings/javascript/sed.instance.test.js b/tests/bindings/javascript/sed.instance.test.js index d8a4dd7ed..c27ef7ede 100644 --- a/tests/bindings/javascript/sed.instance.test.js +++ b/tests/bindings/javascript/sed.instance.test.js @@ -452,6 +452,69 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.hasIssues, false); }); + test('Stop run before output start time', async () => { + // Note: our output start time is such that it would take our simulation a very long time to reach it, so we are + // guaranteed to stop our run before it gets reached. + + const OUTPUT_START_TIME = 1.0e9; + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + const instance = document.instantiate(); + + // Run our instance so that we have some results. + + assert.ok(instance.run() > 0.0); + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + + assert.strictEqual(Number.isNaN(voi[0]), false); + + // Start our run and stop it before it reaches the output start time, which means that we have no results to + // report. Our results should therefore not have been reallocated (since their size has not changed), but they + // should all be NaN values. + + simulation.outputStartTime = OUTPUT_START_TIME; + simulation.outputEndTime = OUTPUT_START_TIME + simulation.numberOfSteps; + + assert.strictEqual(instance.startRun(), true); + + instance.stopRun(); + instance.waitForRun(); + + assert.strictEqual(instance.status, loc.SedInstance.Status.IDLE); + assert.strictEqual(instance.progress, 0.0); + assert.strictEqual(instance.hasIssues, false); + assert.strictEqual(instanceTask.voi.byteOffset, voi.byteOffset); + + const allNaN = (values) => values.length > 0 && values.every((value) => Number.isNaN(value)); + + assert.strictEqual(allNaN(instanceTask.voi), true); + + for (let i = 0; i < instanceTask.stateCount; ++i) { + assert.strictEqual(allNaN(instanceTask.state(i)), true); + assert.strictEqual(allNaN(instanceTask.rate(i)), true); + } + + for (let i = 0; i < instanceTask.constantCount; ++i) { + assert.strictEqual(allNaN(instanceTask.constant(i)), true); + } + + for (let i = 0; i < instanceTask.computedConstantCount; ++i) { + assert.strictEqual(allNaN(instanceTask.computedConstant(i)), true); + } + + for (let i = 0; i < instanceTask.algebraicVariableCount; ++i) { + assert.strictEqual(allNaN(instanceTask.algebraicVariable(i)), true); + } + }); + test('Pause run and resume run', async () => { const SIMULATION_PROPERTY = 1000000; const WAIT_ITERATIONS = 60000; @@ -684,6 +747,41 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.hasIssues, false); }); + test('Start run right after status is idle', async () => { + // Note: a run is flagged as not running anymore just before it completes, so make sure that a new run can be + // started as soon as our instance is reported as idle. + + const NUMBER_OF_STEPS = 10; + const NUMBER_OF_RUNS = 1000; + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + + simulation.numberOfSteps = NUMBER_OF_STEPS; + simulation.outputEndTime = NUMBER_OF_STEPS; + + const instance = document.instantiate(); + + for (let i = 0; i < NUMBER_OF_RUNS; ++i) { + assert.strictEqual(instance.startRun(), true); + + // Note: the threads of our previous runs can only be cleaned up when we return to the event loop, so we must do + // so or we would eventually run out of memory. We do it here rather than once our run has completed so + // that a new run is still started as soon as our instance is reported as idle. + + await sleep(0); + + while (instance.status !== loc.SedInstance.Status.IDLE) {} + } + + assert.ok(instance.waitForRun() > 0.0); + assert.strictEqual(instance.hasIssues, false); + }); + test('Start run after previous run completed', async () => { const WAIT_ITERATIONS = 60000; @@ -721,6 +819,135 @@ test.describe('Sed instance tests', () => { assert.strictEqual(instance.hasIssues, false); }); + test('Results allocated when starting run', () => { + // Note: the results of a task must be (re)allocated before startRun() returns, so that they can be safely + // retrieved while the task is being run (e.g., to plot them progressively). + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + const instance = document.instantiate(); + + // Run our instance so that our results get allocated. + + assert.ok(instance.run() > 0.0); + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const numberOfSteps = simulation.numberOfSteps; + + assert.strictEqual(instanceTask.voi.length, numberOfSteps + 1); + + // Change the size of our results and start running our instance, which means that our results must have been + // reallocated by the time startRun() returns and that they must remain valid for the whole run. + + simulation.numberOfSteps = 2 * numberOfSteps; + + assert.strictEqual(instance.startRun(), true); + + const voi = instanceTask.voi; + const state = instanceTask.state(0); + + assert.strictEqual(voi.length, 2 * numberOfSteps + 1); + assert.strictEqual(state.length, 2 * numberOfSteps + 1); + + // Retrieve our results while our instance is running. They should always be the same arrays and they should never + // contain any NaN values (our results are either not yet computed, i.e. zeros, or computed). + + while (instance.status !== loc.SedInstance.Status.IDLE) { + assert.strictEqual(instanceTask.voi.byteOffset, voi.byteOffset); + assert.strictEqual(instanceTask.state(0).byteOffset, state.byteOffset); + assert.strictEqual( + voi.some((value) => Number.isNaN(value)), + false + ); + assert.strictEqual( + state.some((value) => Number.isNaN(value)), + false + ); + } + + assert.ok(instance.waitForRun() > 0.0); + assert.strictEqual(instance.hasIssues, false); + assert.strictEqual(instance.progress, 1.0); + assert.strictEqual(instanceTask.voi.byteOffset, voi.byteOffset); + assert.strictEqual(instanceTask.state(0).byteOffset, state.byteOffset); + assert.strictEqual(voi[voi.length - 1], simulation.outputEndTime); + assert.strictEqual(Number.isNaN(state[state.length - 1]), false); + }); + + test('Simulation settings used when starting run', () => { + // Note: the simulation settings used by a run are those in effect when the run is started, even if they get + // changed while the run is in progress. + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + const instance = document.instantiate(); + const outputEndTime = simulation.outputEndTime; + const numberOfSteps = simulation.numberOfSteps; + + assert.strictEqual(instance.startRun(), true); + + simulation.outputEndTime = 2.0 * outputEndTime; + simulation.numberOfSteps = 2 * numberOfSteps; + + assert.ok(instance.waitForRun() > 0.0); + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + + assert.strictEqual(voi.length, numberOfSteps + 1); + assert.strictEqual(voi[voi.length - 1], outputEndTime); + + // Running our instance again should use our new simulation settings. + + assert.ok(instance.run() > 0.0); + assert.strictEqual(instance.hasIssues, false); + + assert.strictEqual(instanceTask.voi.length, 2 * numberOfSteps + 1); + assert.strictEqual(instanceTask.voi[instanceTask.voi.length - 1], 2.0 * outputEndTime); + }); + + test('Run while asynchronous run in progress', () => { + // Note: running an instance while it is already being run asynchronously should wait for the asynchronous run to + // complete before running the instance again. + + const MODERATE_STEP_COUNT = 10000; + + const file = new loc.File(utils.resourcePath('cellml_2.cellml')); + + file.setContents(utils.fileContents(file.path)); + + const document = new loc.SedDocument(file); + const simulation = document.simulations.get(0); + + simulation.numberOfSteps = MODERATE_STEP_COUNT; + simulation.outputEndTime = MODERATE_STEP_COUNT; + + const instance = document.instantiate(); + + assert.strictEqual(instance.startRun(), true); + assert.ok(instance.run() > 0.0); + assert.strictEqual(instance.status, loc.SedInstance.Status.IDLE); + assert.strictEqual(instance.progress, 1.0); + assert.strictEqual(instance.hasIssues, false); + + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + + assert.strictEqual(voi.length, MODERATE_STEP_COUNT + 1); + assert.strictEqual(voi[voi.length - 1], MODERATE_STEP_COUNT); + assert.strictEqual(Number.isNaN(instanceTask.state(0)[voi.length - 1]), false); + }); + test('ODE model', () => { const file = new loc.File(utils.resourcePath('cellml_2.cellml')); @@ -1424,10 +1651,25 @@ test.describe('Sed instance tests', () => { const instance = new loc.SedDocument(file).instantiate(); assert.strictEqual(instance.hasIssues, false); + assert.strictEqual(instance.run(), 0.0); + assert.strictEqual(instance.hasIssues, true); - instance.run(); + // Our simulation failed before reaching its output start time, so we have no results to report, i.e. our results + // should all be NaN values. - assert.strictEqual(instance.hasIssues, true); + const instanceTask = instance.tasks[0]; + const voi = instanceTask.voi; + const state = instanceTask.state(0); + + assert.ok(voi.length > 0); + assert.strictEqual( + voi.every((value) => Number.isNaN(value)), + true + ); + assert.strictEqual( + state.every((value) => Number.isNaN(value)), + true + ); }); test('Changes to variables used to initialise other variables', () => { diff --git a/tests/bindings/python/test_sed_coverage.py b/tests/bindings/python/test_sed_coverage.py index 24a98a90d..6efe0dca7 100644 --- a/tests/bindings/python/test_sed_coverage.py +++ b/tests/bindings/python/test_sed_coverage.py @@ -845,6 +845,52 @@ def test_kinsol_with_no_solution_over_time(): assert math.isnan(instance.tasks[0].voi[last_valid_index + 1]) +def test_kinsol_with_no_solution_when_initialising(): + # The first NLA system of our model (a^2+x = 1) has no solution for x > 1, so if the initial value of x gets set to + # 2 after our instance has been created, then our model cannot be (re)initialised when running our instance, + # whether we use a fixed-step ODE solver or CVODE. Our simulation should therefore stop there and we should have no + # results to report. + + expected_issues = [ + [ + loc.Issue.Type.Error, + "Task | KINSOL: the linear solver's setup function failed in an unrecoverable manner.", + ], + ] + number_of_steps = 20 + + file = loc.File( + utils.resource_path("api/sed/kinsol_with_no_solution_over_time.cellml") + ) + forward_euler = loc.SolverForwardEuler() + + forward_euler.step = 0.01 + + for ode_solver in [forward_euler, loc.SolverCvode()]: + document = loc.SedDocument(file) + simulation = document.simulations[0] + + simulation.output_end_time = 2.0 + simulation.number_of_steps = number_of_steps + simulation.ode_solver = ode_solver + + instance = document.instantiate() + + assert not instance.has_issues + + document.model(0).add_change(loc.SedChangeAttribute("my_component", "x", "2.0")) + + assert instance.run() == 0.0 + assert_issues(instance, expected_issues) + assert instance.progress == 0.0 + + instance_task = instance.tasks[0] + + assert len(instance_task.voi) == number_of_steps + 1 + assert all(math.isnan(value) for value in instance_task.voi) + assert all(math.isnan(value) for value in instance_task.state(0)) + + def test_sedml_file_nla_algorithm_and_nla_algorithm(): expected_issues = [ [ diff --git a/tests/bindings/python/test_sed_instance.py b/tests/bindings/python/test_sed_instance.py index a9e3e926c..bfab28aa8 100644 --- a/tests/bindings/python/test_sed_instance.py +++ b/tests/bindings/python/test_sed_instance.py @@ -401,6 +401,66 @@ def test_pause_run_right_after_start_run(): assert not instance.has_issues +def test_stop_run_before_output_start_time(): + # Note: our output start time is such that it would take our simulation a very long time to reach it, so we are + # guaranteed to stop our run before it gets reached. + + OUTPUT_START_TIME = 1.0e9 + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + instance = document.instantiate() + + # Run our instance so that we have some results. + + assert instance.run() > 0.0 + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + + assert not math.isnan(voi[0]) + + # Start our run and stop it before it reaches the output start time, which means that we have no results to + # report. Our results should therefore not have been reallocated (since their size has not changed), but they + # should all be NaN values. + + simulation.output_start_time = OUTPUT_START_TIME + simulation.output_end_time = OUTPUT_START_TIME + float(simulation.number_of_steps) + + assert instance.start_run() is True + + instance.stop_run() + instance.wait_for_run() + + assert instance.status == loc.SedInstance.Status.Idle + assert instance.progress == 0.0 + assert not instance.has_issues + assert ( + voi.__array_interface__["data"][0] + == instance_task.voi.__array_interface__["data"][0] + ) + + def all_nan(values): + return len(values) > 0 and all(math.isnan(value) for value in values) + + assert all_nan(instance_task.voi) + + for i in range(instance_task.state_count): + assert all_nan(instance_task.state(i)) + assert all_nan(instance_task.rate(i)) + + for i in range(instance_task.constant_count): + assert all_nan(instance_task.constant(i)) + + for i in range(instance_task.computed_constant_count): + assert all_nan(instance_task.computed_constant(i)) + + for i in range(instance_task.algebraic_variable_count): + assert all_nan(instance_task.algebraic_variable(i)) + + def test_pause_run_and_resume_run(): SIMULATION_PROPERTY = 1000000 WAIT_ITERATIONS = 60000 @@ -596,6 +656,32 @@ def test_start_run_while_already_running(): assert not instance.has_issues +def test_start_run_right_after_status_is_idle(): + # Note: a run is flagged as not running anymore just before it completes, so make sure that a new run can be + # started as soon as our instance is reported as idle. + + NUMBER_OF_STEPS = 10 + NUMBER_OF_RUNS = 1000 + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + + simulation.number_of_steps = NUMBER_OF_STEPS + simulation.output_end_time = float(NUMBER_OF_STEPS) + + instance = document.instantiate() + + for _ in range(NUMBER_OF_RUNS): + assert instance.start_run() is True + + while instance.status != loc.SedInstance.Status.Idle: + pass + + assert instance.wait_for_run() > 0.0 + assert not instance.has_issues + + def test_start_run_after_previous_run_completed(): WAIT_ITERATIONS = 60000 @@ -658,6 +744,131 @@ def run_ode_model(): assert not instance.has_issues +def test_results_allocated_when_starting_run(): + # Note: the results of a task must be (re)allocated before start_run() returns, so that they can be safely retrieved + # while the task is being run (e.g., to plot them progressively). + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + instance = document.instantiate() + + # Run our instance so that our results get allocated. + + assert instance.run() > 0.0 + assert not instance.has_issues + + instance_task = instance.tasks[0] + number_of_steps = simulation.number_of_steps + + assert len(instance_task.voi) == number_of_steps + 1 + + # Change the size of our results and start running our instance, which means that our results must have been + # reallocated by the time start_run() returns and that they must remain valid for the whole run. + + simulation.number_of_steps = 2 * number_of_steps + + assert instance.start_run() is True + + voi = instance_task.voi + state = instance_task.state(0) + + assert len(voi) == 2 * number_of_steps + 1 + assert len(state) == 2 * number_of_steps + 1 + + # Retrieve our results while our instance is running. They should always be the same arrays and they should never + # contain any NaN values (our results are either not yet computed, i.e. zeros, or computed). + + while instance.status != loc.SedInstance.Status.Idle: + assert ( + instance_task.voi.__array_interface__["data"][0] + == voi.__array_interface__["data"][0] + ) + assert ( + instance_task.state(0).__array_interface__["data"][0] + == state.__array_interface__["data"][0] + ) + assert not any(math.isnan(value) for value in voi) + assert not any(math.isnan(value) for value in state) + + assert instance.wait_for_run() > 0.0 + assert not instance.has_issues + assert instance.progress == 1.0 + assert ( + voi.__array_interface__["data"][0] + == instance_task.voi.__array_interface__["data"][0] + ) + assert ( + state.__array_interface__["data"][0] + == instance_task.state(0).__array_interface__["data"][0] + ) + assert voi[-1] == simulation.output_end_time + assert not math.isnan(state[-1]) + + +def test_simulation_settings_used_when_starting_run(): + # Note: the simulation settings used by a run are those in effect when the run is started, even if they get changed + # while the run is in progress. + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + instance = document.instantiate() + output_end_time = simulation.output_end_time + number_of_steps = simulation.number_of_steps + + assert instance.start_run() is True + + simulation.output_end_time = 2.0 * output_end_time + simulation.number_of_steps = 2 * number_of_steps + + assert instance.wait_for_run() > 0.0 + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + + assert len(voi) == number_of_steps + 1 + assert voi[-1] == output_end_time + + # Running our instance again should use our new simulation settings. + + assert instance.run() > 0.0 + assert not instance.has_issues + + assert len(instance_task.voi) == 2 * number_of_steps + 1 + assert instance_task.voi[-1] == 2.0 * output_end_time + + +def test_run_while_asynchronous_run_in_progress(): + # Note: running an instance while it is already being run asynchronously should wait for the asynchronous run to + # complete before running the instance again. + + MODERATE_STEP_COUNT = 10000 + + file = loc.File(utils.resource_path("cellml_2.cellml")) + document = loc.SedDocument(file) + simulation = document.simulations[0] + + simulation.number_of_steps = MODERATE_STEP_COUNT + simulation.output_end_time = float(MODERATE_STEP_COUNT) + + instance = document.instantiate() + + assert instance.start_run() is True + assert instance.run() > 0.0 + assert instance.status == loc.SedInstance.Status.Idle + assert instance.progress == 1.0 + assert not instance.has_issues + + instance_task = instance.tasks[0] + voi = instance_task.voi + + assert len(voi) == MODERATE_STEP_COUNT + 1 + assert voi[-1] == float(MODERATE_STEP_COUNT) + assert not math.isnan(instance_task.state(0)[-1]) + + def test_ode_model(): run_ode_model() @@ -1299,10 +1510,19 @@ def test_simulation_with_initial_time_failing(): instance = document.instantiate() assert not instance.has_issues + assert instance.run() == 0.0 + assert instance.has_issues - instance.run() + # Our simulation failed before reaching its output start time, so we have no results to report, i.e. our results + # should all be NaN values. - assert instance.has_issues + instance_task = instance.tasks[0] + voi = instance_task.voi + state = instance_task.state(0) + + assert len(voi) > 0 + assert all(math.isnan(value) for value in voi) + assert all(math.isnan(value) for value in state) def test_changes_to_variables_used_to_initialise_other_variables(): diff --git a/tests/utils.cpp b/tests/utils.cpp index 8dd4aad22..43a6b8c5d 100644 --- a/tests/utils.cpp +++ b/tests/utils.cpp @@ -70,7 +70,7 @@ void expectEqualIssues(const LoggerPtr &pLogger, const ExpectedIssues &pExpected { const auto &issues {pLogger->issues()}; - EXPECT_EQ(issues.size(), pExpectedIssues.size()); + GTEST_ASSERT_EQ(issues.size(), pExpectedIssues.size()); for (size_t i {0}; i < issues.size(); ++i) { EXPECT_EQ(issues[i]->type(), pExpectedIssues[i].type);