diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java b/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java
index cc841799d..d8f8d2285 100644
--- a/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java
+++ b/core/src/main/java/info/openrocket/core/file/openrocket/OpenRocketSaver.java
@@ -16,6 +16,7 @@ import info.openrocket.core.logging.ErrorSet;
import info.openrocket.core.logging.SimulationAbort;
import info.openrocket.core.logging.WarningSet;
import info.openrocket.core.material.Material;
+import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
import info.openrocket.core.preferences.DocumentPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -336,8 +337,34 @@ public class OpenRocketSaver extends RocketSaver {
writeElement("launchrodlength", cond.getLaunchRodLength());
writeElement("launchrodangle", cond.getLaunchRodAngle() * 180.0 / Math.PI);
writeElement("launchroddirection", cond.getLaunchRodDirection() * 360.0 / (2.0 * Math.PI));
- writeElement("windaverage", cond.getWindSpeedAverage());
- writeElement("windturbulence", cond.getWindTurbulenceIntensity());
+
+ // TODO: remove once support for OR 23.09 and prior is dropped
+ writeElement("windaverage", cond.getAverageWindModel().getAverage());
+ writeElement("windturbulence", cond.getAverageWindModel().getTurbulenceIntensity());
+ writeElement("winddirection", cond.getAverageWindModel().getDirection());
+
+ writeln("");
+ indent++;
+ writeElement("speed", cond.getAverageWindModel().getAverage());
+ writeElement("direction", cond.getAverageWindModel().getDirection());
+ writeElement("standarddeviation", cond.getAverageWindModel().getStandardDeviation());
+ indent--;
+ writeln("");
+
+ if (!cond.getMultiLevelWindModel().getLevels().isEmpty()) {
+ writeln("");
+ indent++;
+ for (MultiLevelPinkNoiseWindModel.LevelWindModel level : cond.getMultiLevelWindModel().getLevels()) {
+ writeln("");
+ }
+ indent--;
+ writeln("");
+ }
+
+ writeElement("windmodeltype", cond.getWindModelType().toStringValue());
+
writeElement("launchaltitude", cond.getLaunchAltitude());
writeElement("launchlatitude", cond.getLaunchLatitude());
writeElement("launchlongitude", cond.getLaunchLongitude());
diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java b/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java
index 60302542c..cd140e5c2 100644
--- a/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java
+++ b/core/src/main/java/info/openrocket/core/file/openrocket/importt/SimulationConditionsHandler.java
@@ -7,6 +7,7 @@ import info.openrocket.core.file.DocumentLoadingContext;
import info.openrocket.core.file.simplesax.AbstractElementHandler;
import info.openrocket.core.file.simplesax.ElementHandler;
import info.openrocket.core.file.simplesax.PlainTextHandler;
+import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.rocketcomponent.FlightConfigurationId;
import info.openrocket.core.rocketcomponent.Rocket;
import info.openrocket.core.simulation.SimulationOptions;
@@ -17,6 +18,7 @@ class SimulationConditionsHandler extends AbstractElementHandler {
public FlightConfigurationId idToSet = FlightConfigurationId.ERROR_FCID;
private final SimulationOptions options;
private AtmosphereHandler atmosphereHandler;
+ private WindHandler windHandler;
public SimulationConditionsHandler(Rocket rocket, DocumentLoadingContext context) {
this.context = context;
@@ -32,7 +34,10 @@ class SimulationConditionsHandler extends AbstractElementHandler {
@Override
public ElementHandler openElement(String element, HashMap attributes,
WarningSet warnings) {
- if (element.equals("atmosphere")) {
+ if (element.equals("wind")) {
+ windHandler = new WindHandler(attributes.get("model"), options);
+ return windHandler;
+ } else if (element.equals("atmosphere")) {
atmosphereHandler = new AtmosphereHandler(attributes.get("model"), context);
return atmosphereHandler;
}
@@ -49,71 +54,95 @@ class SimulationConditionsHandler extends AbstractElementHandler {
} catch (NumberFormatException ignore) {
}
- if (element.equals("configid")) {
- this.idToSet = new FlightConfigurationId(content);
- } else if (element.equals("launchrodlength")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch rod length defined, ignoring.");
- } else {
- options.setLaunchRodLength(d);
+ switch (element) {
+ case "configid" -> this.idToSet = new FlightConfigurationId(content);
+ case "launchrodlength" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch rod length defined, ignoring.");
+ } else {
+ options.setLaunchRodLength(d);
+ }
}
- } else if (element.equals("launchrodangle")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch rod angle defined, ignoring.");
- } else {
- options.setLaunchRodAngle(d * Math.PI / 180);
+ case "launchrodangle" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch rod angle defined, ignoring.");
+ } else {
+ options.setLaunchRodAngle(d * Math.PI / 180);
+ }
}
- } else if (element.equals("launchroddirection")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch rod direction defined, ignoring.");
- } else {
- options.setLaunchRodDirection(d * 2.0 * Math.PI / 360);
+ case "launchroddirection" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch rod direction defined, ignoring.");
+ } else {
+ options.setLaunchRodDirection(d * 2.0 * Math.PI / 360);
+ }
}
- } else if (element.equals("windaverage")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal average windspeed defined, ignoring.");
- } else {
- options.setWindSpeedAverage(d);
+
+ // TODO: remove once support for OR 23.09 and prior is dropped
+ case "windaverage" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal average windspeed defined, ignoring.");
+ } else {
+ options.getAverageWindModel().setAverage(d);
+ }
}
- } else if (element.equals("windturbulence")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal wind turbulence intensity defined, ignoring.");
- } else {
- options.setWindTurbulenceIntensity(d);
+ case "windturbulence" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal wind turbulence intensity defined, ignoring.");
+ } else {
+ options.getAverageWindModel().setTurbulenceIntensity(d);
+ }
}
- } else if (element.equals("launchaltitude")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch altitude defined, ignoring.");
- } else {
- options.setLaunchAltitude(d);
+ case "winddirection" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal wind direction defined, ignoring.");
+ } else {
+ options.getAverageWindModel().setDirection(d);
+ }
}
- } else if (element.equals("launchlatitude")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch latitude defined, ignoring.");
- } else {
- options.setLaunchLatitude(d);
+
+ case "wind" -> windHandler.storeSettings(options, warnings);
+ case "windmodeltype" -> {
+ options.setWindModelType(WindModelType.fromString(content));
}
- } else if (element.equals("launchlongitude")) {
- if (Double.isNaN(d)) {
- warnings.add("Illegal launch longitude.");
- } else {
- options.setLaunchLongitude(d);
+
+ case "launchaltitude" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch altitude defined, ignoring.");
+ } else {
+ options.setLaunchAltitude(d);
+ }
}
- } else if (element.equals("geodeticmethod")) {
- GeodeticComputationStrategy gcs = (GeodeticComputationStrategy) DocumentConfig.findEnum(content,
- GeodeticComputationStrategy.class);
- if (gcs != null) {
- options.setGeodeticComputation(gcs);
- } else {
- warnings.add("Unknown geodetic computation method '" + content + "'");
+ case "launchlatitude" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch latitude defined, ignoring.");
+ } else {
+ options.setLaunchLatitude(d);
+ }
}
- } else if (element.equals("atmosphere")) {
- atmosphereHandler.storeSettings(options, warnings);
- } else if (element.equals("timestep")) {
- if (Double.isNaN(d) || d <= 0) {
- warnings.add("Illegal time step defined, ignoring.");
- } else {
- options.setTimeStep(d);
+ case "launchlongitude" -> {
+ if (Double.isNaN(d)) {
+ warnings.add("Illegal launch longitude.");
+ } else {
+ options.setLaunchLongitude(d);
+ }
+ }
+ case "geodeticmethod" -> {
+ GeodeticComputationStrategy gcs = (GeodeticComputationStrategy) DocumentConfig.findEnum(content,
+ GeodeticComputationStrategy.class);
+ if (gcs != null) {
+ options.setGeodeticComputation(gcs);
+ } else {
+ warnings.add("Unknown geodetic computation method '" + content + "'");
+ }
+ }
+ case "atmosphere" -> atmosphereHandler.storeSettings(options, warnings);
+ case "timestep" -> {
+ if (Double.isNaN(d) || d <= 0) {
+ warnings.add("Illegal time step defined, ignoring.");
+ } else {
+ options.setTimeStep(d);
+ }
}
}
}
diff --git a/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java b/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java
new file mode 100644
index 000000000..2afa162ae
--- /dev/null
+++ b/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java
@@ -0,0 +1,74 @@
+package info.openrocket.core.file.openrocket.importt;
+
+import info.openrocket.core.file.simplesax.AbstractElementHandler;
+import info.openrocket.core.file.simplesax.ElementHandler;
+import info.openrocket.core.file.simplesax.PlainTextHandler;
+import info.openrocket.core.logging.WarningSet;
+import info.openrocket.core.models.wind.WindModelType;
+import info.openrocket.core.simulation.SimulationOptions;
+
+import java.util.HashMap;
+
+public class WindHandler extends AbstractElementHandler {
+ private final String model;
+ private final SimulationOptions options;
+
+ public WindHandler(String model, SimulationOptions options) {
+ this.model = model;
+ this.options = options;
+ }
+
+ @Override
+ public ElementHandler openElement(String element, HashMap attributes,
+ WarningSet warnings) {
+ return PlainTextHandler.INSTANCE;
+ }
+
+ @Override
+ public void closeElement(String element, HashMap attributes,
+ String content, WarningSet warnings) {
+ double d = Double.NaN;
+ try {
+ d = Double.parseDouble(content);
+ } catch (NumberFormatException ignore) {
+ }
+
+ if ("average".equals(model)) {
+ switch (element) {
+ case "speed" -> {
+ if (!Double.isNaN(d)) {
+ options.getAverageWindModel().setAverage(d);
+ }
+ }
+ case "direction" -> {
+ if (!Double.isNaN(d)) {
+ options.getAverageWindModel().setDirection(d);
+ }
+ }
+ case "standarddeviation" -> {
+ if (!Double.isNaN(d)) {
+ options.getAverageWindModel().setStandardDeviation(d);
+ }
+ }
+ }
+ } else if ("multilevel".equals(model)) {
+ if (element.equals("windlevel")) {
+ double altitude = Double.parseDouble(attributes.get("altitude"));
+ double speed = Double.parseDouble(attributes.get("speed"));
+ double direction = Double.parseDouble(attributes.get("direction"));
+ double standardDeviation = Double.parseDouble(attributes.get("standarddeviation"));
+ options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction, standardDeviation);
+ }
+ }
+ }
+
+ public void storeSettings(SimulationOptions options, WarningSet warnings) {
+ if ("average".equals(model)) {
+ options.setWindModelType(WindModelType.AVERAGE);
+ } else if ("multilevel".equals(model)) {
+ options.setWindModelType(WindModelType.MULTI_LEVEL);
+ } else {
+ warnings.add("Unknown wind model type '" + model + "', using default.");
+ }
+ }
+}
diff --git a/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java b/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java
index 75e8722ed..92f0e8518 100644
--- a/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java
+++ b/core/src/main/java/info/openrocket/core/file/rasaero/export/LaunchSiteDTO.java
@@ -57,7 +57,7 @@ public class LaunchSiteDTO {
setTemperature(RASAeroCommonConstants.OPENROCKET_TO_RASAERO_TEMPERATURE(options.getLaunchTemperature()));
setRodAngle(options.getLaunchRodAngle() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ANGLE);
setRodLength(options.getLaunchRodLength() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ALTITUDE); // It's a length, but stored in RASAero in feet instead of inches
- setWindSpeed(options.getWindSpeedAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
+ setWindSpeed(options.getAverageWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
return;
}
@@ -68,7 +68,7 @@ public class LaunchSiteDTO {
setTemperature(RASAeroCommonConstants.OPENROCKET_TO_RASAERO_TEMPERATURE(prefs.getLaunchTemperature()));
setRodAngle(prefs.getLaunchRodAngle() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ANGLE);
setRodLength(prefs.getLaunchRodLength() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ALTITUDE); // It's a length, but stored in RASAero in feet instead of inches
- setWindSpeed(prefs.getWindSpeedAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
+ setWindSpeed(prefs.getAverageWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
}
public Double getAltitude() {
diff --git a/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java b/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java
index ba81cea3b..07bf1a167 100644
--- a/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java
+++ b/core/src/main/java/info/openrocket/core/file/rasaero/importt/LaunchSiteHandler.java
@@ -59,7 +59,7 @@ public class LaunchSiteHandler extends AbstractElementHandler {
launchSiteSettings.setLaunchTemperature(
RASAeroCommonConstants.RASAERO_TO_OPENROCKET_TEMPERATURE(Double.parseDouble(content)));
} else if (RASAeroCommonConstants.LAUNCH_WIND_SPEED.equals(element)) {
- launchSiteSettings.setWindSpeedAverage(
+ launchSiteSettings.getAverageWindModel().setAverage(
Double.parseDouble(content) / RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
}
} catch (NumberFormatException e) {
diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java
new file mode 100644
index 000000000..9bc777a83
--- /dev/null
+++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java
@@ -0,0 +1,273 @@
+package info.openrocket.core.models.wind;
+
+import java.util.ArrayList;
+import java.util.EventListener;
+import java.util.EventObject;
+import java.util.List;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Objects;
+
+import info.openrocket.core.util.ChangeSource;
+import info.openrocket.core.util.Coordinate;
+import info.openrocket.core.util.ModID;
+import info.openrocket.core.util.StateChangeListener;
+
+public class MultiLevelPinkNoiseWindModel implements WindModel {
+ private List levels;
+
+ private final List listeners = new ArrayList<>();
+
+ public MultiLevelPinkNoiseWindModel() {
+ this.levels = new ArrayList<>();
+ }
+
+ public void addWindLevel(double altitude, double speed, double direction, double standardDeviation) {
+ PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
+ pinkNoiseModel.setAverage(speed);
+ pinkNoiseModel.setStandardDeviation(standardDeviation);
+ pinkNoiseModel.setDirection(direction);
+
+ LevelWindModel newLevel = new LevelWindModel(altitude, pinkNoiseModel);
+ newLevel.addChangeListener(e -> fireChangeEvent());
+ int index = Collections.binarySearch(levels, newLevel, Comparator.comparingDouble(l -> l.altitude));
+ if (index >= 0) {
+ throw new IllegalArgumentException("Wind level already exists for altitude: " + altitude);
+ }
+ levels.add(-index - 1, newLevel);
+ fireChangeEvent();
+ }
+
+ public void removeWindLevel(double altitude) {
+ levels.removeIf(level -> level.altitude == altitude);
+ fireChangeEvent();
+ }
+
+ public void removeWindLevelIdx(int index) {
+ levels.remove(index);
+ fireChangeEvent();
+ }
+
+ public void clearLevels() {
+ levels.clear();
+ fireChangeEvent();
+ }
+
+ public List getLevels() {
+ return new ArrayList<>(levels);
+ }
+
+ public void sortLevels() {
+ levels.sort(Comparator.comparingDouble(l -> l.altitude));
+ }
+
+ @Override
+ public Coordinate getWindVelocity(double time, double altitude) {
+ if (levels.isEmpty()) {
+ return Coordinate.ZERO;
+ }
+
+ int index = Collections.binarySearch(levels, new LevelWindModel(altitude, null),
+ Comparator.comparingDouble(l -> l.altitude));
+
+ // Retrieve the wind level if it exists
+ if (index >= 0) {
+ return levels.get(index).model.getWindVelocity(time, altitude);
+ }
+
+ // Extrapolation (take the value of the outer bounds)
+ int insertionPoint = -index - 1;
+ if (insertionPoint == 0) {
+ return levels.get(0).model.getWindVelocity(time, altitude);
+ }
+ if (insertionPoint == levels.size()) {
+ return levels.get(levels.size() - 1).model.getWindVelocity(time, altitude);
+ }
+
+ // Interpolation (take the value between the closest two bounds)
+ LevelWindModel lowerLevel = levels.get(insertionPoint - 1);
+ LevelWindModel upperLevel = levels.get(insertionPoint);
+ double fraction = (altitude - lowerLevel.altitude) / (upperLevel.altitude - lowerLevel.altitude);
+
+ Coordinate lowerVelocity = lowerLevel.model.getWindVelocity(time, altitude);
+ Coordinate upperVelocity = upperLevel.model.getWindVelocity(time, altitude);
+
+ return lowerVelocity.interpolate(upperVelocity, fraction);
+ }
+
+ public double getWindDirection(double time, double altitude) {
+ Coordinate velocity = getWindVelocity(time, altitude);
+ double direction = Math.atan2(velocity.x, velocity.y);
+
+ // Normalize the result to be between 0 and 2*PI
+ return (direction + 2 * Math.PI) % (2 * Math.PI);
+ }
+
+ @Override
+ public ModID getModID() {
+ return ModID.ZERO; // You might want to create a specific ModID for this model
+ }
+
+ public void loadFrom(MultiLevelPinkNoiseWindModel source) {
+ this.levels.clear();
+ for (LevelWindModel level : source.levels) {
+ this.levels.add(level.clone());
+ }
+ }
+
+ @Override
+ public MultiLevelPinkNoiseWindModel clone() {
+ try {
+ MultiLevelPinkNoiseWindModel clone = (MultiLevelPinkNoiseWindModel) super.clone();
+ clone.levels = new ArrayList<>(this.levels.size());
+ clone.loadFrom(this);
+ return clone;
+ } catch (CloneNotSupportedException e) {
+ throw new AssertionError(); // This should never happen
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ MultiLevelPinkNoiseWindModel that = (MultiLevelPinkNoiseWindModel) o;
+
+ // Compare the levels list
+ if (levels.size() != that.levels.size()) return false;
+ for (int i = 0; i < levels.size(); i++) {
+ if (!levels.get(i).equals(that.levels.get(i))) return false;
+ }
+
+ // If we implement any additional fields in the future, we should compare them here
+
+ return true;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(levels);
+ }
+
+ public static class LevelWindModel implements Cloneable, ChangeSource {
+ protected double altitude;
+ protected PinkNoiseWindModel model;
+
+ private final List listeners = new ArrayList<>();
+
+ LevelWindModel(double altitude, PinkNoiseWindModel model) {
+ this.altitude = altitude;
+ this.model = model;
+ }
+
+ public double getAltitude() {
+ return altitude;
+ }
+
+ public void setAltitude(double altitude) {
+ this.altitude = altitude;
+ fireChangeEvent();
+ }
+
+ public double getSpeed() {
+ return model.getAverage();
+ }
+
+ public void setSpeed(double speed) {
+ model.setAverage(speed);
+ }
+
+ public double getDirection() {
+ return model.getDirection();
+ }
+
+ public void setDirection(double direction) {
+ model.setDirection(direction);
+ }
+
+ public double getStandardDeviation() {
+ return model.getStandardDeviation();
+ }
+
+ public void setStandardDeviation(double standardDeviation) {
+ model.setStandardDeviation(standardDeviation);
+ }
+
+ public double getTurblenceIntensity() {
+ return model.getTurbulenceIntensity();
+ }
+
+ public void setTurbulenceIntensity(double turbulenceIntensity) {
+ model.setTurbulenceIntensity(turbulenceIntensity);
+ }
+
+ @Override
+ public LevelWindModel clone() {
+ try {
+ LevelWindModel clone = (LevelWindModel) super.clone();
+ clone.model = this.model.clone();
+ return clone;
+ } catch (CloneNotSupportedException e) {
+ throw new AssertionError(); // This should never happen
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ LevelWindModel that = (LevelWindModel) o;
+ return Double.compare(that.altitude, altitude) == 0 &&
+ model.equals(that.model);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(altitude, model);
+ }
+
+ @Override
+ public void addChangeListener(StateChangeListener listener) {
+ listeners.add(listener);
+ model.addChangeListener(listener);
+ }
+
+ @Override
+ public void removeChangeListener(StateChangeListener listener) {
+ listeners.remove(listener);
+ model.removeChangeListener(listener);
+ }
+
+ public void fireChangeEvent() {
+ EventObject event = new EventObject(this);
+ // Copy the list before iterating to prevent concurrent modification exceptions.
+ EventListener[] list = listeners.toArray(new EventListener[0]);
+ for (EventListener l : list) {
+ if (l instanceof StateChangeListener) {
+ ((StateChangeListener) l).stateChanged(event);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void addChangeListener(StateChangeListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ public void removeChangeListener(StateChangeListener listener) {
+ listeners.remove(listener);
+ }
+
+ public void fireChangeEvent() {
+ EventObject event = new EventObject(this);
+ // Copy the list before iterating to prevent concurrent modification exceptions.
+ EventListener[] list = listeners.toArray(new EventListener[0]);
+ for (EventListener l : list) {
+ if (l instanceof StateChangeListener) {
+ ((StateChangeListener) l).stateChanged(event);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java
index 6885c063d..4ebfcd721 100644
--- a/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java
+++ b/core/src/main/java/info/openrocket/core/models/wind/PinkNoiseWindModel.java
@@ -1,11 +1,16 @@
package info.openrocket.core.models.wind;
+import java.util.ArrayList;
+import java.util.EventListener;
+import java.util.EventObject;
+import java.util.List;
import java.util.Random;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.MathUtil;
import info.openrocket.core.util.ModID;
import info.openrocket.core.util.PinkNoise;
+import info.openrocket.core.util.StateChangeListener;
/**
* A wind simulator that generates wind speed as pink noise from a specified
@@ -34,7 +39,7 @@ public class PinkNoiseWindModel implements WindModel {
private static final double STDDEV = 2.252;
/** Time difference between random samples. */
- private static final double DELTA_T = 0.05;
+ public static final double DELTA_T = 0.05;
private double average = 0;
private double direction = Math.PI / 2; // this is an East wind
@@ -46,6 +51,8 @@ public class PinkNoiseWindModel implements WindModel {
private double time1;
private double value1, value2;
+ private final List listeners = new ArrayList<>();
+
/**
* Construct a new wind simulation with a specific seed value.
*
@@ -55,6 +62,10 @@ public class PinkNoiseWindModel implements WindModel {
this.seed = seed ^ SEED_RANDOMIZATION;
}
+ public PinkNoiseWindModel() {
+ this(new Random().nextInt());
+ }
+
/**
* Return the average wind speed.
*
@@ -71,13 +82,22 @@ public class PinkNoiseWindModel implements WindModel {
* @param average the average wind speed to set
*/
public void setAverage(double average) {
+ average = Math.max(average, 0);
+ if (average == this.average) {
+ return;
+ }
double intensity = getTurbulenceIntensity();
- this.average = Math.max(average, 0);
+ this.average = average;
setTurbulenceIntensity(intensity);
+ fireChangeEvent();
}
public void setDirection(double direction) {
+ if (direction == this.direction) {
+ return;
+ }
this.direction = direction;
+ fireChangeEvent();
}
public double getDirection() {
@@ -99,7 +119,12 @@ public class PinkNoiseWindModel implements WindModel {
* @param standardDeviation the standardDeviation to set
*/
public void setStandardDeviation(double standardDeviation) {
+ if (standardDeviation == this.standardDeviation) {
+ return;
+ }
this.standardDeviation = Math.max(standardDeviation, 0);
+ setTurbulenceIntensity(standardDeviation / average);
+ fireChangeEvent();
}
/**
@@ -161,9 +186,67 @@ public class PinkNoiseWindModel implements WindModel {
randomSource = null;
}
+ public void loadFrom(PinkNoiseWindModel source) {
+ this.average = source.average;
+ this.direction = source.direction;
+ this.standardDeviation = source.standardDeviation;
+ }
+
@Override
public ModID getModID() {
return ModID.ZERO;
}
+ @Override
+ public PinkNoiseWindModel clone() {
+ try {
+ PinkNoiseWindModel clone = (PinkNoiseWindModel) super.clone();
+ clone.loadFrom(this);
+ return clone;
+ } catch (CloneNotSupportedException e) {
+ throw new AssertionError(); // This should never happen
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PinkNoiseWindModel that = (PinkNoiseWindModel) o;
+ return Double.compare(that.average, average) == 0 &&
+ Double.compare(that.standardDeviation, standardDeviation) == 0 &&
+ Double.compare(that.direction, direction) == 0 &&
+ seed == that.seed;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + Double.hashCode(average);
+ result = 31 * result + Double.hashCode(standardDeviation);
+ result = 31 * result + Double.hashCode(direction);
+ result = 31 * result + seed;
+ return result;
+ }
+
+ @Override
+ public void addChangeListener(StateChangeListener listener) {
+ listeners.add(listener);
+ }
+
+ @Override
+ public void removeChangeListener(StateChangeListener listener) {
+ listeners.remove(listener);
+ }
+
+ public void fireChangeEvent() {
+ EventObject event = new EventObject(this);
+ // Copy the list before iterating to prevent concurrent modification exceptions.
+ EventListener[] list = listeners.toArray(new EventListener[0]);
+ for (EventListener l : list) {
+ if (l instanceof StateChangeListener) {
+ ((StateChangeListener) l).stateChanged(event);
+ }
+ }
+ }
}
diff --git a/core/src/main/java/info/openrocket/core/models/wind/WindModel.java b/core/src/main/java/info/openrocket/core/models/wind/WindModel.java
index 1ac483252..379219d19 100644
--- a/core/src/main/java/info/openrocket/core/models/wind/WindModel.java
+++ b/core/src/main/java/info/openrocket/core/models/wind/WindModel.java
@@ -1,10 +1,11 @@
package info.openrocket.core.models.wind;
+import info.openrocket.core.util.ChangeSource;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.Monitorable;
-public interface WindModel extends Monitorable {
-
- public Coordinate getWindVelocity(double time, double altitude);
+public interface WindModel extends Monitorable, Cloneable, ChangeSource {
+ Coordinate getWindVelocity(double time, double altitude);
+ WindModel clone();
}
diff --git a/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java
new file mode 100644
index 000000000..0e156b403
--- /dev/null
+++ b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java
@@ -0,0 +1,25 @@
+package info.openrocket.core.models.wind;
+
+public enum WindModelType {
+ AVERAGE("Average"),
+ MULTI_LEVEL("MultiLevel");
+
+ private final String stringValue;
+
+ WindModelType(String stringValue) {
+ this.stringValue = stringValue;
+ }
+
+ public String toStringValue() {
+ return stringValue;
+ }
+
+ public static WindModelType fromString(String stringValue) {
+ for (WindModelType type : WindModelType.values()) {
+ if (type.stringValue.equalsIgnoreCase(stringValue)) {
+ return type;
+ }
+ }
+ throw new IllegalArgumentException("No enum constant " + WindModelType.class.getCanonicalName() + " for string value: " + stringValue);
+ }
+}
diff --git a/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java b/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java
index 888518cb9..25286a910 100644
--- a/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java
+++ b/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java
@@ -19,12 +19,14 @@ import info.openrocket.core.file.wavefrontobj.export.OBJExportOptions;
import info.openrocket.core.material.Material;
import info.openrocket.core.models.atmosphere.AtmosphericModel;
import info.openrocket.core.models.atmosphere.ExtendedISAModel;
+import info.openrocket.core.models.wind.PinkNoiseWindModel;
import info.openrocket.core.preset.ComponentPreset;
import info.openrocket.core.rocketcomponent.FlightConfiguration;
import info.openrocket.core.rocketcomponent.MassObject;
import info.openrocket.core.rocketcomponent.Rocket;
import info.openrocket.core.rocketcomponent.RocketComponent;
import info.openrocket.core.simulation.RK4SimulationStepper;
+import info.openrocket.core.simulation.SimulationOptionsInterface;
import info.openrocket.core.startup.Application;
import info.openrocket.core.util.BugException;
import info.openrocket.core.util.BuildProperties;
@@ -35,7 +37,7 @@ import info.openrocket.core.util.LineStyle;
import info.openrocket.core.util.MathUtil;
import info.openrocket.core.util.StateChangeListener;
-public abstract class ApplicationPreferences implements ChangeSource, ORPreferences {
+public abstract class ApplicationPreferences implements ChangeSource, ORPreferences, SimulationOptionsInterface, StateChangeListener {
private static final String SPLIT_CHARACTER = "|";
/*
@@ -153,7 +155,10 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
public static final String SVG_STROKE_WIDTH = "SVGStrokeWidth";
private static final AtmosphericModel ISA_ATMOSPHERIC_MODEL = new ExtendedISAModel();
-
+
+ private PinkNoiseWindModel averageWindModel = null;
+
+
/*
* ******************************************************************************************
*
@@ -347,19 +352,6 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
fireChangeEvent();
}
- public final double getWindTurbulenceIntensity() {
- return Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.1);
- }
-
- public final void setWindTurbulenceIntensity(double wti) {
- double oldWTI = Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.3);
-
- if (MathUtil.equals(oldWTI, wti))
- return;
- this.putDouble(ApplicationPreferences.WIND_TURBULENCE, wti);
- fireChangeEvent();
- }
-
public double getLaunchRodLength() {
return this.getDouble(LAUNCH_ROD_LENGTH, 1);
}
@@ -400,49 +392,34 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
fireChangeEvent();
}
-
-
- public double getWindSpeedAverage() {
- return this.getDouble(WIND_AVERAGE, 2);
+
+
+ protected void loadWindModelState() {
+ double average = getDouble(WIND_AVERAGE, 2.0);
+ double turbulenceIntensity = getDouble(WIND_TURBULENCE, 0.1);
+ double direction = getDouble(WIND_DIRECTION, Math.PI / 2);
+
+ getAverageWindModel().setAverage(average);
+ getAverageWindModel().setTurbulenceIntensity(turbulenceIntensity);
+ getAverageWindModel().setDirection(direction);
}
-
- public void setWindSpeedAverage(double windAverage) {
- if (MathUtil.equals(this.getDouble(WIND_AVERAGE, 2), windAverage))
- return;
- this.putDouble(WIND_AVERAGE, MathUtil.max(windAverage, 0));
- fireChangeEvent();
+
+ protected void storeWindModelState() {
+ putDouble(WIND_AVERAGE, getAverageWindModel().getAverage());
+ putDouble(WIND_TURBULENCE, getAverageWindModel().getTurbulenceIntensity());
+ putDouble(WIND_DIRECTION, getAverageWindModel().getDirection());
}
-
-
- public double getWindSpeedDeviation() {
- return this.getDouble(WIND_AVERAGE, 2) * this.getDouble(WIND_TURBULENCE, 0.1);
- }
-
- public void setWindSpeedDeviation(double windDeviation) {
- double windAverage = this.getDouble(WIND_DIRECTION, 2);
- if (windAverage < 0.1) {
- windAverage = 0.1;
+
+ @Override
+ public PinkNoiseWindModel getAverageWindModel() {
+ if (averageWindModel == null) {
+ averageWindModel = new PinkNoiseWindModel();
+ averageWindModel.addChangeListener(this);
+ loadWindModelState();
}
- setWindTurbulenceIntensity(windDeviation / windAverage);
+ return averageWindModel;
}
-
- public void setWindDirection(double direction) {
- direction = MathUtil.reduce2Pi(direction);
- if (this.getBoolean(LAUNCH_INTO_WIND, true)) {
- this.setLaunchRodDirection(direction);
- }
- if (MathUtil.equals(this.getDouble(WIND_DIRECTION, Math.PI / 2), direction))
- return;
- this.putDouble(WIND_DIRECTION, direction);
- fireChangeEvent();
-
- }
-
- public double getWindDirection() {
- return this.getDouble(WIND_DIRECTION, Math.PI / 2);
-
- }
-
+
public double getLaunchAltitude() {
return this.getDouble(LAUNCH_ALTITUDE, 0);
}
@@ -1364,4 +1341,11 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
}
}
}
+
+ @Override
+ public void stateChanged(EventObject e) {
+ if (e.getSource() == averageWindModel) {
+ storeWindModelState();
+ }
+ }
}
diff --git a/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java b/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java
index c90af2d5e..5b6ebf58b 100644
--- a/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java
+++ b/core/src/main/java/info/openrocket/core/simulation/DefaultSimulationOptionFactory.java
@@ -35,10 +35,9 @@ public class DefaultSimulationOptionFactory {
SimulationOptions defaults = new SimulationOptions();
if (prefs != null) {
- defaults.setWindSpeedAverage(prefs.getDouble(SIMCONDITION_WIND_SPEED, defaults.getWindSpeedAverage()));
- defaults.setWindSpeedDeviation(prefs.getDouble(SIMCONDITION_WIND_STDDEV, defaults.getWindSpeedDeviation()));
- defaults.setWindTurbulenceIntensity(
- prefs.getDouble(SIMCONDITION_WIND_TURB, defaults.getWindTurbulenceIntensity()));
+ defaults.getAverageWindModel().setAverage(prefs.getAverageWindModel().getAverage());
+ defaults.getAverageWindModel().setStandardDeviation(prefs.getAverageWindModel().getStandardDeviation());
+ defaults.getAverageWindModel().setTurbulenceIntensity(prefs.getAverageWindModel().getTurbulenceIntensity());
defaults.setLaunchLatitude(prefs.getDouble(SIMCONDITION_SITE_LAT, defaults.getLaunchLatitude()));
defaults.setLaunchLongitude(prefs.getDouble(SIMCONDITION_SITE_LON, defaults.getLaunchLongitude()));
@@ -59,9 +58,9 @@ public class DefaultSimulationOptionFactory {
public void saveDefault(SimulationOptions newDefaults) {
- prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getWindSpeedAverage());
- prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getWindSpeedDeviation());
- prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getWindTurbulenceIntensity());
+ prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getAverageWindModel().getAverage());
+ prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getAverageWindModel().getStandardDeviation());
+ prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getAverageWindModel().getTurbulenceIntensity());
prefs.putDouble(SIMCONDITION_SITE_LAT, newDefaults.getLaunchLatitude());
prefs.putDouble(SIMCONDITION_SITE_LON, newDefaults.getLaunchLongitude());
diff --git a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java
index 55b749568..e72234366 100644
--- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java
+++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java
@@ -6,6 +6,9 @@ import java.util.EventObject;
import java.util.List;
import java.util.Random;
+import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
+import info.openrocket.core.models.wind.WindModel;
+import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.preferences.ApplicationPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,12 +57,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
private double launchRodLength = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_LENGTH, 1);
private boolean launchIntoWind = preferences.getBoolean(ApplicationPreferences.LAUNCH_INTO_WIND, true);
private double launchRodAngle = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_ANGLE, 0);
- private double windDirection = preferences.getDouble(ApplicationPreferences.WIND_DIRECTION, Math.PI / 2);
private double launchRodDirection = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_DIRECTION, Math.PI / 2);
- private double windAverage = preferences.getDouble(ApplicationPreferences.WIND_AVERAGE, 2.0);
- private double windTurbulence = preferences.getDouble(ApplicationPreferences.WIND_TURBULENCE, 0.1);
-
/*
* SimulationOptions maintains the launch site parameters as separate double values,
* and converts them into a WorldCoordinate when converting to SimulationConditions.
@@ -81,7 +80,15 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
private List listeners = new ArrayList<>();
+ private WindModelType windModelType = WindModelType.AVERAGE;
+ private PinkNoiseWindModel averageWindModel;
+ private MultiLevelPinkNoiseWindModel multiLevelPinkNoiseWindModel;
+
public SimulationOptions() {
+ averageWindModel = new PinkNoiseWindModel(randomSeed);
+ averageWindModel.addChangeListener(e -> fireChangeEvent());
+ multiLevelPinkNoiseWindModel = new MultiLevelPinkNoiseWindModel();
+ multiLevelPinkNoiseWindModel.addChangeListener(e -> fireChangeEvent());
}
public double getLaunchRodLength() {
@@ -120,6 +127,12 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
public double getLaunchRodDirection() {
if (launchIntoWind) {
+ double windDirection;
+ if (windModelType == WindModelType.AVERAGE) {
+ windDirection = averageWindModel.getDirection();
+ } else {
+ windDirection = multiLevelPinkNoiseWindModel.getWindDirection(0, launchAltitude);
+ }
this.setLaunchRodDirection(windDirection);
}
return launchRodDirection;
@@ -133,57 +146,33 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
fireChangeEvent();
}
- public double getWindSpeedAverage() {
- return windAverage;
+ public WindModelType getWindModelType() {
+ return windModelType;
}
- public void setWindSpeedAverage(double windAverage) {
- if (MathUtil.equals(this.windAverage, windAverage))
- return;
- this.windAverage = MathUtil.max(windAverage, 0);
- if (MathUtil.equals(this.windAverage, 0)) {
- setWindTurbulenceIntensity(0);
+ public void setWindModelType(WindModelType windModelType) {
+ if (this.windModelType != windModelType) {
+ this.windModelType = windModelType;
+ fireChangeEvent();
}
- fireChangeEvent();
}
- public double getWindSpeedDeviation() {
- return windAverage * windTurbulence;
- }
-
- public void setWindSpeedDeviation(double windDeviation) {
- if (windAverage < 0.1) {
- windAverage = 0.1;
+ public WindModel getWindModel() {
+ if (windModelType == WindModelType.AVERAGE) {
+ return averageWindModel;
+ } else if (windModelType == WindModelType.MULTI_LEVEL) {
+ return multiLevelPinkNoiseWindModel;
+ } else {
+ throw new IllegalArgumentException("Unknown wind model type: " + windModelType);
}
- setWindTurbulenceIntensity(windDeviation / windAverage);
}
- public double getWindTurbulenceIntensity() {
- return windTurbulence;
+ public PinkNoiseWindModel getAverageWindModel() {
+ return averageWindModel;
}
- public void setWindTurbulenceIntensity(double intensity) {
- // Does not check equality so that setWindSpeedDeviation can be sure of event
- // firing
- this.windTurbulence = intensity;
- fireChangeEvent();
- }
-
- public void setWindDirection(double direction) {
- direction = MathUtil.reduce2Pi(direction);
- if (launchIntoWind) {
- this.setLaunchRodDirection(direction);
- }
- if (MathUtil.equals(this.windDirection, direction))
- return;
- this.windDirection = direction;
- fireChangeEvent();
-
- }
-
- public double getWindDirection() {
- return this.windDirection;
-
+ public MultiLevelPinkNoiseWindModel getMultiLevelWindModel() {
+ return multiLevelPinkNoiseWindModel;
}
public double getLaunchAltitude() {
@@ -349,7 +338,16 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
public SimulationOptions clone() {
try {
SimulationOptions copy = (SimulationOptions) super.clone();
+
+ // Deep clone the wind models
+ copy.averageWindModel = this.averageWindModel.clone();
+ copy.multiLevelPinkNoiseWindModel = this.multiLevelPinkNoiseWindModel.clone();
+
+ copy.windModelType = this.windModelType;
+
+ // Create a new list for listeners
copy.listeners = new ArrayList<>();
+
return copy;
} catch (CloneNotSupportedException e) {
throw new BugException(e);
@@ -361,6 +359,20 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
// only do it if one of the "important" (user specified) parameters has really
// changed.
boolean isChanged = false;
+
+ if (this.windModelType != src.windModelType) {
+ isChanged = true;
+ this.windModelType = src.windModelType;
+ }
+ if (!this.averageWindModel.equals(src.averageWindModel)) {
+ isChanged = true;
+ this.averageWindModel.loadFrom(src.averageWindModel);
+ }
+ if (!this.multiLevelPinkNoiseWindModel.equals(src.multiLevelPinkNoiseWindModel)) {
+ isChanged = true;
+ this.multiLevelPinkNoiseWindModel.loadFrom(src.multiLevelPinkNoiseWindModel);
+ }
+
if (this.launchAltitude != src.launchAltitude) {
isChanged = true;
this.launchAltitude = src.launchAltitude;
@@ -405,18 +417,7 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
isChanged = true;
this.maximumAngle = src.maximumAngle;
}
- if (this.windAverage != src.windAverage) {
- isChanged = true;
- this.windAverage = src.windAverage;
- }
- if (this.windDirection != src.windDirection) {
- isChanged = true;
- this.windDirection = src.windDirection;
- }
- if (this.windTurbulence != src.windTurbulence) {
- isChanged = true;
- this.windTurbulence = src.windTurbulence;
- }
+
if (this.timeStep != src.timeStep) {
isChanged = true;
this.timeStep = src.timeStep;
@@ -453,10 +454,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
MathUtil.equals(this.launchRodLength, o.launchRodLength) &&
MathUtil.equals(this.launchTemperature, o.launchTemperature) &&
MathUtil.equals(this.maximumAngle, o.maximumAngle) &&
- MathUtil.equals(this.timeStep, o.timeStep) &&
- MathUtil.equals(this.windAverage, o.windAverage) &&
- MathUtil.equals(this.windTurbulence, o.windTurbulence) &&
- MathUtil.equals(this.windDirection, o.windDirection));
+ MathUtil.equals(this.timeStep, o.timeStep)) &&
+ this.windModelType == o.windModelType &&
+ this.averageWindModel.equals(o.averageWindModel) &&
+ this.multiLevelPinkNoiseWindModel.equals(o.multiLevelPinkNoiseWindModel);
}
/**
@@ -505,17 +506,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
conditions.setGeodeticComputation(getGeodeticComputation());
conditions.setRandomSeed(randomSeed);
- PinkNoiseWindModel windModel = new PinkNoiseWindModel(randomSeed);
- windModel.setAverage(getWindSpeedAverage());
- windModel.setStandardDeviation(getWindSpeedDeviation());
- windModel.setDirection(windDirection);
-
+ WindModel windModel = getWindModel().clone();
conditions.setWindModel(windModel);
-
conditions.setAtmosphericModel(getAtmosphericModel());
-
GravityModel gravityModel = new WGSGravityModel();
-
conditions.setGravityModel(gravityModel);
conditions.setAerodynamicCalculator(new BarrowmanCalculator());
@@ -533,10 +527,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
.concat(String.format(" launchRodLength: %f\n", launchRodLength))
.concat(String.format(" launchIntoWind: %b\n", launchIntoWind))
.concat(String.format(" launchRodAngle: %f\n", launchRodAngle))
- .concat(String.format(" windDirection: %f\n", windDirection))
.concat(String.format(" launchRodDirection: %f\n", launchRodDirection))
- .concat(String.format(" windAverage: %f\n", windAverage))
- .concat(String.format(" windTurbulence: %f\n", windTurbulence))
+ .concat(String.format(" windModelType: %s\n", windModelType))
+ .concat(String.format(" pinkNoiseWindModel: %s\n", averageWindModel))
+ .concat(String.format(" multiLevelPinkNoiseWindModel: %s\n", multiLevelPinkNoiseWindModel))
.concat(String.format(" launchAltitude: %f\n", launchAltitude))
.concat(String.format(" launchLatitude: %f\n", launchLatitude))
.concat(String.format(" launchLongitude: %f\n", launchLongitude))
diff --git a/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java b/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java
index 7e8bb51dc..464ac8f85 100644
--- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java
+++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java
@@ -1,5 +1,6 @@
package info.openrocket.core.simulation;
+import info.openrocket.core.models.wind.PinkNoiseWindModel;
import info.openrocket.core.util.ChangeSource;
import info.openrocket.core.util.GeodeticComputationStrategy;
@@ -20,31 +21,7 @@ public interface SimulationOptionsInterface extends ChangeSource {
void setLaunchRodDirection(double launchRodDirection);
- double getWindSpeedAverage();
-
- void setWindSpeedAverage(double windAverage);
-
- double getWindSpeedDeviation();
-
- void setWindSpeedDeviation(double windDeviation);
-
- /**
- * Return the wind turbulence intensity (standard deviation / average).
- *
- * @return the turbulence intensity
- */
- double getWindTurbulenceIntensity();
-
- /**
- * Set the wind standard deviation to match the given turbulence intensity.
- *
- * @param intensity the turbulence intensity
- */
- void setWindTurbulenceIntensity(double intensity);
-
- void setWindDirection(double direction);
-
- double getWindDirection();
+ PinkNoiseWindModel getAverageWindModel();
double getLaunchAltitude();
diff --git a/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java b/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java
index d5d5a285b..e25e49a94 100644
--- a/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java
+++ b/core/src/main/java/info/openrocket/core/util/AbstractChangeSource.java
@@ -42,7 +42,6 @@ public class AbstractChangeSource implements ChangeSource {
((StateChangeListener) l).stateChanged(event);
}
}
-
}
/**
diff --git a/core/src/main/java/info/openrocket/core/util/Coordinate.java b/core/src/main/java/info/openrocket/core/util/Coordinate.java
index 59ef7d07d..2ea26dcd1 100644
--- a/core/src/main/java/info/openrocket/core/util/Coordinate.java
+++ b/core/src/main/java/info/openrocket/core/util/Coordinate.java
@@ -311,6 +311,20 @@ public final class Coordinate implements Cloneable, Serializable {
}
return new Coordinate(x1, y1, z1, w1);
}
+
+ /**
+ * Interpolate between two coordinates. The fraction is the weight of the other coordinate.
+ * @param other Coordinate to interpolate to.
+ * @param fraction Interpolation fraction (0 = this, 1 = other).
+ * @return Interpolated coordinate.
+ */
+ public Coordinate interpolate(Coordinate other, double fraction) {
+ double x1 = this.x + (other.x - this.x) * fraction;
+ double y1 = this.y + (other.y - this.y) * fraction;
+ double z1 = this.z + (other.z - this.z) * fraction;
+ double w1 = this.weight + (other.weight - this.weight) * fraction;
+ return new Coordinate(x1, y1, z1, w1);
+ }
/**
diff --git a/core/src/main/java/info/openrocket/core/util/MathUtil.java b/core/src/main/java/info/openrocket/core/util/MathUtil.java
index 64bc4a14a..9ae7ed656 100644
--- a/core/src/main/java/info/openrocket/core/util/MathUtil.java
+++ b/core/src/main/java/info/openrocket/core/util/MathUtil.java
@@ -331,7 +331,6 @@ public class MathUtil {
* or if t is outsize the domain.
*/
public static double interpolate(List domain, List range, double t) {
-
if (domain == null || range == null || domain.size() != range.size()) {
return Double.NaN;
}
@@ -368,7 +367,16 @@ public class MathUtil {
}
return range.get(left) + (t - domain.get(left)) * deltay / deltax;
-
}
+ /**
+ * Use interpolation to determine the value of the function at point t.
+ * @param a the lower bound
+ * @param b the upper bound
+ * @param fraction the fraction between a and b
+ * @return the interpolated value
+ */
+ public static double interpolate(double a, double b, double fraction) {
+ return a + (b - a) * fraction;
+ }
}
diff --git a/core/src/main/java/info/openrocket/core/util/Quaternion.java b/core/src/main/java/info/openrocket/core/util/Quaternion.java
index 4c76cb345..dbe0245d6 100644
--- a/core/src/main/java/info/openrocket/core/util/Quaternion.java
+++ b/core/src/main/java/info/openrocket/core/util/Quaternion.java
@@ -242,7 +242,7 @@ public class Quaternion {
// return = (a,b,c,d) * (this)^-1 = (a,b,c,d) * (w,-x,-y,-z)
// Assert that the w-value is zero
- assert (Math.abs(a * w + b * x + c * y + d * z) < coord.max() * MathUtil.EPSILON)
+ assert (Math.abs(a * w + b * x + c * y + d * z) <= coord.max() * MathUtil.EPSILON)
: ("Should be zero: " + (a * w + b * x + c * y + d * z) + " in " + this + " c=" + coord);
return new Coordinate(
diff --git a/core/src/main/resources/l10n/messages.properties b/core/src/main/resources/l10n/messages.properties
index 5ae862f24..272459b40 100644
--- a/core/src/main/resources/l10n/messages.properties
+++ b/core/src/main/resources/l10n/messages.properties
@@ -506,6 +506,15 @@ simedtdlg.lbl.ttip.Pressure = The atmospheric pressure at the launch site.
simedtdlg.lbl.Launchsite = Launch site
simedtdlg.lbl.Latitude = Latitude:
simedtdlg.lbl.ttip.Latitude = The launch site latitude affects the gravitational pull of Earth.
Positive values are on the Northern hemisphere, negative values on the Southern hemisphere.
+simedtdlg.col.Altitude = Altitude
+simedtdlg.col.Speed = Speed
+simedtdlg.col.Direction = Direction
+simedtdlg.col.StandardDeviation = Deviation
+simedtdlg.col.Turbulence = Turbulence
+simedtdlg.col.Intensity = Intensity
+simedtdlg.col.Unit = Unit
+simedtdlg.col.Delete = Delete
+simedtdlg.popupmenu.Delete = Delete level
simedtdlg.lbl.Longitude = Longitude:
simedtdlg.lbl.ttip.Longitude = Required for weather prediction and elevation models.
@@ -555,6 +564,20 @@ simedtdlg.IntensityDesc.Medium = Medium
simedtdlg.IntensityDesc.High = High
simedtdlg.IntensityDesc.Veryhigh = Very high
simedtdlg.IntensityDesc.Extreme = Extreme
+simedtdlg.lbl.WindModelSelection = Wind model to use:
+simedtdlg.radio.AverageWind = Average
+simedtdlg.radio.AverageWind.ttip = Model the wind as pink noise from the average wind speed and standard deviation.
+simedtdlg.radio.MultiLevelWind = Multi-level
+simedtdlg.radio.MultiLevelWind.ttip = Model the wind using speed and direction entries at various altitude levels.
+simedtdlg.but.addWindLevel = Add level
+simedtdlg.but.deleteWindLevel = Delete level
+simedtdlg.but.visualizeWindLevels = Visualize levels
+
+! WindLevelVisualizationDialog
+WindLevelVisualizationDialog.title.WindLevelVisualization = Wind Level Visualization
+WindLevelVisualizationDialog.lbl.WindSpeed = Wind speed
+WindLevelVisualizationDialog.lbl.Altitude = Altitude
+WindLevelVisualizationDialog.checkbox.ShowDirections = Show wind direction vectors
! SimulationConfigDialog
SimulationConfigDialog.tab.Settings = Settings
diff --git a/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java
new file mode 100644
index 000000000..0b462cd53
--- /dev/null
+++ b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java
@@ -0,0 +1,203 @@
+package info.openrocket.core.models.wind;
+
+import info.openrocket.core.util.MathUtil;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.DisplayName;
+
+import info.openrocket.core.util.Coordinate;
+import info.openrocket.core.util.ModID;
+import info.openrocket.core.util.StateChangeListener;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class MultiLevelWindModelTest {
+ private final static double EPSILON = MathUtil.EPSILON;
+ private static final double DELTA_T = PinkNoiseWindModel.DELTA_T;
+ private static final int SAMPLE_SIZE = 1000;
+
+ private MultiLevelPinkNoiseWindModel model;
+
+ @BeforeEach
+ void setUp() {
+ model = new MultiLevelPinkNoiseWindModel();
+ }
+
+ @Test
+ @DisplayName("Add and remove wind levels")
+ void testAddAndRemoveWindLevels() {
+ model.addWindLevel(100, 5, Math.PI / 4, 1);
+ model.addWindLevel(200, 10, Math.PI / 2, 1);
+ assertEquals(2, model.getLevels().size());
+
+ model.removeWindLevel(100);
+ assertEquals(1, model.getLevels().size());
+ assertEquals(200, model.getLevels().get(0).altitude, EPSILON);
+
+ model.removeWindLevel(0);
+ assertEquals(1, model.getLevels().size());
+ assertEquals(200, model.getLevels().get(0).altitude, EPSILON);
+
+ model.removeWindLevel(200);
+ assertTrue(model.getLevels().isEmpty());
+ }
+
+ @Test
+ @DisplayName("Adding duplicate altitude throws IllegalArgumentException")
+ void testAddDuplicateAltitude() {
+ model.addWindLevel(100, 5, Math.PI / 4, 1);
+ assertThrows(IllegalArgumentException.class, () -> model.addWindLevel(100, 10, Math.PI / 2, 1));
+ }
+
+ @Test
+ @DisplayName("Get wind velocity")
+ void testGetWindVelocity() {
+ model.addWindLevel(0, 5, 0, 1);
+ model.addWindLevel(1000, 10, Math.PI / 2, 2);
+
+ verifyWind(0, 5, 0, 1);
+ verifyWind(1000, 10, Math.PI / 2, 2);
+ }
+
+ @Test
+ @DisplayName("Interpolation between levels")
+ void testInterpolationBetweenLevels() {
+ // Test speed interpolation
+ model.addWindLevel(0, 5, 0, 0.1);
+ model.addWindLevel(1000, 10, 0, 0.3);
+
+ verifyWind(200, 6, 0, 0.14);
+ verifyWind(500, 7.5, 0, 0.2);
+ verifyWind(900, 9.5, 0, 0.28);
+
+ model.clearLevels();
+
+ // Test direction interpolation when speed vectors are parallel
+ model.addWindLevel(0, 5, 0, 0);
+ model.addWindLevel(1000, 5, Math.PI, 0);
+
+ verifyWind(200, 3, 0, EPSILON);
+ verifyWind(501, 0, Math.PI, 0.01);
+ verifyWind(900, 4, Math.PI, EPSILON);
+
+ model.clearLevels();
+
+ // Test direction interpolation when speed vectors are not parallel
+ model.addWindLevel(0, 5, 0, 0);
+ model.addWindLevel(1000, 5, Math.PI / 2, 0);
+
+ verifyWind(200, 4.1231056256, 0.2449786631, EPSILON);
+ verifyWind(500, 3.5355339059, Math.PI / 4, EPSILON);
+ verifyWind(800, 4.1231056256, 1.3258176637, EPSILON);
+ }
+
+ @Test
+ @DisplayName("Extrapolation outside levels")
+ void testExtrapolationOutsideLevels() {
+ model.addWindLevel(100, 5, 0, 1.4);
+ model.addWindLevel(200, 10, Math.PI / 2, 2.2);
+
+ verifyWind(0, 5, 0, 1.4);
+ verifyWind(300, 10, Math.PI / 2, 2.2);
+ verifyWind(1000, 10, Math.PI / 2, 2.2);
+ }
+
+ @Test
+ @DisplayName("Resort levels")
+ void testSortLevels() {
+ model.addWindLevel(200, 10, Math.PI / 2, 1);
+ model.addWindLevel(100, 5, Math.PI / 4, 1);
+ model.addWindLevel(300, 15, 3 * Math.PI / 4, 1);
+
+ model.sortLevels();
+
+ List levels = model.getLevels();
+ assertEquals(3, levels.size());
+ assertEquals(100, levels.get(0).altitude, EPSILON);
+ assertEquals(200, levels.get(1).altitude, EPSILON);
+ assertEquals(300, levels.get(2).altitude, EPSILON);
+ }
+
+ @Test
+ @DisplayName("Clone model")
+ void testClone() {
+ model.addWindLevel(100, 5, Math.PI / 4, 1);
+ model.addWindLevel(200, 10, Math.PI / 2, 2);
+
+ MultiLevelPinkNoiseWindModel clonedModel = model.clone();
+ assertNotSame(model, clonedModel);
+ assertEquals(model, clonedModel);
+
+ clonedModel.addWindLevel(300, 15, 3 * Math.PI / 4, 1);
+ assertNotEquals(model, clonedModel);
+ }
+
+ @Test
+ @DisplayName("Load from another model")
+ void testLoadFrom() {
+ model.addWindLevel(100, 5, Math.PI / 4, 2);
+ model.addWindLevel(200, 10, Math.PI / 2, 1);
+
+ MultiLevelPinkNoiseWindModel newModel = new MultiLevelPinkNoiseWindModel();
+ newModel.loadFrom(model);
+
+ assertEquals(model, newModel);
+ }
+
+ @Test
+ @DisplayName("Get ModID")
+ void testGetModID() {
+ assertEquals(ModID.ZERO, model.getModID());
+ }
+
+ @Test
+ @DisplayName("Change listeners")
+ void testChangeListeners() {
+ final boolean[] listenerCalled = {false};
+ StateChangeListener listener = event -> listenerCalled[0] = true;
+
+ model.addChangeListener(listener);
+ model.fireChangeEvent();
+ assertTrue(listenerCalled[0]);
+
+ listenerCalled[0] = false;
+ model.removeChangeListener(listener);
+ model.fireChangeEvent();
+ assertFalse(listenerCalled[0]);
+ }
+
+ private void verifyWind(double altitude, double expectedSpeed, double expectedDirection, double standardDeviation) {
+ double[] speeds = new double[SAMPLE_SIZE];
+ double[] directions = new double[SAMPLE_SIZE];
+
+ for (int i = 0; i < SAMPLE_SIZE; i++) {
+ Coordinate velocity = model.getWindVelocity(i * DELTA_T, altitude);
+ speeds[i] = velocity.length();
+ directions[i] = Math.atan2(velocity.x, velocity.y);
+ }
+
+ double avgSpeed = Arrays.stream(speeds, 0, SAMPLE_SIZE).average().orElse(0.0);
+ double avgDirection = averageAngle(directions);
+
+ // Check average speed and direction
+ assertEquals(expectedSpeed, avgSpeed, standardDeviation, "Average wind speed at altitude " + altitude);
+ assertEquals(expectedDirection, avgDirection, EPSILON, "Average wind direction at altitude " + altitude);
+
+ // Check that some values are above and below the expected value
+ //assertTrue(IntStream.range(0, SAMPLE_SIZE).anyMatch(i -> speeds[i] >= expectedSpeed));
+ //assertTrue(IntStream.range(0, SAMPLE_SIZE).anyMatch(i -> speeds[i] <= expectedSpeed));
+ }
+
+ private double averageAngle(double[] angles) {
+ double sumSin = 0, sumCos = 0;
+ for (double angle : angles) {
+ sumSin += Math.sin(angle);
+ sumCos += Math.cos(angle);
+ }
+ return Math.atan2(sumSin, sumCos);
+ }
+}
\ No newline at end of file
diff --git a/core/src/test/java/info/openrocket/core/simulation/DisableStageTest.java b/core/src/test/java/info/openrocket/core/simulation/DisableStageTest.java
index e2c3cd7fb..4931e9e65 100644
--- a/core/src/test/java/info/openrocket/core/simulation/DisableStageTest.java
+++ b/core/src/test/java/info/openrocket/core/simulation/DisableStageTest.java
@@ -174,11 +174,13 @@ public class DisableStageTest extends BaseTestCase {
simRemoved.setFlightConfigurationId(fcid);
simRemoved.getOptions().setISAAtmosphere(true);
simRemoved.getOptions().setTimeStep(0.05);
+ simRemoved.getOptions().getAverageWindModel().setStandardDeviation(0.0);
Simulation simDisabled = new Simulation(rocketDisabled);
simDisabled.setFlightConfigurationId(fcid);
simDisabled.getOptions().setISAAtmosphere(true);
simDisabled.getOptions().setTimeStep(0.05);
+ simDisabled.getOptions().getAverageWindModel().setStandardDeviation(0.0);
compareSims(simRemoved, simDisabled, DELTA);
diff --git a/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java b/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java
index b04f221f7..fa6a893f3 100644
--- a/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java
+++ b/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java
@@ -69,6 +69,7 @@ public class FlightEventsTest extends BaseTestCase {
final Simulation sim = new Simulation(rocket);
sim.getOptions().setISAAtmosphere(true);
sim.getOptions().setTimeStep(0.05);
+ sim.getOptions ().getAverageWindModel().setAverage(0.1);
rocket.getSelectedConfiguration().setAllStages();
FlightConfigurationId fcid = rocket.getSelectedConfiguration().getFlightConfigurationID();
sim.setFlightConfigurationId(fcid);
diff --git a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java
index 148d9a288..a5919373f 100644
--- a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java
+++ b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java
@@ -10,15 +10,20 @@ import info.openrocket.core.formatting.RocketDescriptor;
import info.openrocket.core.formatting.RocketDescriptorImpl;
import info.openrocket.core.l10n.DebugTranslator;
import info.openrocket.core.l10n.Translator;
+import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
+import info.openrocket.core.models.wind.PinkNoiseWindModel;
import info.openrocket.core.plugin.PluginModule;
import info.openrocket.core.preferences.ApplicationPreferences;
import info.openrocket.core.startup.Application;
import info.openrocket.core.startup.MockPreferences;
+import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.MathUtil;
import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -60,16 +65,99 @@ public class SimulationConditionsTest {
assertEquals(Math.PI / 2, options.getLaunchRodDirection(), EPSILON);
assertEquals(0.0, options.getLaunchRodAngle(), EPSILON);
assertTrue(options.getLaunchIntoWind());
- assertEquals(Math.PI / 2, options.getWindDirection(), EPSILON);
- assertEquals(0.1, options.getWindTurbulenceIntensity(), EPSILON);
- assertEquals(2.0, options.getWindSpeedAverage(), EPSILON);
- assertEquals(0.2, options.getWindSpeedDeviation(), EPSILON);
+ assertEquals(Math.PI / 2, options.getAverageWindModel().getDirection(), EPSILON);
+ assertEquals(0.1, options.getAverageWindModel().getTurbulenceIntensity(), EPSILON);
+ assertEquals(2.0, options.getAverageWindModel().getAverage(), EPSILON);
+ assertEquals(0.2, options.getAverageWindModel().getStandardDeviation(), EPSILON);
assertEquals(0.05, options.getTimeStep(), EPSILON);
assertEquals(3 * Math.PI / 180, options.getMaximumStepAngle(), EPSILON);
-
}
+ @Test
+ @DisplayName("Compare PinkNoiseWindModel and MultiLevelPinkNoiseWindModel in SimulationConditions")
+ public void testWindModelComparison() {
+ SimulationConditions conditions = new SimulationConditions();
+
+ // Test PinkNoiseWindModel
+ PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
+ pinkNoiseModel.setAverage(5.0);
+ pinkNoiseModel.setStandardDeviation(1.0);
+ pinkNoiseModel.setDirection(Math.PI / 4); // 45 degrees
+
+ conditions.setWindModel(pinkNoiseModel);
+
+ Coordinate pinkNoiseVelocity = conditions.getWindModel().getWindVelocity(0, 100);
+ assertNotNull(pinkNoiseVelocity);
+ assertTrue(pinkNoiseVelocity.length() > 0);
+
+ // Test MultiLevelPinkNoiseWindModel
+ MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
+ multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4, 1);
+ multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 2);
+
+ conditions.setWindModel(multiLevelModel);
+
+ Coordinate multiLevelVelocity = conditions.getWindModel().getWindVelocity(0, 100);
+ assertNotNull(multiLevelVelocity);
+ assertTrue(multiLevelVelocity.length() > 0);
+
+ // Compare behaviors
+ assertNotEquals(pinkNoiseVelocity, multiLevelVelocity);
+ }
+
+ @Test
+ @DisplayName("Test wind velocity consistency for MultiLevelPinkNoiseWindModel")
+ public void testMultiLevelWindModelConsistency() {
+ SimulationConditions conditions = new SimulationConditions();
+ MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
+ multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4, 2);
+ multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 1);
+
+ conditions.setWindModel(multiLevelModel);
+
+ Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 500);
+ Coordinate velocity2 = conditions.getWindModel().getWindVelocity(0, 500);
+
+ assertEquals(velocity1, velocity2);
+ }
+
+ @Test
+ @DisplayName("Test wind velocity variation for PinkNoiseWindModel")
+ public void testPinkNoiseWindModelVariation() {
+ SimulationConditions conditions = new SimulationConditions();
+ PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
+ pinkNoiseModel.setAverage(5.0);
+ pinkNoiseModel.setStandardDeviation(1.0);
+ pinkNoiseModel.setDirection(Math.PI / 4);
+
+ conditions.setWindModel(pinkNoiseModel);
+
+ Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 100);
+ Coordinate velocity2 = conditions.getWindModel().getWindVelocity(1, 100);
+
+ assertNotEquals(velocity1, velocity2);
+ }
+
+ @Test
+ @DisplayName("Test altitude dependence of MultiLevelPinkNoiseWindModel")
+ public void testMultiLevelWindModelAltitudeDependence() {
+ SimulationConditions conditions = new SimulationConditions();
+ MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
+ multiLevelModel.addWindLevel(0, 5.0, 0, 0);
+ multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 0);
+
+ conditions.setWindModel(multiLevelModel);
+
+ Coordinate velocityLow = conditions.getWindModel().getWindVelocity(0, 0);
+ Coordinate velocityHigh = conditions.getWindModel().getWindVelocity(0, 1000);
+ Coordinate velocityMid = conditions.getWindModel().getWindVelocity(0, 500);
+
+ assertNotEquals(velocityLow, velocityHigh);
+ assertTrue(velocityMid.length() > velocityLow.length() && velocityMid.length() < velocityHigh.length());
+ }
+
+
private static class PreferencesModule extends AbstractModule {
@Override
protected void configure() {
diff --git a/core/src/test/java/info/openrocket/core/util/CoordinateTest.java b/core/src/test/java/info/openrocket/core/util/CoordinateTest.java
index cc797bbe0..17ede794e 100644
--- a/core/src/test/java/info/openrocket/core/util/CoordinateTest.java
+++ b/core/src/test/java/info/openrocket/core/util/CoordinateTest.java
@@ -9,54 +9,144 @@ public class CoordinateTest {
private static final double EPS = 0.0000000001;
@Test
- public void coordinateTest() {
+ public void testConstructors() {
+ Coordinate c1 = new Coordinate();
+ assertCoordinateEquals(new Coordinate(0, 0, 0, 0), c1);
+ Coordinate c2 = new Coordinate(1);
+ assertCoordinateEquals(new Coordinate(1, 0, 0, 0), c2);
+
+ Coordinate c3 = new Coordinate(1, 2);
+ assertCoordinateEquals(new Coordinate(1, 2, 0, 0), c3);
+
+ Coordinate c4 = new Coordinate(1, 2, 3);
+ assertCoordinateEquals(new Coordinate(1, 2, 3, 0), c4);
+
+ Coordinate c5 = new Coordinate(1, 2, 3, 4);
+ assertCoordinateEquals(new Coordinate(1, 2, 3, 4), c5);
+ }
+
+ @Test
+ public void testSetters() {
Coordinate x = new Coordinate(1, 1, 1, 1);
- Coordinate y = new Coordinate(1, 2, 3, 4);
assertCoordinateEquals(new Coordinate(2, 1, 1, 1), x.setX(2));
assertCoordinateEquals(new Coordinate(1, 2, 1, 1), x.setY(2));
assertCoordinateEquals(new Coordinate(1, 1, 2, 1), x.setZ(2));
assertCoordinateEquals(new Coordinate(1, 1, 1, 2), x.setWeight(2));
- assertCoordinateEquals(new Coordinate(2, 3, 4, 1), x.setXYZ(y).add(1, 1, 1));
- assertFalse(x.isNaN());
- assertTrue(x.setX(Double.NaN).isNaN());
+ Coordinate y = new Coordinate(1, 2, 3, 4);
+ assertCoordinateEquals(new Coordinate(1, 2, 3, 1), x.setXYZ(y));
+ }
+
+ @Test
+ public void testIsWeighted() {
+ assertTrue(new Coordinate(1, 1, 1, 1).isWeighted());
+ assertFalse(new Coordinate(1, 1, 1, 0).isWeighted());
+ }
+
+ @Test
+ public void testIsNaN() {
+ assertFalse(new Coordinate(1, 1, 1, 1).isNaN());
+ assertTrue(new Coordinate(Double.NaN, 1, 1, 1).isNaN());
+ assertTrue(new Coordinate(1, Double.NaN, 1, 1).isNaN());
+ assertTrue(new Coordinate(1, 1, Double.NaN, 1).isNaN());
+ assertTrue(new Coordinate(1, 1, 1, Double.NaN).isNaN());
assertTrue(Coordinate.NaN.isNaN());
+ }
- assertTrue(x.isWeighted());
- assertFalse(x.setWeight(0).isWeighted());
+ @Test
+ public void testAdd() {
+ Coordinate x = new Coordinate(1, 1, 1, 1);
+ Coordinate y = new Coordinate(1, 2, 3, 4);
- assertCoordinateEquals(x, x.add(Coordinate.NUL));
assertCoordinateEquals(new Coordinate(2, 3, 4, 5), x.add(y));
assertCoordinateEquals(new Coordinate(2, 3, 4, 1), x.add(1, 2, 3));
assertCoordinateEquals(new Coordinate(2, 3, 4, 5), x.add(1, 2, 3, 4));
+ }
+
+ @Test
+ public void testSub() {
+ Coordinate x = new Coordinate(1, 1, 1, 1);
+ Coordinate y = new Coordinate(1, 2, 3, 4);
assertCoordinateEquals(new Coordinate(0, -1, -2, 1), x.sub(y));
assertCoordinateEquals(new Coordinate(0, -1, -2, 1), x.sub(1, 2, 3));
-
- assertCoordinateEquals(new Coordinate(2, 4, 6, 8), y.multiply(2));
-
- assertEquals(1 + 2 + 3, y.dot(x), EPS);
- assertEquals(1 + 2 + 3, x.dot(y), EPS);
- assertEquals(1 + 2 + 3, Coordinate.dot(x, y), EPS);
- assertEquals(x.dot(x), x.length2(), EPS);
- assertEquals(y.dot(y), y.length2(), EPS);
- assertEquals(3.7416573867739413, y.length(), EPS);
- assertEquals(1, y.normalize().length(), EPS);
-
- assertCoordinateEquals(new Coordinate(1.75, 1.75, 1.75, 4),
- new Coordinate(1, 1, 1, 1).average(new Coordinate(2, 2, 2, 3)));
- assertCoordinateEquals(new Coordinate(1, 1, 1, 1),
- new Coordinate(1, 1, 1, 1).average(new Coordinate(2, 2, 2, 0)));
- assertCoordinateEquals(new Coordinate(1.5, 1.5, 1.5, 0),
- new Coordinate(1, 1, 1, 0).average(new Coordinate(2, 2, 2, 0)));
-
}
- private void assertCoordinateEquals(Coordinate a, Coordinate b) {
- assertEquals(a, b);
- assertEquals(a.weight, b.weight, EPS);
+ @Test
+ public void testMultiply() {
+ Coordinate x = new Coordinate(1, 2, 3, 4);
+
+ assertCoordinateEquals(new Coordinate(2, 4, 6, 8), x.multiply(2));
+ assertCoordinateEquals(new Coordinate(1, 4, 9, 16), x.multiply(x));
}
+ @Test
+ public void testDot() {
+ Coordinate x = new Coordinate(1, 1, 1, 1);
+ Coordinate y = new Coordinate(1, 2, 3, 4);
+
+ assertEquals(6, x.dot(y), EPS);
+ assertEquals(6, y.dot(x), EPS);
+ assertEquals(6, Coordinate.dot(x, y), EPS);
+ }
+
+ @Test
+ public void testLength() {
+ Coordinate x = new Coordinate(3, 4, 0, 1);
+
+ assertEquals(5, x.length(), EPS);
+ assertEquals(25, x.length2(), EPS);
+ }
+
+ @Test
+ public void testMax() {
+ assertEquals(3, new Coordinate(1, -2, 3, 4).max(), EPS);
+ }
+
+ @Test
+ public void testNormalize() {
+ Coordinate x = new Coordinate(3, 4, 0, 2);
+ Coordinate normalized = x.normalize();
+
+ assertEquals(1, normalized.length(), EPS);
+ assertEquals(2, normalized.weight, EPS);
+ }
+
+ @Test
+ public void testCross() {
+ Coordinate x = new Coordinate(1, 0, 0);
+ Coordinate y = new Coordinate(0, 1, 0);
+
+ assertCoordinateEquals(new Coordinate(0, 0, 1), x.cross(y));
+ assertCoordinateEquals(new Coordinate(0, 0, 1), Coordinate.cross(x, y));
+ }
+
+ @Test
+ public void testAverage() {
+ Coordinate x = new Coordinate(1, 2, 4, 1);
+ Coordinate y = new Coordinate(3, 5, 9, 1);
+
+ assertCoordinateEquals(new Coordinate(2, 3.5, 6.5, 2), x.average(y));
+
+ y = new Coordinate(3, 5, 9, 3);
+
+ assertCoordinateEquals(new Coordinate(2.5, 4.25, 7.75, 4), x.average(y));
+ }
+
+ @Test
+ public void testInterpolate() {
+ Coordinate x = new Coordinate(0, 0, 0, 0);
+ Coordinate y = new Coordinate(10, 10, 10, 10);
+
+ assertCoordinateEquals(new Coordinate(5, 5, 5, 5), x.interpolate(y, 0.5));
+ }
+
+ private void assertCoordinateEquals(Coordinate expected, Coordinate actual) {
+ assertEquals(expected.x, actual.x, EPS);
+ assertEquals(expected.y, actual.y, EPS);
+ assertEquals(expected.z, actual.z, EPS);
+ assertEquals(expected.weight, actual.weight, EPS);
+ }
}
diff --git a/fileformat.txt b/fileformat.txt
index 03b3e25f6..144751d0b 100644
--- a/fileformat.txt
+++ b/fileformat.txt
@@ -70,3 +70,4 @@ The following file format versions exist:
1.10: Introduced with OpenRocket 24.XX.
Added a priority attribute to simulation warnings.
Added document preferences ().
+ Added wind model settings (), and windmodeltype to simulation conditions.
diff --git a/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java b/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java
index 1d1cea8fa..c27630627 100644
--- a/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java
+++ b/swing/src/main/java/info/openrocket/swing/gui/dialogs/preferences/LaunchPreferencesPanel.java
@@ -34,8 +34,7 @@ public class LaunchPreferencesPanel extends PreferencesPanel {
add(warning, "spanx, growx 0, gapbottom para, wrap");
// Simulation conditions
- SimulationConditionsPanel.addSimulationConditionsPanel(this, preferences);
-
+ SimulationConditionsPanel.addSimulationConditionsPanel(this, preferences, false);
}
private static void initColors() {
diff --git a/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java b/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java
index 2e0fc0399..c380a0816 100644
--- a/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java
+++ b/swing/src/main/java/info/openrocket/swing/gui/simulation/SimulationConditionsPanel.java
@@ -1,33 +1,74 @@
package info.openrocket.swing.gui.simulation;
+import java.awt.CardLayout;
import java.awt.Component;
-import java.awt.event.ActionEvent;
-import java.awt.event.ActionListener;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.Window;
+import java.awt.event.FocusAdapter;
+import java.awt.event.FocusEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.util.Comparator;
import java.util.EventObject;
+import java.util.List;
+import javax.swing.AbstractCellEditor;
import javax.swing.BorderFactory;
+import javax.swing.ButtonGroup;
+import javax.swing.DefaultCellEditor;
import javax.swing.JButton;
import javax.swing.JCheckBox;
+import javax.swing.JComboBox;
import javax.swing.JLabel;
+import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JRadioButton;
+import javax.swing.JScrollPane;
+import javax.swing.JSeparator;
import javax.swing.JSpinner;
+import javax.swing.JTable;
import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+import javax.swing.RowSorter;
+import javax.swing.SortOrder;
+import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+import javax.swing.event.RowSorterEvent;
+import javax.swing.event.RowSorterListener;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.table.DefaultTableCellRenderer;
+import javax.swing.table.TableCellEditor;
+import javax.swing.table.TableCellRenderer;
+import javax.swing.table.TableColumn;
+import javax.swing.table.TableColumnModel;
+import javax.swing.table.TableRowSorter;
import info.openrocket.core.document.Simulation;
import info.openrocket.core.l10n.Translator;
import info.openrocket.core.models.atmosphere.ExtendedISAModel;
+import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
+import info.openrocket.core.models.wind.PinkNoiseWindModel;
+import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.simulation.DefaultSimulationOptionFactory;
import info.openrocket.core.simulation.SimulationOptions;
import info.openrocket.core.simulation.SimulationOptionsInterface;
import info.openrocket.core.startup.Application;
+import info.openrocket.core.unit.Unit;
import info.openrocket.core.unit.UnitGroup;
import info.openrocket.core.util.StateChangeListener;
+import info.openrocket.swing.gui.util.Icons;
+import info.openrocket.swing.gui.widgets.IconButton;
import net.miginfocom.swing.MigLayout;
import info.openrocket.swing.gui.SpinnerEditor;
import info.openrocket.swing.gui.adaptors.BooleanModel;
@@ -37,55 +78,29 @@ import info.openrocket.swing.gui.components.UnitSelector;
public class SimulationConditionsPanel extends JPanel {
private static final Translator trans = Application.getTranslator();
-
-
+
+ private WindLevelVisualizationDialog visualizationDialog;
+
SimulationConditionsPanel(final Simulation simulation) {
super(new MigLayout("fill"));
-
- final SimulationOptions conditions = simulation.getOptions();
+
+ SimulationOptions simulationOptions = simulation.getOptions();
// Simulation conditions settings
- addSimulationConditionsPanel(this, conditions);
-
-
- JButton restoreDefaults = new JButton(trans.get("simedtdlg.but.resettodefault"));
- restoreDefaults.addActionListener(new ActionListener() {
-
- @Override
- public void actionPerformed(ActionEvent e) {
-
- DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class);
- SimulationOptions defaults = f.getDefault();
- conditions.copyConditionsFrom(defaults);
-
- }
-
- });
- this.add(restoreDefaults, "span, split 3, skip, gapbottom para, gapright para, right");
-
- JButton saveDefaults = new JButton(trans.get("simedtdlg.but.savedefault"));
- saveDefaults.addActionListener(new ActionListener() {
-
- @Override
- public void actionPerformed(ActionEvent e) {
-
- DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class);
- f.saveDefault(conditions);
-
- }
-
- });
-
- this.add(saveDefaults, "gapbottom para, gapright para, right");
+ addSimulationConditionsPanel(this, simulationOptions);
+ // Add buttons for restoring and saving defaults
+ addDefaultButtons(simulationOptions);
}
/**
* Adds the simulation conditions panel to the parent panel.
* @param parent The parent panel.
* @param target The object containing the simulation conditions setters/getters.
+ * @param addAllWindModels if false, only the average wind model will be added.
*/
- public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target) {
+ public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target,
+ boolean addAllWindModels) {
JPanel sub;
DoubleModel pressureModel;
DoubleModel m;
@@ -96,139 +111,21 @@ public class SimulationConditionsPanel extends JPanel {
UnitSelector unit;
//// Wind settings: Average wind speed, turbulence intensity, std. deviation, and direction
- sub = new JPanel(new MigLayout("fill, gap rel unrel",
- "[grow][75lp!][30lp!][75lp!]", ""));
+ sub = new JPanel(new MigLayout("fill, ins 20 20 0 20", "[grow]", ""));
//// Wind
sub.setBorder(BorderFactory.createTitledBorder(trans.get("simedtdlg.lbl.Wind")));
parent.add(sub, "growx, split 2, aligny 0, flowy, gapright para");
-
- // Wind average
- //// Average windspeed:
- JLabel label = new JLabel(trans.get("simedtdlg.lbl.Averwindspeed"));
- //// The average windspeed relative to the ground.
- tip = trans.get("simedtdlg.lbl.ttip.Averwindspeed");
- label.setToolTipText(tip);
- sub.add(label);
-
- DoubleModel windSpeedAverage = new DoubleModel(target, "WindSpeedAverage", UnitGroup.UNITS_WINDSPEED, 0);
-
- spin = new JSpinner(windSpeedAverage.getSpinnerModel());
- spin.setEditor(new SpinnerEditor(spin));
- spin.setToolTipText(tip);
- sub.add(spin, "growx");
-
- unit = new UnitSelector(windSpeedAverage);
- unit.setToolTipText(tip);
- sub.add(unit, "growx");
- slider = new BasicSlider(windSpeedAverage.getSliderModel(0, 10.0));
- slider.setToolTipText(tip);
- sub.add(slider, "w 75lp, wrap");
-
-
- // Wind std. deviation
- //// Standard deviation:
- label = new JLabel(trans.get("simedtdlg.lbl.Stddeviation"));
- //// The standard deviation of the windspeed.
- //// The windspeed is within twice the standard deviation from the average for 95% of the time.
- tip = trans.get("simedtdlg.lbl.ttip.Stddeviation");
- label.setToolTipText(tip);
- sub.add(label);
-
- DoubleModel windSpeedDeviation = new DoubleModel(target, "WindSpeedDeviation", UnitGroup.UNITS_WINDSPEED, 0);
- DoubleModel m2 = new DoubleModel(target, "WindSpeedAverage", 0.25, UnitGroup.UNITS_COEFFICIENT, 0);
-
- spin = new JSpinner(windSpeedDeviation.getSpinnerModel());
- spin.setEditor(new SpinnerEditor(spin));
- spin.setToolTipText(tip);
- addEasterEgg(spin, parent);
- sub.add(spin, "growx");
-
- unit = new UnitSelector(windSpeedDeviation);
- unit.setToolTipText(tip);
- sub.add(unit, "growx");
- slider = new BasicSlider(windSpeedDeviation.getSliderModel(new DoubleModel(0), m2));
- slider.setToolTipText(tip);
- sub.add(slider, "w 75lp, wrap");
-
- windSpeedAverage.addChangeListener(new ChangeListener() {
- @Override
- public void stateChanged(ChangeEvent e) {
- windSpeedDeviation.stateChanged(e);
- }
- });
-
-
- // Wind turbulence intensity
- //// Turbulence intensity:
- label = new JLabel(trans.get("simedtdlg.lbl.Turbulenceintensity"));
- //// The turbulence intensity is the standard deviation divided by the average windspeed.
- //// Typical values range from
- //// to
- tip = trans.get("simedtdlg.lbl.ttip.Turbulenceintensity1") +
- trans.get("simedtdlg.lbl.ttip.Turbulenceintensity2") + " " +
- UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.05) +
- " " + trans.get("simedtdlg.lbl.ttip.Turbulenceintensity3") + " " +
- UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.20) + ".";
- label.setToolTipText(tip);
- sub.add(label);
-
- DoubleModel windTurbulenceIntensity = new DoubleModel(target, "WindTurbulenceIntensity", UnitGroup.UNITS_RELATIVE, 0);
-
- spin = new JSpinner(windTurbulenceIntensity.getSpinnerModel());
- spin.setEditor(new SpinnerEditor(spin));
- spin.setToolTipText(tip);
- sub.add(spin, "growx");
-
- unit = new UnitSelector(windTurbulenceIntensity);
- unit.setToolTipText(tip);
- sub.add(unit, "growx");
-
- final JLabel intensityLabel = new JLabel(
- getIntensityDescription(target.getWindTurbulenceIntensity()));
- intensityLabel.setToolTipText(tip);
- sub.add(intensityLabel, "w 75lp, wrap");
- windTurbulenceIntensity.addChangeListener(new ChangeListener() {
- @Override
- public void stateChanged(ChangeEvent e) {
- intensityLabel.setText(
- getIntensityDescription(target.getWindTurbulenceIntensity()));
- windSpeedDeviation.stateChanged(e);
- }
- });
- windSpeedDeviation.addChangeListener(new ChangeListener() {
- @Override
- public void stateChanged(ChangeEvent e) {
- windTurbulenceIntensity.stateChanged(e);
- }
- });
-
- // Wind Direction:
- label = new JLabel(trans.get("simedtdlg.lbl.Winddirection"));
- //// Direction of the wind. 0 is north
- tip = trans.get("simedtdlg.lbl.ttip.Winddirection");
- label.setToolTipText(tip);
- sub.add(label);
-
- m = new DoubleModel(target, "WindDirection", 1.0, UnitGroup.UNITS_ANGLE,
- 0, 2*Math.PI);
-
- spin = new JSpinner(m.getSpinnerModel());
- spin.setEditor(new SpinnerEditor(spin));
- spin.setToolTipText(tip);
- sub.add(spin, "growx");
-
- unit = new UnitSelector(m);
- unit.setToolTipText(tip);
- sub.add(unit, "growx");
- slider = new BasicSlider(m.getSliderModel(0, 2*Math.PI));
- slider.setToolTipText(tip);
- sub.add(slider, "w 75lp, wrap");
-
+ // Add wind model selection and configuration panel
+ if (addAllWindModels) {
+ addWindModelPanel(sub, target);
+ } else {
+ addAverageWindSettings(sub, target);
+ }
//// Temperature and pressure
- sub = new JPanel(new MigLayout("fill, gap rel unrel",
- "[grow][85lp!][35lp!][75lp!]", ""));
+ sub = new JPanel(new MigLayout("gap rel unrel",
+ "[][85lp!][35lp!][75lp!]", ""));
//// Atmospheric conditions
sub.setBorder(BorderFactory.createTitledBorder(trans.get("simedtdlg.border.Atmoscond")));
parent.add(sub, "growx, aligny 0, gapright para");
@@ -250,12 +147,12 @@ public class SimulationConditionsPanel extends JPanel {
sub.add(check, "spanx, wrap unrel");
// Temperature:
- label = new JLabel(trans.get("simedtdlg.lbl.Temperature"));
+ JLabel label = new JLabel(trans.get("simedtdlg.lbl.Temperature"));
//// The temperature at the launch site.
tip = trans.get("simedtdlg.lbl.ttip.Temperature");
label.setToolTipText(tip);
isa.addEnableComponent(label, false);
- sub.add(label);
+ sub.add(label, "gapright 50lp");
temperatureModel = new DoubleModel(target, "LaunchTemperature", UnitGroup.UNITS_TEMPERATURE, 0);
@@ -483,6 +380,364 @@ public class SimulationConditionsPanel extends JPanel {
intoWind.addEnableComponent(directionSlider, false);
}
+ public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target) {
+ addSimulationConditionsPanel(parent, target, true);
+ }
+
+ private static void addWindModelPanel(JPanel panel, SimulationOptionsInterface target) {
+ ButtonGroup windModelGroup = new ButtonGroup();
+
+ // Wind model to use
+ panel.add(new JLabel(trans.get("simedtdlg.lbl.WindModelSelection")), "spanx, split 3, gapright para");
+
+ //// Average
+ JRadioButton averageButton = new JRadioButton(trans.get("simedtdlg.radio.AverageWind"));
+ averageButton.setToolTipText(trans.get("simedtdlg.radio.AverageWind.ttip"));
+ averageButton.setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 20));
+
+ //// Multi-level
+ JRadioButton multiLevelButton = new JRadioButton(trans.get("simedtdlg.radio.MultiLevelWind"));
+ multiLevelButton.setToolTipText(trans.get("simedtdlg.radio.MultiLevelWind.ttip"));
+
+ windModelGroup.add(averageButton);
+ windModelGroup.add(multiLevelButton);
+
+ panel.add(averageButton);
+ panel.add(multiLevelButton, "wrap");
+
+ panel.add(new JSeparator(JSeparator.HORIZONTAL), "spanx, growx, wrap");
+
+ JPanel windSettingsPanel = new JPanel(new CardLayout());
+
+ JPanel averagePanel = new JPanel(new MigLayout("fill, ins 0", "[grow][75lp!][30lp!][75lp!]", ""));
+ JPanel multiLevelPanel = new JPanel(new MigLayout("fill, ins 0"));
+
+ addAverageWindSettings(averagePanel, target);
+ addMultiLevelSettings(multiLevelPanel, target);
+
+ windSettingsPanel.add(averagePanel, "Average");
+ windSettingsPanel.add(multiLevelPanel, "MultiLevel");
+
+ panel.add(windSettingsPanel, "grow, wrap");
+
+ averageButton.addActionListener(e -> {
+ ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "Average");
+ if (target instanceof SimulationOptions) {
+ ((SimulationOptions) target).setWindModelType(WindModelType.AVERAGE);
+ }
+ });
+
+ multiLevelButton.addActionListener(e -> {
+ ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel");
+ if (target instanceof SimulationOptions) {
+ ((SimulationOptions) target).setWindModelType(WindModelType.MULTI_LEVEL);
+ }
+ });
+
+ // Set initial selection based on current wind model
+ if (target instanceof SimulationOptions) {
+ SimulationOptions options = (SimulationOptions) target;
+ if (options.getWindModelType() == WindModelType.AVERAGE) {
+ averageButton.setSelected(true);
+ ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "Average");
+ } else {
+ multiLevelButton.setSelected(true);
+ ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel");
+ }
+ }
+ }
+
+ private static void addAverageWindSettings(JPanel panel, SimulationOptionsInterface target) {
+ PinkNoiseWindModel model = target.getAverageWindModel();
+
+ // Wind average
+ final DoubleModel windSpeedAverage = addDoubleModel(panel, "Averwindspeed", trans.get("simedtdlg.lbl.ttip.Averwindspeed"), model, "Average",
+ UnitGroup.UNITS_WINDSPEED, 0, 10.0);
+
+ // Wind standard deviation
+ final DoubleModel windSpeedDeviation = addDoubleModel(panel, "Stddeviation", trans.get("simedtdlg.lbl.ttip.Stddeviation"),
+ model, "StandardDeviation", UnitGroup.UNITS_WINDSPEED, 0,
+ new DoubleModel(model, "Average", 0.25, UnitGroup.UNITS_COEFFICIENT, 0));
+
+ windSpeedAverage.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ windSpeedDeviation.stateChanged(e);
+ }
+ });
+
+ // Turbulence intensity
+ String tip = trans.get("simedtdlg.lbl.ttip.Turbulenceintensity1") +
+ trans.get("simedtdlg.lbl.ttip.Turbulenceintensity2") + " " +
+ UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.05) +
+ " " + trans.get("simedtdlg.lbl.ttip.Turbulenceintensity3") + " " +
+ UnitGroup.UNITS_RELATIVE.getDefaultUnit().toStringUnit(0.20) + ".";
+ final DoubleModel windTurbulenceIntensity = addDoubleModel(panel, "Turbulenceintensity", tip, model,
+ "TurbulenceIntensity", UnitGroup.UNITS_RELATIVE, 0, 1.0, true);
+
+ final JLabel intensityLabel = new JLabel(
+ getIntensityDescription(target.getAverageWindModel().getTurbulenceIntensity()));
+ intensityLabel.setToolTipText(tip);
+ panel.add(intensityLabel, "w 75lp, skip 1, wrap");
+ windTurbulenceIntensity.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ intensityLabel.setText(
+ getIntensityDescription(target.getAverageWindModel().getTurbulenceIntensity()));
+ windSpeedDeviation.stateChanged(e);
+ }
+ });
+ windSpeedDeviation.addChangeListener(new ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ windTurbulenceIntensity.stateChanged(e);
+ }
+ });
+
+ // Wind direction
+ addDoubleModel(panel, "Winddirection", trans.get("simedtdlg.lbl.ttip.Winddirection"), model, "Direction",
+ UnitGroup.UNITS_ANGLE, 0, 2 * Math.PI);
+ }
+
+ private static void addMultiLevelSettings(JPanel panel, SimulationOptionsInterface target) {
+ if (!(target instanceof SimulationOptions options)) {
+ return;
+ }
+ MultiLevelPinkNoiseWindModel model = options.getMultiLevelWindModel();
+
+ // Create the levels table
+ WindLevelTableModel tableModel = new WindLevelTableModel(model);
+ JTable windLevelTable = new JTable(tableModel);
+ windLevelTable.setRowSelectionAllowed(true);
+ windLevelTable.setColumnSelectionAllowed(false);
+ windLevelTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+ windLevelTable.setAutoResizeMode(JTable.AUTO_RESIZE_OFF); // Allow horizontal scrolling
+
+ // Set up value columns
+ SelectAllCellEditor selectAllEditor = new SelectAllCellEditor(windLevelTable);
+ ValueCellRenderer valueCellRenderer = new ValueCellRenderer();
+ for (int i = 0; i < windLevelTable.getColumnCount() - 2; i += 2) {
+ windLevelTable.getColumnModel().getColumn(i).setCellRenderer(valueCellRenderer);
+ windLevelTable.getColumnModel().getColumn(i).setCellEditor(selectAllEditor);
+ }
+
+ // Set up unit selector columns
+ for (int i = 1; i < windLevelTable.getColumnCount(); i += 2) {
+ windLevelTable.getColumnModel().getColumn(i).setCellRenderer(new UnitSelectorRenderer());
+ windLevelTable.getColumnModel().getColumn(i).setCellEditor(new UnitSelectorEditor());
+ }
+
+ // Set up delete button column
+ TableColumn deleteColumn = windLevelTable.getColumnModel().getColumn(windLevelTable.getColumnCount() - 1);
+ deleteColumn.setCellRenderer(new DeleteButtonRenderer());
+ deleteColumn.setCellEditor(new DeleteButtonEditor(windLevelTable));
+
+ // Adjust column widths
+ adjustColumnWidths(windLevelTable);
+
+ windLevelTable.setRowHeight(windLevelTable.getRowHeight() + 10);
+
+ // Set up sorting
+ TableRowSorter sorter = new TableRowSorter<>(tableModel);
+ windLevelTable.setRowSorter(sorter);
+ sorter.setSortable(0, true);
+ for (int i = 1; i < windLevelTable.getColumnCount(); i++) {
+ sorter.setSortable(i, false);
+ }
+ sorter.addRowSorterListener(new RowSorterListener() {
+ @Override
+ public void sorterChanged(RowSorterEvent e) {
+ model.sortLevels();
+ }
+ });
+ sorter.setComparator(0, Comparator.comparingDouble(a -> (Double) a));
+ sorter.setSortKeys(List.of(new RowSorter.SortKey(0, SortOrder.ASCENDING)));
+
+ JScrollPane scrollPane = new JScrollPane(windLevelTable);
+ scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED);
+ scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED);
+ scrollPane.setPreferredSize(new Dimension(400, 150));
+
+ panel.add(scrollPane, "grow, wrap");
+
+ //// Buttons
+ JPanel buttonPanel = new JPanel(new MigLayout("ins 0"));
+
+ // Add level
+ JButton addButton = new IconButton(trans.get("simedtdlg.but.addWindLevel"), Icons.FILE_NEW);
+ addButton.addActionListener(e -> {
+ tableModel.addWindLevel();
+ sorter.sort();
+ });
+ buttonPanel.add(addButton);
+
+ // Delete level
+ JButton deleteButton = new IconButton(trans.get("simedtdlg.but.deleteWindLevel"), Icons.EDIT_DELETE);
+ deleteButton.addActionListener(e -> {
+ int selectedRow = windLevelTable.getSelectedRow();
+ tableModel.deleteWindLevel(selectedRow);
+ sorter.sort();
+ });
+ deleteButton.setEnabled(false);
+ buttonPanel.add(deleteButton, "gapright unrel");
+
+ // Visualization levels
+ JButton visualizeButton = new IconButton(trans.get("simedtdlg.but.visualizeWindLevels"), Icons.SIM_PLOT);
+ visualizeButton.addActionListener(e -> {
+ Window owner = SwingUtilities.getWindowAncestor(panel);
+ if (owner instanceof Dialog) {
+ WindLevelVisualizationDialog visualizationDialog = new WindLevelVisualizationDialog(
+ (Dialog) owner,
+ model,
+ tableModel.getCurrentUnits()[0],
+ tableModel.getCurrentUnits()[1]
+ );
+ tableModel.setVisualizationDialog(visualizationDialog);
+ visualizationDialog.setVisible(true);
+ }
+ });
+ visualizeButton.setEnabled(!tableModel.getLevels().isEmpty());
+ buttonPanel.add(visualizeButton);
+
+ panel.add(buttonPanel, "grow, wrap");
+
+ // Add listener to update visualization when table data changes
+ tableModel.addTableModelListener(e -> {
+ sorter.sort();
+ if (tableModel.getVisualizationDialog() != null) {
+ tableModel.getVisualizationDialog().repaint();
+ }
+ visualizeButton.setEnabled(!tableModel.getLevels().isEmpty());
+ });
+
+ // Add listener to update selected row when table data changes
+ windLevelTable.getSelectionModel().addListSelectionListener(e -> {
+ if (!e.getValueIsAdjusting()) {
+ int selectedRow = windLevelTable.getSelectedRow();
+ if (selectedRow != -1) {
+ windLevelTable.setRowSelectionInterval(selectedRow, selectedRow);
+ }
+ deleteButton.setEnabled(windLevelTable.getSelectedRow() != -1);
+ }
+ });
+
+ // Add context menu
+ JPopupMenu contextMenu = createContextMenu(windLevelTable, tableModel);
+ windLevelTable.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mousePressed(MouseEvent e) {
+ if (e.isPopupTrigger()) {
+ showContextMenu(e);
+ }
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (e.isPopupTrigger()) {
+ showContextMenu(e);
+ }
+ }
+
+ private void showContextMenu(MouseEvent e) {
+ int row = windLevelTable.rowAtPoint(e.getPoint());
+ if (row >= 0 && row < windLevelTable.getRowCount()) {
+ windLevelTable.setRowSelectionInterval(row, row);
+ contextMenu.show(e.getComponent(), e.getX(), e.getY());
+ }
+ }
+ });
+ }
+
+ private static DoubleModel addDoubleModel(JPanel panel, String labelKey, String tooltipText, Object source, String sourceKey,
+ UnitGroup unit, double min, Object max, boolean easterEgg) {
+ JLabel label = new JLabel(trans.get("simedtdlg.lbl." + labelKey));
+ panel.add(label);
+
+ DoubleModel model;
+ if (max instanceof Double) {
+ model = new DoubleModel(source, sourceKey, unit, min, (Double) max);
+ } else if (max instanceof DoubleModel) {
+ model = new DoubleModel(source, sourceKey, unit, min, (DoubleModel) max);
+ } else {
+ throw new IllegalArgumentException("Invalid max value");
+ }
+
+ JSpinner spin = new JSpinner(model.getSpinnerModel());
+ spin.setEditor(new SpinnerEditor(spin));
+ if (tooltipText != null) {
+ spin.setToolTipText(tooltipText);
+ }
+ panel.add(spin, "growx");
+
+ if (easterEgg) {
+ addEasterEgg(spin, panel);
+ }
+
+ UnitSelector unitSelector = new UnitSelector(model);
+ panel.add(unitSelector, "growx");
+
+ BasicSlider slider = new BasicSlider(model.getSliderModel());
+ panel.add(slider, "w 75lp, wrap");
+
+ return model;
+ }
+
+ private static DoubleModel addDoubleModel(JPanel panel, String labelKey, String tooltipText, Object source, String sourceKey,
+ UnitGroup unit, double min, Object max) {
+ return addDoubleModel(panel, labelKey, tooltipText, source, sourceKey, unit, min, max, false);
+ }
+
+ private static void adjustColumnWidths(JTable table) {
+ TableColumnModel columnModel = table.getColumnModel();
+ for (int column = 0; column < table.getColumnCount(); column++) {
+ TableColumn tableColumn = columnModel.getColumn(column);
+ int preferredWidth = getPreferredColumnWidth(table, column);
+ preferredWidth = column == 0 ? preferredWidth + 20 : preferredWidth; // Add extra padding to first column (for sorting arrow)
+ tableColumn.setPreferredWidth(preferredWidth);
+ }
+ }
+
+ private static int getPreferredColumnWidth(JTable table, int column) {
+ TableColumn tableColumn = table.getColumnModel().getColumn(column);
+
+ // Get width of column header
+ TableCellRenderer headerRenderer = tableColumn.getHeaderRenderer();
+ if (headerRenderer == null) {
+ headerRenderer = table.getTableHeader().getDefaultRenderer();
+ }
+ Object headerValue = tableColumn.getHeaderValue();
+ Component headerComp = headerRenderer.getTableCellRendererComponent(table, headerValue, false, false, 0, column);
+ int headerWidth = headerComp.getPreferredSize().width;
+
+ // Get maximum width of column data
+ int maxWidth = headerWidth;
+ for (int row = 0; row < table.getRowCount(); row++) {
+ TableCellRenderer cellRenderer = table.getCellRenderer(row, column);
+ Component comp = table.prepareRenderer(cellRenderer, row, column);
+ maxWidth = Math.max(maxWidth, comp.getPreferredSize().width);
+ }
+
+ // Add some padding
+ return maxWidth + 10;
+ }
+
+ private void addDefaultButtons(SimulationOptions options) {
+ JButton restoreDefaults = new JButton(trans.get("simedtdlg.but.resettodefault"));
+ restoreDefaults.addActionListener(e -> {
+ DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class);
+ SimulationOptions defaults = f.getDefault();
+ options.copyConditionsFrom(defaults);
+ });
+ this.add(restoreDefaults, "span, split 3, skip, gapright para, right");
+
+ JButton saveDefaults = new JButton(trans.get("simedtdlg.but.savedefault"));
+ saveDefaults.addActionListener(e -> {
+ DefaultSimulationOptionFactory f = Application.getInjector().getInstance(DefaultSimulationOptionFactory.class);
+ f.saveDefault(options);
+ });
+ this.add(saveDefaults, "gapright para, right");
+ }
+
private static String getIntensityDescription(double i) {
if (i < 0.001)
//// None
@@ -524,4 +779,397 @@ public class SimulationConditionsPanel extends JPanel {
}
});
}
+
+ public void cleanup() {
+ // Dispose of the visualization dialog if it exists
+ if (visualizationDialog != null) {
+ visualizationDialog.dispose();
+ visualizationDialog = null;
+ }
+
+ // Remove all components from the panel
+ removeAll();
+ }
+
+ @Override
+ public void addNotify() {
+ super.addNotify();
+
+ // Now that the panel is added to a container, we can safely get the parent window
+ Window parent = SwingUtilities.getWindowAncestor(this);
+ if (parent != null) {
+ parent.addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent e) {
+ cleanup();
+ }
+ });
+ }
+ }
+
+ private static JPopupMenu createContextMenu(JTable table, WindLevelTableModel model) {
+ JPopupMenu popupMenu = new JPopupMenu();
+ JMenuItem deleteItem = new JMenuItem(trans.get("simedtdlg.popupmenu.Delete"));
+ deleteItem.setIcon(Icons.EDIT_DELETE);
+ deleteItem.addActionListener(e -> {
+ int selectedRow = table.getSelectedRow();
+ if (selectedRow != -1) {
+ int modelRow = table.convertRowIndexToModel(selectedRow);
+ model.deleteWindLevel(modelRow);
+ }
+ });
+ popupMenu.add(deleteItem);
+
+ // Disable the delete item if no row is selected
+ popupMenu.addPopupMenuListener(new PopupMenuListener() {
+ @Override
+ public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+ int selectedRow = table.getSelectedRow();
+ deleteItem.setEnabled(selectedRow != -1 && table.getRowCount() > 1);
+ }
+
+ @Override
+ public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {}
+
+ @Override
+ public void popupMenuCanceled(PopupMenuEvent e) {}
+ });
+
+ return popupMenu;
+ }
+
+ private static class WindLevelTableModel extends AbstractTableModel {
+ private final MultiLevelPinkNoiseWindModel model;
+ private static final String[] columnNames = {
+ trans.get("simedtdlg.col.Altitude"),
+ trans.get("simedtdlg.col.Unit"),
+ trans.get("simedtdlg.col.Speed"),
+ trans.get("simedtdlg.col.Unit"),
+ trans.get("simedtdlg.col.Direction"),
+ trans.get("simedtdlg.col.Unit"),
+ trans.get("simedtdlg.col.StandardDeviation"),
+ trans.get("simedtdlg.col.Unit"),
+ trans.get("simedtdlg.col.Turbulence"),
+ trans.get("simedtdlg.col.Unit"),
+ trans.get("simedtdlg.col.Intensity"),
+ trans.get("simedtdlg.col.Delete")
+ };
+ private static final UnitGroup[] unitGroups = {
+ UnitGroup.UNITS_DISTANCE, UnitGroup.UNITS_VELOCITY, UnitGroup.UNITS_ANGLE,
+ UnitGroup.UNITS_VELOCITY, UnitGroup.UNITS_RELATIVE};
+ private final Unit[] currentUnits = {
+ UnitGroup.UNITS_DISTANCE.getDefaultUnit(),
+ UnitGroup.UNITS_VELOCITY.getDefaultUnit(),
+ UnitGroup.UNITS_ANGLE.getDefaultUnit(),
+ UnitGroup.UNITS_VELOCITY.getDefaultUnit(),
+ UnitGroup.UNITS_RELATIVE.getDefaultUnit(),
+ };
+ private WindLevelVisualizationDialog visualizationDialog;
+
+ public WindLevelTableModel(MultiLevelPinkNoiseWindModel model) {
+ this.model = model;
+ }
+
+ public List getLevels() {
+ return model.getLevels();
+ }
+
+ public void setVisualizationDialog(WindLevelVisualizationDialog visualizationDialog) {
+ this.visualizationDialog = visualizationDialog;
+ }
+
+ public WindLevelVisualizationDialog getVisualizationDialog() {
+ return visualizationDialog;
+ }
+
+ @Override
+ public int getRowCount() {
+ return model.getLevels().size();
+ }
+
+ @Override
+ public int getColumnCount() {
+ return columnNames.length;
+ }
+
+ @Override
+ public String getColumnName(int column) {
+ return columnNames[column];
+ }
+
+ @Override
+ public Class> getColumnClass(int columnIndex) {
+ // Intensity column
+ if (columnIndex == getColumnCount() - 2) {
+ return String.class;
+ }
+ // Delete button column
+ if (columnIndex == getColumnCount() - 1) {
+ return JButton.class;
+ }
+ return (columnIndex % 2 == 0) ? Double.class : Unit.class;
+ }
+
+ public Object getSIValueAt(int rowIndex, int columnIndex) {
+ MultiLevelPinkNoiseWindModel.LevelWindModel level = model.getLevels().get(rowIndex);
+ return switch (columnIndex) {
+ case 0 -> level.getAltitude();
+ case 2 -> level.getSpeed();
+ case 4 -> level.getDirection();
+ case 6 -> level.getStandardDeviation();
+ case 8 -> level.getTurblenceIntensity();
+ default -> null;
+ };
+ }
+
+ @Override
+ public Object getValueAt(int rowIndex, int columnIndex) {
+ // Intensity column
+ if (columnIndex == getColumnCount()-2) {
+ return getIntensityDescription(model.getLevels().get(rowIndex).getTurblenceIntensity());
+ }
+ if (columnIndex == getColumnCount()-1) {
+ return null;
+ }
+ if (columnIndex % 2 == 0) {
+ Object rawValue = getSIValueAt(rowIndex, columnIndex);
+ if (rawValue == null) {
+ return null;
+ }
+ return currentUnits[columnIndex / 2].toUnit((double) rawValue);
+ } else {
+ return currentUnits[columnIndex / 2];
+ }
+ }
+
+ public Unit[] getCurrentUnits() {
+ return currentUnits;
+ }
+
+ @Override
+ public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
+ if (columnIndex >= getColumnCount() - 2) {
+ return;
+ }
+ MultiLevelPinkNoiseWindModel.LevelWindModel level = model.getLevels().get(rowIndex);
+ if (columnIndex % 2 == 0) {
+ // Value column
+ double value = aValue instanceof Double ? (Double) aValue : Double.parseDouble(aValue.toString());
+ switch (columnIndex) {
+ case 0:
+ level.setAltitude(currentUnits[0].fromUnit(value));
+ break;
+ case 2:
+ // Handle negative speed
+ if (value < 0) {
+ level.setSpeed(currentUnits[1].fromUnit(Math.abs(value)));
+ // Adjust direction by 180 degrees
+ level.setDirection((level.getDirection() + Math.PI) % (2 * Math.PI));
+ } else {
+ level.setSpeed(currentUnits[1].fromUnit(value));
+ }
+ break;
+ case 4:
+ level.setDirection(currentUnits[2].fromUnit(value));
+ break;
+ case 6:
+ level.setStandardDeviation(currentUnits[3].fromUnit(value));
+ break;
+ case 8:
+ level.setTurbulenceIntensity(currentUnits[4].fromUnit(value));
+ break;
+ }
+ } else {
+ // Unit column
+ Unit unit = (Unit) aValue;
+ currentUnits[columnIndex / 2] = unit;
+ if (visualizationDialog != null) {
+ visualizationDialog.updateUnits(currentUnits[0], currentUnits[1]);
+ }
+ }
+ fireTableDataChanged();
+ }
+
+ @Override
+ public boolean isCellEditable(int rowIndex, int columnIndex) {
+ return columnIndex != columnNames.length - 2; // Intensity & remove column is not editable
+ }
+
+ public void addWindLevel() {
+ List levels = model.getLevels();
+ double newAltitude = levels.isEmpty() ? 0 : levels.get(levels.size() - 1).getAltitude() + 100;
+ double newSpeed = levels.isEmpty() ? 5 : levels.get(levels.size() - 1).getSpeed();
+ double newDirection = levels.isEmpty() ? Math.PI / 2 : levels.get(levels.size() - 1).getDirection();
+ double newDeviation = levels.isEmpty() ? 0.2 : levels.get(levels.size() - 1).getStandardDeviation();
+
+ model.addWindLevel(newAltitude, newSpeed, newDirection, newDeviation);
+ fireTableDataChanged();
+ }
+
+ public void deleteWindLevel(int index) {
+ if (index >= 0 && index < model.getLevels().size()) {
+ model.removeWindLevelIdx(index);
+ fireTableDataChanged();
+ }
+ }
+
+ public UnitGroup getUnitGroup(int columnIndex) {
+ return unitGroups[columnIndex / 2];
+ }
+ }
+
+ private static class UnitSelectorRenderer extends DefaultTableCellRenderer {
+ @Override
+ public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+ Unit unit = (Unit) value;
+ return super.getTableCellRendererComponent(table, unit.getUnit(), isSelected, hasFocus, row, column);
+ }
+ }
+
+ private static class UnitSelectorEditor extends DefaultCellEditor {
+ private final JComboBox comboBox;
+
+ public UnitSelectorEditor() {
+ super(new JComboBox<>());
+ comboBox = (JComboBox) getComponent();
+ }
+
+ @Override
+ public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
+ WindLevelTableModel model = (WindLevelTableModel) table.getModel();
+ UnitGroup unitGroup = model.getUnitGroup(column);
+ comboBox.removeAllItems();
+ for (Unit unit : unitGroup.getUnits()) {
+ comboBox.addItem(unit);
+ }
+ comboBox.setSelectedItem(value);
+ return comboBox;
+ }
+ }
+
+ private static class ValueCellRenderer extends DefaultTableCellRenderer {
+ @Override
+ public Component getTableCellRendererComponent(JTable table, Object value,
+ boolean isSelected, boolean hasFocus, int row, int column) {
+ if (value instanceof Double) {
+ WindLevelTableModel model = (WindLevelTableModel) table.getModel();
+ Unit unit = model.getCurrentUnits()[column / 2];
+ double SIValue = unit.fromUnit((Double) value);
+ value = unit.toString(SIValue);
+ }
+ return super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
+ }
+ }
+
+ private static class SelectAllCellEditor extends DefaultCellEditor {
+ private final JTable table;
+ private int editingRow;
+ private int editingColumn;
+ private Object originalValue;
+
+ public SelectAllCellEditor(JTable table) {
+ super(new JTextField());
+ this.table = table;
+ setClickCountToStart(1);
+
+ JTextField textField = (JTextField) getComponent();
+ textField.addFocusListener(new FocusAdapter() {
+ @Override
+ public void focusGained(FocusEvent e) {
+ editingRow = table.getEditingRow();
+ editingColumn = table.getEditingColumn();
+ if (editingRow != -1) {
+ table.setRowSelectionInterval(editingRow, editingRow);
+ }
+ }
+
+ @Override
+ public void focusLost(FocusEvent e) {
+ stopCellEditing();
+ }
+ });
+ }
+
+ @Override
+ public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
+ editingRow = row;
+ editingColumn = column;
+ originalValue = value;
+ JTextField textField = (JTextField) super.getTableCellEditorComponent(table, value, isSelected, row, column);
+ textField.setText(value != null ? value.toString() : "");
+ SwingUtilities.invokeLater(textField::selectAll);
+ return textField;
+ }
+
+ @Override
+ public boolean stopCellEditing() {
+ JTextField textField = (JTextField) getComponent();
+ try {
+ // Attempt to parse the value as a double
+ double value = Double.parseDouble(textField.getText());
+ // If successful, update the cell value
+ table.getModel().setValueAt(value, editingRow, editingColumn);
+ } catch (NumberFormatException e) {
+ // Revert to the original value if parsing fails
+ textField.setText(originalValue != null ? originalValue.toString() : "");
+ return false;
+ }
+ boolean result = super.stopCellEditing();
+ if (result && editingRow != -1) {
+ SwingUtilities.invokeLater(() -> table.setRowSelectionInterval(editingRow, editingRow));
+ }
+ return result;
+ }
+
+ @Override
+ public Object getCellEditorValue() {
+ return ((JTextField) getComponent()).getText();
+ }
+
+ @Override
+ public boolean isCellEditable(EventObject e) {
+ if (e instanceof MouseEvent) {
+ return ((MouseEvent) e).getClickCount() >= getClickCountToStart();
+ }
+ return super.isCellEditable(e);
+ }
+ }
+
+ private static class DeleteButtonRenderer extends JButton implements TableCellRenderer {
+ public DeleteButtonRenderer() {
+ setOpaque(true);
+ setIcon(Icons.EDIT_DELETE);
+ }
+
+ @Override
+ public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
+ return this;
+ }
+ }
+
+ private static class DeleteButtonEditor extends AbstractCellEditor implements TableCellEditor {
+ private final JButton button;
+ private int row;
+
+ public DeleteButtonEditor(JTable table) {
+ button = new JButton();
+ button.setIcon(Icons.EDIT_DELETE);
+ button.addActionListener(e -> {
+ WindLevelTableModel model = (WindLevelTableModel) table.getModel();
+ model.deleteWindLevel(row);
+ fireEditingStopped();
+ });
+ }
+
+ @Override
+ public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
+ this.row = row;
+ return button;
+ }
+
+ @Override
+ public Object getCellEditorValue() {
+ return null;
+ }
+ }
}
diff --git a/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java b/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java
new file mode 100644
index 000000000..055f73e8f
--- /dev/null
+++ b/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java
@@ -0,0 +1,244 @@
+package info.openrocket.swing.gui.simulation;
+
+import info.openrocket.core.l10n.Translator;
+import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
+import info.openrocket.core.startup.Application;
+import info.openrocket.core.unit.Unit;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JDialog;
+import javax.swing.JPanel;
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dialog;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.FontMetrics;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.event.WindowListener;
+import java.awt.geom.AffineTransform;
+import java.util.Comparator;
+import java.util.List;
+
+public class WindLevelVisualizationDialog extends JDialog {
+ private static final Translator trans = Application.getTranslator();
+
+ private final WindLevelVisualization visualization;
+ private final JCheckBox showDirectionsCheckBox;
+
+ public WindLevelVisualizationDialog(Dialog owner, MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) {
+ super(owner, trans.get("WindLevelVisualizationDialog.title.WindLevelVisualization"), false);
+
+ visualization = new WindLevelVisualization(model, altitudeUnit, speedUnit);
+ visualization.setPreferredSize(new Dimension(400, 500));
+
+ JPanel contentPane = new JPanel(new BorderLayout());
+ contentPane.add(visualization, BorderLayout.CENTER);
+
+ // Use BorderLayout for the control panel
+ JPanel controlPanel = new JPanel(new BorderLayout());
+ controlPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); // Add some padding
+
+ // Checkbox on the left
+ showDirectionsCheckBox = new JCheckBox(trans.get("WindLevelVisualizationDialog.checkbox.ShowDirections"));
+ showDirectionsCheckBox.setSelected(true);
+ showDirectionsCheckBox.addActionListener(e -> {
+ visualization.setShowDirections(showDirectionsCheckBox.isSelected());
+ visualization.repaint();
+ });
+ controlPanel.add(showDirectionsCheckBox, BorderLayout.WEST);
+
+ // Close button on the right
+ JButton closeButton = new JButton(trans.get("button.close"));
+ closeButton.addActionListener(e -> dispose());
+ JPanel closeButtonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
+ closeButtonPanel.add(closeButton);
+ controlPanel.add(closeButtonPanel, BorderLayout.EAST);
+
+ contentPane.add(controlPanel, BorderLayout.SOUTH);
+
+ setContentPane(contentPane);
+ pack();
+ setLocationRelativeTo(owner);
+
+ setDefaultCloseOperation(HIDE_ON_CLOSE);
+ setAlwaysOnTop(true);
+ }
+
+ public void updateUnits(Unit altitudeUnit, Unit speedUnit) {
+ visualization.updateUnits(altitudeUnit, speedUnit);
+ }
+
+ private static class WindLevelVisualization extends JPanel {
+ private final MultiLevelPinkNoiseWindModel model;
+ private static final int MARGIN = 50;
+ private static final int ARROW_SIZE = 10;
+ private static final int TICK_LENGTH = 5;
+
+ private Unit altitudeUnit;
+ private Unit speedUnit;
+ private boolean showDirections = true;
+
+ public WindLevelVisualization(MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) {
+ this.model = model;
+ this.altitudeUnit = altitudeUnit;
+ this.speedUnit = speedUnit;
+ }
+
+ public void updateUnits(Unit altitudeUnit, Unit speedUnit) {
+ this.altitudeUnit = altitudeUnit;
+ this.speedUnit = speedUnit;
+ repaint();
+ }
+
+ public void setShowDirections(boolean showDirections) {
+ this.showDirections = showDirections;
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+ Graphics2D g2d = (Graphics2D) g;
+ int width = getWidth();
+ int height = getHeight();
+
+ // Enable antialiasing for smoother lines
+ g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ // Draw background
+ g2d.setColor(Color.WHITE);
+ g2d.fillRect(0, 0, width, height);
+
+ List levels = model.getLevels();
+ if (levels.isEmpty()) return;
+
+ // Sort levels before drawing
+ levels.sort(Comparator.comparingDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getAltitude));
+
+ double maxAltitude = levels.stream().mapToDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getAltitude).max().orElse(1000);
+ double maxSpeed = levels.stream().mapToDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getSpeed).max().orElse(10);
+
+ // Extend axis ranges by 10% for drawing
+ double extendedMaxAltitude = maxAltitude * 1.1;
+ double extendedMaxSpeed = maxSpeed * 1.1;
+
+ // Draw axes
+ drawAxes(g2d, width, height, maxSpeed, maxAltitude, extendedMaxSpeed, extendedMaxAltitude);
+
+ // Draw wind levels
+ for (int i = 0; i < levels.size(); i++) {
+ MultiLevelPinkNoiseWindModel.LevelWindModel level = levels.get(i);
+
+ int x = MARGIN + (int) (level.getSpeed() / extendedMaxSpeed * (width - 2 * MARGIN));
+ int y = height - MARGIN - (int) (level.getAltitude() / extendedMaxAltitude * (height - 2 * MARGIN));
+
+ // Draw point
+ g2d.setColor(Color.BLUE);
+ g2d.fillOval(x - 3, y - 3, 6, 6);
+
+ // Draw wind direction arrow
+ if (showDirections) {
+ drawWindArrow(g2d, x, y, level.getDirection());
+ }
+
+ // Draw connecting line if not the first point
+ if (i > 0) {
+ MultiLevelPinkNoiseWindModel.LevelWindModel prevLevel = levels.get(i - 1);
+ int prevX = MARGIN + (int) (prevLevel.getSpeed() / extendedMaxSpeed * (width - 2 * MARGIN));
+ int prevY = height - MARGIN - (int) (prevLevel.getAltitude() / extendedMaxAltitude * (height - 2 * MARGIN));
+ g2d.setColor(Color.GRAY);
+ g2d.drawLine(prevX, prevY, x, y);
+ }
+ }
+ }
+
+ private void drawAxes(Graphics2D g2d, int width, int height, double maxSpeed, double maxAltitude,
+ double extendedMaxSpeed, double extendedMaxAltitude) {
+ g2d.setColor(Color.BLACK);
+
+ // Draw X-axis
+ g2d.drawLine(MARGIN, height - MARGIN, width - MARGIN, height - MARGIN);
+ drawFilledArrowHead(g2d, width - MARGIN + (ARROW_SIZE-2), height - MARGIN, ARROW_SIZE, 0);
+
+ // Draw Y-axis
+ g2d.drawLine(MARGIN, height - MARGIN, MARGIN, MARGIN);
+ drawFilledArrowHead(g2d, MARGIN, MARGIN - (ARROW_SIZE-2), ARROW_SIZE, -Math.PI / 2);
+
+ // Draw max value ticks and labels
+ g2d.setFont(g2d.getFont().deriveFont(10f));
+ FontMetrics fm = g2d.getFontMetrics();
+
+ // X-axis max value
+ int xTickX = MARGIN + (int) ((maxSpeed / extendedMaxSpeed) * (width - 2 * MARGIN));
+ g2d.drawLine(xTickX, height - MARGIN, xTickX, height - MARGIN + TICK_LENGTH);
+ String xMaxLabel = speedUnit.toString(maxSpeed);
+ g2d.drawString(xMaxLabel, xTickX - fm.stringWidth(xMaxLabel) / 2, height - MARGIN + TICK_LENGTH + fm.getHeight());
+
+ // Y-axis max value
+ int yTickY = height - MARGIN - (int) ((maxAltitude / extendedMaxAltitude) * (height - 2 * MARGIN));
+ g2d.drawLine(MARGIN - TICK_LENGTH, yTickY, MARGIN, yTickY);
+ String yMaxLabel = altitudeUnit.toString(maxAltitude);
+ g2d.drawString(yMaxLabel, MARGIN - TICK_LENGTH - fm.stringWidth(yMaxLabel) - 2, yTickY + fm.getAscent() / 2);
+
+ // Draw axis labels
+ g2d.setFont(g2d.getFont().deriveFont(12f));
+ fm = g2d.getFontMetrics();
+
+ // X-axis label
+ String xLabel = trans.get("WindLevelVisualizationDialog.lbl.WindSpeed") + " (" + speedUnit.getUnit() + ")";
+ g2d.drawString(xLabel, width / 2 - fm.stringWidth(xLabel) / 2, height - 10);
+
+ // Y-axis label
+ String yLabel = trans.get("WindLevelVisualizationDialog.lbl.Altitude") + " (" + altitudeUnit.getUnit() + ")";
+ AffineTransform originalTransform = g2d.getTransform();
+ g2d.rotate(-Math.PI / 2);
+ g2d.drawString(yLabel, -height / 2 - fm.stringWidth(yLabel) / 2, MARGIN / 2);
+ g2d.setTransform(originalTransform);
+ }
+
+ private void drawFilledArrowHead(Graphics2D g, int x, int y, int arrowSize, double angle) {
+ int[] xPoints = new int[3];
+ int[] yPoints = new int[3];
+
+ xPoints[0] = x;
+ yPoints[0] = y;
+ xPoints[1] = x - (int) (arrowSize * Math.cos(angle - Math.PI / 6));
+ yPoints[1] = y - (int) (arrowSize * Math.sin(angle - Math.PI / 6));
+ xPoints[2] = x - (int) (arrowSize * Math.cos(angle + Math.PI / 6));
+ yPoints[2] = y - (int) (arrowSize * Math.sin(angle + Math.PI / 6));
+
+ g.fillPolygon(xPoints, yPoints, 3);
+ }
+
+ private void drawWindArrow(Graphics2D g, int x, int y, double direction) {
+ int directionVectorLength = 15;
+ int dx = (int) (directionVectorLength * Math.sin(direction));
+ int dy = (int) (directionVectorLength * Math.cos(direction));
+ int arrowSize = 10;
+
+ g.setColor(Color.RED);
+
+ // Draw the main line
+ g.drawLine(x, y, x + dx, y - dy);
+
+ int dx_arrow = (int) ((arrowSize-1) * Math.sin(direction));
+ int dy_arrow = (int) ((arrowSize-1) * Math.cos(direction));
+
+ // Draw filled arrow head
+ drawFilledArrowHead(g, x + dx + dx_arrow, y - dy - dy_arrow, arrowSize, direction - Math.PI/2);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ for (WindowListener listener : getWindowListeners()) {
+ removeWindowListener(listener);
+ }
+
+ super.dispose();
+ }
+}
diff --git a/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java b/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java
index 0d404be9a..89cda70f9 100644
--- a/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java
+++ b/swing/src/main/java/info/openrocket/swing/gui/util/SwingPreferences.java
@@ -40,7 +40,6 @@ import info.openrocket.core.rocketcomponent.RailButton;
import info.openrocket.core.rocketcomponent.RecoveryDevice;
import info.openrocket.core.rocketcomponent.RocketComponent;
import info.openrocket.core.rocketcomponent.TubeFinSet;
-import info.openrocket.core.simulation.SimulationOptionsInterface;
import info.openrocket.core.util.ORColor;
import info.openrocket.core.arch.SystemInfo;
import info.openrocket.core.document.Simulation;
@@ -59,7 +58,7 @@ import info.openrocket.core.util.BuildProperties;
import info.openrocket.swing.communication.AssetHandler.UpdatePlatform;
-public class SwingPreferences extends ApplicationPreferences implements SimulationOptionsInterface {
+public class SwingPreferences extends ApplicationPreferences {
private static final Logger log = LoggerFactory.getLogger(SwingPreferences.class);