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
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ message(STATUS "System name: ${CMAKE_SYSTEM_NAME}")
# compiler setup

enable_language(Fortran)
# Force debug flags for Fortran, including preprocessed .F files
# set(CMAKE_Fortran_FLAGS_DEBUG "-g -O0" CACHE STRING "" FORCE)
# set(CMAKE_Fortran_FLAGS "-g -O0" CACHE STRING "" FORCE)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
Expand Down
1 change: 1 addition & 0 deletions pyoptgra/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@
triangular_wave_fourier_grad,
)
from .optgra import optgra # noqa
from .timeout import get_optimize_with_timeout_function # noqa
5 changes: 0 additions & 5 deletions pyoptgra/core/ogexec.F
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ SUBROUTINE OGEXEC (VALVAR, VALCON, FINOPT, FINITE, CALVAL, CALDER)
PYGFLA = 3 ! pygmo flag in COMMON: no covergence
CALL OGEVAL (VARVAL, CONVAL, VARDER, CONDER(1:NUMCON+1,:),
& CALVAL, CALDER)

GOTO 9999
ELSEIF (NUMITE .GE. MAXITE .OR.
& (NUMITE-ITECOR .GE. OPTITE-1 .AND. ITECOR .NE. 0)) THEN
Expand All @@ -150,7 +149,6 @@ SUBROUTINE OGEXEC (VALVAR, VALCON, FINOPT, FINITE, CALVAL, CALDER)
PYGFLA = 2 ! pygmo flag in COMMON: constraints matched
CALL OGEVAL (VARVAL, CONVAL, VARDER, CONDER(1:NUMCON+1,:),
& CALVAL, CALDER)

GOTO 9999
ENDIF
C ----------------------------------------------------------------------
Expand Down Expand Up @@ -293,7 +291,6 @@ SUBROUTINE OGEXEC (VALVAR, VALCON, FINOPT, FINITE, CALVAL, CALDER)
PYGFLA = 4 ! pygmo flag in COMMON: infeasible
CALL OGEVAL (VARVAL, CONVAL, VARDER, CONDER(1:NUMCON+1,:),
& CALVAL, CALDER)

GOTO 9999
ENDIF
C ----------------------------------------------------------------------
Expand Down Expand Up @@ -326,7 +323,6 @@ SUBROUTINE OGEXEC (VALVAR, VALCON, FINOPT, FINITE, CALVAL, CALDER)
PYGFLA = 2 ! pygmo flag in COMMON: matched
CALL OGEVAL (VARVAL, CONVAL, VARDER, CONDER(1:NUMCON+1,:),
& CALVAL, CALDER)

GOTO 9999
ENDIF
C ======================================================================
Expand Down Expand Up @@ -374,7 +370,6 @@ SUBROUTINE OGEXEC (VALVAR, VALCON, FINOPT, FINITE, CALVAL, CALDER)
PYGFLA = 1 ! covergence
CALL OGEVAL (VARVAL, CONVAL, VARDER, CONDER(1:NUMCON+1,:),
& CALVAL, CALDER)

C WRITE (STR,*) "DIF=",NORM2(VARVAL-VARREF)
C CALL OGWRIT (1,STR)
C ======================================================================
Expand Down
10 changes: 8 additions & 2 deletions pyoptgra/core/wrapper.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,18 +203,23 @@ namespace optgra

// Ensure that at most one optgra_raii object is active at the same time
optgra_mutex.lock();


// Set number of variables and constraints. Will allocate arrays accordingly
oginit_(&num_variables, &num_constraints);
// Set constraint types: 1: GTE, -1: LTE, 0: EQU, -2=DERIVED DATA
ogctyp_(constraint_types.data());
// Set derivatives computation mode. 1: user-defined, 2: double diff., 3: single diff.
ogderi_(&derivatives_computation, autodiff_deltas.data());
// Set maximum distance per iteration and eps for 2nd order derivatives
ogdist_(&max_distance_per_iteration, &perturbation_for_snd_order_derivatives);

// Set variable types. 0: free variable, 1: parameter for sensitivity
ogvtyp_(variable_types.data());

// Haven't figured out what the others do, but maxiter is an upper bound anyway
int otheriters = max_iterations; // TODO: figure out what it does.
ogiter_(&max_iterations, &max_correction_iterations, &otheriters, &otheriters, &otheriters);

// Set optimization method flag
ogomet_(&optimization_method);

// original OPTGRA screen output configuration
Expand Down Expand Up @@ -278,6 +283,7 @@ namespace optgra
static_callable_store::set_x_dim(num_variables);
static_callable_store::set_c_dim(num_constraints + 1);

// Disable sensitivity mode
int sensitivity_mode = 0;
ogsopt_(&sensitivity_mode);

Expand Down
21 changes: 20 additions & 1 deletion pyoptgra/optgra.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
khan_function_tanh,
khan_function_triangle,
)
from .timeout import get_optimize_with_timeout_function


def _get_constraint_violation(
Expand Down Expand Up @@ -249,6 +250,7 @@ def __init__(
khan_bounds: Union[str, bool] = False,
optimization_method: int = 2,
log_level: int = 0,
timeout_seconds: Optional[float] = None,
) -> None:
r"""
Initialize a wrapper instance for the OPTGRA algorithm.
Expand Down Expand Up @@ -315,6 +317,9 @@ def __init__(
log_level: Control the original screen output of OPTGRA. 0 has no output,
4 and higher have maximum output`. Set this to 0 if you want to use the pygmo
logging system based on `set_verbosity()`.
timeout_seconds: Activate timeout of the optimization process. If given, the
optimization will be launched in a separate process and killed if timeout is
exceeded. By default None

Raises:

Expand Down Expand Up @@ -343,6 +348,7 @@ def __init__(

self.log_level = log_level
self.verbosity = 0 # by default no pygmo-style output
self.timeout_seconds = timeout_seconds
self._sens_state = None
self._sens_constraint_types: Union[List[int], None] = None

Expand Down Expand Up @@ -559,7 +565,16 @@ def extract_trailing_integer(s):

# get initial x
x0 = population.get_x()[idx]
result = optimize(

# use timeout function (using multiprocessing module) if required
if self.timeout_seconds is not None:
optimize_func = get_optimize_with_timeout_function(
optimize, self.timeout_seconds, x0, fitness_func
)
else:
optimize_func = optimize

result = optimize_func(
initial_x=khanf.eval_inv(x0) if khanf else x0,
constraint_types=constraint_types,
fitness_callback=fitness_func,
Expand Down Expand Up @@ -880,6 +895,8 @@ def get_extra_info(self) -> str:
result_str += "Not converged.\n"
elif self.__last_result["finopt"] == 4:
result_str += "Problem appears infeasible.\n"
elif self.__last_result["finopt"] == 5:
result_str += "Timeout reached.\n"
else:
grad_str = ""
result_str = (
Expand All @@ -906,6 +923,7 @@ def get_extra_info(self) -> str:
+ "\toptimization_method = {optimization_method},\n"
+ "\tlog_level = {log_level}\n"
+ "\tverbosity = {verbosity}\n"
+ "\ttimeout_seconds = {timeout_seconds}\n"
+ result_str
).format(
max_iterations=self.max_iterations,
Expand All @@ -923,4 +941,5 @@ def get_extra_info(self) -> str:
optimization_method=self.optimization_method,
log_level=self.log_level,
verbosity=self.verbosity,
timeout_seconds=self.timeout_seconds,
)
96 changes: 96 additions & 0 deletions pyoptgra/timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright 2008, 2021 European Space Agency
#
# This file is part of pyoptgra, a pygmo affiliated library.
#
# This Source Code Form is available under two different licenses.
# You may choose to license and use it under version 3 of the
# GNU General Public License or under the
# ESA Software Community Licence (ESCL) 2.4 Weak Copyleft.
# We explicitly reserve the right to release future versions of
# Pyoptgra and Optgra under different licenses.
# If copies of GPL3 and ESCL 2.4 were not distributed with this
# file, you can obtain them at https://www.gnu.org/licenses/gpl-3.0.txt
# and https://essr.esa.int/license/european-space-agency-community-license-v2-4-weak-copyleft

import multiprocessing as mp
from typing import Any, Callable, List, Tuple

__all__ = ["get_optimize_with_timeout_function"]


def _run_optimize(
func: Callable[..., Tuple[List[float], List[float], int]],
args: tuple,
kwargs: dict,
return_dict: Any,
) -> None:
"""Worker process that runs the C++ optimizer and stores its result."""
try:
result = func(*args, **kwargs)
return_dict["result"] = result
except Exception as e:
return_dict["error"] = str(e)


def get_optimize_with_timeout_function(
optimize_func: Callable[..., Tuple[List[float], List[float], int]],
timeout_seconds: float,
x_timeout: List[float],
fitness_func: Callable,
) -> Callable[..., Tuple[List[float], List[float], int]]:
"""
Wrap the Pybind11-based `optimize` function with a timeout safeguard.

Parameters
----------
optimize_func : callable
The Pybind11-bound `optimize` function to execute.
Must return a tuple `(x_opt, f_opt, status)`.
timeout_seconds : float
Maximum runtime in seconds before the optimizer process is terminated.
x_timeout : List[float]
Decision vector to return on timeout
fitness_func : callable
Fitness function to return on timeout

Returns
-------
callable
A wrapped version of `optimize_func` with the same signature.
When called:
* Returns `(x_opt, multipliers, status)` if the optimizer completes.
* Returns `([], [], 5)` if the optimizer exceeds the timeout.

Notes
-----
- The wrapped function runs the optimizer in a separate process using
:mod:`multiprocessing` to allow safe termination if the Fortran backend hangs.
- Status code `5` indicates a timeout occurred.
- This approach ensures that the main Python process remains responsive
and that no hanging Fortran thread blocks program exit.
"""

def wrapped_optimize(*args, **kwargs) -> Tuple[List[float], List[float], int]:
manager = mp.Manager()
return_dict = manager.dict()

process = mp.Process(target=_run_optimize, args=(optimize_func, args, kwargs, return_dict))
process.start()
process.join(timeout_seconds)

if process.is_alive():
print(
f"⚠️ Optimization timed out after {timeout_seconds} seconds — terminating process."
)
process.terminate()
process.join()
# Return timeout status code instead of raising
return (x_timeout, fitness_func(x_timeout), 5)

if "error" in return_dict:
print(f"⚠️ Optimizer process failed: {return_dict['error']}")
return (x_timeout, fitness_func(x_timeout), 5)

return return_dict.get("result", (x_timeout, fitness_func(x_timeout), 5))

return wrapped_optimize
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ license = { text = "GPL-3.0 or ESCL-2.4" }
name = "pyoptgra"
readme = "README.rst"
requires-python = ">=3.9"
version = "1.2.2"
version = "1.3.0"

[build-system]
build-backend = "scikit_build_core.build"
Expand Down
45 changes: 42 additions & 3 deletions tests/python/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# and https://essr.esa.int/license/european-space-agency-community-license-v2-4-weak-copyleft

import unittest

import time
import numpy as np

import pygmo
Expand All @@ -24,6 +24,10 @@
# problem class with numerical gradient, equality and inequality constraints from
# https://esa.github.io/pygmo2/tutorials/coding_udp_constrained.html
class luksan_vlcek:
def __init__(self, sleep_per_call: int = None):
# optional sleep per fitness call to test timeout function
self.sleep_per_call = sleep_per_call

def fitness(self, x):
obj = 0
for i in range(3):
Expand Down Expand Up @@ -78,6 +82,8 @@ def fitness(self, x):
ci2 = -(
8 * x[5] * (x[5] ** 2 - x[4]) - 2 * (1 - x[5]) + x[4] ** 2 - x[3] + x[3] ** 2 - x[4]
)
if self.sleep_per_call is not None:
time.sleep(self.sleep_per_call)
return [obj, ce1, ce2, ce3, ce4, ci1, ci2]

def get_bounds(self):
Expand Down Expand Up @@ -169,6 +175,7 @@ def runTest(self):
self.get_extra_info_test()
self.verbosity_test()
self.triangle_test()
self.timeout_test()

def constructor_test(self):
# Check that invalid optimization method is rejected
Expand Down Expand Up @@ -334,7 +341,7 @@ def gradient_with_constraints_test(self):
# objective function
self.assertLess(pop.champion_f[0], 2.26)
# checking exact value as regression test
self.assertEqual(pop.champion_f[0], 0.82929210248477)
self.assertAlmostEqual(pop.champion_f[0], 0.82929210248477)

# equality constraints
for i in [1, 2, 3, 4]:
Expand Down Expand Up @@ -366,7 +373,7 @@ def gradient_with_constraints_test(self):
# objective function
self.assertLess(pop2.champion_f[0], 2.26)
# checking exact value as regression test
self.assertEqual(pop2.champion_f[0], 0.8292921025820391)
self.assertAlmostEqual(pop2.champion_f[0], 0.8292921025820391)

# equality constraints
for i in [1, 2, 3, 4]:
Expand Down Expand Up @@ -878,6 +885,38 @@ def triangle_test(self):
tri_grad = pyoptgra.triangular_wave_fourier_grad(0, x)
np.testing.assert_array_equal(tri_grad, np.zeros_like(x, dtype=np.float64))

def timeout_test(self):
"""Testing timeout functionality (2 seconds)"""
# 1. Run Luksan-Vlcek problem with timeout of 0.1 seconds per fitness call
prob = pygmo.problem(luksan_vlcek(0.1))
prob.c_tol = 1e-7
og = pyoptgra.optgra(
optimization_method=1,
max_iterations=100,
max_correction_iterations=100,
max_distance_per_iteration=10,
timeout_seconds=2,
)
og.set_verbosity(1)
algo = pygmo.algorithm(og)
pop = pygmo.population(prob, size=0, seed=1) # empty population
pop.push_back([0.5, 0.5, -0.5, 0.4, 0.3, 0.7]) # add initial guess

# Calling optgra
# 2. Measure execution time of evolve()
start_time = time.time()
pop = algo.evolve(pop)
elapsed = time.time() - start_time

# 3. Check that timeout message is recorded
self.assertIn("Timeout reached", algo.get_extra_info())

# 4. Check that runtime is within expected bounds
# Allow small overhead (±0.5 s)
self.assertTrue(
1.5 <= elapsed <= 3.0, msg=f"Evolve took {elapsed:.2f} s, expected about 2 s"
)


if __name__ == "__main__":
unittest.main()