diff --git a/build.gradle b/build.gradle index 646c9057..93b6740c 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ sourceCompatibility = '17' repositories { mavenCentral() + maven { url 'https://jitpack.io' } } dependencies { @@ -18,6 +19,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.0.4' implementation 'com.bpodgursky:jbool_expressions:1.24' + implementation 'com.github.ProvideQ.jplex:input:8267d8be09' implementation files('lib/de.ovgu.featureide.lib.fm-v3.9.1.jar', 'lib/uvl-parser.jar') testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.mockito:mockito-core:5.+' diff --git a/src/main/java/edu/kit/provideq/toolbox/exception/AuthenticationException.java b/src/main/java/edu/kit/provideq/toolbox/exception/AuthenticationException.java new file mode 100644 index 00000000..bdafe91d --- /dev/null +++ b/src/main/java/edu/kit/provideq/toolbox/exception/AuthenticationException.java @@ -0,0 +1,11 @@ +package edu.kit.provideq.toolbox.exception; + +public class AuthenticationException extends Exception { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/edu/kit/provideq/toolbox/integration/planqk/PlanQkApi.java b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/PlanQkApi.java new file mode 100644 index 00000000..3b7d9e59 --- /dev/null +++ b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/PlanQkApi.java @@ -0,0 +1,235 @@ +package edu.kit.provideq.toolbox.integration.planqk; + +import edu.kit.provideq.toolbox.integration.planqk.exception.PlanQkJobFailedException; +import edu.kit.provideq.toolbox.integration.planqk.exception.PlanQkJobPendingException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Date; +import java.util.function.Function; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +@Component +public class PlanQkApi { + private static final String BASE_URL = "https://gateway.platform.planqk.de"; + + /** + * Calls a service using the PlanQk API. + * + * @param The type of the problem. + * @param The type of the status. + * @param The type of the result. + * @param service The url path of the service to use. + * @param problem The data of the problem to solve. + * @param authenticationToken The authentication token to use. + * @param problemProperties The properties of the problem. + * @param statusProperties The properties of the status. + * @param resultProperties The properties of the result. + * @return The result to the problem. + */ + public Mono call( + String service, + ProblemT problem, + String authenticationToken, + ProblemProperties problemProperties, + StatusProperties statusProperties, + ResultProperties resultProperties) { + var webClientBuilder = WebClient.builder(); + if (authenticationToken != null) { + webClientBuilder.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + authenticationToken); + } + + WebClient webClient = webClientBuilder.build(); + + // Define the URL for the token endpoint + final String serviceEndpoint = BASE_URL + service; + + // Make the POST request to obtain the access token with client_credentials grant type + return webClient.post() + .uri(serviceEndpoint + problemProperties.createJobEndpoint()) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(problem)) + .retrieve() + .bodyToMono(String.class) + .flatMap(jobId -> webClient.get() + .uri(serviceEndpoint + statusProperties.jobStatusEndpoint().formatted(jobId)) + .exchangeToMono(response -> { + // Handle different kinds of status responses + // Usually the JobStatus is returned as json object + // but some might use just an enum in plain text + MediaType contentType = response.headers().contentType().orElseThrow(); + if (MediaType.TEXT_PLAIN.equals(contentType)) { + return response.bodyToMono(String.class) + .map(status -> parseEnumStatus(statusProperties, status)); + } + + // Otherwise, assume the content type is application/json + return response.bodyToMono(statusProperties.statusClass()); + }) + .map(customStatus -> statusProperties.statusMapper().apply(customStatus)) + .flatMap(status -> { + switch (status) { + case SUCCEEDED -> { + return Mono.just(status); + } + case FAILED -> { + return Mono.error(new PlanQkJobFailedException()); + } + default -> { + return Mono.error(new PlanQkJobPendingException()); + } + } + }) + .retryWhen(Retry.backoff(100, Duration.ofSeconds(1)) + .filter(PlanQkJobPendingException.class::isInstance)) + .flatMap(succeededJobStatus -> + // The job finished successfully, so get the result + webClient.get() + .uri(serviceEndpoint + + resultProperties.jobResultsEndpoint().formatted(jobId)) + .retrieve() + .bodyToMono(resultProperties.jobResultsClass()) + ) + ); + } + + private static StatusT parseEnumStatus( + StatusProperties statusProperties, + String status) throws IllegalArgumentException { + if (!statusProperties.statusClass().isEnum()) { + throw new IllegalArgumentException("Not an enum: " + statusProperties.statusClass()); + } + + return Arrays.stream(statusProperties + .statusClass() + .getEnumConstants()) + .filter(enumValue -> enumValue.toString().equalsIgnoreCase(status)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid status: " + status)); + } + + public enum JobStatus { + UNKNOWN, + PENDING, + RUNNING, + SUCCEEDED, + FAILED + } + + public static class JobInfo { + private String id; + private JobStatus status; + private Date createdAt; + private Date startedAt; + private Date endedAt; + + /** + * Job ID. + */ + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + /** + * Job status. + */ + public JobStatus getStatus() { + return status; + } + + public void setStatus(JobStatus status) { + this.status = status; + } + + /** + * Date of creation. + */ + public Date getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + /** + * Date of starting. + */ + public Date getStartedAt() { + return startedAt; + } + + public void setStartedAt(Date startedAt) { + this.startedAt = startedAt; + } + + /** + * Date of ending. + */ + public Date getEndedAt() { + return endedAt; + } + + public void setEndedAt(Date endedAt) { + this.endedAt = endedAt; + } + } + + /** + * Properties for the problem. + * + * @param createJobEndpoint The POST endpoint to create a job. + * Must contain %s placeholder for the job ID. + */ + public record ProblemProperties( + String createJobEndpoint) { + public static PlanQkApi.ProblemProperties defaultProblemProperties() { + return new PlanQkApi.ProblemProperties("/jobs"); + } + } + + /** + * Properties for the status. + * + * @param statusClass The class of the custom status. + * @param jobStatusEndpoint The endpoint to get the status of a job. + * Must contain %s placeholder for the job ID. + * @param statusMapper Maps the custom status to a status of the PlanQK API. + * @param The type of the custom status. + */ + public record StatusProperties( + Class statusClass, + String jobStatusEndpoint, + Function statusMapper) { + public static PlanQkApi.StatusProperties defaultStatusProperties() { + return new PlanQkApi.StatusProperties<>( + PlanQkApi.JobInfo.class, + "/jobs/%s", + PlanQkApi.JobInfo::getStatus); + } + } + + /** + * Properties for the result. + * + * @param jobResultsClass The class of the custom result. + * @param jobResultsEndpoint The endpoint to get the result of a job. + */ + public record ResultProperties( + Class jobResultsClass, + String jobResultsEndpoint) { + public static PlanQkApi.ResultProperties defaultResultProperties( + Class resultClass) { + return new PlanQkApi.ResultProperties<>(resultClass, "/jobs/%s/results"); + } + } +} diff --git a/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobFailedException.java b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobFailedException.java new file mode 100644 index 00000000..6e9a69fd --- /dev/null +++ b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobFailedException.java @@ -0,0 +1,7 @@ +package edu.kit.provideq.toolbox.integration.planqk.exception; + +public class PlanQkJobFailedException extends Exception { + public PlanQkJobFailedException() { + super("PlanQK job failed"); + } +} diff --git a/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobPendingException.java b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobPendingException.java new file mode 100644 index 00000000..b28f4b63 --- /dev/null +++ b/src/main/java/edu/kit/provideq/toolbox/integration/planqk/exception/PlanQkJobPendingException.java @@ -0,0 +1,7 @@ +package edu.kit.provideq.toolbox.integration.planqk.exception; + +public class PlanQkJobPendingException extends Exception { + public PlanQkJobPendingException() { + super("PlanQK job is still pending"); + } +} diff --git a/src/main/java/edu/kit/provideq/toolbox/qubo/QuboConfiguration.java b/src/main/java/edu/kit/provideq/toolbox/qubo/QuboConfiguration.java index 6c58be28..8d9a46e8 100644 --- a/src/main/java/edu/kit/provideq/toolbox/qubo/QuboConfiguration.java +++ b/src/main/java/edu/kit/provideq/toolbox/qubo/QuboConfiguration.java @@ -8,6 +8,7 @@ import edu.kit.provideq.toolbox.qubo.solvers.DwaveQuboSolver; import edu.kit.provideq.toolbox.qubo.solvers.QiskitQuboSolver; import edu.kit.provideq.toolbox.qubo.solvers.QrispQuboSolver; +import edu.kit.provideq.toolbox.qubo.solvers.QuantagoniaQuboSolver; import java.io.IOException; import java.util.Objects; import java.util.Set; @@ -36,11 +37,12 @@ ProblemManager getQuboManager( QiskitQuboSolver qiskitSolver, DwaveQuboSolver dwaveSolver, QrispQuboSolver qrispSolver, + QuantagoniaQuboSolver quantagoniaQuboSolver, ResourceProvider resourceProvider ) { return new ProblemManager<>( QUBO, - Set.of(qiskitSolver, dwaveSolver, qrispSolver), + Set.of(qiskitSolver, dwaveSolver, qrispSolver, quantagoniaQuboSolver), loadExampleProblems(resourceProvider) ); } diff --git a/src/main/java/edu/kit/provideq/toolbox/qubo/solvers/QuantagoniaQuboSolver.java b/src/main/java/edu/kit/provideq/toolbox/qubo/solvers/QuantagoniaQuboSolver.java new file mode 100644 index 00000000..01e54183 --- /dev/null +++ b/src/main/java/edu/kit/provideq/toolbox/qubo/solvers/QuantagoniaQuboSolver.java @@ -0,0 +1,285 @@ +package edu.kit.provideq.toolbox.qubo.solvers; + +import com.fasterxml.jackson.annotation.JsonProperty; +import de.asbestian.jplex.input.LpFileReader; +import de.asbestian.jplex.input.Variable; +import edu.kit.provideq.toolbox.Solution; +import edu.kit.provideq.toolbox.exception.ConversionException; +import edu.kit.provideq.toolbox.integration.planqk.PlanQkApi; +import edu.kit.provideq.toolbox.meta.SolvingProperties; +import edu.kit.provideq.toolbox.meta.SubRoutineResolver; +import edu.kit.provideq.toolbox.meta.setting.SolverSetting; +import edu.kit.provideq.toolbox.meta.setting.basic.TextSetting; +import edu.kit.provideq.toolbox.qubo.QuboConfiguration; +import java.io.BufferedReader; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * {@link QuboConfiguration#QUBO} solver using the + * Quantagonia QUBO solver hosted on the PlanQK platform. + */ +@Component +public class QuantagoniaQuboSolver extends QuboSolver { + private static final String SETTING_PLANQK_TOKEN = "PlanQK Access Token"; + + @Override + public String getName() { + return "(PlanQK) Quantagonia QUBO Solver"; + } + + @Override + public List getSolverSettings() { + return List.of( + new TextSetting( + SETTING_PLANQK_TOKEN, + "Create access token as shown in docs: https://docs.planqk.de/services/applications.html" + ) + ); + } + + @Override + public Mono> solve( + String input, + SubRoutineResolver subRoutineResolver, + SolvingProperties properties) { + Optional planQkToken = properties + .getSetting(SETTING_PLANQK_TOKEN) + .map(TextSetting::getText); + + // Return if no token is provided + if (planQkToken.isEmpty()) { + var solution = new Solution<>(this); + solution.setDebugData("No PlanQK token provided."); + solution.abort(); + return Mono.just(solution); + } + + // Convert problem data string to buffered reader + var problemDataReader = new BufferedReader(new StringReader(input)); + // Parse lp data + LpFileReader lpReader = new LpFileReader(problemDataReader); + + QuantagoniaQuboProblem quantagoniaQubo; + try { + quantagoniaQubo = parseQuantagoniaQubo(lpReader); + } catch (ConversionException e) { + var solution = new Solution<>(this); + solution.setDebugData(e.getMessage()); + solution.abort(); + return Mono.just(solution); + } + + PlanQkApi api = new PlanQkApi(); + return api.call( + "/quantagonia/quantagonia-s-free-qubo-solver/1.0.0", + quantagoniaQubo, + planQkToken.get(), + new PlanQkApi.ProblemProperties("/v1/hqp/job"), + new PlanQkApi.StatusProperties<>( + QuantagoniaQuboStatus.class, + "/v1/hqp/job/%s/status", + status -> switch (status) { + case FINISHED -> PlanQkApi.JobStatus.SUCCEEDED; + case TERMINATED, ERROR -> PlanQkApi.JobStatus.FAILED; + case RUNNING, TIMEOUT -> PlanQkApi.JobStatus.RUNNING; + case CREATED -> PlanQkApi.JobStatus.PENDING; + }), + new PlanQkApi.ResultProperties<>( + QuantagoniaQuboSolution.class, + "/v1/hqp/job/%s/results" + ) + ).flatMap(result -> { + if (result == null) { + var solution = new Solution<>(this); + solution.setDebugData("Job couldn't be solved."); + solution.abort(); + return Mono.just(solution); + } + + var solution = new Solution<>(this); + String solutionString = String.join("\n", + result.getSolution().stream().map(Object::toString).toList()); + + solution.setSolutionData(solutionString); + solution.setDebugData(result.getLog()); + + solution.complete(); + + return Mono.just(solution); + }); + } + + private static QuantagoniaQuboProblem parseQuantagoniaQubo(LpFileReader lpReader) + throws ConversionException { + var qubo = new QuantagoniaQuboProblem(); + + switch (lpReader.getObjective(0).sense()) { + case MAX: + qubo.setSense("MAXIMIZE"); + break; + case MIN: + qubo.setSense("MINIMIZE"); + break; + case UNDEF: + throw new ConversionException("Objective sense is undefined"); + default: + throw new ConversionException("Unknown objective sense"); + } + + var variables = new HashSet(); + for (Variable variable : lpReader.getContinuousVariables()) { + // This name is either + // - a single variable (x0) + // - a product of variables (x0 * x1) + // - an exponent of a variable (x0 ^ 2) + String name = variable.name(); + + // Check product + String[] factors = name.split("\\*"); + if (factors.length > 1) { + var coefficients = lpReader.getObjective(0).coefficients(); + var coefficient = coefficients.get(name); + + // Get factors (x0 => 0) + double i = getVariableIndex(factors[0]); + double j = getVariableIndex(factors[1]); + + variables.add(factors[0].trim()); + variables.add(factors[1].trim()); + + // Add quadratic term at i j with coefficient / -4 + qubo.getMatrix().getQuadratic().add(List.of(i, j, coefficient / -4)); + } else { + // Check exponent + String[] exponent = name.split("\\^"); + if (exponent.length > 1) { + var coefficients = lpReader.getObjective(0).coefficients(); + var coefficient = coefficients.get(name); + + // Get factor (x0 => 0) + double i = Double.parseDouble(exponent[0].trim().replace("x", "")); + + variables.add(exponent[0].trim()); + + // Add quadratic term at i i with coefficient / 2 + qubo.getMatrix().getLinear().add(List.of(i, i, Math.abs(coefficient / 2))); + } + + // Ignore single variable + } + } + + qubo.getMatrix().setNumberOfVariables(variables.size()); + + return qubo; + } + + /** + * Get the index of a variable from its name. + * Currently only supports variables of the form x0, x1, x2, ... + * + * @param variableName The name of the variable + * @return The index of the variable + */ + static double getVariableIndex(String variableName) { + return Double.parseDouble(variableName.trim().replace("x", "")); + } + + static class QuantagoniaQuboProblem { + private String sense; + private Matrix matrix = new Matrix(); + + public String getSense() { + return sense; + } + + public void setSense(String sense) { + this.sense = sense; + } + + public Matrix getMatrix() { + return matrix; + } + + public void setMatrix( + Matrix matrix) { + this.matrix = matrix; + } + + public static class Matrix { + @JsonProperty("n") + private int numberOfVariables; + private List> linear = new ArrayList<>(); + private List> quadratic = new ArrayList<>(); + + public int getNumberOfVariables() { + return numberOfVariables; + } + + public void setNumberOfVariables(int numberOfVariables) { + this.numberOfVariables = numberOfVariables; + } + + public List> getLinear() { + return linear; + } + + public void setLinear(List> linear) { + this.linear = linear; + } + + public List> getQuadratic() { + return quadratic; + } + + public void setQuadratic(List> quadratic) { + this.quadratic = quadratic; + } + } + } + + enum QuantagoniaQuboStatus { + FINISHED, + TERMINATED, + ERROR, + RUNNING, + CREATED, + TIMEOUT + } + + static class QuantagoniaQuboSolution { + private String log; + private String objective; + private List solution; + + public String getLog() { + return log; + } + + public void setLog(String log) { + this.log = log; + } + + public String getObjective() { + return objective; + } + + public void setObjective(String objective) { + this.objective = objective; + } + + public List getSolution() { + return solution; + } + + public void setSolution(List solution) { + this.solution = solution; + } + } +}