Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 177 additions & 6 deletions process/caller.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,178 @@
from __future__ import annotations
from process import fortran as ft
import numpy as np
import logging
from process.final import finalise
from process.io.mfile import MFile
from process.utilities.f2py_string_patch import f2py_compatible_to_string
from typing import Union, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
from process.main import Models

logger = logging.getLogger(__name__)


class Caller:
"""Calls physics and engineering models."""

def __init__(self, models, x):
def __init__(self, models: Models) -> None:
"""Initialise all physics and engineering models.

To ensure that, at the start of a run, all physics/engineering
variables are fully initialised with consistent values, the models are
called with the initial optimisation paramters, x.

:param models: physics and engineering model objects
:type models: process.main.Models
:param x: optimisation parameters
:type x: np.ndarray
:type models: Models
"""
self.models = models
self.call_models(x)

def call_models(self, xc):
@staticmethod
def check_agreement(
previous: Union[float, np.ndarray], current: Union[float, np.ndarray]
) -> bool:
"""Compare previous and current arrays for agreement within a tolerance.

:param previous: value(s) from previous models evaluation
:type previous: float | np.ndarray
:param current: value(s) from current models evaluation
:type current: float | np.ndarray
:return: whether values agree or not
:rtype: bool
"""
return np.allclose(previous, current, rtol=1.0e-6)

def call_models(self, xc: np.ndarray, m: int) -> Tuple[float, np.ndarray]:
"""Evalutate models until results are idempotent.

Ensure objective function and constraints are idempotent before returning.

:param xc: optimisation parameters
:type xc: np.ndarray
:param m: number of constraints
:type m: int
:raises RuntimeError: if values are non-idempotent after successive
evaluations
:return: objective function and constraints
:rtype: Tuple[float, np.ndarray]
"""
objf_prev = None
conf_prev = None

# Evaluate models up to 10 times; any more implies non-converging values
for _ in range(10):
self._call_models_once(xc)
# Evaluate objective function and constraints
objf = ft.function_evaluator.funfom()
conf, _, _, _, _ = ft.constraints.constraint_eqns(m, -1)

if objf_prev is None and conf_prev is None:
# First run: run again to check idempotence
logger.debug("New optimisation parameter vector being evaluated")
objf_prev = objf
conf_prev = conf
continue

# Check for idempotence
if self.check_agreement(objf_prev, objf) and self.check_agreement(
conf_prev, conf
):
# Idempotent: no longer changing, so return
logger.debug(
"Model evaluations idempotent, returning objective "
"function and constraints"
)
return objf, conf

# Not idempotent: still changing, so evaluate models again
logger.debug("Model evaluations not idempotent: evaluating again")
objf_prev = objf
conf_prev = conf

raise RuntimeError(
Comment thread
jonmaddock marked this conversation as resolved.
"After 10 model evaluations at the current optimisation parameter "
"vector, values for the objective function and constraints haven't "
"converged (don't produce idempotent values)."
)

def call_models_and_write_output(self, xc: np.ndarray, ifail: int) -> None:
"""Evaluate models until results are idempotent, then write output files.

Ensure all outputs in mfile are idempotent before returning, by
evaluating models multiple times. Typically used at the end of an
optimisation, or in a non-optimising evaluation. Writes OUT.DAT and
MFILE.DAT with final results.

:param xc: optimisation parameter
:type xc: np.ndarray
:param ifail: return code of solver
:type ifail: int
:raises RuntimeError: if values are non-idempotent after successive
evaluations
"""
# TODO The only way to ensure idempotence in all outputs is by comparing
# mfiles at this stage
previous_mfile_arr = None

try:
# Evaluate models up to 10 times; any more implies non-converging values
for _ in range(10):
# Divert OUT.DAT and MFILE.DAT output to scratch files for
# idempotence checking
ft.init_module.open_idempotence_files()
self._call_models_once(xc)
# Write mfile
finalise(self.models, ifail)

# Extract data from intermediate idempotence-checking mfile
mfile_path = (
f2py_compatible_to_string(ft.global_variables.output_prefix)
+ "IDEM_MFILE.DAT"
)
mfile = MFile(mfile_path)
mfile_data = {}
for var in mfile.data.keys():
mfile_data[var] = mfile.data[var].get_scan(-1)

# Extract floats from mfile dict into array for straightforward
# comparison: only compare floats
current_mfile_arr = np.array(
[val for val in mfile_data.values() if isinstance(val, float)]
)
if previous_mfile_arr is None:
# First run: need another run to compare with
logger.debug(
"New mfile created: evaluating models again to check idempotence"
)
previous_mfile_arr = np.copy(current_mfile_arr)
continue

if self.check_agreement(previous_mfile_arr, current_mfile_arr):
# Previous and current mfiles agree (idempotent)
logger.debug("Mfiles idempotent, returning")
# Divert OUT.DAT and MFILE.DAT output back to original files
# now idempotence checking complete
ft.init_module.close_idempotence_files()
# Write final output file and mfile
finalise(self.models, ifail)
return

# Mfiles not yet idempotent: re-evaluate models
logger.debug("Mfiles not idempotent, evaluating models again")
previous_mfile_arr = np.copy(current_mfile_arr)

raise RuntimeError(
"Model evaluations at the current optimisation parameter vector "
"don't produce idempotent values in the final output."
)
except Exception:
# If exception in model evaluations or idempotence can't be
# achieved, delete intermediate idempotence files to clean up
ft.init_module.close_idempotence_files()
raise

def _call_models_once(self, xc: np.ndarray) -> None:
"""Call the physics and engineering models.

This method is the principal caller of all the physics and
Expand Down Expand Up @@ -155,3 +308,21 @@ def call_models(self, xc):
self.models.costs.run()

# FISPACT and LOCA model (not used)- removed


def write_output_files(models: Models, ifail: int) -> None:
"""Evaluate models and write output files (OUT.DAT and MFILE.DAT).

:param models: physics and engineering models
:type models: Models
:param ifail: solver return code
:type ifail: int
"""
n = ft.numerics.nvar
x = ft.numerics.xcm[:n]
# Call models, ensuring output mfiles are fully idempotent
caller = Caller(models)
caller.call_models_and_write_output(
xc=x,
ifail=ifail,
)
39 changes: 5 additions & 34 deletions process/evaluators.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
from process.caller import Caller
from process.fortran import global_variables as gv
from process.fortran import constraints
from process.fortran import cost_variables as cv
from process.fortran import numerics
from process.fortran import physics_variables as pv
from process.fortran import stellarator_variables as sv
from process.fortran import times_variables as tv
from process.fortran import function_evaluator
import numpy as np
import math
import logging
Expand All @@ -25,7 +22,7 @@ def __init__(self, models, x):
:param x: optimisation parameters
:type x: np.ndarray
"""
self.caller = Caller(models, x)
self.caller = Caller(models)

def fcnvmc1(self, n, m, xv, ifail):
"""Function evaluator for VMCON.
Expand Down Expand Up @@ -53,29 +50,7 @@ def fcnvmc1(self, n, m, xv, ifail):
conf = np.zeros(m, dtype=np.float64, order="F")

# Evaluate machine parameters at xv
self.caller.call_models(xv)

# Convergence loop to ensure burn time consistency
if sv.istell == 0:
for _ in range(10):
if abs((tv.tburn - tv.tburn0) / max(tv.tburn, 0.01)) <= 0.001:
break

self.caller.call_models(xv)
if gv.verbose == 1:
print("Internal tburn consistency check: ", tv.tburn, tv.tburn0)
else:
print(
"Burn time values are not consistent in iteration: ",
numerics.nviter,
)
print("tburn, tburn0: ", tv.tburn, tv.tburn0)

# Evaluate figure of merit (objective function)
objf = function_evaluator.funfom()

# Evaluate constraint equations
conf, _, _, _, _ = constraints.constraint_eqns(m, -1)
objf, conf = self.caller.call_models(xv, m)

# Verbose diagnostics
if gv.verbose == 1:
Expand Down Expand Up @@ -141,14 +116,10 @@ def fcnvmc2(self, n, m, xv, lcnorm):
xbac[i] = xv[j] * (1.0 - numerics.epsfcn)

# Evaluate at (x+dx)
self.caller.call_models(xfor)
ffor = function_evaluator.funfom()
cfor, _, _, _, _ = constraints.constraint_eqns(m, -1)
ffor, cfor = self.caller.call_models(xfor, m)

# Evaluate at (x-dx)
self.caller.call_models(xbac)
fbac = function_evaluator.funfom()
cbac, _, _, _, _ = constraints.constraint_eqns(m, -1)
fbac, cbac = self.caller.call_models(xbac, m)

# Calculate finite difference gradients
fgrd[i] = (ffor - fbac) / (xfor[i] - xbac[i])
Expand All @@ -162,6 +133,6 @@ def fcnvmc2(self, n, m, xv, lcnorm):
# variable in the solution vector is inconsistent with its value
# shown elsewhere in the output file, which is a factor (1-epsfcn)
# smaller (i.e. its xbac value above).
self.caller.call_models(xv)
self.caller.call_models(xv, m)

return fgrd, cnorm
5 changes: 4 additions & 1 deletion process/final.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Final output at the end of a scan."""

from process import fortran as ft
from process.fortran import final_module as fm
from process import output as op
Expand All @@ -7,6 +8,8 @@
def finalise(models, ifail):
"""Routine to print out the final point in the scan.

Writes to OUT.DAT and MFILE.DAT.

:param models: physics and engineering model objects
:type models: process.main.Models
:param ifail: error flag
Expand All @@ -18,7 +21,7 @@ def finalise(models, ifail):
if ft.numerics.ioptimz == -2:
fm.no_optimisation()

# Write output to OUT.DAT
# Write output to OUT.DAT and MFILE.DAT
op.write(models, ft.constants.nout)

fm.final_output()
34 changes: 15 additions & 19 deletions process/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
from process.plasma_geometry import PlasmaGeom
from process.pulse import Pulse
from process.scan import Scan
from process import final
from process.stellarator import Stellarator
from process.structure import Structure
from process.build import Build
Expand All @@ -61,7 +60,6 @@
from process.availability import Availability
from process.ife import IFE
from process.costs_2015 import Costs2015
from process.caller import Caller
from process.power import Power
from process.cs_fatigue import CsFatigue
from process.physics import Physics
Expand All @@ -73,6 +71,8 @@
from process.fw import Fw
from process.current_drive import CurrentDrive
from process.impurity_radiation import initialise_imprad
from process.caller import write_output_files


from pathlib import Path
import os
Expand Down Expand Up @@ -459,25 +459,21 @@ def run_scan(self, solver):
:param solver: which solver to use, as specified in solver.py
:type solver: str
"""
if fortran.numerics.ioptimz >= 0:
if fortran.numerics.ioptimz == 1:
# VMCON optimisation
self.scan = Scan(self.models, solver)
else:
# If no optimisation will be done, compute the OP variables now
if fortran.numerics.ioptimz == -2:
# Get optimisation parameters x, perform first evaluation of models
fortran.define_iteration_variables.loadxc()
n = fortran.numerics.nvar
x = fortran.numerics.xcm[:n]
caller = Caller(self.models, x)

# To ensure that, at the start of a run, all physics/engineering
# variables are fully initialised with consistent values, we perform
# a second evaluation call here
caller.call_models(x)
self.ifail = 6

final.finalise(self.models, self.ifail)
elif fortran.numerics.ioptimz == -2:
# No optimisation: compute the output variables now
# Get optimisation parameters x, evaluate models
fortran.define_iteration_variables.loadxc()
self.ifail = 6
write_output_files(models=self.models, ifail=self.ifail)
self.show_errors()
else:
raise ValueError(
f"Invalid ioptimz value: {fortran.numerics.ioptimz}. Please "
"select either 1 (optimise) or -2 (no optimisation)."
)

def show_errors(self):
"""Report all informational/error messages encountered."""
Expand Down
Loading