diff --git a/package-lock.json b/package-lock.json index 28dff601..d847a098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "shapediver-appbuilder", - "version": "0.3.0-beta.3", + "version": "0.3.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shapediver-appbuilder", - "version": "0.3.0-beta.3", + "version": "0.3.0-beta.4", "dependencies": { "@emotion/react": "^11.11.4", "@mantine/core": "^7.6.2", "@mantine/hooks": "^7.6.2", + "@shapediver/sdk.geometry-api-sdk-v2": "^1.8.2", "@shapediver/sdk.platform-api-sdk-v1": "^2.18.10", "@shapediver/viewer": "2.12.7", "@shapediver/viewer.utils.mime-type": "1.0.1", @@ -3703,8 +3704,9 @@ }, "node_modules/@shapediver/sdk.geometry-api-sdk-v2": { "version": "1.8.2", - "resolved": "https://registry.npmjs.org/@shapediver/sdk.geometry-api-sdk-v2/-/sdk.geometry-api-sdk-v2-1.8.2.tgz", + "resolved": "https://npm.pkg.github.com/download/@shapediver/sdk.geometry-api-sdk-v2/1.8.2/fe52ed2bb30eae3a360c4b8304bd5ee409b3ef52", "integrity": "sha512-NBnWILQMBsgAOeYcR6AlDyFEwe80YVYez18eSpRfzNy9Op9ZVNhUImwnb21s3QwqmXV/10JZFm/AygEhWEbqig==", + "license": "ISC", "dependencies": { "@shapediver/api.geometry-api-dto-v2": "~1.15.1", "@shapediver/sdk.geometry-api-sdk-core": "~1.4.0" diff --git a/package.json b/package.json index f5395038..1245eabf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shapediver-appbuilder", - "version": "0.3.0-beta.4", + "version": "0.3.0-optimizer", "private": true, "type": "module", "homepage": "./", @@ -8,6 +8,7 @@ "@emotion/react": "^11.11.4", "@mantine/core": "^7.6.2", "@mantine/hooks": "^7.6.2", + "@shapediver/sdk.geometry-api-sdk-v2": "^1.8.2", "@shapediver/sdk.platform-api-sdk-v1": "^2.18.10", "@shapediver/viewer": "2.12.7", "@shapediver/viewer.utils.mime-type": "1.0.1", diff --git a/public/testoptimizer.json b/public/testoptimizer.json new file mode 100644 index 00000000..16766d64 --- /dev/null +++ b/public/testoptimizer.json @@ -0,0 +1,25 @@ +{ + "version": "1.0", + "sessions": [ + { + "id": "Test optimizer", + "ticket": "2b94da591fa259fc612403a43f2b49c5e0ca3011692130e68c2eb2c6dac6168bbe2296ccb0a3a743bfa3387f7e1ac2d138ac9f7cf6502a5fb599ba92ff16c5c6f56015d2ae8e732c7eb2fb2522a2d6217ec67d7a2b5d84519e3db4cef104b44cf8363e686b8212-925db841bfe1e38401560841c4220c52", + "modelViewUrl": "https://sdr8euc1.eu-central-1.shapediver.com" + } + ], + "appBuilderOverride": { + "version": "1.0", + "containers": [ + { + "name": "right", + "widgets": [ + { + "type": "optimizer", + "props": { + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/src/components/shapediver/appbuilder/AppBuilderOptimizerWidgetComponent.tsx b/src/components/shapediver/appbuilder/AppBuilderOptimizerWidgetComponent.tsx new file mode 100644 index 00000000..65e8b6c3 --- /dev/null +++ b/src/components/shapediver/appbuilder/AppBuilderOptimizerWidgetComponent.tsx @@ -0,0 +1,266 @@ +import { AppBuilderSettingsContext } from "context/AppBuilderContext"; +import { Paper, Title, Checkbox, Button, Switch, Slider, Text, Progress, Group} from "@mantine/core"; +import { useSessionPropsParameter } from "hooks/shapediver/parameters/useSessionPropsParameter"; +import { useOutputContent } from "hooks/shapediver/viewer/useOutputContent"; +import { INSGA2ResultExt, IObjectiveOutputData, IShapeDiverModelOptimizer, OBJECTIVE_OUTPUT_NAME, ParameterValuesType, ShapeDiverModelOptimizerNsga2 } from "optimization/optimizer"; +import AlertPage from "pages/misc/AlertPage"; +import React, { useContext, useEffect, useState } from "react"; +import { IAppBuilderWidgetPropsOptimizer } from "types/shapediver/appbuilder"; +import { useParametersStateless } from "hooks/shapediver/parameters/useParametersStateless"; +import { useParameterChanges } from "hooks/shapediver/parameters/useParameterChanges"; +import { ShapeDiverResponseParameterType } from "@shapediver/sdk.geometry-api-sdk-v2"; +import { INSGA2Props } from "optimization/nsga2"; + +interface Props extends IAppBuilderWidgetPropsOptimizer { + /** + * Default session id to use for optimization. + */ + sessionId: string +} + +export default function AppBuilderOptimizerWidgetComponent(props: Props) { + const { + sessionId, + } = props; + + // get information about the AppBuilder settings (session, etc.) + const { settings: appBuilderSettings } = useContext(AppBuilderSettingsContext); + const sessionDto = appBuilderSettings!.sessions.find(s => s.id === sessionId)!; + + // get the objectives output + const { outputContent } = useOutputContent( sessionId, OBJECTIVE_OUTPUT_NAME ); + const objectives = outputContent?.[0]?.data as IObjectiveOutputData | undefined; + if (!objectives) return An output named {OBJECTIVE_OUTPUT_NAME} could not be found or provides no data.; + + const [solution, setSolution] = useState({}); + + // get parameter stores + const parameters = useParametersStateless(sessionId); + const customize = (values: ParameterValuesType) => { + Object.keys(values).forEach(id => { + const actions = parameters[id].actions; + if (actions.setUiValue(values[id])) { + actions.execute(false); + } + }); + setSolution(values); + }; + + // get parameter changes + const parameterProps = useSessionPropsParameter(sessionId); + const parameterChanges = useParameterChanges(parameterProps); + useEffect(() => { + parameterChanges.forEach(c => c.accept()); + }, [solution]); + + // get default values for the parameters + const defaultValues: ParameterValuesType = {}; + Object.keys(parameters).forEach(id => { + defaultValues[id] = parameters[id].definition.defval; + }); + + // define parameters to be optimized + const parameterIds = Object.keys(parameters).filter(id => + parameters[id].definition.type === ShapeDiverResponseParameterType.FLOAT || + parameters[id].definition.type === ShapeDiverResponseParameterType.INT || + parameters[id].definition.type === ShapeDiverResponseParameterType.BOOL || + parameters[id].definition.type === ShapeDiverResponseParameterType.ODD || + parameters[id].definition.type === ShapeDiverResponseParameterType.EVEN + ); + + const [populationSize, setPopulationSize] = useState(10); + const [maxGenerations, setMaxGenerations] = useState(10); + const [checkboxValues, setCheckboxValues] = useState([true, true]); + const [isRunning, setIsRunning] = useState(false); + const [optimizer, setOptimizer] = useState>|undefined>(undefined); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState< INSGA2ResultExt>(); + + const updateWeights = (index,value,custom) => { + const newWeights = [...weights]; + newWeights[index].value = value; + setWeights(newWeights); + if (result&&custom) { + console.log(currentBest(result)); + customize(result.parameterValues[currentBest(result)]); + } + }; + + const setInitialWeights = () => { + const newWeights= []; + {Object.entries(objectives).map(([key, value], index) => ( + newWeights.push({key: key, value: 0.5}) + ))} + return newWeights; + }; + + const [weights, setWeights] = useState(setInitialWeights); + + const individualScore= (ind) => { + + let sum=0; + const weightValues = weights.sort((a, b) => a.key.localeCompare(b.key)).map(item => item.value); + + for (let i = 0; i < ind.objectives.length; i++) { + sum += ind.objectives[i]*weightValues[i]; + } + + return sum; + } + + const currentBest = (result: INSGA2ResultExt) => { + + let bestIndex = 0; + let best = individualScore(result.individuals[0]); + console.log("looking for curr best: "); + console.log(result.individuals); + for (let i = 0; i < result.individuals.length; i++) { + let curr = individualScore(result.individuals[i]); + console.log(curr); + if (curr < best) { + best=curr; + bestIndex=i; + } + } + + return bestIndex; + }; + + const runOptimizer = async (values) => { + const optimizer = await ShapeDiverModelOptimizerNsga2.create({ + sessionDto, + defaultParameterValues: defaultValues, + }); + setOptimizer(optimizer); + console.log("Running optimizer with values:", values); + const currResult = await optimizer.optimize({ + parameterIds, + optimizerProps: { + populationSize: populationSize, + maxGenerations: maxGenerations, + mutationRate: 0.5, + crossoverRate: 0.5, + progressCallback: (progress) => setProgress(progress*100), + }, + generationCallback: (generation) => customize(generation.parameterValues[currentBest(generation)]) + }); + setIsRunning(false); + console.log(currResult); + setResult(currResult); + customize(currResult.parameterValues[currentBest(currResult)]); + + }; + + const [isVisible, setIsVisible] = useState(false); + + const handleToggle = (event) => { + setIsVisible(event.currentTarget.checked); + }; + + const handleCheckboxChange = (index) => { + const newValues = [...checkboxValues]; + newValues[index] = !newValues[index]; + setCheckboxValues(newValues); + }; + + const handleStartClick = () => { + setIsRunning(true); + runOptimizer(checkboxValues); + }; + + const handleEndClick = () => { + optimizer?.requestCancellation(); + }; + + return <> + + + Goals to optimize + {Object.entries(objectives).map(([key, value], index) => ( + + + handleCheckboxChange(index)} + key={index} + style={{ marginTop: 20 }} + /> + + updateWeights(index,val, false)} + onChangeEnd={(val) => updateWeights(index,val, true)} + marks={[ + { value: 0, label: "0" }, + { value: 1, label: "1" } + ]} + style={{ flexGrow: 1 }} + /> + + + + + ))} + + + + {isRunning && ( + + )} + + + + + + {isVisible && ( + + Optimization options + Population size + + + Maximum number of generations + + + + )} + ; + +} diff --git a/src/components/shapediver/appbuilder/AppBuilderWidgetsComponent.tsx b/src/components/shapediver/appbuilder/AppBuilderWidgetsComponent.tsx index 3cddb35c..e3ef6e69 100644 --- a/src/components/shapediver/appbuilder/AppBuilderWidgetsComponent.tsx +++ b/src/components/shapediver/appbuilder/AppBuilderWidgetsComponent.tsx @@ -1,8 +1,9 @@ import React from "react"; -import { IAppBuilderWidget, isAccordionWidget, isImageWidget, isTextWidget } from "types/shapediver/appbuilder"; +import { IAppBuilderWidget, isAccordionWidget, isImageWidget, isOptimizerWidget, isTextWidget } from "types/shapediver/appbuilder"; import AppBuilderTextWidgetComponent from "./AppBuilderTextWidgetComponent"; import AppBuilderImageWidgetComponent from "./AppBuilderImageWidgetComponent"; import AppBuilderAccordionWidgetComponent from "./AppBuilderAccordionWidgetComponent"; +import AppBuilderOptimizerWidgetComponent from "./AppBuilderOptimizerWidgetComponent"; interface Props { /** @@ -28,6 +29,8 @@ export default function AppBuilderWidgetsComponent({ sessionId, widgets }: Props return ; else if (isAccordionWidget(w)) return ; + else if (isOptimizerWidget(w)) + return ; else return null; })} diff --git a/src/context/AppBuilderContext.ts b/src/context/AppBuilderContext.ts index f0c3041d..129e667f 100644 --- a/src/context/AppBuilderContext.ts +++ b/src/context/AppBuilderContext.ts @@ -1,4 +1,5 @@ import { createContext } from "react"; +import { IAppBuilderSettings } from "types/shapediver/appbuilder"; /** Types of containers */ export type AppBuilderContainerOrientationType = "unspecified" | "horizontal" | "vertical"; @@ -24,4 +25,13 @@ export interface IAppBuilderTemplateContext { /** Information about a template. */ export const AppBuilderTemplateContext = createContext({ name: "unspecified" +}); + +export interface IAppBuilderSettingsContext { + settings?: IAppBuilderSettings +} + +/** Information about AppBuilder settings. */ +export const AppBuilderSettingsContext = createContext({ + settings: undefined }); \ No newline at end of file diff --git a/src/hooks/shapediver/parameters/useParametersStateless.ts b/src/hooks/shapediver/parameters/useParametersStateless.ts new file mode 100644 index 00000000..401f3fca --- /dev/null +++ b/src/hooks/shapediver/parameters/useParametersStateless.ts @@ -0,0 +1,21 @@ +import { useShapeDiverStoreParameters } from "store/useShapeDiverStoreParameters"; +import { IShapeDiverParameter } from "types/shapediver/parameter"; + +/** + * Hook providing a shortcut to all abstracted parameters of a session managed by {@link useShapeDiverStoreParameters}. + * + * @see {@link IShapeDiverParameter} + * + * @param sessionId + * @param parameterId Id, name, or displayname of the parameter + * @returns + */ +export function useParametersStateless(sessionId: string) { + + const parametersStore = useShapeDiverStoreParameters(); + const paramStores = parametersStore.getParameters(sessionId); + const parameters: { [key: string]: IShapeDiverParameter } = {}; + Object.keys(paramStores).forEach((id) => parameters[id] = paramStores[id].getState()); + + return parameters; +} diff --git a/src/optimization/README.md b/src/optimization/README.md new file mode 100644 index 00000000..6b54d042 --- /dev/null +++ b/src/optimization/README.md @@ -0,0 +1,40 @@ +# NSGA-II + +Find the original paper by Kalyanmoy Deb et al [here](https://www.cse.unr.edu/~sushil/class/gas/papers/nsga2.pdf). + +A video explaining NSGA-II can be found [here](https://www.youtube.com/watch?v=SL-u_7hIqjA). + +We started our implementation based on the code available [here](https://github.com/Aiei/nsga2/tree/master), and +extended it by extended TypeScript types, support for asynchronous execution, and implementing a connector +to ShapeDiver Geometry Backend systems. + +## NSGA-II as summarized by ChatGPT + +The NSGA-II (Non-dominated Sorting Genetic Algorithm II) is a popular multi-objective optimization algorithm designed to solve problems involving multiple conflicting objectives. Developed by Kalyanmoy Deb et al. in 2002, the algorithm is an enhancement of its predecessor, NSGA, and is specifically known for its efficiency and ability to maintain a diverse set of solutions. Here’s a breakdown of its key principles and how it operates: + +### 1. **Population Initialization** + NSGA-II starts with a randomly initialized population of potential solutions. Each solution in the population, called an individual, represents a possible set of decision variables for the optimization problem. + +### 2. **Fitness Evaluation** + Each individual in the population is evaluated based on the multiple objective functions of the problem. The fitness evaluation helps to determine how well each individual meets the criteria set by the objectives. + +### 3. **Non-dominated Sorting** + This is a crucial step where the population is sorted into different levels or fronts based on the concept of Pareto dominance. An individual solution \( A \) is said to dominate another solution \( B \) if it is at least as good as \( B \) in all objectives and better in at least one objective. The first front (Front 1) consists of solutions that are not dominated by any other solution. The second front consists of solutions that are only dominated by those in the first front, and so on. + +### 4. **Crowding Distance Calculation** + Within each front, a crowding distance is calculated for each individual. The crowding distance is a measure of how close an individual is to its neighbors. A larger crowding distance means a solution is more isolated, which is preferable as it promotes diversity in the solution set. + +### 5. **Selection** + NSGA-II uses a tournament selection process based on dominance and crowding distance. When selecting individuals for crossover and mutation, the algorithm prefers individuals that are less dominated (i.e., belong to a lower front) and are more isolated (i.e., have a higher crowding distance). + +### 6. **Crossover and Mutation** + These genetic operators are applied to selected individuals to generate new offspring, potentially exploring new and better regions of the solution space. Crossover combines the features of two parent solutions, while mutation introduces random changes to an individual, mimicking natural genetic variations. + +### 7. **Survivor Selection** + NSGA-II combines the parent and offspring populations and applies non-dominated sorting and crowding distance calculations again. The best individuals (based on fronts and crowding distance) are selected to form the new population for the next generation, ensuring that the population size remains constant. + +### 8. **Termination** + The algorithm repeats the process of selection, crossover, mutation, and survivor selection until a stopping criterion is met, which could be a maximum number of generations or a satisfactory convergence level. + +The result of NSGA-II is a set of solutions known as the Pareto front. These solutions are not dominated by any other feasible solutions and represent different trade-offs among the objectives, providing a diverse range of choices for decision-making scenarios. + diff --git a/src/optimization/nsga2.pdf b/src/optimization/nsga2.pdf new file mode 100644 index 00000000..9c8e76dd Binary files /dev/null and b/src/optimization/nsga2.pdf differ diff --git a/src/optimization/nsga2.ts b/src/optimization/nsga2.ts new file mode 100644 index 00000000..225b390a --- /dev/null +++ b/src/optimization/nsga2.ts @@ -0,0 +1,525 @@ + + +/** + * Type of the objective function. + */ +type ObjectiveFunctionType = (chromosome: Tchromosome[]) => Promise; + +/** + * Type of the genome function which creates a random chromosome based on the index of the chromosome. + */ +type GenomeFunctionType = (index: number) => Tchromosome; + +/** + * Type of the progress callback. "progress" is a number between 0 and 1. + */ +export type ProgressCallbackType = (progress: number) => void; + +/** + * Type of the generation callback. "individuals" is the current population. + */ +export type GenerationCallbackType = (generation: INSGA2Result) => void; + +/** + * User-facing properties of the NSGA-II optimization algorithm. + */ +export interface INSGA2Props { + /** + * Size of the population. + */ + populationSize: number, + /** + * Maximum number of generations to compute. + */ + maxGenerations: number, + /** + * Mutation rate, number between 0 and 1. + */ + mutationRate: number, + /** + * Crossover rate, number between 0 and 1. + */ + crossoverRate: number, + /** + * Optional callback for being notified about the progress of the optimization. + */ + progressCallback?: ProgressCallbackType +} + +/** + * Result of the NSGA-II optimization algorithm. + */ +export interface INSGA2Result { + /** + * The individuals on the pareto front. + */ + individuals: IIndividual[] +} + +/** + * Internal properties of the NSGA-II optimization algorithm. + */ +export interface INSGA2InternalProps extends INSGA2Props { + /** number of parameters to optimize */ + chromosomeSize: number, + /** number of objectives (metrics) */ + objectiveSize: number, + /** + * Compute the objectives of a chromosome. + */ + objectiveFunction: ObjectiveFunctionType, + /** + * Create a random chromosome based on the index. + */ + genomeFunction: GenomeFunctionType + /** + * Optional callback for being notified about the current generation. + */ + generationCallback?: GenerationCallbackType +} + +/** + * A class for the NSGA-II optimization algorithm. + * Copied and adapted from https://github.com/Aiei/nsga2/tree/master + */ +export class NSGA2 +{ + chromosomeSize: number; + objectiveSize: number; + populationSize: number; + maxGenerations: number; + mutationRate: number; + crossoverRate: number; + objectiveFunction: ObjectiveFunctionType; + genomeFunction: GenomeFunctionType; + currentProgress: number = 0; + progressCallback?: ProgressCallbackType; + cancellationRequested: boolean = false; + generationCallback?: GenerationCallbackType; + + constructor( { + chromosomeSize, + objectiveSize, + populationSize, + maxGenerations, + mutationRate, + crossoverRate, + objectiveFunction, + genomeFunction, + progressCallback, + generationCallback, + }: INSGA2InternalProps ) { + this.chromosomeSize = chromosomeSize; + this.objectiveSize = objectiveSize; + this.populationSize = populationSize; + this.maxGenerations = maxGenerations; + this.progressCallback = progressCallback; + this.genomeFunction = genomeFunction; + this.objectiveFunction = objectiveFunction; + this.crossoverRate = crossoverRate; + this.mutationRate = mutationRate; + this.generationCallback = generationCallback; + } + + /** + * Run the optimization. + * @param frontOnly If true return the individuals on the pareto front only. + * @returns + */ + async optimize(frontOnly: boolean = false): Promise> { + const timeStamp = Date.now(); + this.setProgress(0); + // First parents + let pop: Individual[]; + pop = await this.initPopulation(); + this.sort(pop); + pop = this.setCrowdingDistances(pop); + // Main loop + let generationCount: number = 1; + while (generationCount < this.maxGenerations) { + if (this.cancellationRequested) break; + this.setProgress(generationCount / this.maxGenerations); + // create offsprings + const offsprings = await this.generateOffsprings(pop); + // extend the population with the offsprings + pop = pop.concat(offsprings); + const sortedPop = this.sort(pop); + pop = this.setCrowdingDistances(pop); + let nextPop: Individual[] = []; + const sortedPopLength = sortedPop.length; + for (let i = 0; i < sortedPopLength; i++) { + if (sortedPop[i].length + nextPop.length <= this.populationSize) + { + nextPop = nextPop.concat(sortedPop[i]); + } else if (nextPop.length < this.populationSize) { + this.sortByCrowdingDistance(sortedPop[i]); + let j = 0; + while (nextPop.length < this.populationSize) { + nextPop.push(sortedPop[i][j]); + j++; + } + } + } + pop = nextPop; + if (generationCount < this.maxGenerations) + this.generationCallback?.(this.createResult(pop, frontOnly)); + generationCount++; + } + // Timestamp + console.log(`NSGA2 Finished in ${(Date.now() - timeStamp)} milliseconds.`); + + return this.createResult(pop, frontOnly); + } + + createResult(pop: Individual[], frontOnly: boolean): INSGA2Result { + if (frontOnly) { + // Return pareto front only + const fpop: Individual[] = []; + for (const p of pop) { + if (p.paretoRank == 1) { + fpop.push(p); + } + } + + return { individuals: fpop.map(i => i.getData()) }; + } + + return { individuals: pop.map(i => i.getData()) }; + } + + setProgress(progress: number) { + this.currentProgress = progress; + this.progressCallback?.(progress); + } + + addProgress(progress: number) { + this.currentProgress += progress; + this.progressCallback?.(this.currentProgress); + } + + /** + * Request cancellation of the optimization. + */ + async requestCancellation() { + this.cancellationRequested = true; + } + + /** + * Create initial randome population. + * The size is given by the globally configured population size. + * @returns + */ + protected async initPopulation(): Promise[]> { + const population : Promise>[] = []; + for (let i = 0; i < this.populationSize; i++) { + population.push((() => { + const res = this.createRandomIndividual(); + this.addProgress(1 / (this.populationSize * this.maxGenerations)); + + return res; + })()); + } + + return await Promise.all(population); + } + + /** + * Create a random individual using the genome function. + * @returns + */ + protected async createRandomIndividual(): Promise> { + const newIndividual = new Individual(this.chromosomeSize); + for (let i = 0; i < this.chromosomeSize; i++) { + newIndividual.chromosome[i] = this.genomeFunction(i); + } + await newIndividual.calculateObjectives(this.objectiveFunction); + + return newIndividual; + } + + protected sort(individuals: Individual[]): Individual[][] { + const fronts: Individual[][] = []; + fronts[0] = []; + const l = individuals.length; + for (let i = 0; i < l; i++) { + individuals[i].individualsDominated = []; + individuals[i].dominatedCount = 0; + for (let j = 0; j < l; j++) { + if (i == j) { continue; } + if (individuals[i].dominate(individuals[j])) { + individuals[i].individualsDominated + .push(individuals[j]); + } else if (individuals[j].dominate(individuals[i])) { + individuals[i].dominatedCount += 1; + } + } + if (individuals[i].dominatedCount <= 0) { + individuals[i].paretoRank = 1; + fronts[0].push(individuals[i]); + } + } + let rank = 0; + // [i-1] because stupid scientists always start arrays at 1 + while (fronts[rank].length > 0) { + const nextFront: Individual[] = []; + for (let k = 0; k < fronts[rank].length; k++) { + for (let j = 0; j < fronts[rank][k].individualsDominated.length; j++) { + fronts[rank][k].individualsDominated[j] + .dominatedCount -= 1; + if (fronts[rank][k].individualsDominated[j].dominatedCount == 0) { + fronts[rank][k].individualsDominated[j] + .paretoRank = rank + 2; + nextFront.push( + fronts[rank][k].individualsDominated[j]); + } + } + } + rank += 1; + fronts[rank] = nextFront; + } + + return fronts; + } + + protected setCrowdingDistances(individuals: Individual[]): Individual[] { + for (let i = 0; i < individuals.length; i++) { + individuals[i].crowdingDistance = 0; + } + for (let m = 0; m < this.objectiveSize; m++) { + + let objectiveMin: number = Infinity; + let objectiveMax: number = 0; + for (const idv of individuals) { + if (idv.objectives[m] > objectiveMax) { + objectiveMax = idv.objectives[m]; + } + if (idv.objectives[m] < objectiveMin) { + objectiveMin = idv.objectives[m]; + } + } + + this.sortByObjective(individuals, m); + // Prevent NaN + if (objectiveMax - objectiveMin <= 0) { + continue; + } + individuals[0].crowdingDistance = Infinity; + const lastIndex = individuals.length - 1; + individuals[lastIndex].crowdingDistance = Infinity; + for (let i = 1; i < individuals.length - 1; i++) { + individuals[i].crowdingDistance = individuals[i].crowdingDistance + + ( (individuals[i+1].objectives[m] - individuals[i-1].objectives[m]) / (objectiveMax - objectiveMin) ); + } + } + + return individuals; + } + + protected sortByObjective(individuals: Individual[], objectiveId: number) { + let tmp; + for (let i = 0; i < individuals.length; i++) { + for (let j = i; j > 0; j--) { + if (individuals[j].objectives[objectiveId] - + individuals[j - 1].objectives[objectiveId] < 0) + { + tmp = individuals[j]; + individuals[j] = individuals[j - 1]; + individuals[j - 1] = tmp; + } + } + } + } + + /** + * Create offsprings from the given parents. + * The number of offsprings is given by the globally configured population size. + * @param parents + * @returns + */ + protected async generateOffsprings(parents: Individual[]): Promise[]> { + const offspring: Promise[]>[] = []; + let numOffsprings = 0; + while (numOffsprings < this.populationSize) { + const parentA = this.getGoodParent(parents); + const parentB = this.getGoodParent(parents); + offspring.push((async () => { + const res = await this.mate(parentA, parentB); + this.addProgress(2 / (this.populationSize * this.maxGenerations)); + + return res; + })()); + numOffsprings += 2; + } + + return (await Promise.all(offspring)).flat(1); + } + + /** + * Mate two parents and return two childs. + * @param parentA + * @param parentB + * @returns + */ + protected async mate(parentA: Individual, parentB: Individual): Promise[]> { + // Create two childs + const childs = [new Individual(this.chromosomeSize), new Individual(this.chromosomeSize)]; + childs[0].chromosome = + parentA.chromosome.slice(0, this.chromosomeSize); + childs[1].chromosome = + parentB.chromosome.slice(0, this.chromosomeSize); + // Crossovers + this.crossover(childs[0], childs[1], this.crossoverRate); + // Mutations + this.mutate(childs[0], this.mutationRate); + this.mutate(childs[1], this.mutationRate); + await childs[0].calculateObjectives(this.objectiveFunction); + await childs[1].calculateObjectives(this.objectiveFunction); + + return childs; + } + + /** + * Crossover two individuals using the given rate. + * @param a + * @param b + * @param rate + */ + protected crossover(a: Individual, b: Individual, rate: number) { + for (let i = 0; i < this.chromosomeSize; i++) { + if (Math.random() < rate) { + const tmp = a.chromosome[i]; + a.chromosome[i] = b.chromosome[i]; + b.chromosome[i] = tmp; + } + } + } + + /** + * Mutate the individual using the given rate. + * @param individual + * @param rate + */ + protected mutate(individual: Individual, rate: number) { + for (let i = 0; i < individual.chromosome.length; i++) { + if (Math.random() < rate) { + individual.chromosome[i] = this.genomeFunction(i); + } + } + } + + /** + * Choose a random good parent from the given parents. + * @param parents + * @returns + */ + protected getGoodParent(parents: Individual[]): Individual { + let r: number[]; + do { + r = [ + Math.floor(Math.random() * parents.length), + Math.floor(Math.random() * parents.length) + ]; + } while (r[0] == r[1]); + if (parents[r[0]].paretoRank < parents[r[1]].paretoRank) { + return parents[r[0]]; + } + if (parents[r[0]].paretoRank > parents[r[1]].paretoRank) { + return parents[r[1]]; + } + if (parents[r[0]].paretoRank == parents[r[1]].paretoRank) { + if (parents[r[0]].crowdingDistance >= + parents[r[1]].crowdingDistance) { + return parents[r[0]]; + } + if (parents[r[0]].crowdingDistance < + parents[r[1]].crowdingDistance) { + return parents[r[1]]; + } + } + + /** + * This should never happen. + */ + return parents[0]; + } + + protected sortByCrowdingDistance(individuals: Individual[]) { + let tmp; + for (let i = 0; i < individuals.length; i++) { + for (let j = i; j > 0; j--) { + if (individuals[j].crowdingDistance - + individuals[j - 1].crowdingDistance < 0) + { + tmp = individuals[j]; + individuals[j] = individuals[j - 1]; + individuals[j - 1] = tmp; + } + } + } + individuals.reverse(); + } +} + +export interface IIndividual { + chromosome: Tchromosome[]; + objectives: number[]; + paretoRank: number; + dominatedCount: number; + crowdingDistance: number; +} + +/** + * An individual in the population. + */ +export class Individual implements IIndividual +{ + chromosome: Tchromosome[]; + objectives: number[] = []; + paretoRank: number = 0; + individualsDominated: Individual[] = []; + dominatedCount: number = 0; + crowdingDistance: number = 0; + + /** + * Constructor. + * @param chromosomeSize + */ + constructor(chromosomeSize: number) { + this.chromosome = new Array(chromosomeSize); + } + + getData(): IIndividual { + + return { + chromosome: this.chromosome, + objectives: this.objectives, + paretoRank: this.paretoRank, + dominatedCount: this.dominatedCount, + crowdingDistance: this.crowdingDistance + }; + } + + /** + * Calculate the objectives of the individual using the given objective function. + * @param objectiveFunction + */ + async calculateObjectives(objectiveFunction: ObjectiveFunctionType) { + this.objectives = await objectiveFunction(this.chromosome); + } + + /** + * Check whether this individual dominates the other individual. + * @param other + * @returns True if this individual dominates the other individual. + */ + dominate(other: Individual): boolean { + const l = this.objectives.length; + for (let i = 0; i < l; i++) { + if (this.objectives[i] > other.objectives[i]) { + return false; + } + } + + return true; + } +} diff --git a/src/optimization/optimizer.ts b/src/optimization/optimizer.ts new file mode 100644 index 00000000..5ca12d1d --- /dev/null +++ b/src/optimization/optimizer.ts @@ -0,0 +1,271 @@ +import { + ShapeDiverResponseDto, + ShapeDiverResponseOutput, + ShapeDiverResponseParameterType, + ShapeDiverSdk, + create +} from "@shapediver/sdk.geometry-api-sdk-v2"; +import { SessionCreationDefinition } from "@shapediver/viewer"; +import { INSGA2Props, INSGA2Result, NSGA2 } from "./nsga2"; + +/** + * Type for default parameter values (parameters not to be optimized) + */ +export type ParameterValuesType = { [key: string]: string }; + +/** + * Type used for Chromosomes + */ +type ChromosomeType = string; + +/** + * Properties for creating a ShapeDiverModelOptimizer + */ +export interface IShapeDiverModelOptimizerCreateProps { + /** + * Definition of the session to create + */ + sessionDto: SessionCreationDefinition, + /** + * Default values for the parameters which should not be optimized + */ + defaultParameterValues: ParameterValuesType, +} + +/** + * Type of the generation callback. "individuals" is the current population. + */ +export type GenerationCallbackTypeExt = (generation: INSGA2ResultExt) => void; + +/** + * Properties for optimizing a ShapeDiver model + */ +export interface IShapeDiverModelOptimizerProps { + /** + * Ids of the parameters to be included in the optimization. If not set, all parameters are included. + */ + parameterIds?: string[], + /** + * Optimizer-specific properties + */ + optimizerProps: Tprops, + /** + * Callback function to be called after each generation. + */ + generationCallback?: GenerationCallbackTypeExt, +} + +/** + * Interface for a ShapeDiver model optimizer + */ +export interface IShapeDiverModelOptimizer { + /** + * Run the optimization + * @param options + * @returns + */ + optimize: (options: IShapeDiverModelOptimizerProps) => Promise; + /** + * Request the cancellation of the optimization + */ + requestCancellation: () => void; +} + +export interface INSGA2ResultExt extends INSGA2Result { + /** + * Parameter values corresponding to the individuals + */ + parameterValues: ParameterValuesType[]; +} + +/** + * Name of the data output which defines the objectives + */ +export const OBJECTIVE_OUTPUT_NAME = "Objectives"; + +/** + * Interface for the data output of the objectives + */ +export interface IObjectiveOutputData { [key: string]: number } + +/** + * ShapeDiver model optimizer using NSGA2 + */ +export class ShapeDiverModelOptimizerNsga2 implements IShapeDiverModelOptimizer> { + + initialObjectives: IObjectiveOutputData; + objectiveSize: number; + + constructor( + private sdk: ShapeDiverSdk, + private modelDto: ShapeDiverResponseDto, + private defaultParameterValues: ParameterValuesType, + private nsga2?: NSGA2 + ) { + this.initialObjectives = this.getObjectives(modelDto); + this.objectiveSize = Object.keys(this.initialObjectives).length; + } + + requestCancellation() { + if (this.nsga2) { + this.nsga2.requestCancellation(); + } + } + + getObjectives(response: ShapeDiverResponseDto): IObjectiveOutputData { + const _objectiveOutput = Object.values(response.outputs!).find(output => output.name === OBJECTIVE_OUTPUT_NAME); + if (!_objectiveOutput) { + throw new Error(`Output "${OBJECTIVE_OUTPUT_NAME}" not found in model`); + } + + const objectiveOutput = _objectiveOutput as ShapeDiverResponseOutput; + if (objectiveOutput.status_computation !== "success" || objectiveOutput.status_collect !== "success") { + throw new Error(`Output "${OBJECTIVE_OUTPUT_NAME}" not successfully computed`); + } + + const content = objectiveOutput.content; + if (!content || content.length !== 1) { + throw new Error(`Output "${OBJECTIVE_OUTPUT_NAME}" must have exactly one content item`); + } + + return content[0].data as IObjectiveOutputData; + } + + /** + * Create an instance of the NSGA-II optimizer. + * @param props + * @returns + */ + public static async create(props: IShapeDiverModelOptimizerCreateProps): Promise>> { + + const { sessionDto, defaultParameterValues } = props; + + const sdk = create(sessionDto.modelViewUrl, sessionDto.jwtToken); + const modelDto = await sdk.session.init(sessionDto.ticket!); + + return new ShapeDiverModelOptimizerNsga2(sdk, modelDto, defaultParameterValues); + } + + public async optimize(props: IShapeDiverModelOptimizerProps): Promise> { + const { + parameterIds: _parameterIds, + optimizerProps, + generationCallback: _generationCallback, + } = props; + + const parameterIds = _parameterIds ?? Object.keys(this.modelDto.parameters!); + const chromosomeSize = parameterIds.length; + + const genomeFunction = (index: number): string => { + const parameterId = parameterIds[index]; + const parameterDefinition = this.modelDto.parameters![parameterId]; + if (parameterDefinition.type === ShapeDiverResponseParameterType.BOOL) + { + return Math.random() < 0.5 ? "true" : "false"; + } + else if (parameterDefinition.type === ShapeDiverResponseParameterType.INT) + { + const min = parameterDefinition.min as number; + const max = parameterDefinition.max as number; + const range = max - min; + const value = Math.floor(Math.random() * (range + 1)) + min; + + return (value >= max ? max : value).toString(); + } + else if (parameterDefinition.type === ShapeDiverResponseParameterType.FLOAT) + { + const min = parameterDefinition.min as number; + const max = parameterDefinition.max as number; + const range = max - min; + + return (Math.random() * range + min).toFixed(parameterDefinition.decimalplaces).toString(); + } + else if (parameterDefinition.type === ShapeDiverResponseParameterType.ODD || parameterDefinition.type === ShapeDiverResponseParameterType.EVEN) + { + const min = parameterDefinition.min as number; + const max = parameterDefinition.max as number; + const range = Math.round((max - min) / 2); + const value = 2 * Math.floor(Math.random() * (range + 1)) + min; + + return (value >= max ? max : value).toString(); + } + else if (parameterDefinition.type === ShapeDiverResponseParameterType.STRINGLIST) + { + const choices = parameterDefinition.choices as string[]; + const index = Math.floor(Math.random() * choices.length); + + return choices[index >= choices.length ? choices.length - 1 : index]; + } + else { + throw new Error(`Unsupported parameter type: ${parameterDefinition.type}`); + } + }; + + const objectiveFunction = async (chromosome: string[]): Promise => { + // run ShapeDiver computation + //console.debug("Running ShapeDiver computation with chromosome", chromosome); + + const body: { [key: string]: string } = { ...this.defaultParameterValues }; + chromosome.forEach((value, index) => { + const parameterId = parameterIds[index]; + body[parameterId] = value; + }); + + const result = await this.sdk.output.customize(this.modelDto.sessionId!, body); + const objectives = this.getObjectives(result); + + if (Object.keys(objectives).length !== this.objectiveSize) { + throw new Error(`Expected ${this.objectiveSize} objectives, but got ${Object.keys(objectives).length}`); + } + + for (const key in this.initialObjectives) { + if (!(key in objectives)) { + throw new Error(`Objective "${key}" not found in result`); + } + } + + const objectiveValues: number[] = []; + Object.keys(this.initialObjectives).sort().forEach(key => { + objectiveValues.push(objectives[key]); + }); + + return objectiveValues; + }; + + const generationCallback = _generationCallback ? + (generation: INSGA2Result) => { + _generationCallback(this.mapResult(generation, parameterIds)); + } : undefined; + + this.nsga2 = new NSGA2({ + chromosomeSize, + genomeFunction, + objectiveSize: this.objectiveSize, + objectiveFunction, + generationCallback, + ...optimizerProps}); + + const result = await this.nsga2.optimize(true); + this.nsga2 = undefined; + + return this.mapResult(result, parameterIds); + } + + mapResult(result: INSGA2Result, parameterIds: string[]): INSGA2ResultExt { + const { individuals } = result; + const resultExt = { + individuals, + parameterValues: individuals.map(individual => { + const parameterValues = { ...this.defaultParameterValues}; + individual.chromosome.forEach((value, index) => { + parameterValues[parameterIds[index]] = value; + }); + + return parameterValues; + }) + }; + + return resultExt; + } + +} diff --git a/src/pages/appbuilder/AppBuilderPage.tsx b/src/pages/appbuilder/AppBuilderPage.tsx index 1abd2ec0..d33437f0 100644 --- a/src/pages/appbuilder/AppBuilderPage.tsx +++ b/src/pages/appbuilder/AppBuilderPage.tsx @@ -15,6 +15,7 @@ import useDefaultSessionDto from "hooks/shapediver/useDefaultSessionDto"; import LoaderPage from "pages/misc/LoaderPage"; import MarkdownWidgetComponent from "components/shapediver/ui/MarkdownWidgetComponent"; import AppBuilderTemplateSelector from "pages/templates/AppBuilderTemplateSelector"; +import { AppBuilderSettingsContext } from "context/AppBuilderContext"; const VIEWPORT_ID = "viewport_1"; @@ -113,7 +114,7 @@ You need to disable the *Require strong authorization* setting for your model. : error ? {error.message} : loading ? : - show ? - + : <> ); } diff --git a/src/types/shapediver/appbuilder.ts b/src/types/shapediver/appbuilder.ts index fdaeb12f..13a22887 100644 --- a/src/types/shapediver/appbuilder.ts +++ b/src/types/shapediver/appbuilder.ts @@ -34,7 +34,7 @@ export interface IAppBuilderExportRef { } /** Types of widgets */ -export type AppBuilderWidgetType = "accordion" | "text" | "image"; +export type AppBuilderWidgetType = "accordion" | "text" | "image" | "optimizer"; /** Common properties of widgets. */ export interface IAppBuilderWidgetPropsCommon { @@ -82,12 +82,18 @@ export interface IAppBuilderWidgetPropsImage extends IAppBuilderWidgetPropsCommo href?: string } +/** Properties of an optimizer widget. */ +export interface IAppBuilderWidgetPropsOptimizer extends IAppBuilderWidgetPropsCommon { + /** TODO add properties which can be defined from Grasshopper. */ + +} + /** A widget. */ export interface IAppBuilderWidget { /** Type of the widget. */ type: AppBuilderWidgetType /** Properties of the widget. */ - props: IAppBuilderWidgetPropsAccordion | IAppBuilderWidgetPropsText | IAppBuilderWidgetPropsImage + props: IAppBuilderWidgetPropsAccordion | IAppBuilderWidgetPropsText | IAppBuilderWidgetPropsImage | IAppBuilderWidgetPropsOptimizer } /** @@ -156,6 +162,11 @@ export function isImageWidget(widget: IAppBuilderWidget): widget is { type: "ima return widget.type === "image"; } +/** assert widget type "optimizer" */ +export function isOptimizerWidget(widget: IAppBuilderWidget): widget is { type: "optimizer", props: IAppBuilderWidgetPropsOptimizer } { + return widget.type === "optimizer"; +} + /** * Settings for a session used by the AppBuilder. */ diff --git a/src/types/shapediver/appbuildertypecheck.ts b/src/types/shapediver/appbuildertypecheck.ts index 6185cf37..31c2b4a4 100644 --- a/src/types/shapediver/appbuildertypecheck.ts +++ b/src/types/shapediver/appbuildertypecheck.ts @@ -72,11 +72,17 @@ const IAppBuilderWidgetPropsImageSchema = z.object({ target: z.string().default("_blank"), }).extend(IAppBuilderWidgetPropsCommonSchema.shape); +// Zod type definition for IAppBuilderWidgetPropsOptimizer +const IAppBuilderWidgetPropsOptimizerSchema = z.object({ + +}).extend(IAppBuilderWidgetPropsCommonSchema.shape); + // Zod type definition for IAppBuilderWidget const IAppBuilderWidgetSchema = z.discriminatedUnion("type", [ z.object({type: z.literal("accordion"), props: IAppBuilderWidgetPropsAccordionSchema}), z.object({type: z.literal("text"), props: IAppBuilderWidgetPropsTextSchema}), z.object({type: z.literal("image"), props: IAppBuilderWidgetPropsImageSchema}), + z.object({type: z.literal("optimizer"), props: IAppBuilderWidgetPropsOptimizerSchema}), ]); // Zod type definition for IAppBuilderTab