[#2060, #2558] Implement multi-level wind input

This commit is contained in:
SiboVG 2024-09-10 15:24:26 +02:00
parent 3e60597ade
commit f645f580ec
22 changed files with 1610 additions and 347 deletions

View File

@ -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("<wind model=\"pinknoise\">");
indent++;
writeElement("windaverage", cond.getPinkNoiseWindModel().getAverage());
writeElement("windturbulence", cond.getPinkNoiseWindModel().getTurbulenceIntensity());
writeElement("winddirection", cond.getPinkNoiseWindModel().getDirection());
indent--;
writeln("</wind>");
if (!cond.getMultiLevelWindModel().getLevels().isEmpty()) {
writeln("<wind model=\"multilevel\">");
indent++;
for (MultiLevelWindModel.WindLevel level : cond.getMultiLevelWindModel().getLevels()) {
writeln("<windlevel altitude=\"" + level.altitude + "\" speed=\"" + level.speed + "\" direction=\"" + level.direction + "\"/>");
}
indent--;
writeln("</wind>");
}
writeElement("launchaltitude", cond.getLaunchAltitude());
writeElement("launchlatitude", cond.getLaunchLatitude());
writeElement("launchlongitude", cond.getLaunchLongitude());

View File

@ -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<String, String> 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 {

View File

@ -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<String, String> attributes,
WarningSet warnings) {
return PlainTextHandler.INSTANCE;
}
@Override
public void closeElement(String element, HashMap<String, String> 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.");
}
}
}

View File

@ -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() {

View File

@ -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) {

View File

@ -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<WindLevel> 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<WindLevel> 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;
}
}
}

View File

@ -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;
}
}

View File

@ -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<StateChangeListener> 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);
}
}
}
}

View File

@ -0,0 +1,6 @@
package info.openrocket.core.models.wind;
public enum WindModelType {
PINK_NOISE,
MULTI_LEVEL
}

View File

@ -19,12 +19,14 @@ import info.openrocket.core.file.wavefrontobj.export.OBJExportOptions;
import info.openrocket.core.material.Material;
import info.openrocket.core.models.atmosphere.AtmosphericModel;
import info.openrocket.core.models.atmosphere.ExtendedISAModel;
import info.openrocket.core.models.wind.PinkNoiseWindModel;
import info.openrocket.core.preset.ComponentPreset;
import info.openrocket.core.rocketcomponent.FlightConfiguration;
import info.openrocket.core.rocketcomponent.MassObject;
import info.openrocket.core.rocketcomponent.Rocket;
import info.openrocket.core.rocketcomponent.RocketComponent;
import info.openrocket.core.simulation.RK4SimulationStepper;
import info.openrocket.core.simulation.SimulationOptionsInterface;
import info.openrocket.core.startup.Application;
import info.openrocket.core.util.BugException;
import info.openrocket.core.util.BuildProperties;
@ -35,7 +37,7 @@ import info.openrocket.core.util.LineStyle;
import info.openrocket.core.util.MathUtil;
import info.openrocket.core.util.StateChangeListener;
public abstract class ApplicationPreferences implements ChangeSource, ORPreferences {
public abstract class ApplicationPreferences implements ChangeSource, ORPreferences, SimulationOptionsInterface, StateChangeListener {
private static final String SPLIT_CHARACTER = "|";
/*
@ -153,6 +155,9 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
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);
}
@ -401,45 +393,30 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
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);
}
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);
return pinkNoiseWindModel;
}
public double getLaunchAltitude() {
@ -1291,4 +1268,11 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
}
}
}
@Override
public void stateChanged(EventObject e) {
if (e.getSource() == pinkNoiseWindModel) {
storeWindModelState();
}
}
}

View File

@ -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());

View File

@ -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<EventListener> 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))

View File

@ -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();

View File

@ -42,7 +42,6 @@ public class AbstractChangeSource implements ChangeSource {
((StateChangeListener) l).stateChanged(event);
}
}
}
/**

View File

@ -331,7 +331,6 @@ public class MathUtil {
* or if t is outsize the domain.
*/
public static double interpolate(List<Double> domain, List<Double> 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;
}
}

View File

@ -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 = <html>The launch site latitude affects the gravitational pull of Earth.<br>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 = <html>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

View File

@ -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<MultiLevelWindModel.WindLevel> 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);
}
}

View File

@ -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() {

View File

@ -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() {

View File

@ -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;
@ -38,54 +66,28 @@ 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"));
//// <html>The standard deviation of the windspeed.<br>
//// 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"));
//// <html>The turbulence intensity is the standard deviation divided by the average windspeed.<br>
//// 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<WindLevelTableModel> 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<MultiLevelWindModel.WindLevel> 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<Unit> comboBox;
public UnitSelectorEditor() {
super(new JComboBox<>());
comboBox = (JComboBox<Unit>) 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);
}
}
}

View File

@ -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<MultiLevelWindModel.WindLevel> 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();
}
}

View File

@ -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);