From f645f580ecff99f5763bec985dde19ebd9b217d9 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Tue, 10 Sep 2024 15:24:26 +0200 Subject: [PATCH 01/17] [#2060, #2558] Implement multi-level wind input --- .../core/file/openrocket/OpenRocketSaver.java | 27 +- .../importt/SimulationConditionsHandler.java | 28 +- .../file/openrocket/importt/WindHandler.java | 69 ++ .../file/rasaero/export/LaunchSiteDTO.java | 4 +- .../rasaero/importt/LaunchSiteHandler.java | 2 +- .../core/models/wind/MultiLevelWindModel.java | 193 +++++ .../core/models/wind/PinkNoiseWindModel.java | 58 ++ .../core/models/wind/WindModel.java | 34 +- .../core/models/wind/WindModelType.java | 6 + .../preferences/ApplicationPreferences.java | 92 +-- .../DefaultSimulationOptionFactory.java | 13 +- .../core/simulation/SimulationOptions.java | 123 ++- .../SimulationOptionsInterface.java | 27 +- .../core/util/AbstractChangeSource.java | 1 - .../info/openrocket/core/util/MathUtil.java | 12 +- .../main/resources/l10n/messages.properties | 18 + .../models/wind/MultiLevelWindModelTest.java | 155 ++++ .../simulation/SimulationConditionsTest.java | 98 ++- .../preferences/LaunchPreferencesPanel.java | 3 +- .../simulation/SimulationConditionsPanel.java | 747 ++++++++++++++---- .../WindLevelVisualizationDialog.java | 244 ++++++ .../swing/gui/util/SwingPreferences.java | 3 +- 22 files changed, 1610 insertions(+), 347 deletions(-) create mode 100644 core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java create mode 100644 core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java create mode 100644 core/src/main/java/info/openrocket/core/models/wind/WindModelType.java create mode 100644 core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java create mode 100644 swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java 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..dbce206f7 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.MultiLevelWindModel; import info.openrocket.core.preferences.DocumentPreferences; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -336,8 +337,30 @@ 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.getPinkNoiseWindModel().getAverage()); + writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); + writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + + writeln(""); + indent++; + writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage()); + writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); + writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + indent--; + writeln(""); + + if (!cond.getMultiLevelWindModel().getLevels().isEmpty()) { + writeln(""); + indent++; + for (MultiLevelWindModel.WindLevel level : cond.getMultiLevelWindModel().getLevels()) { + writeln(""); + } + indent--; + writeln(""); + } + 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..be84187ac 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 @@ -17,6 +17,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 +33,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; } @@ -69,19 +73,33 @@ class SimulationConditionsHandler extends AbstractElementHandler { } else { options.setLaunchRodDirection(d * 2.0 * Math.PI / 360); } - } else if (element.equals("windaverage")) { + } + // TODO: remove once support for OR 23.09 and prior is dropped + else if (element.equals("windaverage")) { if (Double.isNaN(d)) { warnings.add("Illegal average windspeed defined, ignoring."); } else { - options.setWindSpeedAverage(d); + options.getPinkNoiseWindModel().setAverage(d); } } else if (element.equals("windturbulence")) { if (Double.isNaN(d)) { warnings.add("Illegal wind turbulence intensity defined, ignoring."); } else { - options.setWindTurbulenceIntensity(d); + options.getPinkNoiseWindModel().setTurbulenceIntensity(d); } - } else if (element.equals("launchaltitude")) { + } else if (element.equals("winddirection")) { + if (Double.isNaN(d)) { + warnings.add("Illegal wind direction defined, ignoring."); + } else { + options.getPinkNoiseWindModel().setDirection(d); + } + } + + else if (element.equals("wind")) { + windHandler.storeSettings(options, warnings); + } + + else if (element.equals("launchaltitude")) { if (Double.isNaN(d)) { warnings.add("Illegal launch altitude defined, ignoring."); } else { 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..9c36b48ec --- /dev/null +++ b/core/src/main/java/info/openrocket/core/file/openrocket/importt/WindHandler.java @@ -0,0 +1,69 @@ +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 ("pinknoise".equals(model)) { + if (element.equals("windaverage")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setAverage(d); + } + } else if (element.equals("windturbulence")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setTurbulenceIntensity(d); + } + } else if (element.equals("winddirection")) { + if (!Double.isNaN(d)) { + options.getPinkNoiseWindModel().setDirection(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")); + options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction); + } + } + } + + public void storeSettings(SimulationOptions options, WarningSet warnings) { + if ("pinknoise".equals(model)) { + options.setWindModelType(WindModelType.PINK_NOISE); + } 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..ef7995317 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.getPinkNoiseWindModel().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.getPinkNoiseWindModel().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..551c04366 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.getPinkNoiseWindModel().setAverage( Double.parseDouble(content) / RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED); } } catch (NumberFormatException e) { diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java new file mode 100644 index 000000000..ecc2202c7 --- /dev/null +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java @@ -0,0 +1,193 @@ +package info.openrocket.core.models.wind; + +import java.util.ArrayList; +import java.util.List; +import java.util.Collections; +import java.util.Comparator; +import info.openrocket.core.util.Coordinate; +import info.openrocket.core.util.MathUtil; +import info.openrocket.core.util.ModID; + +public class MultiLevelWindModel implements WindModel { + + private List levels; + + public MultiLevelWindModel() { + this.levels = new ArrayList<>(); + } + + public void addWindLevel(double altitude, double speed, double direction) { + WindLevel newLevel = new WindLevel(altitude, speed, direction); + 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); + } + + public void removeWindLevel(double altitude) { + levels.removeIf(level -> level.altitude == altitude); + } + + public void removeWindLevelIdx(int index) { + levels.remove(index); + } + + public List getLevels() { + return new ArrayList<>(levels); + } + + public void resortLevels() { + 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 WindLevel(altitude, 0, 0), + Comparator.comparingDouble(l -> l.altitude)); + + // Retrieve the wind level if it exists + if (index >= 0) { + return levels.get(index).toCoordinate(); + } + + // Extrapolation (take the value of the outer bounds) + int insertionPoint = -index - 1; + if (insertionPoint == 0) { + return levels.get(0).toCoordinate(); + } + if (insertionPoint == levels.size()) { + return levels.get(levels.size() - 1).toCoordinate(); + } + + // Interpolation (take the value between the closest two bounds) + WindLevel lower = levels.get(insertionPoint - 1); + WindLevel upper = levels.get(insertionPoint); + + double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); + double speed = MathUtil.interpolate(lower.speed, upper.speed, fraction); + double direction = MathUtil.interpolate(lower.direction, upper.direction, fraction); + + return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); + } + + public double getWindDirection(double altitude) { + if (levels.isEmpty()) { + return 0; + } + + int index = Collections.binarySearch(levels, new WindLevel(altitude, 0, 0), + Comparator.comparingDouble(l -> l.altitude)); + + if (index >= 0) { + return levels.get(index).direction; + } + + int insertionPoint = -index - 1; + if (insertionPoint == 0) { + return levels.get(0).direction; + } + if (insertionPoint == levels.size()) { + return levels.get(levels.size() - 1).direction; + } + + WindLevel lower = levels.get(insertionPoint - 1); + WindLevel upper = levels.get(insertionPoint); + + double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); + return MathUtil.interpolate(lower.direction, upper.direction, fraction); + } + + @Override + public ModID getModID() { + return ModID.ZERO; // You might want to create a specific ModID for this model + } + + public void loadFrom(MultiLevelWindModel source) { + this.levels.clear(); + for (WindLevel level : source.levels) { + this.levels.add(level.clone()); + } + } + + @Override + public MultiLevelWindModel clone() { + try { + MultiLevelWindModel clone = (MultiLevelWindModel) 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; + MultiLevelWindModel that = (MultiLevelWindModel) o; + return levels.equals(that.levels); + } + + @Override + public int hashCode() { + return levels.hashCode(); + } + + public static class WindLevel implements Cloneable { + public double altitude; + public double speed; + public double direction; + + public WindLevel(double altitude, double speed, double direction) { + this.altitude = altitude; + this.speed = speed; + this.direction = direction; + } + + Coordinate toCoordinate() { + return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); + } + + public void loadFrom(WindLevel source) { + this.altitude = source.altitude; + this.speed = source.speed; + this.direction = source.direction; + } + + @Override + public WindLevel clone() { + try { + WindLevel clone = (WindLevel) 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; + WindLevel windLevel = (WindLevel) o; + return Double.compare(windLevel.altitude, altitude) == 0 && + Double.compare(windLevel.speed, speed) == 0 && + Double.compare(windLevel.direction, direction) == 0; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + Double.hashCode(altitude); + result = 31 * result + Double.hashCode(speed); + result = 31 * result + Double.hashCode(direction); + return result; + } + } +} \ 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..3349320fa 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,14 @@ package info.openrocket.core.models.wind; +import java.util.ArrayList; +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 @@ -55,6 +58,10 @@ public class PinkNoiseWindModel implements WindModel { this.seed = seed ^ SEED_RANDOMIZATION; } + public PinkNoiseWindModel() { + this(new Random().nextInt()); + } + /** * Return the average wind speed. * @@ -71,13 +78,21 @@ public class PinkNoiseWindModel implements WindModel { * @param average the average wind speed to set */ public void setAverage(double average) { + if (average == this.average) { + return; + } double intensity = getTurbulenceIntensity(); this.average = Math.max(average, 0); setTurbulenceIntensity(intensity); + fireChangeEvent(); } public void setDirection(double direction) { + if (direction == this.direction) { + return; + } this.direction = direction; + fireChangeEvent(); } public double getDirection() { @@ -99,7 +114,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 +181,47 @@ 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; + } + } 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..2e4150ef8 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,40 @@ package info.openrocket.core.models.wind; +import info.openrocket.core.util.ChangeSource; import info.openrocket.core.util.Coordinate; import info.openrocket.core.util.Monitorable; +import info.openrocket.core.util.StateChangeListener; -public interface WindModel extends Monitorable { +import java.util.ArrayList; +import java.util.EventListener; +import java.util.EventObject; +import java.util.List; - public Coordinate getWindVelocity(double time, double altitude); +public interface WindModel extends Monitorable, Cloneable, ChangeSource { + List listeners = new ArrayList<>(); + Coordinate getWindVelocity(double time, double altitude); + + WindModel clone(); + + @Override + default void addChangeListener(StateChangeListener listener) { + listeners.add(listener); + } + + @Override + default void removeChangeListener(StateChangeListener listener) { + listeners.remove(listener); + } + + default 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/WindModelType.java b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java new file mode 100644 index 000000000..3097a8f91 --- /dev/null +++ b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java @@ -0,0 +1,6 @@ +package info.openrocket.core.models.wind; + +public enum WindModelType { + PINK_NOISE, + MULTI_LEVEL +} 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 8666e5f5a..cb5da8159 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 = "|"; /* @@ -152,7 +154,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 pinkNoiseWindModel = null; + + /* * ****************************************************************************************** * @@ -346,19 +351,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); } @@ -399,49 +391,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); + + getPinkNoiseWindModel().setAverage(average); + getPinkNoiseWindModel().setTurbulenceIntensity(turbulenceIntensity); + getPinkNoiseWindModel().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, getPinkNoiseWindModel().getAverage()); + putDouble(WIND_TURBULENCE, getPinkNoiseWindModel().getTurbulenceIntensity()); + putDouble(WIND_DIRECTION, getPinkNoiseWindModel().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 getPinkNoiseWindModel() { + if (pinkNoiseWindModel == null) { + pinkNoiseWindModel = new PinkNoiseWindModel(); + pinkNoiseWindModel.addChangeListener(this); + loadWindModelState(); } - setWindTurbulenceIntensity(windDeviation / windAverage); + return pinkNoiseWindModel; } - - 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); } @@ -1291,4 +1268,11 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen } } } + + @Override + public void stateChanged(EventObject e) { + if (e.getSource() == pinkNoiseWindModel) { + 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..2fc981ba1 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.getPinkNoiseWindModel().setAverage(prefs.getPinkNoiseWindModel().getAverage()); + defaults.getPinkNoiseWindModel().setStandardDeviation(prefs.getPinkNoiseWindModel().getStandardDeviation()); + defaults.getPinkNoiseWindModel().setTurbulenceIntensity(prefs.getPinkNoiseWindModel().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.getPinkNoiseWindModel().getAverage()); + prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getPinkNoiseWindModel().getStandardDeviation()); + prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getPinkNoiseWindModel().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..7aa443cfc 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.MultiLevelWindModel; +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,13 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt private List listeners = new ArrayList<>(); + private WindModelType windModelType = WindModelType.PINK_NOISE; + private final PinkNoiseWindModel pinkNoiseWindModel; + private final MultiLevelWindModel multiLevelWindModel; + public SimulationOptions() { + pinkNoiseWindModel = new PinkNoiseWindModel(randomSeed); + multiLevelWindModel = new MultiLevelWindModel(); } public double getLaunchRodLength() { @@ -120,6 +125,12 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt public double getLaunchRodDirection() { if (launchIntoWind) { + double windDirection; + if (windModelType == WindModelType.PINK_NOISE) { + windDirection = pinkNoiseWindModel.getDirection(); + } else { + windDirection = multiLevelWindModel.getWindDirection(launchAltitude); + } this.setLaunchRodDirection(windDirection); } return launchRodDirection; @@ -133,57 +144,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.PINK_NOISE) { + return pinkNoiseWindModel; + } else if (windModelType == WindModelType.MULTI_LEVEL) { + return multiLevelWindModel; + } else { + throw new IllegalArgumentException("Unknown wind model type: " + windModelType); } - setWindTurbulenceIntensity(windDeviation / windAverage); } - public double getWindTurbulenceIntensity() { - return windTurbulence; + public PinkNoiseWindModel getPinkNoiseWindModel() { + return pinkNoiseWindModel; } - 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 MultiLevelWindModel getMultiLevelWindModel() { + return multiLevelWindModel; } public double getLaunchAltitude() { @@ -361,6 +348,16 @@ 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.pinkNoiseWindModel.equals(src.pinkNoiseWindModel)) { + isChanged = true; + this.pinkNoiseWindModel.loadFrom(src.pinkNoiseWindModel); + } + if (!this.multiLevelWindModel.equals(src.multiLevelWindModel)) { + isChanged = true; + this.multiLevelWindModel.loadFrom(src.multiLevelWindModel); + } + if (this.launchAltitude != src.launchAltitude) { isChanged = true; this.launchAltitude = src.launchAltitude; @@ -405,18 +402,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 +439,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.pinkNoiseWindModel.equals(o.pinkNoiseWindModel) && + this.multiLevelWindModel.equals(o.multiLevelWindModel); } /** @@ -505,17 +491,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 +512,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", pinkNoiseWindModel)) + .concat(String.format(" multiLevelWindModel: %s\n", multiLevelWindModel)) .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..9843098b5 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 getPinkNoiseWindModel(); 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/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/resources/l10n/messages.properties b/core/src/main/resources/l10n/messages.properties index 8ea9ca2cc..0ae8101a1 100644 --- a/core/src/main/resources/l10n/messages.properties +++ b/core/src/main/resources/l10n/messages.properties @@ -503,6 +503,10 @@ 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.Unit = Unit simedtdlg.lbl.Longitude = Longitude: simedtdlg.lbl.ttip.Longitude = Required for weather prediction and elevation models. @@ -552,6 +556,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.PinkNoiseWind = Pink noise +simedtdlg.radio.PinkNoiseWind.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.removeWindLevel = Remove 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..af4e95b76 --- /dev/null +++ b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java @@ -0,0 +1,155 @@ +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.List; + +import static org.junit.jupiter.api.Assertions.*; + +class MultiLevelWindModelTest { + private final static double EPSILON = MathUtil.EPSILON; + + private MultiLevelWindModel model; + + @BeforeEach + void setUp() { + model = new MultiLevelWindModel(); + } + + @Test + @DisplayName("Add and remove wind levels") + void testAddAndRemoveWindLevels() { + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(200, 10, Math.PI / 2); + 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); + assertThrows(IllegalArgumentException.class, () -> model.addWindLevel(100, 10, Math.PI / 2)); + } + + @Test + @DisplayName("Get wind velocity") + void testGetWindVelocity() { + model.addWindLevel(0, 5, 0); + model.addWindLevel(1000, 10, Math.PI / 2); + + verifyWind(500, 7.5, Math.PI / 4); + } + + @Test + @DisplayName("Interpolation between levels") + void testInterpolationBetweenLevels() { + model.addWindLevel(0, 5, 0); + model.addWindLevel(1000, 10, Math.PI); + + verifyWind(200, 6, Math.PI / 5); + verifyWind(500, 7.5, Math.PI / 2); + verifyWind(900, 9.5, 9 * Math.PI / 10); + } + + @Test + @DisplayName("Extrapolation outside levels") + void testExtrapolationOutsideLevels() { + model.addWindLevel(100, 5, 0); + model.addWindLevel(200, 10, Math.PI / 2); + + verifyWind(0, 5, 0); + verifyWind(300, 10, Math.PI / 2); + verifyWind(1000, 10, Math.PI / 2); + } + + @Test + @DisplayName("Resort levels") + void testResortLevels() { + model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(300, 15, 3 * Math.PI / 4); + + model.resortLevels(); + + 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); + model.addWindLevel(200, 10, Math.PI / 2); + + MultiLevelWindModel clonedModel = model.clone(); + assertNotSame(model, clonedModel); + assertEquals(model, clonedModel); + + clonedModel.addWindLevel(300, 15, 3 * Math.PI / 4); + assertNotEquals(model, clonedModel); + } + + @Test + @DisplayName("Load from another model") + void testLoadFrom() { + model.addWindLevel(100, 5, Math.PI / 4); + model.addWindLevel(200, 10, Math.PI / 2); + + MultiLevelWindModel newModel = new MultiLevelWindModel(); + 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) { + Coordinate velocity = model.getWindVelocity(0, altitude); + assertEquals(expectedSpeed, velocity.length(), EPSILON, "Wind speed at altitude " + altitude); + assertEquals(expectedSpeed * Math.sin(expectedDirection), velocity.x, EPSILON, "Wind velocity X component at altitude " + altitude); + assertEquals(expectedSpeed * Math.cos(expectedDirection), velocity.y, EPSILON, "Wind velocity Y component at altitude " + altitude); + assertEquals(0, velocity.z, EPSILON, "Wind velocity Z component at altitude " + altitude); + assertEquals(expectedDirection, Math.atan2(velocity.x, velocity.y), EPSILON, "Wind direction at altitude " + altitude); + } +} \ No newline at end of file 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..932dd38e3 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.MultiLevelWindModel; +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.getPinkNoiseWindModel().getDirection(), EPSILON); + assertEquals(0.1, options.getPinkNoiseWindModel().getTurbulenceIntensity(), EPSILON); + assertEquals(2.0, options.getPinkNoiseWindModel().getAverage(), EPSILON); + assertEquals(0.2, options.getPinkNoiseWindModel().getStandardDeviation(), EPSILON); assertEquals(0.05, options.getTimeStep(), EPSILON); assertEquals(3 * Math.PI / 180, options.getMaximumStepAngle(), EPSILON); - } + @Test + @DisplayName("Compare PinkNoiseWindModel and MultiLevelWindModel 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 MultiLevelWindModel + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 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 MultiLevelWindModel") + public void testMultiLevelWindModelConsistency() { + SimulationConditions conditions = new SimulationConditions(); + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + + 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 MultiLevelWindModel") + public void testMultiLevelWindModelAltitudeDependence() { + SimulationConditions conditions = new SimulationConditions(); + MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); + multiLevelModel.addWindLevel(0, 5.0, 0); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + + 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/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..bfdbc6351 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,30 +1,58 @@ 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.KeyAdapter; import java.awt.event.KeyEvent; +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.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.JOptionPane; import javax.swing.JPanel; +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.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +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.MultiLevelWindModel; +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; @@ -37,55 +65,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 pink noise 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,135 +98,17 @@ 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, gap rel unrel", "[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 { + addPinkNoiseSettings(sub, target); + } //// Temperature and pressure sub = new JPanel(new MigLayout("fill, gap rel unrel", @@ -250,7 +134,7 @@ 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); @@ -483,6 +367,302 @@ 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(); + + panel.add(new JLabel(trans.get("simedtdlg.lbl.WindModelSelection")), "spanx, split 3, gapright para"); + + JRadioButton pinkNoiseButton = new JRadioButton(trans.get("simedtdlg.radio.PinkNoiseWind")); + pinkNoiseButton.setToolTipText(trans.get("simedtdlg.radio.PinkNoiseWind.ttip")); + JRadioButton multiLevelButton = new JRadioButton(trans.get("simedtdlg.radio.MultiLevelWind")); + multiLevelButton.setToolTipText(trans.get("simedtdlg.radio.MultiLevelWind.ttip")); + + windModelGroup.add(pinkNoiseButton); + windModelGroup.add(multiLevelButton); + + panel.add(pinkNoiseButton); + panel.add(multiLevelButton, "wrap"); + + panel.add(new JSeparator(JSeparator.HORIZONTAL), "spanx, growx, wrap"); + + JPanel windSettingsPanel = new JPanel(new CardLayout()); + + JPanel pinkNoisePanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow][75lp!][30lp!][75lp!]", "")); + JPanel multiLevelPanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow]", "")); + + addPinkNoiseSettings(pinkNoisePanel, target); + addMultiLevelSettings(multiLevelPanel, target); + + windSettingsPanel.add(pinkNoisePanel, "PinkNoise"); + windSettingsPanel.add(multiLevelPanel, "MultiLevel"); + + panel.add(windSettingsPanel, "grow, wrap"); + + pinkNoiseButton.addActionListener(e -> { + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + if (target instanceof SimulationOptions) { + ((SimulationOptions) target).setWindModelType(WindModelType.PINK_NOISE); + } + }); + + 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.PINK_NOISE) { + pinkNoiseButton.setSelected(true); + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + } else { + multiLevelButton.setSelected(true); + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel"); + } + } + } + + private static void addPinkNoiseSettings(JPanel panel, SimulationOptionsInterface target) { + PinkNoiseWindModel model = target.getPinkNoiseWindModel(); + + // 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.getPinkNoiseWindModel().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.getPinkNoiseWindModel().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; + } + MultiLevelWindModel model = options.getMultiLevelWindModel(); + + // Create the levels table + WindLevelTableModel tableModel = new WindLevelTableModel(model); + JTable windLevelTable = new JTable(tableModel); + windLevelTable.setRowSelectionAllowed(false); + windLevelTable.setColumnSelectionAllowed(false); + windLevelTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + // Set up value columns + SelectAllCellEditor selectAllEditor = new SelectAllCellEditor(); + ValueCellRenderer valueCellRenderer = new ValueCellRenderer(); + for (int i = 0; i < windLevelTable.getColumnCount(); 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()); + } + + // 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); + sorter.setSortable(1, false); + sorter.setSortable(2, false); + sorter.setComparator(0, Comparator.comparingDouble(a -> (Double) a)); + sorter.setSortKeys(List.of(new RowSorter.SortKey(0, SortOrder.ASCENDING))); + + JScrollPane scrollPane = new JScrollPane(windLevelTable); + scrollPane.setPreferredSize(new Dimension(350, 150)); + panel.add(scrollPane, "grow"); + + //// Buttons + JPanel buttonPanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow]", "")); + + // Add wind level + JButton addButton = new JButton(trans.get("simedtdlg.but.addWindLevel")); + addButton.addActionListener(e -> { + tableModel.addWindLevel(); + sorter.sort(); + }); + buttonPanel.add(addButton, "growx, wrap"); + + // Remove wind level + JButton removeButton = new JButton(trans.get("simedtdlg.but.removeWindLevel")); + removeButton.addActionListener(e -> { + int selectedRow = windLevelTable.getSelectedRow(); + tableModel.removeWindLevel(selectedRow); + sorter.sort(); + }); + buttonPanel.add(removeButton, "growx, wrap"); + + // Visualization button + JButton visualizeButton = new JButton(trans.get("simedtdlg.but.visualizeWindLevels")); + 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); + } + }); + buttonPanel.add(visualizeButton, "growx, wrap"); + + 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(); + } + }); + } + + 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); + 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, gapbottom para, 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, "gapbottom para, gapright para, right"); + } + private static String getIntensityDescription(double i) { if (i < 0.001) //// None @@ -524,4 +704,241 @@ 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 class WindLevelTableModel extends AbstractTableModel { + private final MultiLevelWindModel 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"), + }; + private static final UnitGroup[] unitGroups = { + UnitGroup.UNITS_DISTANCE, UnitGroup.UNITS_VELOCITY, UnitGroup.UNITS_ANGLE}; + private final Unit[] currentUnits = { + UnitGroup.UNITS_DISTANCE.getDefaultUnit(), + UnitGroup.UNITS_VELOCITY.getDefaultUnit(), + UnitGroup.UNITS_ANGLE.getDefaultUnit() + }; + private WindLevelVisualizationDialog visualizationDialog; + + public WindLevelTableModel(MultiLevelWindModel model) { + this.model = model; + } + + 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) { + return (columnIndex % 2 == 0) ? Double.class : Unit.class; + } + + public Object getSIValueAt(int rowIndex, int columnIndex) { + MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + return switch (columnIndex) { + case 0 -> level.altitude; + case 2 -> level.speed; + case 4 -> level.direction; + default -> null; + }; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + 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) { + MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + if (columnIndex % 2 == 0) { + // Value column + double value = Double.parseDouble((String) aValue); + switch (columnIndex) { + case 0: + level.altitude = currentUnits[0].fromUnit(value); + break; + case 2: + // Handle negative speed + if (value < 0) { + level.speed = currentUnits[1].fromUnit(Math.abs(value)); + // Adjust direction by 180 degrees + level.direction = (level.direction + Math.PI) % (2 * Math.PI); + } else { + level.speed = currentUnits[1].fromUnit(value); + } + break; + case 4: + level.direction = currentUnits[2].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 true; + } + + public void addWindLevel() { + List levels = model.getLevels(); + double newAltitude = levels.isEmpty() ? 0 : levels.get(levels.size() - 1).altitude + 100; + double newSpeed = levels.isEmpty() ? 5 : levels.get(levels.size() - 1).speed; + double newDirection = levels.isEmpty() ? Math.PI / 2 : levels.get(levels.size() - 1).direction; + + model.addWindLevel(newAltitude, newSpeed, newDirection); + fireTableDataChanged(); + } + + public void removeWindLevel(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 { + public SelectAllCellEditor() { + super(new JTextField()); + setClickCountToStart(1); + } + + @Override + public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { + JTextField textField = (JTextField) super.getTableCellEditorComponent(table, value, isSelected, row, column); + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + textField.selectAll(); + } + }); + return textField; + } + + @Override + public boolean isCellEditable(EventObject e) { + if (e instanceof MouseEvent) { + return ((MouseEvent) e).getClickCount() >= getClickCountToStart(); + } + return super.isCellEditable(e); + } + } } 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..ca5f6c1a9 --- /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.MultiLevelWindModel; +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, MultiLevelWindModel 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 MultiLevelWindModel 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(MultiLevelWindModel 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(level -> level.altitude)); + + double maxAltitude = levels.stream().mapToDouble(l -> l.altitude).max().orElse(1000); + double maxSpeed = levels.stream().mapToDouble(l -> l.speed).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++) { + MultiLevelWindModel.WindLevel level = levels.get(i); + + int x = MARGIN + (int) (level.speed / extendedMaxSpeed * (width - 2 * MARGIN)); + int y = height - MARGIN - (int) (level.altitude / 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.direction); + } + + // Draw connecting line if not the first point + if (i > 0) { + MultiLevelWindModel.WindLevel prevLevel = levels.get(i - 1); + int prevX = MARGIN + (int) (prevLevel.speed / extendedMaxSpeed * (width - 2 * MARGIN)); + int prevY = height - MARGIN - (int) (prevLevel.altitude / 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); From 93bf3df7427343b68d55c6d3fae8697e76093cd0 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Fri, 13 Sep 2024 02:29:32 +0100 Subject: [PATCH 02/17] Store which wind model type to use --- .../core/file/openrocket/OpenRocketSaver.java | 2 + .../importt/SimulationConditionsHandler.java | 151 ++++++++++-------- .../core/models/wind/WindModelType.java | 23 ++- 3 files changed, 104 insertions(+), 72 deletions(-) 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 dbce206f7..18f59a833 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 @@ -361,6 +361,8 @@ public class OpenRocketSaver extends RocketSaver { 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 be84187ac..136faa15a 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; @@ -53,85 +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); + } } - } - // TODO: remove once support for OR 23.09 and prior is dropped - else if (element.equals("windaverage")) { - if (Double.isNaN(d)) { - warnings.add("Illegal average windspeed defined, ignoring."); - } else { - options.getPinkNoiseWindModel().setAverage(d); - } - } else if (element.equals("windturbulence")) { - if (Double.isNaN(d)) { - warnings.add("Illegal wind turbulence intensity defined, ignoring."); - } else { - options.getPinkNoiseWindModel().setTurbulenceIntensity(d); - } - } else if (element.equals("winddirection")) { - if (Double.isNaN(d)) { - warnings.add("Illegal wind direction defined, ignoring."); - } else { - options.getPinkNoiseWindModel().setDirection(d); - } - } - else if (element.equals("wind")) { - windHandler.storeSettings(options, warnings); - } + // 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.getPinkNoiseWindModel().setAverage(d); + } + } + case "windturbulence" -> { + if (Double.isNaN(d)) { + warnings.add("Illegal wind turbulence intensity defined, ignoring."); + } else { + options.getPinkNoiseWindModel().setTurbulenceIntensity(d); + } + } + case "winddirection" -> { + if (Double.isNaN(d)) { + warnings.add("Illegal wind direction defined, ignoring."); + } else { + options.getPinkNoiseWindModel().setDirection(d); + } + } - else if (element.equals("launchaltitude")) { - if (Double.isNaN(d)) { - warnings.add("Illegal launch altitude defined, ignoring."); - } else { - options.setLaunchAltitude(d); + case "wind" -> windHandler.storeSettings(options, warnings); + case "windmodeltype" -> { + options.setWindModelType(WindModelType.fromString(content)); } - } else if (element.equals("launchlatitude")) { - if (Double.isNaN(d)) { - warnings.add("Illegal launch latitude defined, ignoring."); - } else { - options.setLaunchLatitude(d); + + case "launchaltitude" -> { + if (Double.isNaN(d)) { + warnings.add("Illegal launch altitude defined, ignoring."); + } else { + options.setLaunchAltitude(d); + } } - } else if (element.equals("launchlongitude")) { - if (Double.isNaN(d)) { - warnings.add("Illegal launch longitude."); - } else { - options.setLaunchLongitude(d); + case "launchlatitude" -> { + if (Double.isNaN(d)) { + warnings.add("Illegal launch latitude defined, ignoring."); + } else { + options.setLaunchLatitude(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 "launchlongitude" -> { + if (Double.isNaN(d)) { + warnings.add("Illegal launch longitude."); + } else { + options.setLaunchLongitude(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 "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/models/wind/WindModelType.java b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java index 3097a8f91..c8004ae5b 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java +++ b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java @@ -1,6 +1,25 @@ package info.openrocket.core.models.wind; public enum WindModelType { - PINK_NOISE, - MULTI_LEVEL + PINK_NOISE("PinkNoise"), + 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); + } } From 1506696c1136873e57fd7e541d4a4773be4c360a Mon Sep 17 00:00:00 2001 From: SiboVG Date: Fri, 13 Sep 2024 06:54:46 +0100 Subject: [PATCH 03/17] Document .ork file change --- fileformat.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fileformat.txt b/fileformat.txt index 03b3e25f6..dd973cf4a 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. From ff638366a20d915961ff76ce64c00152c0fde016 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 14 Sep 2024 02:43:38 +0100 Subject: [PATCH 04/17] Clamp min wind speed to 0.1 --- .../info/openrocket/core/models/wind/MultiLevelWindModel.java | 2 +- .../info/openrocket/core/models/wind/PinkNoiseWindModel.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java index ecc2202c7..fe99f77dd 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java @@ -146,7 +146,7 @@ public class MultiLevelWindModel implements WindModel { public WindLevel(double altitude, double speed, double direction) { this.altitude = altitude; - this.speed = speed; + this.speed = Math.max(speed, 0.1); this.direction = direction; } 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 3349320fa..7f0001fe7 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 @@ -78,11 +78,12 @@ public class PinkNoiseWindModel implements WindModel { * @param average the average wind speed to set */ public void setAverage(double average) { + average = Math.max(average, 0.1); if (average == this.average) { return; } double intensity = getTurbulenceIntensity(); - this.average = Math.max(average, 0); + this.average = average; setTurbulenceIntensity(intensity); fireChangeEvent(); } From c3614330b954e7c4e31e07e23450ad32e3cb9dd5 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 14 Sep 2024 02:48:16 +0100 Subject: [PATCH 05/17] Actually, scrap that, allow 0-speed wind --- .../info/openrocket/core/models/wind/MultiLevelWindModel.java | 2 +- .../info/openrocket/core/models/wind/PinkNoiseWindModel.java | 2 +- core/src/main/java/info/openrocket/core/util/Quaternion.java | 2 +- .../java/info/openrocket/core/simulation/FlightEventsTest.java | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java index fe99f77dd..4523574f7 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java @@ -146,7 +146,7 @@ public class MultiLevelWindModel implements WindModel { public WindLevel(double altitude, double speed, double direction) { this.altitude = altitude; - this.speed = Math.max(speed, 0.1); + this.speed = Math.max(speed, 0); this.direction = direction; } 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 7f0001fe7..6d8ecffcf 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 @@ -78,7 +78,7 @@ public class PinkNoiseWindModel implements WindModel { * @param average the average wind speed to set */ public void setAverage(double average) { - average = Math.max(average, 0.1); + average = Math.max(average, 0); if (average == this.average) { return; } 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/test/java/info/openrocket/core/simulation/FlightEventsTest.java b/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java index b04f221f7..45af0f29f 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 ().getPinkNoiseWindModel().setAverage(0.1); rocket.getSelectedConfiguration().setAllStages(); FlightConfigurationId fcid = rocket.getSelectedConfiguration().getFlightConfigurationID(); sim.setFlightConfigurationId(fcid); From 19f8e3c8f3f613508e7d109eee6f44327df03746 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 04:39:47 +0100 Subject: [PATCH 06/17] Add coordinate interpolation method + improve unit tests --- .../info/openrocket/core/util/Coordinate.java | 14 ++ .../openrocket/core/util/CoordinateTest.java | 148 ++++++++++++++---- 2 files changed, 133 insertions(+), 29 deletions(-) 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/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); + } } From ea210bac8d4882561a9f89341a1c7e55bd504d16 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 05:12:33 +0100 Subject: [PATCH 07/17] Also use pink noise for multi-level wind --- .../core/file/openrocket/OpenRocketSaver.java | 22 +- .../importt/SimulationConditionsHandler.java | 6 +- .../file/openrocket/importt/WindHandler.java | 31 ++- .../file/rasaero/export/LaunchSiteDTO.java | 4 +- .../rasaero/importt/LaunchSiteHandler.java | 2 +- .../wind/MultiLevelPinkNoiseWindModel.java | 244 ++++++++++++++++++ .../core/models/wind/MultiLevelWindModel.java | 193 -------------- .../core/models/wind/PinkNoiseWindModel.java | 5 +- .../core/models/wind/WindModelType.java | 2 +- .../preferences/ApplicationPreferences.java | 26 +- .../DefaultSimulationOptionFactory.java | 12 +- .../core/simulation/SimulationOptions.java | 48 ++-- .../SimulationOptionsInterface.java | 2 +- .../main/resources/l10n/messages.properties | 7 +- .../models/wind/MultiLevelWindModelTest.java | 126 ++++++--- .../core/simulation/FlightEventsTest.java | 2 +- .../simulation/SimulationConditionsTest.java | 36 +-- fileformat.txt | 2 +- .../simulation/SimulationConditionsPanel.java | 162 +++++++----- .../WindLevelVisualizationDialog.java | 30 +-- 20 files changed, 556 insertions(+), 406 deletions(-) create mode 100644 core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java delete mode 100644 core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java 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 18f59a833..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,7 +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.MultiLevelWindModel; +import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel; import info.openrocket.core.preferences.DocumentPreferences; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -339,23 +339,25 @@ public class OpenRocketSaver extends RocketSaver { writeElement("launchroddirection", cond.getLaunchRodDirection() * 360.0 / (2.0 * Math.PI)); // TODO: remove once support for OR 23.09 and prior is dropped - writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage()); - writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); - writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + writeElement("windaverage", cond.getAverageWindModel().getAverage()); + writeElement("windturbulence", cond.getAverageWindModel().getTurbulenceIntensity()); + writeElement("winddirection", cond.getAverageWindModel().getDirection()); - writeln(""); + writeln(""); indent++; - writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage()); - writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity()); - writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection()); + 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 (MultiLevelWindModel.WindLevel level : cond.getMultiLevelWindModel().getLevels()) { - writeln(""); + for (MultiLevelPinkNoiseWindModel.LevelWindModel level : cond.getMultiLevelWindModel().getLevels()) { + writeln(""); } indent--; writeln(""); 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 136faa15a..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 @@ -83,21 +83,21 @@ class SimulationConditionsHandler extends AbstractElementHandler { if (Double.isNaN(d)) { warnings.add("Illegal average windspeed defined, ignoring."); } else { - options.getPinkNoiseWindModel().setAverage(d); + options.getAverageWindModel().setAverage(d); } } case "windturbulence" -> { if (Double.isNaN(d)) { warnings.add("Illegal wind turbulence intensity defined, ignoring."); } else { - options.getPinkNoiseWindModel().setTurbulenceIntensity(d); + options.getAverageWindModel().setTurbulenceIntensity(d); } } case "winddirection" -> { if (Double.isNaN(d)) { warnings.add("Illegal wind direction defined, ignoring."); } else { - options.getPinkNoiseWindModel().setDirection(d); + options.getAverageWindModel().setDirection(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 index 9c36b48ec..2afa162ae 100644 --- 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 @@ -33,18 +33,22 @@ public class WindHandler extends AbstractElementHandler { } catch (NumberFormatException ignore) { } - if ("pinknoise".equals(model)) { - if (element.equals("windaverage")) { - if (!Double.isNaN(d)) { - options.getPinkNoiseWindModel().setAverage(d); + if ("average".equals(model)) { + switch (element) { + case "speed" -> { + if (!Double.isNaN(d)) { + options.getAverageWindModel().setAverage(d); + } } - } else if (element.equals("windturbulence")) { - if (!Double.isNaN(d)) { - options.getPinkNoiseWindModel().setTurbulenceIntensity(d); + case "direction" -> { + if (!Double.isNaN(d)) { + options.getAverageWindModel().setDirection(d); + } } - } else if (element.equals("winddirection")) { - if (!Double.isNaN(d)) { - options.getPinkNoiseWindModel().setDirection(d); + case "standarddeviation" -> { + if (!Double.isNaN(d)) { + options.getAverageWindModel().setStandardDeviation(d); + } } } } else if ("multilevel".equals(model)) { @@ -52,14 +56,15 @@ public class WindHandler extends AbstractElementHandler { double altitude = Double.parseDouble(attributes.get("altitude")); double speed = Double.parseDouble(attributes.get("speed")); double direction = Double.parseDouble(attributes.get("direction")); - options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction); + double standardDeviation = Double.parseDouble(attributes.get("standarddeviation")); + options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction, standardDeviation); } } } public void storeSettings(SimulationOptions options, WarningSet warnings) { - if ("pinknoise".equals(model)) { - options.setWindModelType(WindModelType.PINK_NOISE); + if ("average".equals(model)) { + options.setWindModelType(WindModelType.AVERAGE); } else if ("multilevel".equals(model)) { options.setWindModelType(WindModelType.MULTI_LEVEL); } else { 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 ef7995317..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.getPinkNoiseWindModel().getAverage() * 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.getPinkNoiseWindModel().getAverage() * 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 551c04366..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.getPinkNoiseWindModel().setAverage( + 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..646a664a6 --- /dev/null +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java @@ -0,0 +1,244 @@ +package info.openrocket.core.models.wind; + +import java.util.ArrayList; +import java.util.List; +import java.util.Collections; +import java.util.Comparator; +import info.openrocket.core.util.Coordinate; +import info.openrocket.core.util.ModID; + +public class MultiLevelPinkNoiseWindModel implements WindModel { + private List levels; + + 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); + 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); + } + + public void removeWindLevel(double altitude) { + levels.removeIf(level -> level.altitude == altitude); + } + + public void removeWindLevelIdx(int index) { + levels.remove(index); + } + + public void clearLevels() { + levels.clear(); + } + + 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); + } + + private static double getInterpolatedDirection(LevelWindModel lowerLevel, LevelWindModel upperLevel, double fractionBetweenLevels) { + double lowerDirection = lowerLevel.model.getDirection(); + double upperDirection = upperLevel.model.getDirection(); + double directionDifference = upperDirection - lowerDirection; + + // Ensure we take the shortest path around the circle + if (directionDifference > Math.PI) { + directionDifference -= 2 * Math.PI; + } else if (directionDifference < -Math.PI) { + directionDifference += 2 * Math.PI; + } + + double interpolatedDirection = lowerDirection + fractionBetweenLevels * directionDifference; + return interpolatedDirection; + } + + private double getPinkNoiseValue(double time, LevelWindModel lower, LevelWindModel upper, double fraction) { + double lowerNoise = lower.model.getWindVelocity(time, lower.altitude).length() - lower.model.getAverage(); + double upperNoise = upper.model.getWindVelocity(time, upper.altitude).length() - upper.model.getAverage(); + return lowerNoise + fraction * (upperNoise - lowerNoise); + } + + public double getWindDirection(double altitude) { + if (levels.isEmpty()) { + return 0; + } + + int index = Collections.binarySearch(levels, new LevelWindModel(altitude, null), + Comparator.comparingDouble(l -> l.altitude)); + + if (index >= 0) { + return levels.get(index).model.getDirection(); + } + + int insertionPoint = -index - 1; + if (insertionPoint == 0) { + return levels.get(0).model.getDirection(); + } + if (insertionPoint == levels.size()) { + return levels.get(levels.size() - 1).model.getDirection(); + } + + // Interpolation (take the value between the closest two bounds) + LevelWindModel lowerLevel = levels.get(insertionPoint - 1); + LevelWindModel upperLevel = levels.get(insertionPoint); + double fractionBetweenLevels = (altitude - lowerLevel.altitude) / (upperLevel.altitude - lowerLevel.altitude); + + // Interpolate direction + double interpolatedDirection = getInterpolatedDirection(lowerLevel, upperLevel, fractionBetweenLevels); + + // Normalize the result to be between 0 and 2*PI + return (interpolatedDirection + 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; + return levels.equals(that.levels); + } + + @Override + public int hashCode() { + return levels.hashCode(); + } + + public static class LevelWindModel implements Cloneable { + protected double altitude; + protected PinkNoiseWindModel model; + + LevelWindModel(double altitude, PinkNoiseWindModel model) { + this.altitude = altitude; + this.model = model; + } + + public double getAltitude() { + return altitude; + } + + public void setAltitude(double altitude) { + this.altitude = altitude; + } + + 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 obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + LevelWindModel that = (LevelWindModel) obj; + return Double.compare(that.altitude, altitude) == 0 && model.equals(that.model); + } + } +} \ No newline at end of file diff --git a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java deleted file mode 100644 index 4523574f7..000000000 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelWindModel.java +++ /dev/null @@ -1,193 +0,0 @@ -package info.openrocket.core.models.wind; - -import java.util.ArrayList; -import java.util.List; -import java.util.Collections; -import java.util.Comparator; -import info.openrocket.core.util.Coordinate; -import info.openrocket.core.util.MathUtil; -import info.openrocket.core.util.ModID; - -public class MultiLevelWindModel implements WindModel { - - private List levels; - - public MultiLevelWindModel() { - this.levels = new ArrayList<>(); - } - - public void addWindLevel(double altitude, double speed, double direction) { - WindLevel newLevel = new WindLevel(altitude, speed, direction); - 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); - } - - public void removeWindLevel(double altitude) { - levels.removeIf(level -> level.altitude == altitude); - } - - public void removeWindLevelIdx(int index) { - levels.remove(index); - } - - public List getLevels() { - return new ArrayList<>(levels); - } - - public void resortLevels() { - 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 WindLevel(altitude, 0, 0), - Comparator.comparingDouble(l -> l.altitude)); - - // Retrieve the wind level if it exists - if (index >= 0) { - return levels.get(index).toCoordinate(); - } - - // Extrapolation (take the value of the outer bounds) - int insertionPoint = -index - 1; - if (insertionPoint == 0) { - return levels.get(0).toCoordinate(); - } - if (insertionPoint == levels.size()) { - return levels.get(levels.size() - 1).toCoordinate(); - } - - // Interpolation (take the value between the closest two bounds) - WindLevel lower = levels.get(insertionPoint - 1); - WindLevel upper = levels.get(insertionPoint); - - double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); - double speed = MathUtil.interpolate(lower.speed, upper.speed, fraction); - double direction = MathUtil.interpolate(lower.direction, upper.direction, fraction); - - return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); - } - - public double getWindDirection(double altitude) { - if (levels.isEmpty()) { - return 0; - } - - int index = Collections.binarySearch(levels, new WindLevel(altitude, 0, 0), - Comparator.comparingDouble(l -> l.altitude)); - - if (index >= 0) { - return levels.get(index).direction; - } - - int insertionPoint = -index - 1; - if (insertionPoint == 0) { - return levels.get(0).direction; - } - if (insertionPoint == levels.size()) { - return levels.get(levels.size() - 1).direction; - } - - WindLevel lower = levels.get(insertionPoint - 1); - WindLevel upper = levels.get(insertionPoint); - - double fraction = (altitude - lower.altitude) / (upper.altitude - lower.altitude); - return MathUtil.interpolate(lower.direction, upper.direction, fraction); - } - - @Override - public ModID getModID() { - return ModID.ZERO; // You might want to create a specific ModID for this model - } - - public void loadFrom(MultiLevelWindModel source) { - this.levels.clear(); - for (WindLevel level : source.levels) { - this.levels.add(level.clone()); - } - } - - @Override - public MultiLevelWindModel clone() { - try { - MultiLevelWindModel clone = (MultiLevelWindModel) 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; - MultiLevelWindModel that = (MultiLevelWindModel) o; - return levels.equals(that.levels); - } - - @Override - public int hashCode() { - return levels.hashCode(); - } - - public static class WindLevel implements Cloneable { - public double altitude; - public double speed; - public double direction; - - public WindLevel(double altitude, double speed, double direction) { - this.altitude = altitude; - this.speed = Math.max(speed, 0); - this.direction = direction; - } - - Coordinate toCoordinate() { - return new Coordinate(speed * Math.sin(direction), speed * Math.cos(direction), 0); - } - - public void loadFrom(WindLevel source) { - this.altitude = source.altitude; - this.speed = source.speed; - this.direction = source.direction; - } - - @Override - public WindLevel clone() { - try { - WindLevel clone = (WindLevel) 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; - WindLevel windLevel = (WindLevel) o; - return Double.compare(windLevel.altitude, altitude) == 0 && - Double.compare(windLevel.speed, speed) == 0 && - Double.compare(windLevel.direction, direction) == 0; - } - - @Override - public int hashCode() { - int result = 17; - result = 31 * result + Double.hashCode(altitude); - result = 31 * result + Double.hashCode(speed); - result = 31 * result + Double.hashCode(direction); - return result; - } - } -} \ 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 6d8ecffcf..a2415cb07 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,14 +1,11 @@ package info.openrocket.core.models.wind; -import java.util.ArrayList; -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 @@ -37,7 +34,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 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 index c8004ae5b..0e156b403 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java +++ b/core/src/main/java/info/openrocket/core/models/wind/WindModelType.java @@ -1,7 +1,7 @@ package info.openrocket.core.models.wind; public enum WindModelType { - PINK_NOISE("PinkNoise"), + AVERAGE("Average"), MULTI_LEVEL("MultiLevel"); private final String 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 cb5da8159..d8b7a62cf 100644 --- a/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java +++ b/core/src/main/java/info/openrocket/core/preferences/ApplicationPreferences.java @@ -155,7 +155,7 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen private static final AtmosphericModel ISA_ATMOSPHERIC_MODEL = new ExtendedISAModel(); - private PinkNoiseWindModel pinkNoiseWindModel = null; + private PinkNoiseWindModel averageWindModel = null; /* @@ -398,25 +398,25 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen double turbulenceIntensity = getDouble(WIND_TURBULENCE, 0.1); double direction = getDouble(WIND_DIRECTION, Math.PI / 2); - getPinkNoiseWindModel().setAverage(average); - getPinkNoiseWindModel().setTurbulenceIntensity(turbulenceIntensity); - getPinkNoiseWindModel().setDirection(direction); + getAverageWindModel().setAverage(average); + getAverageWindModel().setTurbulenceIntensity(turbulenceIntensity); + getAverageWindModel().setDirection(direction); } protected void storeWindModelState() { - putDouble(WIND_AVERAGE, getPinkNoiseWindModel().getAverage()); - putDouble(WIND_TURBULENCE, getPinkNoiseWindModel().getTurbulenceIntensity()); - putDouble(WIND_DIRECTION, getPinkNoiseWindModel().getDirection()); + putDouble(WIND_AVERAGE, getAverageWindModel().getAverage()); + putDouble(WIND_TURBULENCE, getAverageWindModel().getTurbulenceIntensity()); + putDouble(WIND_DIRECTION, getAverageWindModel().getDirection()); } @Override - public PinkNoiseWindModel getPinkNoiseWindModel() { - if (pinkNoiseWindModel == null) { - pinkNoiseWindModel = new PinkNoiseWindModel(); - pinkNoiseWindModel.addChangeListener(this); + public PinkNoiseWindModel getAverageWindModel() { + if (averageWindModel == null) { + averageWindModel = new PinkNoiseWindModel(); + averageWindModel.addChangeListener(this); loadWindModelState(); } - return pinkNoiseWindModel; + return averageWindModel; } public double getLaunchAltitude() { @@ -1271,7 +1271,7 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen @Override public void stateChanged(EventObject e) { - if (e.getSource() == pinkNoiseWindModel) { + 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 2fc981ba1..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,9 +35,9 @@ public class DefaultSimulationOptionFactory { SimulationOptions defaults = new SimulationOptions(); if (prefs != null) { - defaults.getPinkNoiseWindModel().setAverage(prefs.getPinkNoiseWindModel().getAverage()); - defaults.getPinkNoiseWindModel().setStandardDeviation(prefs.getPinkNoiseWindModel().getStandardDeviation()); - defaults.getPinkNoiseWindModel().setTurbulenceIntensity(prefs.getPinkNoiseWindModel().getTurbulenceIntensity()); + 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())); @@ -58,9 +58,9 @@ public class DefaultSimulationOptionFactory { public void saveDefault(SimulationOptions newDefaults) { - prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getPinkNoiseWindModel().getAverage()); - prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getPinkNoiseWindModel().getStandardDeviation()); - prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getPinkNoiseWindModel().getTurbulenceIntensity()); + 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 7aa443cfc..730bec966 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -6,7 +6,7 @@ import java.util.EventObject; import java.util.List; import java.util.Random; -import info.openrocket.core.models.wind.MultiLevelWindModel; +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; @@ -80,13 +80,13 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt private List listeners = new ArrayList<>(); - private WindModelType windModelType = WindModelType.PINK_NOISE; - private final PinkNoiseWindModel pinkNoiseWindModel; - private final MultiLevelWindModel multiLevelWindModel; + private WindModelType windModelType = WindModelType.AVERAGE; + private final PinkNoiseWindModel averageWindModel; + private final MultiLevelPinkNoiseWindModel multiLevelPinkNoiseWindModel; public SimulationOptions() { - pinkNoiseWindModel = new PinkNoiseWindModel(randomSeed); - multiLevelWindModel = new MultiLevelWindModel(); + averageWindModel = new PinkNoiseWindModel(randomSeed); + multiLevelPinkNoiseWindModel = new MultiLevelPinkNoiseWindModel(); } public double getLaunchRodLength() { @@ -126,10 +126,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt public double getLaunchRodDirection() { if (launchIntoWind) { double windDirection; - if (windModelType == WindModelType.PINK_NOISE) { - windDirection = pinkNoiseWindModel.getDirection(); + if (windModelType == WindModelType.AVERAGE) { + windDirection = averageWindModel.getDirection(); } else { - windDirection = multiLevelWindModel.getWindDirection(launchAltitude); + windDirection = multiLevelPinkNoiseWindModel.getWindDirection(launchAltitude); } this.setLaunchRodDirection(windDirection); } @@ -156,21 +156,21 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt } public WindModel getWindModel() { - if (windModelType == WindModelType.PINK_NOISE) { - return pinkNoiseWindModel; + if (windModelType == WindModelType.AVERAGE) { + return averageWindModel; } else if (windModelType == WindModelType.MULTI_LEVEL) { - return multiLevelWindModel; + return multiLevelPinkNoiseWindModel; } else { throw new IllegalArgumentException("Unknown wind model type: " + windModelType); } } - public PinkNoiseWindModel getPinkNoiseWindModel() { - return pinkNoiseWindModel; + public PinkNoiseWindModel getAverageWindModel() { + return averageWindModel; } - public MultiLevelWindModel getMultiLevelWindModel() { - return multiLevelWindModel; + public MultiLevelPinkNoiseWindModel getMultiLevelWindModel() { + return multiLevelPinkNoiseWindModel; } public double getLaunchAltitude() { @@ -349,13 +349,13 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt // changed. boolean isChanged = false; - if (!this.pinkNoiseWindModel.equals(src.pinkNoiseWindModel)) { + if (!this.averageWindModel.equals(src.averageWindModel)) { isChanged = true; - this.pinkNoiseWindModel.loadFrom(src.pinkNoiseWindModel); + this.averageWindModel.loadFrom(src.averageWindModel); } - if (!this.multiLevelWindModel.equals(src.multiLevelWindModel)) { + if (!this.multiLevelPinkNoiseWindModel.equals(src.multiLevelPinkNoiseWindModel)) { isChanged = true; - this.multiLevelWindModel.loadFrom(src.multiLevelWindModel); + this.multiLevelPinkNoiseWindModel.loadFrom(src.multiLevelPinkNoiseWindModel); } if (this.launchAltitude != src.launchAltitude) { @@ -441,8 +441,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt MathUtil.equals(this.maximumAngle, o.maximumAngle) && MathUtil.equals(this.timeStep, o.timeStep)) && this.windModelType == o.windModelType && - this.pinkNoiseWindModel.equals(o.pinkNoiseWindModel) && - this.multiLevelWindModel.equals(o.multiLevelWindModel); + this.averageWindModel.equals(o.averageWindModel) && + this.multiLevelPinkNoiseWindModel.equals(o.multiLevelPinkNoiseWindModel); } /** @@ -514,8 +514,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt .concat(String.format(" launchRodAngle: %f\n", launchRodAngle)) .concat(String.format(" launchRodDirection: %f\n", launchRodDirection)) .concat(String.format(" windModelType: %s\n", windModelType)) - .concat(String.format(" pinkNoiseWindModel: %s\n", pinkNoiseWindModel)) - .concat(String.format(" multiLevelWindModel: %s\n", multiLevelWindModel)) + .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 9843098b5..464ac8f85 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptionsInterface.java @@ -21,7 +21,7 @@ public interface SimulationOptionsInterface extends ChangeSource { void setLaunchRodDirection(double launchRodDirection); - PinkNoiseWindModel getPinkNoiseWindModel(); + PinkNoiseWindModel getAverageWindModel(); double getLaunchAltitude(); diff --git a/core/src/main/resources/l10n/messages.properties b/core/src/main/resources/l10n/messages.properties index 0ae8101a1..867e8c821 100644 --- a/core/src/main/resources/l10n/messages.properties +++ b/core/src/main/resources/l10n/messages.properties @@ -506,6 +506,9 @@ simedtdlg.lbl.ttip.Latitude = The launch site latitude affects the gravita 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.lbl.Longitude = Longitude: @@ -557,8 +560,8 @@ simedtdlg.IntensityDesc.High = High simedtdlg.IntensityDesc.Veryhigh = Very high simedtdlg.IntensityDesc.Extreme = Extreme simedtdlg.lbl.WindModelSelection = Wind model to use: -simedtdlg.radio.PinkNoiseWind = Pink noise -simedtdlg.radio.PinkNoiseWind.ttip = Model the wind as pink noise from the average wind speed and standard deviation. +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 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 index af4e95b76..0b462cd53 100644 --- a/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java +++ b/core/src/test/java/info/openrocket/core/models/wind/MultiLevelWindModelTest.java @@ -9,25 +9,29 @@ 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 MultiLevelWindModel model; + private MultiLevelPinkNoiseWindModel model; @BeforeEach void setUp() { - model = new MultiLevelWindModel(); + model = new MultiLevelPinkNoiseWindModel(); } @Test @DisplayName("Add and remove wind levels") void testAddAndRemoveWindLevels() { - model.addWindLevel(100, 5, Math.PI / 4); - model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, Math.PI / 4, 1); + model.addWindLevel(200, 10, Math.PI / 2, 1); assertEquals(2, model.getLevels().size()); model.removeWindLevel(100); @@ -45,51 +49,73 @@ class MultiLevelWindModelTest { @Test @DisplayName("Adding duplicate altitude throws IllegalArgumentException") void testAddDuplicateAltitude() { - model.addWindLevel(100, 5, Math.PI / 4); - assertThrows(IllegalArgumentException.class, () -> model.addWindLevel(100, 10, Math.PI / 2)); + 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); - model.addWindLevel(1000, 10, Math.PI / 2); + model.addWindLevel(0, 5, 0, 1); + model.addWindLevel(1000, 10, Math.PI / 2, 2); - verifyWind(500, 7.5, Math.PI / 4); + verifyWind(0, 5, 0, 1); + verifyWind(1000, 10, Math.PI / 2, 2); } @Test @DisplayName("Interpolation between levels") void testInterpolationBetweenLevels() { - model.addWindLevel(0, 5, 0); - model.addWindLevel(1000, 10, Math.PI); + // Test speed interpolation + model.addWindLevel(0, 5, 0, 0.1); + model.addWindLevel(1000, 10, 0, 0.3); - verifyWind(200, 6, Math.PI / 5); - verifyWind(500, 7.5, Math.PI / 2); - verifyWind(900, 9.5, 9 * Math.PI / 10); + 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); - model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, 0, 1.4); + model.addWindLevel(200, 10, Math.PI / 2, 2.2); - verifyWind(0, 5, 0); - verifyWind(300, 10, Math.PI / 2); - verifyWind(1000, 10, Math.PI / 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 testResortLevels() { - model.addWindLevel(200, 10, Math.PI / 2); - model.addWindLevel(100, 5, Math.PI / 4); - model.addWindLevel(300, 15, 3 * Math.PI / 4); + 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.resortLevels(); + model.sortLevels(); - List levels = model.getLevels(); + List levels = model.getLevels(); assertEquals(3, levels.size()); assertEquals(100, levels.get(0).altitude, EPSILON); assertEquals(200, levels.get(1).altitude, EPSILON); @@ -99,24 +125,24 @@ class MultiLevelWindModelTest { @Test @DisplayName("Clone model") void testClone() { - model.addWindLevel(100, 5, Math.PI / 4); - model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, Math.PI / 4, 1); + model.addWindLevel(200, 10, Math.PI / 2, 2); - MultiLevelWindModel clonedModel = model.clone(); + MultiLevelPinkNoiseWindModel clonedModel = model.clone(); assertNotSame(model, clonedModel); assertEquals(model, clonedModel); - clonedModel.addWindLevel(300, 15, 3 * Math.PI / 4); + 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); - model.addWindLevel(200, 10, Math.PI / 2); + model.addWindLevel(100, 5, Math.PI / 4, 2); + model.addWindLevel(200, 10, Math.PI / 2, 1); - MultiLevelWindModel newModel = new MultiLevelWindModel(); + MultiLevelPinkNoiseWindModel newModel = new MultiLevelPinkNoiseWindModel(); newModel.loadFrom(model); assertEquals(model, newModel); @@ -144,12 +170,34 @@ class MultiLevelWindModelTest { assertFalse(listenerCalled[0]); } - private void verifyWind(double altitude, double expectedSpeed, double expectedDirection) { - Coordinate velocity = model.getWindVelocity(0, altitude); - assertEquals(expectedSpeed, velocity.length(), EPSILON, "Wind speed at altitude " + altitude); - assertEquals(expectedSpeed * Math.sin(expectedDirection), velocity.x, EPSILON, "Wind velocity X component at altitude " + altitude); - assertEquals(expectedSpeed * Math.cos(expectedDirection), velocity.y, EPSILON, "Wind velocity Y component at altitude " + altitude); - assertEquals(0, velocity.z, EPSILON, "Wind velocity Z component at altitude " + altitude); - assertEquals(expectedDirection, Math.atan2(velocity.x, velocity.y), EPSILON, "Wind direction at altitude " + altitude); + 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/FlightEventsTest.java b/core/src/test/java/info/openrocket/core/simulation/FlightEventsTest.java index 45af0f29f..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,7 +69,7 @@ public class FlightEventsTest extends BaseTestCase { final Simulation sim = new Simulation(rocket); sim.getOptions().setISAAtmosphere(true); sim.getOptions().setTimeStep(0.05); - sim.getOptions ().getPinkNoiseWindModel().setAverage(0.1); + 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 932dd38e3..720eb6e53 100644 --- a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java +++ b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java @@ -10,7 +10,7 @@ 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.MultiLevelWindModel; +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; @@ -65,17 +65,17 @@ public class SimulationConditionsTest { assertEquals(Math.PI / 2, options.getLaunchRodDirection(), EPSILON); assertEquals(0.0, options.getLaunchRodAngle(), EPSILON); assertTrue(options.getLaunchIntoWind()); - assertEquals(Math.PI / 2, options.getPinkNoiseWindModel().getDirection(), EPSILON); - assertEquals(0.1, options.getPinkNoiseWindModel().getTurbulenceIntensity(), EPSILON); - assertEquals(2.0, options.getPinkNoiseWindModel().getAverage(), EPSILON); - assertEquals(0.2, options.getPinkNoiseWindModel().getStandardDeviation(), 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 MultiLevelWindModel in SimulationConditions") + @DisplayName("Compare PinkNoiseWindModel and MultiLevelPinkNoiseWindModel in SimulationConditions") public void testWindModelComparison() { SimulationConditions conditions = new SimulationConditions(); @@ -91,10 +91,10 @@ public class SimulationConditionsTest { assertNotNull(pinkNoiseVelocity); assertTrue(pinkNoiseVelocity.length() > 0); - // Test MultiLevelWindModel - MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); - multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); - multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + // 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); @@ -107,12 +107,12 @@ public class SimulationConditionsTest { } @Test - @DisplayName("Test wind velocity consistency for MultiLevelWindModel") + @DisplayName("Test wind velocity consistency for MultiLevelPinkNoiseWindModel") public void testMultiLevelWindModelConsistency() { SimulationConditions conditions = new SimulationConditions(); - MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); - multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4); - multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + 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); @@ -140,12 +140,12 @@ public class SimulationConditionsTest { } @Test - @DisplayName("Test altitude dependence of MultiLevelWindModel") + @DisplayName("Test altitude dependence of MultiLevelPinkNoiseWindModel") public void testMultiLevelWindModelAltitudeDependence() { SimulationConditions conditions = new SimulationConditions(); - MultiLevelWindModel multiLevelModel = new MultiLevelWindModel(); - multiLevelModel.addWindLevel(0, 5.0, 0); - multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2); + MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel(); + multiLevelModel.addWindLevel(0, 5.0, 0, 3); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 4.2); conditions.setWindModel(multiLevelModel); diff --git a/fileformat.txt b/fileformat.txt index dd973cf4a..144751d0b 100644 --- a/fileformat.txt +++ b/fileformat.txt @@ -70,4 +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. + Added wind model settings (), and windmodeltype to simulation conditions. 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 bfdbc6351..ab6bdb59a 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 @@ -35,6 +35,8 @@ import javax.swing.SortOrder; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; +import javax.swing.event.RowSorterEvent; +import javax.swing.event.RowSorterListener; import javax.swing.table.AbstractTableModel; import javax.swing.table.DefaultTableCellRenderer; import javax.swing.table.TableCellRenderer; @@ -45,7 +47,7 @@ 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.MultiLevelWindModel; +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; @@ -84,7 +86,7 @@ public class SimulationConditionsPanel extends JPanel { * 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 pink noise wind model will be added. + * @param addAllWindModels if false, only the average wind model will be added. */ public static void addSimulationConditionsPanel(JPanel parent, SimulationOptionsInterface target, boolean addAllWindModels) { @@ -98,7 +100,7 @@ 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]", "")); + 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"); @@ -107,12 +109,12 @@ public class SimulationConditionsPanel extends JPanel { if (addAllWindModels) { addWindModelPanel(sub, target); } else { - addPinkNoiseSettings(sub, target); + 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"); @@ -139,7 +141,7 @@ public class SimulationConditionsPanel extends JPanel { 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); @@ -374,38 +376,43 @@ public class SimulationConditionsPanel extends JPanel { 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"); - JRadioButton pinkNoiseButton = new JRadioButton(trans.get("simedtdlg.radio.PinkNoiseWind")); - pinkNoiseButton.setToolTipText(trans.get("simedtdlg.radio.PinkNoiseWind.ttip")); + //// 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(pinkNoiseButton); + windModelGroup.add(averageButton); windModelGroup.add(multiLevelButton); - panel.add(pinkNoiseButton); + panel.add(averageButton); panel.add(multiLevelButton, "wrap"); panel.add(new JSeparator(JSeparator.HORIZONTAL), "spanx, growx, wrap"); JPanel windSettingsPanel = new JPanel(new CardLayout()); - JPanel pinkNoisePanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow][75lp!][30lp!][75lp!]", "")); - JPanel multiLevelPanel = new JPanel(new MigLayout("fill, ins 0, gap rel unrel", "[grow]", "")); + JPanel averagePanel = new JPanel(new MigLayout("fill, ins 0", "[grow][75lp!][30lp!][75lp!]", "")); + JPanel multiLevelPanel = new JPanel(new MigLayout("fill, ins 0")); - addPinkNoiseSettings(pinkNoisePanel, target); + addAverageWindSettings(averagePanel, target); addMultiLevelSettings(multiLevelPanel, target); - windSettingsPanel.add(pinkNoisePanel, "PinkNoise"); + windSettingsPanel.add(averagePanel, "Average"); windSettingsPanel.add(multiLevelPanel, "MultiLevel"); panel.add(windSettingsPanel, "grow, wrap"); - pinkNoiseButton.addActionListener(e -> { - ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + averageButton.addActionListener(e -> { + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "Average"); if (target instanceof SimulationOptions) { - ((SimulationOptions) target).setWindModelType(WindModelType.PINK_NOISE); + ((SimulationOptions) target).setWindModelType(WindModelType.AVERAGE); } }); @@ -419,9 +426,9 @@ public class SimulationConditionsPanel extends JPanel { // Set initial selection based on current wind model if (target instanceof SimulationOptions) { SimulationOptions options = (SimulationOptions) target; - if (options.getWindModelType() == WindModelType.PINK_NOISE) { - pinkNoiseButton.setSelected(true); - ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "PinkNoise"); + if (options.getWindModelType() == WindModelType.AVERAGE) { + averageButton.setSelected(true); + ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "Average"); } else { multiLevelButton.setSelected(true); ((CardLayout) windSettingsPanel.getLayout()).show(windSettingsPanel, "MultiLevel"); @@ -429,8 +436,8 @@ public class SimulationConditionsPanel extends JPanel { } } - private static void addPinkNoiseSettings(JPanel panel, SimulationOptionsInterface target) { - PinkNoiseWindModel model = target.getPinkNoiseWindModel(); + 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", @@ -458,14 +465,14 @@ public class SimulationConditionsPanel extends JPanel { "TurbulenceIntensity", UnitGroup.UNITS_RELATIVE, 0, 1.0, true); final JLabel intensityLabel = new JLabel( - getIntensityDescription(target.getPinkNoiseWindModel().getTurbulenceIntensity())); + 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.getPinkNoiseWindModel().getTurbulenceIntensity())); + getIntensityDescription(target.getAverageWindModel().getTurbulenceIntensity())); windSpeedDeviation.stateChanged(e); } }); @@ -485,7 +492,7 @@ public class SimulationConditionsPanel extends JPanel { if (!(target instanceof SimulationOptions options)) { return; } - MultiLevelWindModel model = options.getMultiLevelWindModel(); + MultiLevelPinkNoiseWindModel model = options.getMultiLevelWindModel(); // Create the levels table WindLevelTableModel tableModel = new WindLevelTableModel(model); @@ -493,6 +500,7 @@ public class SimulationConditionsPanel extends JPanel { windLevelTable.setRowSelectionAllowed(false); 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(); @@ -517,36 +525,46 @@ public class SimulationConditionsPanel extends JPanel { TableRowSorter sorter = new TableRowSorter<>(tableModel); windLevelTable.setRowSorter(sorter); sorter.setSortable(0, true); - sorter.setSortable(1, false); - sorter.setSortable(2, false); + 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.setPreferredSize(new Dimension(350, 150)); - panel.add(scrollPane, "grow"); + 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("fill, ins 0, gap rel unrel", "[grow]", "")); + JPanel buttonPanel = new JPanel(new MigLayout("ins 0")); - // Add wind level + // Add level JButton addButton = new JButton(trans.get("simedtdlg.but.addWindLevel")); addButton.addActionListener(e -> { tableModel.addWindLevel(); sorter.sort(); }); - buttonPanel.add(addButton, "growx, wrap"); + buttonPanel.add(addButton); - // Remove wind level + // Remove level JButton removeButton = new JButton(trans.get("simedtdlg.but.removeWindLevel")); removeButton.addActionListener(e -> { int selectedRow = windLevelTable.getSelectedRow(); tableModel.removeWindLevel(selectedRow); sorter.sort(); }); - buttonPanel.add(removeButton, "growx, wrap"); + buttonPanel.add(removeButton, "gapright unrel"); - // Visualization button + // Visualization levels JButton visualizeButton = new JButton(trans.get("simedtdlg.but.visualizeWindLevels")); visualizeButton.addActionListener(e -> { Window owner = SwingUtilities.getWindowAncestor(panel); @@ -561,7 +579,7 @@ public class SimulationConditionsPanel extends JPanel { visualizationDialog.setVisible(true); } }); - buttonPanel.add(visualizeButton, "growx, wrap"); + buttonPanel.add(visualizeButton); panel.add(buttonPanel, "grow, wrap"); @@ -618,6 +636,7 @@ public class SimulationConditionsPanel extends JPanel { 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); } } @@ -653,14 +672,14 @@ public class SimulationConditionsPanel extends JPanel { SimulationOptions defaults = f.getDefault(); options.copyConditionsFrom(defaults); }); - this.add(restoreDefaults, "span, split 3, skip, gapbottom para, gapright para, right"); + 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, "gapbottom para, gapright para, right"); + this.add(saveDefaults, "gapright para, right"); } private static String getIntensityDescription(double i) { @@ -733,7 +752,7 @@ public class SimulationConditionsPanel extends JPanel { } private static class WindLevelTableModel extends AbstractTableModel { - private final MultiLevelWindModel model; + private final MultiLevelPinkNoiseWindModel model; private static final String[] columnNames = { trans.get("simedtdlg.col.Altitude"), trans.get("simedtdlg.col.Unit"), @@ -741,17 +760,25 @@ public class SimulationConditionsPanel extends JPanel { 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"), }; private static final UnitGroup[] unitGroups = { - UnitGroup.UNITS_DISTANCE, UnitGroup.UNITS_VELOCITY, UnitGroup.UNITS_ANGLE}; + 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_ANGLE.getDefaultUnit(), + UnitGroup.UNITS_VELOCITY.getDefaultUnit(), + UnitGroup.UNITS_RELATIVE.getDefaultUnit(), }; private WindLevelVisualizationDialog visualizationDialog; - public WindLevelTableModel(MultiLevelWindModel model) { + public WindLevelTableModel(MultiLevelPinkNoiseWindModel model) { this.model = model; } @@ -780,21 +807,31 @@ public class SimulationConditionsPanel extends JPanel { @Override public Class getColumnClass(int columnIndex) { + // Intensity column + if (columnIndex == getColumnCount()-1) { + return String.class; + } return (columnIndex % 2 == 0) ? Double.class : Unit.class; } public Object getSIValueAt(int rowIndex, int columnIndex) { - MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + MultiLevelPinkNoiseWindModel.LevelWindModel level = model.getLevels().get(rowIndex); return switch (columnIndex) { - case 0 -> level.altitude; - case 2 -> level.speed; - case 4 -> level.direction; + 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()-1) { + return getIntensityDescription(model.getLevels().get(rowIndex).getTurblenceIntensity()); + } if (columnIndex % 2 == 0) { Object rawValue = getSIValueAt(rowIndex, columnIndex); if (rawValue == null) { @@ -812,26 +849,32 @@ public class SimulationConditionsPanel extends JPanel { @Override public void setValueAt(Object aValue, int rowIndex, int columnIndex) { - MultiLevelWindModel.WindLevel level = model.getLevels().get(rowIndex); + MultiLevelPinkNoiseWindModel.LevelWindModel level = model.getLevels().get(rowIndex); if (columnIndex % 2 == 0) { // Value column double value = Double.parseDouble((String) aValue); switch (columnIndex) { case 0: - level.altitude = currentUnits[0].fromUnit(value); + level.setAltitude(currentUnits[0].fromUnit(value)); break; case 2: // Handle negative speed if (value < 0) { - level.speed = currentUnits[1].fromUnit(Math.abs(value)); + level.setSpeed(currentUnits[1].fromUnit(Math.abs(value))); // Adjust direction by 180 degrees - level.direction = (level.direction + Math.PI) % (2 * Math.PI); + level.setDirection((level.getDirection() + Math.PI) % (2 * Math.PI)); } else { - level.speed = currentUnits[1].fromUnit(value); + level.setSpeed(currentUnits[1].fromUnit(value)); } break; case 4: - level.direction = currentUnits[2].fromUnit(value); + 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 { @@ -847,16 +890,17 @@ public class SimulationConditionsPanel extends JPanel { @Override public boolean isCellEditable(int rowIndex, int columnIndex) { - return true; + return columnIndex != columnNames.length - 1; // Intensity column is not editable } public void addWindLevel() { - List levels = model.getLevels(); - double newAltitude = levels.isEmpty() ? 0 : levels.get(levels.size() - 1).altitude + 100; - double newSpeed = levels.isEmpty() ? 5 : levels.get(levels.size() - 1).speed; - double newDirection = levels.isEmpty() ? Math.PI / 2 : levels.get(levels.size() - 1).direction; + 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); + model.addWindLevel(newAltitude, newSpeed, newDirection, newDeviation); fireTableDataChanged(); } 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 index ca5f6c1a9..055f73e8f 100644 --- a/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java +++ b/swing/src/main/java/info/openrocket/swing/gui/simulation/WindLevelVisualizationDialog.java @@ -1,7 +1,7 @@ package info.openrocket.swing.gui.simulation; import info.openrocket.core.l10n.Translator; -import info.openrocket.core.models.wind.MultiLevelWindModel; +import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel; import info.openrocket.core.startup.Application; import info.openrocket.core.unit.Unit; @@ -30,7 +30,7 @@ public class WindLevelVisualizationDialog extends JDialog { private final WindLevelVisualization visualization; private final JCheckBox showDirectionsCheckBox; - public WindLevelVisualizationDialog(Dialog owner, MultiLevelWindModel model, Unit altitudeUnit, Unit speedUnit) { + public WindLevelVisualizationDialog(Dialog owner, MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) { super(owner, trans.get("WindLevelVisualizationDialog.title.WindLevelVisualization"), false); visualization = new WindLevelVisualization(model, altitudeUnit, speedUnit); @@ -74,7 +74,7 @@ public class WindLevelVisualizationDialog extends JDialog { } private static class WindLevelVisualization extends JPanel { - private final MultiLevelWindModel model; + private final MultiLevelPinkNoiseWindModel model; private static final int MARGIN = 50; private static final int ARROW_SIZE = 10; private static final int TICK_LENGTH = 5; @@ -83,7 +83,7 @@ public class WindLevelVisualizationDialog extends JDialog { private Unit speedUnit; private boolean showDirections = true; - public WindLevelVisualization(MultiLevelWindModel model, Unit altitudeUnit, Unit speedUnit) { + public WindLevelVisualization(MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) { this.model = model; this.altitudeUnit = altitudeUnit; this.speedUnit = speedUnit; @@ -113,14 +113,14 @@ public class WindLevelVisualizationDialog extends JDialog { g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, width, height); - List levels = model.getLevels(); + List levels = model.getLevels(); if (levels.isEmpty()) return; // Sort levels before drawing - levels.sort(Comparator.comparingDouble(level -> level.altitude)); + levels.sort(Comparator.comparingDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getAltitude)); - double maxAltitude = levels.stream().mapToDouble(l -> l.altitude).max().orElse(1000); - double maxSpeed = levels.stream().mapToDouble(l -> l.speed).max().orElse(10); + 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; @@ -131,10 +131,10 @@ public class WindLevelVisualizationDialog extends JDialog { // Draw wind levels for (int i = 0; i < levels.size(); i++) { - MultiLevelWindModel.WindLevel level = levels.get(i); + MultiLevelPinkNoiseWindModel.LevelWindModel level = levels.get(i); - int x = MARGIN + (int) (level.speed / extendedMaxSpeed * (width - 2 * MARGIN)); - int y = height - MARGIN - (int) (level.altitude / extendedMaxAltitude * (height - 2 * MARGIN)); + 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); @@ -142,14 +142,14 @@ public class WindLevelVisualizationDialog extends JDialog { // Draw wind direction arrow if (showDirections) { - drawWindArrow(g2d, x, y, level.direction); + drawWindArrow(g2d, x, y, level.getDirection()); } // Draw connecting line if not the first point if (i > 0) { - MultiLevelWindModel.WindLevel prevLevel = levels.get(i - 1); - int prevX = MARGIN + (int) (prevLevel.speed / extendedMaxSpeed * (width - 2 * MARGIN)); - int prevY = height - MARGIN - (int) (prevLevel.altitude / extendedMaxAltitude * (height - 2 * MARGIN)); + 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); } From 7d5db58eec42e0b881b9bea668564c9f55e11f81 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 05:16:25 +0100 Subject: [PATCH 08/17] Use IconButton for multi-level wind buttons --- .../swing/gui/simulation/SimulationConditionsPanel.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 ab6bdb59a..a17f6f943 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 @@ -58,6 +58,8 @@ 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; @@ -548,7 +550,7 @@ public class SimulationConditionsPanel extends JPanel { JPanel buttonPanel = new JPanel(new MigLayout("ins 0")); // Add level - JButton addButton = new JButton(trans.get("simedtdlg.but.addWindLevel")); + JButton addButton = new IconButton(trans.get("simedtdlg.but.addWindLevel"), Icons.FILE_NEW); addButton.addActionListener(e -> { tableModel.addWindLevel(); sorter.sort(); @@ -556,7 +558,7 @@ public class SimulationConditionsPanel extends JPanel { buttonPanel.add(addButton); // Remove level - JButton removeButton = new JButton(trans.get("simedtdlg.but.removeWindLevel")); + JButton removeButton = new IconButton(trans.get("simedtdlg.but.removeWindLevel"), Icons.EDIT_DELETE); removeButton.addActionListener(e -> { int selectedRow = windLevelTable.getSelectedRow(); tableModel.removeWindLevel(selectedRow); @@ -565,7 +567,7 @@ public class SimulationConditionsPanel extends JPanel { buttonPanel.add(removeButton, "gapright unrel"); // Visualization levels - JButton visualizeButton = new JButton(trans.get("simedtdlg.but.visualizeWindLevels")); + JButton visualizeButton = new IconButton(trans.get("simedtdlg.but.visualizeWindLevels"), Icons.SIM_PLOT); visualizeButton.addActionListener(e -> { Window owner = SwingUtilities.getWindowAncestor(panel); if (owner instanceof Dialog) { From e8b2fdb4489516abc9a84ae76f27fc92850812d4 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 05:18:04 +0100 Subject: [PATCH 09/17] Don't use deviation --- .../openrocket/core/simulation/SimulationConditionsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 720eb6e53..a5919373f 100644 --- a/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java +++ b/core/src/test/java/info/openrocket/core/simulation/SimulationConditionsTest.java @@ -144,8 +144,8 @@ public class SimulationConditionsTest { public void testMultiLevelWindModelAltitudeDependence() { SimulationConditions conditions = new SimulationConditions(); MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel(); - multiLevelModel.addWindLevel(0, 5.0, 0, 3); - multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 4.2); + multiLevelModel.addWindLevel(0, 5.0, 0, 0); + multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 0); conditions.setWindModel(multiLevelModel); From b9d2ebd16b30f74c81d503d2266f9e5e64e6de54 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 05:24:17 +0100 Subject: [PATCH 10/17] Get wind direction from velocity --- .../wind/MultiLevelPinkNoiseWindModel.java | 54 ++----------------- .../core/simulation/SimulationOptions.java | 2 +- 2 files changed, 5 insertions(+), 51 deletions(-) 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 index 646a664a6..58954ad36 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java @@ -82,58 +82,12 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { return lowerVelocity.interpolate(upperVelocity, fraction); } - private static double getInterpolatedDirection(LevelWindModel lowerLevel, LevelWindModel upperLevel, double fractionBetweenLevels) { - double lowerDirection = lowerLevel.model.getDirection(); - double upperDirection = upperLevel.model.getDirection(); - double directionDifference = upperDirection - lowerDirection; - - // Ensure we take the shortest path around the circle - if (directionDifference > Math.PI) { - directionDifference -= 2 * Math.PI; - } else if (directionDifference < -Math.PI) { - directionDifference += 2 * Math.PI; - } - - double interpolatedDirection = lowerDirection + fractionBetweenLevels * directionDifference; - return interpolatedDirection; - } - - private double getPinkNoiseValue(double time, LevelWindModel lower, LevelWindModel upper, double fraction) { - double lowerNoise = lower.model.getWindVelocity(time, lower.altitude).length() - lower.model.getAverage(); - double upperNoise = upper.model.getWindVelocity(time, upper.altitude).length() - upper.model.getAverage(); - return lowerNoise + fraction * (upperNoise - lowerNoise); - } - - public double getWindDirection(double altitude) { - if (levels.isEmpty()) { - return 0; - } - - int index = Collections.binarySearch(levels, new LevelWindModel(altitude, null), - Comparator.comparingDouble(l -> l.altitude)); - - if (index >= 0) { - return levels.get(index).model.getDirection(); - } - - int insertionPoint = -index - 1; - if (insertionPoint == 0) { - return levels.get(0).model.getDirection(); - } - if (insertionPoint == levels.size()) { - return levels.get(levels.size() - 1).model.getDirection(); - } - - // Interpolation (take the value between the closest two bounds) - LevelWindModel lowerLevel = levels.get(insertionPoint - 1); - LevelWindModel upperLevel = levels.get(insertionPoint); - double fractionBetweenLevels = (altitude - lowerLevel.altitude) / (upperLevel.altitude - lowerLevel.altitude); - - // Interpolate direction - double interpolatedDirection = getInterpolatedDirection(lowerLevel, upperLevel, fractionBetweenLevels); + 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 (interpolatedDirection + 2 * Math.PI) % (2 * Math.PI); + return (direction + 2 * Math.PI) % (2 * Math.PI); } @Override 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 730bec966..997a5adf9 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -129,7 +129,7 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt if (windModelType == WindModelType.AVERAGE) { windDirection = averageWindModel.getDirection(); } else { - windDirection = multiLevelPinkNoiseWindModel.getWindDirection(launchAltitude); + windDirection = multiLevelPinkNoiseWindModel.getWindDirection(0, launchAltitude); } this.setLaunchRodDirection(windDirection); } From 4a3b9efe763e779bd11eef97f485b06b92e4dbf7 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 06:33:06 +0100 Subject: [PATCH 11/17] Properly fire changes after wind model change --- .../wind/MultiLevelPinkNoiseWindModel.java | 61 ++++++++++++++++++- .../core/models/wind/PinkNoiseWindModel.java | 27 ++++++++ .../core/models/wind/WindModel.java | 29 --------- .../core/simulation/SimulationOptions.java | 2 + 4 files changed, 89 insertions(+), 30 deletions(-) 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 index 58954ad36..62365ef64 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java @@ -1,15 +1,22 @@ 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 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<>(); } @@ -21,23 +28,28 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { 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() { @@ -127,10 +139,12 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { return levels.hashCode(); } - public static class LevelWindModel implements Cloneable { + 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; @@ -142,6 +156,7 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { public void setAltitude(double altitude) { this.altitude = altitude; + fireChangeEvent(); } public double getSpeed() { @@ -194,5 +209,49 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { LevelWindModel that = (LevelWindModel) obj; return Double.compare(that.altitude, altitude) == 0 && model.equals(that.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 a2415cb07..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 @@ -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. * @@ -222,4 +229,24 @@ public class PinkNoiseWindModel implements WindModel { 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 2e4150ef8..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 @@ -3,38 +3,9 @@ package info.openrocket.core.models.wind; import info.openrocket.core.util.ChangeSource; import info.openrocket.core.util.Coordinate; import info.openrocket.core.util.Monitorable; -import info.openrocket.core.util.StateChangeListener; - -import java.util.ArrayList; -import java.util.EventListener; -import java.util.EventObject; -import java.util.List; public interface WindModel extends Monitorable, Cloneable, ChangeSource { - List listeners = new ArrayList<>(); - Coordinate getWindVelocity(double time, double altitude); WindModel clone(); - - @Override - default void addChangeListener(StateChangeListener listener) { - listeners.add(listener); - } - - @Override - default void removeChangeListener(StateChangeListener listener) { - listeners.remove(listener); - } - - default 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/simulation/SimulationOptions.java b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java index 997a5adf9..ad828f8ee 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -86,7 +86,9 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt public SimulationOptions() { averageWindModel = new PinkNoiseWindModel(randomSeed); + averageWindModel.addChangeListener(e -> fireChangeEvent()); multiLevelPinkNoiseWindModel = new MultiLevelPinkNoiseWindModel(); + multiLevelPinkNoiseWindModel.addChangeListener(e -> fireChangeEvent()); } public double getLaunchRodLength() { From db08a4ffc25d4d59b1aec046a396427a52bca92c Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 06:53:43 +0100 Subject: [PATCH 12/17] Fix cloning and equality of wind models --- .../wind/MultiLevelPinkNoiseWindModel.java | 30 ++++++++++++++----- .../core/simulation/SimulationOptions.java | 11 +++++-- 2 files changed, 32 insertions(+), 9 deletions(-) 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 index 62365ef64..9bc777a83 100644 --- a/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java +++ b/core/src/main/java/info/openrocket/core/models/wind/MultiLevelPinkNoiseWindModel.java @@ -6,6 +6,7 @@ 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; @@ -131,12 +132,21 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MultiLevelPinkNoiseWindModel that = (MultiLevelPinkNoiseWindModel) o; - return levels.equals(that.levels); + + // 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 levels.hashCode(); + return Objects.hash(levels); } public static class LevelWindModel implements Cloneable, ChangeSource { @@ -203,11 +213,17 @@ public class MultiLevelPinkNoiseWindModel implements WindModel { } @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - LevelWindModel that = (LevelWindModel) obj; - return Double.compare(that.altitude, altitude) == 0 && model.equals(that.model); + 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 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 ad828f8ee..6140dcd76 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -81,8 +81,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt private List listeners = new ArrayList<>(); private WindModelType windModelType = WindModelType.AVERAGE; - private final PinkNoiseWindModel averageWindModel; - private final MultiLevelPinkNoiseWindModel multiLevelPinkNoiseWindModel; + private PinkNoiseWindModel averageWindModel; + private MultiLevelPinkNoiseWindModel multiLevelPinkNoiseWindModel; public SimulationOptions() { averageWindModel = new PinkNoiseWindModel(randomSeed); @@ -338,7 +338,14 @@ 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(); + + // Create a new list for listeners copy.listeners = new ArrayList<>(); + return copy; } catch (CloneNotSupportedException e) { throw new BugException(e); From 3d583eda513764db3126b1dec434fb3c39f87dd5 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sat, 21 Sep 2024 06:58:16 +0100 Subject: [PATCH 13/17] Apply cell value when focus is lost --- .../simulation/SimulationConditionsPanel.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) 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 a17f6f943..2c984a12e 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 @@ -5,6 +5,8 @@ import java.awt.Component; 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.MouseEvent; @@ -962,23 +964,41 @@ public class SimulationConditionsPanel extends JPanel { } private static class SelectAllCellEditor extends DefaultCellEditor { + private Object cellEditorValue; + public SelectAllCellEditor() { super(new JTextField()); setClickCountToStart(1); + + JTextField textField = (JTextField) getComponent(); + textField.addFocusListener(new FocusAdapter() { + @Override + public void focusLost(FocusEvent e) { + stopCellEditing(); + } + }); } @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { JTextField textField = (JTextField) super.getTableCellEditorComponent(table, value, isSelected, row, column); - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - textField.selectAll(); - } - }); + cellEditorValue = value; + SwingUtilities.invokeLater(textField::selectAll); return textField; } + @Override + public boolean stopCellEditing() { + JTextField textField = (JTextField) getComponent(); + cellEditorValue = textField.getText(); + return super.stopCellEditing(); + } + + @Override + public Object getCellEditorValue() { + return cellEditorValue; + } + @Override public boolean isCellEditable(EventObject e) { if (e instanceof MouseEvent) { From f7455bd385259e7ac5cdd75240ed54f171196a53 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sun, 22 Sep 2024 07:13:18 +0100 Subject: [PATCH 14/17] Fix level delete action --- .../main/resources/l10n/messages.properties | 4 +- .../simulation/SimulationConditionsPanel.java | 197 ++++++++++++++++-- 2 files changed, 181 insertions(+), 20 deletions(-) diff --git a/core/src/main/resources/l10n/messages.properties b/core/src/main/resources/l10n/messages.properties index 867e8c821..532c5dad7 100644 --- a/core/src/main/resources/l10n/messages.properties +++ b/core/src/main/resources/l10n/messages.properties @@ -510,6 +510,8 @@ 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. @@ -565,7 +567,7 @@ simedtdlg.radio.AverageWind.ttip = Model the wind as pink noise from the average 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.removeWindLevel = Remove level +simedtdlg.but.deleteWindLevel = Delete level simedtdlg.but.visualizeWindLevels = Visualize levels ! WindLevelVisualizationDialog 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 2c984a12e..8e1c18dcb 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 @@ -9,6 +9,7 @@ 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; @@ -16,6 +17,7 @@ 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; @@ -23,8 +25,10 @@ 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; @@ -37,10 +41,13 @@ 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; @@ -501,15 +508,15 @@ public class SimulationConditionsPanel extends JPanel { // Create the levels table WindLevelTableModel tableModel = new WindLevelTableModel(model); JTable windLevelTable = new JTable(tableModel); - windLevelTable.setRowSelectionAllowed(false); + 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(); + SelectAllCellEditor selectAllEditor = new SelectAllCellEditor(windLevelTable); ValueCellRenderer valueCellRenderer = new ValueCellRenderer(); - for (int i = 0; i < windLevelTable.getColumnCount(); i += 2) { + for (int i = 0; i < windLevelTable.getColumnCount() - 2; i += 2) { windLevelTable.getColumnModel().getColumn(i).setCellRenderer(valueCellRenderer); windLevelTable.getColumnModel().getColumn(i).setCellEditor(selectAllEditor); } @@ -520,6 +527,11 @@ public class SimulationConditionsPanel extends JPanel { 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); @@ -559,14 +571,15 @@ public class SimulationConditionsPanel extends JPanel { }); buttonPanel.add(addButton); - // Remove level - JButton removeButton = new IconButton(trans.get("simedtdlg.but.removeWindLevel"), Icons.EDIT_DELETE); - removeButton.addActionListener(e -> { + // Delete level + JButton deleteButton = new IconButton(trans.get("simedtdlg.but.deleteWindLevel"), Icons.EDIT_DELETE); + deleteButton.addActionListener(e -> { int selectedRow = windLevelTable.getSelectedRow(); - tableModel.removeWindLevel(selectedRow); + tableModel.deleteWindLevel(selectedRow); sorter.sort(); }); - buttonPanel.add(removeButton, "gapright unrel"); + deleteButton.setEnabled(false); + buttonPanel.add(deleteButton, "gapright unrel"); // Visualization levels JButton visualizeButton = new IconButton(trans.get("simedtdlg.but.visualizeWindLevels"), Icons.SIM_PLOT); @@ -583,6 +596,7 @@ public class SimulationConditionsPanel extends JPanel { visualizationDialog.setVisible(true); } }); + visualizeButton.setEnabled(!tableModel.getLevels().isEmpty()); buttonPanel.add(visualizeButton); panel.add(buttonPanel, "grow, wrap"); @@ -593,6 +607,44 @@ public class SimulationConditionsPanel extends JPanel { 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()); + } + } }); } @@ -755,6 +807,37 @@ public class SimulationConditionsPanel extends JPanel { } } + 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 = { @@ -769,6 +852,7 @@ public class SimulationConditionsPanel extends JPanel { 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, @@ -786,6 +870,10 @@ public class SimulationConditionsPanel extends JPanel { this.model = model; } + public List getLevels() { + return model.getLevels(); + } + public void setVisualizationDialog(WindLevelVisualizationDialog visualizationDialog) { this.visualizationDialog = visualizationDialog; } @@ -812,9 +900,13 @@ public class SimulationConditionsPanel extends JPanel { @Override public Class getColumnClass(int columnIndex) { // Intensity column - if (columnIndex == getColumnCount()-1) { + 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; } @@ -833,9 +925,12 @@ public class SimulationConditionsPanel extends JPanel { @Override public Object getValueAt(int rowIndex, int columnIndex) { // Intensity column - if (columnIndex == getColumnCount()-1) { + 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) { @@ -853,10 +948,13 @@ public class SimulationConditionsPanel extends JPanel { @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 = Double.parseDouble((String) aValue); + double value = aValue instanceof Double ? (Double) aValue : Double.parseDouble(aValue.toString()); switch (columnIndex) { case 0: level.setAltitude(currentUnits[0].fromUnit(value)); @@ -894,7 +992,7 @@ public class SimulationConditionsPanel extends JPanel { @Override public boolean isCellEditable(int rowIndex, int columnIndex) { - return columnIndex != columnNames.length - 1; // Intensity column is not editable + return columnIndex != columnNames.length - 2; // Intensity & remove column is not editable } public void addWindLevel() { @@ -908,7 +1006,7 @@ public class SimulationConditionsPanel extends JPanel { fireTableDataChanged(); } - public void removeWindLevel(int index) { + public void deleteWindLevel(int index) { if (index >= 0 && index < model.getLevels().size()) { model.removeWindLevelIdx(index); fireTableDataChanged(); @@ -964,14 +1062,26 @@ public class SimulationConditionsPanel extends JPanel { } private static class SelectAllCellEditor extends DefaultCellEditor { - private Object cellEditorValue; + private final JTable table; + private int editingRow; + private int editingColumn; - public SelectAllCellEditor() { + 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(); @@ -982,7 +1092,6 @@ public class SimulationConditionsPanel extends JPanel { @Override public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) { JTextField textField = (JTextField) super.getTableCellEditorComponent(table, value, isSelected, row, column); - cellEditorValue = value; SwingUtilities.invokeLater(textField::selectAll); return textField; } @@ -990,13 +1099,25 @@ public class SimulationConditionsPanel extends JPanel { @Override public boolean stopCellEditing() { JTextField textField = (JTextField) getComponent(); - cellEditorValue = textField.getText(); - return super.stopCellEditing(); + 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) { + // If parsing fails, revert to the original value + return false; + } + boolean result = super.stopCellEditing(); + if (result && editingRow != -1) { + SwingUtilities.invokeLater(() -> table.setRowSelectionInterval(editingRow, editingRow)); + } + return result; } @Override public Object getCellEditorValue() { - return cellEditorValue; + return ((JTextField) getComponent()).getText(); } @Override @@ -1007,4 +1128,42 @@ public class SimulationConditionsPanel extends JPanel { 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; + } + } } From 78e68ecf204822fe8c3a8ba1421089c06c5b4ec4 Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sun, 22 Sep 2024 07:18:38 +0100 Subject: [PATCH 15/17] Also copy the wind model type --- .../info/openrocket/core/simulation/SimulationOptions.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 6140dcd76..e72234366 100644 --- a/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java +++ b/core/src/main/java/info/openrocket/core/simulation/SimulationOptions.java @@ -343,6 +343,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt copy.averageWindModel = this.averageWindModel.clone(); copy.multiLevelPinkNoiseWindModel = this.multiLevelPinkNoiseWindModel.clone(); + copy.windModelType = this.windModelType; + // Create a new list for listeners copy.listeners = new ArrayList<>(); @@ -358,6 +360,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt // 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); From 2bddbf84d2d746ee3cd5215079207ad328d7a30a Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sun, 22 Sep 2024 07:26:02 +0100 Subject: [PATCH 16/17] Fix selectall cell after selecting another cell --- .../swing/gui/simulation/SimulationConditionsPanel.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 8e1c18dcb..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 @@ -1065,6 +1065,7 @@ public class SimulationConditionsPanel extends JPanel { private final JTable table; private int editingRow; private int editingColumn; + private Object originalValue; public SelectAllCellEditor(JTable table) { super(new JTextField()); @@ -1091,7 +1092,11 @@ public class SimulationConditionsPanel extends JPanel { @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; } @@ -1105,7 +1110,8 @@ public class SimulationConditionsPanel extends JPanel { // If successful, update the cell value table.getModel().setValueAt(value, editingRow, editingColumn); } catch (NumberFormatException e) { - // If parsing fails, revert to the original value + // Revert to the original value if parsing fails + textField.setText(originalValue != null ? originalValue.toString() : ""); return false; } boolean result = super.stopCellEditing(); From ba657940c7975bf24277a53c79f78348ab021e0c Mon Sep 17 00:00:00 2001 From: SiboVG Date: Sun, 22 Sep 2024 07:30:07 +0100 Subject: [PATCH 17/17] Use deterministic wind for testing --- .../java/info/openrocket/core/simulation/DisableStageTest.java | 2 ++ 1 file changed, 2 insertions(+) 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);