Merge pull request #2559 from SiboVG/issue-2060

[#2060, #2558] Implement multi-level wind input
This commit is contained in:
Sibo Van Gool 2024-09-25 22:48:47 +02:00 committed by GitHub
commit 7a4833f99c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2220 additions and 435 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.MultiLevelPinkNoiseWindModel;
import info.openrocket.core.preferences.DocumentPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -336,8 +337,34 @@ public class OpenRocketSaver extends RocketSaver {
writeElement("launchrodlength", cond.getLaunchRodLength());
writeElement("launchrodangle", cond.getLaunchRodAngle() * 180.0 / Math.PI);
writeElement("launchroddirection", cond.getLaunchRodDirection() * 360.0 / (2.0 * Math.PI));
writeElement("windaverage", cond.getWindSpeedAverage());
writeElement("windturbulence", cond.getWindTurbulenceIntensity());
// TODO: remove once support for OR 23.09 and prior is dropped
writeElement("windaverage", cond.getAverageWindModel().getAverage());
writeElement("windturbulence", cond.getAverageWindModel().getTurbulenceIntensity());
writeElement("winddirection", cond.getAverageWindModel().getDirection());
writeln("<wind model=\"average\">");
indent++;
writeElement("speed", cond.getAverageWindModel().getAverage());
writeElement("direction", cond.getAverageWindModel().getDirection());
writeElement("standarddeviation", cond.getAverageWindModel().getStandardDeviation());
indent--;
writeln("</wind>");
if (!cond.getMultiLevelWindModel().getLevels().isEmpty()) {
writeln("<wind model=\"multilevel\">");
indent++;
for (MultiLevelPinkNoiseWindModel.LevelWindModel level : cond.getMultiLevelWindModel().getLevels()) {
writeln("<windlevel altitude=\"" + level.getAltitude() + "\" speed=\"" + level.getSpeed() +
"\" direction=\"" + level.getDirection() + "\" standarddeviation=\"" + level.getStandardDeviation() +
"\"/>");
}
indent--;
writeln("</wind>");
}
writeElement("windmodeltype", cond.getWindModelType().toStringValue());
writeElement("launchaltitude", cond.getLaunchAltitude());
writeElement("launchlatitude", cond.getLaunchLatitude());
writeElement("launchlongitude", cond.getLaunchLongitude());

View File

@ -7,6 +7,7 @@ import info.openrocket.core.file.DocumentLoadingContext;
import info.openrocket.core.file.simplesax.AbstractElementHandler;
import info.openrocket.core.file.simplesax.ElementHandler;
import info.openrocket.core.file.simplesax.PlainTextHandler;
import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.rocketcomponent.FlightConfigurationId;
import info.openrocket.core.rocketcomponent.Rocket;
import info.openrocket.core.simulation.SimulationOptions;
@ -17,6 +18,7 @@ class SimulationConditionsHandler extends AbstractElementHandler {
public FlightConfigurationId idToSet = FlightConfigurationId.ERROR_FCID;
private final SimulationOptions options;
private AtmosphereHandler atmosphereHandler;
private WindHandler windHandler;
public SimulationConditionsHandler(Rocket rocket, DocumentLoadingContext context) {
this.context = context;
@ -32,7 +34,10 @@ class SimulationConditionsHandler extends AbstractElementHandler {
@Override
public ElementHandler openElement(String element, HashMap<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;
}
@ -49,71 +54,95 @@ class SimulationConditionsHandler extends AbstractElementHandler {
} catch (NumberFormatException ignore) {
}
if (element.equals("configid")) {
this.idToSet = new FlightConfigurationId(content);
} else if (element.equals("launchrodlength")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod length defined, ignoring.");
} else {
options.setLaunchRodLength(d);
switch (element) {
case "configid" -> this.idToSet = new FlightConfigurationId(content);
case "launchrodlength" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod length defined, ignoring.");
} else {
options.setLaunchRodLength(d);
}
}
} else if (element.equals("launchrodangle")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod angle defined, ignoring.");
} else {
options.setLaunchRodAngle(d * Math.PI / 180);
case "launchrodangle" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod angle defined, ignoring.");
} else {
options.setLaunchRodAngle(d * Math.PI / 180);
}
}
} else if (element.equals("launchroddirection")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod direction defined, ignoring.");
} else {
options.setLaunchRodDirection(d * 2.0 * Math.PI / 360);
case "launchroddirection" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch rod direction defined, ignoring.");
} else {
options.setLaunchRodDirection(d * 2.0 * Math.PI / 360);
}
}
} else if (element.equals("windaverage")) {
if (Double.isNaN(d)) {
warnings.add("Illegal average windspeed defined, ignoring.");
} else {
options.setWindSpeedAverage(d);
// TODO: remove once support for OR 23.09 and prior is dropped
case "windaverage" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal average windspeed defined, ignoring.");
} else {
options.getAverageWindModel().setAverage(d);
}
}
} else if (element.equals("windturbulence")) {
if (Double.isNaN(d)) {
warnings.add("Illegal wind turbulence intensity defined, ignoring.");
} else {
options.setWindTurbulenceIntensity(d);
case "windturbulence" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal wind turbulence intensity defined, ignoring.");
} else {
options.getAverageWindModel().setTurbulenceIntensity(d);
}
}
} else if (element.equals("launchaltitude")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch altitude defined, ignoring.");
} else {
options.setLaunchAltitude(d);
case "winddirection" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal wind direction defined, ignoring.");
} else {
options.getAverageWindModel().setDirection(d);
}
}
} else if (element.equals("launchlatitude")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch latitude defined, ignoring.");
} else {
options.setLaunchLatitude(d);
case "wind" -> windHandler.storeSettings(options, warnings);
case "windmodeltype" -> {
options.setWindModelType(WindModelType.fromString(content));
}
} else if (element.equals("launchlongitude")) {
if (Double.isNaN(d)) {
warnings.add("Illegal launch longitude.");
} else {
options.setLaunchLongitude(d);
case "launchaltitude" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch altitude defined, ignoring.");
} else {
options.setLaunchAltitude(d);
}
}
} else if (element.equals("geodeticmethod")) {
GeodeticComputationStrategy gcs = (GeodeticComputationStrategy) DocumentConfig.findEnum(content,
GeodeticComputationStrategy.class);
if (gcs != null) {
options.setGeodeticComputation(gcs);
} else {
warnings.add("Unknown geodetic computation method '" + content + "'");
case "launchlatitude" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch latitude defined, ignoring.");
} else {
options.setLaunchLatitude(d);
}
}
} else if (element.equals("atmosphere")) {
atmosphereHandler.storeSettings(options, warnings);
} else if (element.equals("timestep")) {
if (Double.isNaN(d) || d <= 0) {
warnings.add("Illegal time step defined, ignoring.");
} else {
options.setTimeStep(d);
case "launchlongitude" -> {
if (Double.isNaN(d)) {
warnings.add("Illegal launch longitude.");
} else {
options.setLaunchLongitude(d);
}
}
case "geodeticmethod" -> {
GeodeticComputationStrategy gcs = (GeodeticComputationStrategy) DocumentConfig.findEnum(content,
GeodeticComputationStrategy.class);
if (gcs != null) {
options.setGeodeticComputation(gcs);
} else {
warnings.add("Unknown geodetic computation method '" + content + "'");
}
}
case "atmosphere" -> atmosphereHandler.storeSettings(options, warnings);
case "timestep" -> {
if (Double.isNaN(d) || d <= 0) {
warnings.add("Illegal time step defined, ignoring.");
} else {
options.setTimeStep(d);
}
}
}
}

View File

@ -0,0 +1,74 @@
package info.openrocket.core.file.openrocket.importt;
import info.openrocket.core.file.simplesax.AbstractElementHandler;
import info.openrocket.core.file.simplesax.ElementHandler;
import info.openrocket.core.file.simplesax.PlainTextHandler;
import info.openrocket.core.logging.WarningSet;
import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.simulation.SimulationOptions;
import java.util.HashMap;
public class WindHandler extends AbstractElementHandler {
private final String model;
private final SimulationOptions options;
public WindHandler(String model, SimulationOptions options) {
this.model = model;
this.options = options;
}
@Override
public ElementHandler openElement(String element, HashMap<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 ("average".equals(model)) {
switch (element) {
case "speed" -> {
if (!Double.isNaN(d)) {
options.getAverageWindModel().setAverage(d);
}
}
case "direction" -> {
if (!Double.isNaN(d)) {
options.getAverageWindModel().setDirection(d);
}
}
case "standarddeviation" -> {
if (!Double.isNaN(d)) {
options.getAverageWindModel().setStandardDeviation(d);
}
}
}
} else if ("multilevel".equals(model)) {
if (element.equals("windlevel")) {
double altitude = Double.parseDouble(attributes.get("altitude"));
double speed = Double.parseDouble(attributes.get("speed"));
double direction = Double.parseDouble(attributes.get("direction"));
double standardDeviation = Double.parseDouble(attributes.get("standarddeviation"));
options.getMultiLevelWindModel().addWindLevel(altitude, speed, direction, standardDeviation);
}
}
}
public void storeSettings(SimulationOptions options, WarningSet warnings) {
if ("average".equals(model)) {
options.setWindModelType(WindModelType.AVERAGE);
} else if ("multilevel".equals(model)) {
options.setWindModelType(WindModelType.MULTI_LEVEL);
} else {
warnings.add("Unknown wind model type '" + model + "', using default.");
}
}
}

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.getAverageWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
return;
}
@ -68,7 +68,7 @@ public class LaunchSiteDTO {
setTemperature(RASAeroCommonConstants.OPENROCKET_TO_RASAERO_TEMPERATURE(prefs.getLaunchTemperature()));
setRodAngle(prefs.getLaunchRodAngle() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ANGLE);
setRodLength(prefs.getLaunchRodLength() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_ALTITUDE); // It's a length, but stored in RASAero in feet instead of inches
setWindSpeed(prefs.getWindSpeedAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
setWindSpeed(prefs.getAverageWindModel().getAverage() * RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
}
public Double getAltitude() {

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.getAverageWindModel().setAverage(
Double.parseDouble(content) / RASAeroCommonConstants.OPENROCKET_TO_RASAERO_SPEED);
}
} catch (NumberFormatException e) {

View File

@ -0,0 +1,273 @@
package info.openrocket.core.models.wind;
import java.util.ArrayList;
import java.util.EventListener;
import java.util.EventObject;
import java.util.List;
import java.util.Collections;
import java.util.Comparator;
import java.util.Objects;
import info.openrocket.core.util.ChangeSource;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.ModID;
import info.openrocket.core.util.StateChangeListener;
public class MultiLevelPinkNoiseWindModel implements WindModel {
private List<LevelWindModel> levels;
private final List<StateChangeListener> listeners = new ArrayList<>();
public MultiLevelPinkNoiseWindModel() {
this.levels = new ArrayList<>();
}
public void addWindLevel(double altitude, double speed, double direction, double standardDeviation) {
PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
pinkNoiseModel.setAverage(speed);
pinkNoiseModel.setStandardDeviation(standardDeviation);
pinkNoiseModel.setDirection(direction);
LevelWindModel newLevel = new LevelWindModel(altitude, pinkNoiseModel);
newLevel.addChangeListener(e -> fireChangeEvent());
int index = Collections.binarySearch(levels, newLevel, Comparator.comparingDouble(l -> l.altitude));
if (index >= 0) {
throw new IllegalArgumentException("Wind level already exists for altitude: " + altitude);
}
levels.add(-index - 1, newLevel);
fireChangeEvent();
}
public void removeWindLevel(double altitude) {
levels.removeIf(level -> level.altitude == altitude);
fireChangeEvent();
}
public void removeWindLevelIdx(int index) {
levels.remove(index);
fireChangeEvent();
}
public void clearLevels() {
levels.clear();
fireChangeEvent();
}
public List<LevelWindModel> getLevels() {
return new ArrayList<>(levels);
}
public void sortLevels() {
levels.sort(Comparator.comparingDouble(l -> l.altitude));
}
@Override
public Coordinate getWindVelocity(double time, double altitude) {
if (levels.isEmpty()) {
return Coordinate.ZERO;
}
int index = Collections.binarySearch(levels, new LevelWindModel(altitude, null),
Comparator.comparingDouble(l -> l.altitude));
// Retrieve the wind level if it exists
if (index >= 0) {
return levels.get(index).model.getWindVelocity(time, altitude);
}
// Extrapolation (take the value of the outer bounds)
int insertionPoint = -index - 1;
if (insertionPoint == 0) {
return levels.get(0).model.getWindVelocity(time, altitude);
}
if (insertionPoint == levels.size()) {
return levels.get(levels.size() - 1).model.getWindVelocity(time, altitude);
}
// Interpolation (take the value between the closest two bounds)
LevelWindModel lowerLevel = levels.get(insertionPoint - 1);
LevelWindModel upperLevel = levels.get(insertionPoint);
double fraction = (altitude - lowerLevel.altitude) / (upperLevel.altitude - lowerLevel.altitude);
Coordinate lowerVelocity = lowerLevel.model.getWindVelocity(time, altitude);
Coordinate upperVelocity = upperLevel.model.getWindVelocity(time, altitude);
return lowerVelocity.interpolate(upperVelocity, fraction);
}
public double getWindDirection(double time, double altitude) {
Coordinate velocity = getWindVelocity(time, altitude);
double direction = Math.atan2(velocity.x, velocity.y);
// Normalize the result to be between 0 and 2*PI
return (direction + 2 * Math.PI) % (2 * Math.PI);
}
@Override
public ModID getModID() {
return ModID.ZERO; // You might want to create a specific ModID for this model
}
public void loadFrom(MultiLevelPinkNoiseWindModel source) {
this.levels.clear();
for (LevelWindModel level : source.levels) {
this.levels.add(level.clone());
}
}
@Override
public MultiLevelPinkNoiseWindModel clone() {
try {
MultiLevelPinkNoiseWindModel clone = (MultiLevelPinkNoiseWindModel) super.clone();
clone.levels = new ArrayList<>(this.levels.size());
clone.loadFrom(this);
return clone;
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // This should never happen
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MultiLevelPinkNoiseWindModel that = (MultiLevelPinkNoiseWindModel) o;
// Compare the levels list
if (levels.size() != that.levels.size()) return false;
for (int i = 0; i < levels.size(); i++) {
if (!levels.get(i).equals(that.levels.get(i))) return false;
}
// If we implement any additional fields in the future, we should compare them here
return true;
}
@Override
public int hashCode() {
return Objects.hash(levels);
}
public static class LevelWindModel implements Cloneable, ChangeSource {
protected double altitude;
protected PinkNoiseWindModel model;
private final List<StateChangeListener> listeners = new ArrayList<>();
LevelWindModel(double altitude, PinkNoiseWindModel model) {
this.altitude = altitude;
this.model = model;
}
public double getAltitude() {
return altitude;
}
public void setAltitude(double altitude) {
this.altitude = altitude;
fireChangeEvent();
}
public double getSpeed() {
return model.getAverage();
}
public void setSpeed(double speed) {
model.setAverage(speed);
}
public double getDirection() {
return model.getDirection();
}
public void setDirection(double direction) {
model.setDirection(direction);
}
public double getStandardDeviation() {
return model.getStandardDeviation();
}
public void setStandardDeviation(double standardDeviation) {
model.setStandardDeviation(standardDeviation);
}
public double getTurblenceIntensity() {
return model.getTurbulenceIntensity();
}
public void setTurbulenceIntensity(double turbulenceIntensity) {
model.setTurbulenceIntensity(turbulenceIntensity);
}
@Override
public LevelWindModel clone() {
try {
LevelWindModel clone = (LevelWindModel) super.clone();
clone.model = this.model.clone();
return clone;
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // This should never happen
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
LevelWindModel that = (LevelWindModel) o;
return Double.compare(that.altitude, altitude) == 0 &&
model.equals(that.model);
}
@Override
public int hashCode() {
return Objects.hash(altitude, model);
}
@Override
public void addChangeListener(StateChangeListener listener) {
listeners.add(listener);
model.addChangeListener(listener);
}
@Override
public void removeChangeListener(StateChangeListener listener) {
listeners.remove(listener);
model.removeChangeListener(listener);
}
public void fireChangeEvent() {
EventObject event = new EventObject(this);
// Copy the list before iterating to prevent concurrent modification exceptions.
EventListener[] list = listeners.toArray(new EventListener[0]);
for (EventListener l : list) {
if (l instanceof StateChangeListener) {
((StateChangeListener) l).stateChanged(event);
}
}
}
}
@Override
public void addChangeListener(StateChangeListener listener) {
listeners.add(listener);
}
@Override
public void removeChangeListener(StateChangeListener listener) {
listeners.remove(listener);
}
public void fireChangeEvent() {
EventObject event = new EventObject(this);
// Copy the list before iterating to prevent concurrent modification exceptions.
EventListener[] list = listeners.toArray(new EventListener[0]);
for (EventListener l : list) {
if (l instanceof StateChangeListener) {
((StateChangeListener) l).stateChanged(event);
}
}
}
}

View File

@ -1,11 +1,16 @@
package info.openrocket.core.models.wind;
import java.util.ArrayList;
import java.util.EventListener;
import java.util.EventObject;
import java.util.List;
import java.util.Random;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.MathUtil;
import info.openrocket.core.util.ModID;
import info.openrocket.core.util.PinkNoise;
import info.openrocket.core.util.StateChangeListener;
/**
* A wind simulator that generates wind speed as pink noise from a specified
@ -34,7 +39,7 @@ public class PinkNoiseWindModel implements WindModel {
private static final double STDDEV = 2.252;
/** Time difference between random samples. */
private static final double DELTA_T = 0.05;
public static final double DELTA_T = 0.05;
private double average = 0;
private double direction = Math.PI / 2; // this is an East wind
@ -46,6 +51,8 @@ public class PinkNoiseWindModel implements WindModel {
private double time1;
private double value1, value2;
private final List<StateChangeListener> listeners = new ArrayList<>();
/**
* Construct a new wind simulation with a specific seed value.
*
@ -55,6 +62,10 @@ public class PinkNoiseWindModel implements WindModel {
this.seed = seed ^ SEED_RANDOMIZATION;
}
public PinkNoiseWindModel() {
this(new Random().nextInt());
}
/**
* Return the average wind speed.
*
@ -71,13 +82,22 @@ public class PinkNoiseWindModel implements WindModel {
* @param average the average wind speed to set
*/
public void setAverage(double average) {
average = Math.max(average, 0);
if (average == this.average) {
return;
}
double intensity = getTurbulenceIntensity();
this.average = Math.max(average, 0);
this.average = average;
setTurbulenceIntensity(intensity);
fireChangeEvent();
}
public void setDirection(double direction) {
if (direction == this.direction) {
return;
}
this.direction = direction;
fireChangeEvent();
}
public double getDirection() {
@ -99,7 +119,12 @@ public class PinkNoiseWindModel implements WindModel {
* @param standardDeviation the standardDeviation to set
*/
public void setStandardDeviation(double standardDeviation) {
if (standardDeviation == this.standardDeviation) {
return;
}
this.standardDeviation = Math.max(standardDeviation, 0);
setTurbulenceIntensity(standardDeviation / average);
fireChangeEvent();
}
/**
@ -161,9 +186,67 @@ public class PinkNoiseWindModel implements WindModel {
randomSource = null;
}
public void loadFrom(PinkNoiseWindModel source) {
this.average = source.average;
this.direction = source.direction;
this.standardDeviation = source.standardDeviation;
}
@Override
public ModID getModID() {
return ModID.ZERO;
}
@Override
public PinkNoiseWindModel clone() {
try {
PinkNoiseWindModel clone = (PinkNoiseWindModel) super.clone();
clone.loadFrom(this);
return clone;
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // This should never happen
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PinkNoiseWindModel that = (PinkNoiseWindModel) o;
return Double.compare(that.average, average) == 0 &&
Double.compare(that.standardDeviation, standardDeviation) == 0 &&
Double.compare(that.direction, direction) == 0 &&
seed == that.seed;
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + Double.hashCode(average);
result = 31 * result + Double.hashCode(standardDeviation);
result = 31 * result + Double.hashCode(direction);
result = 31 * result + seed;
return result;
}
@Override
public void addChangeListener(StateChangeListener listener) {
listeners.add(listener);
}
@Override
public void removeChangeListener(StateChangeListener listener) {
listeners.remove(listener);
}
public void fireChangeEvent() {
EventObject event = new EventObject(this);
// Copy the list before iterating to prevent concurrent modification exceptions.
EventListener[] list = listeners.toArray(new EventListener[0]);
for (EventListener l : list) {
if (l instanceof StateChangeListener) {
((StateChangeListener) l).stateChanged(event);
}
}
}
}

View File

@ -1,10 +1,11 @@
package info.openrocket.core.models.wind;
import info.openrocket.core.util.ChangeSource;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.Monitorable;
public interface WindModel extends Monitorable {
public Coordinate getWindVelocity(double time, double altitude);
public interface WindModel extends Monitorable, Cloneable, ChangeSource {
Coordinate getWindVelocity(double time, double altitude);
WindModel clone();
}

View File

@ -0,0 +1,25 @@
package info.openrocket.core.models.wind;
public enum WindModelType {
AVERAGE("Average"),
MULTI_LEVEL("MultiLevel");
private final String stringValue;
WindModelType(String stringValue) {
this.stringValue = stringValue;
}
public String toStringValue() {
return stringValue;
}
public static WindModelType fromString(String stringValue) {
for (WindModelType type : WindModelType.values()) {
if (type.stringValue.equalsIgnoreCase(stringValue)) {
return type;
}
}
throw new IllegalArgumentException("No enum constant " + WindModelType.class.getCanonicalName() + " for string value: " + stringValue);
}
}

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,7 +155,10 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
public static final String SVG_STROKE_WIDTH = "SVGStrokeWidth";
private static final AtmosphericModel ISA_ATMOSPHERIC_MODEL = new ExtendedISAModel();
private PinkNoiseWindModel averageWindModel = null;
/*
* ******************************************************************************************
*
@ -347,19 +352,6 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
fireChangeEvent();
}
public final double getWindTurbulenceIntensity() {
return Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.1);
}
public final void setWindTurbulenceIntensity(double wti) {
double oldWTI = Application.getPreferences().getChoice(ApplicationPreferences.WIND_TURBULENCE, 0.9, 0.3);
if (MathUtil.equals(oldWTI, wti))
return;
this.putDouble(ApplicationPreferences.WIND_TURBULENCE, wti);
fireChangeEvent();
}
public double getLaunchRodLength() {
return this.getDouble(LAUNCH_ROD_LENGTH, 1);
}
@ -400,49 +392,34 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
fireChangeEvent();
}
public double getWindSpeedAverage() {
return this.getDouble(WIND_AVERAGE, 2);
protected void loadWindModelState() {
double average = getDouble(WIND_AVERAGE, 2.0);
double turbulenceIntensity = getDouble(WIND_TURBULENCE, 0.1);
double direction = getDouble(WIND_DIRECTION, Math.PI / 2);
getAverageWindModel().setAverage(average);
getAverageWindModel().setTurbulenceIntensity(turbulenceIntensity);
getAverageWindModel().setDirection(direction);
}
public void setWindSpeedAverage(double windAverage) {
if (MathUtil.equals(this.getDouble(WIND_AVERAGE, 2), windAverage))
return;
this.putDouble(WIND_AVERAGE, MathUtil.max(windAverage, 0));
fireChangeEvent();
protected void storeWindModelState() {
putDouble(WIND_AVERAGE, getAverageWindModel().getAverage());
putDouble(WIND_TURBULENCE, getAverageWindModel().getTurbulenceIntensity());
putDouble(WIND_DIRECTION, getAverageWindModel().getDirection());
}
public double getWindSpeedDeviation() {
return this.getDouble(WIND_AVERAGE, 2) * this.getDouble(WIND_TURBULENCE, 0.1);
}
public void setWindSpeedDeviation(double windDeviation) {
double windAverage = this.getDouble(WIND_DIRECTION, 2);
if (windAverage < 0.1) {
windAverage = 0.1;
@Override
public PinkNoiseWindModel getAverageWindModel() {
if (averageWindModel == null) {
averageWindModel = new PinkNoiseWindModel();
averageWindModel.addChangeListener(this);
loadWindModelState();
}
setWindTurbulenceIntensity(windDeviation / windAverage);
return averageWindModel;
}
public void setWindDirection(double direction) {
direction = MathUtil.reduce2Pi(direction);
if (this.getBoolean(LAUNCH_INTO_WIND, true)) {
this.setLaunchRodDirection(direction);
}
if (MathUtil.equals(this.getDouble(WIND_DIRECTION, Math.PI / 2), direction))
return;
this.putDouble(WIND_DIRECTION, direction);
fireChangeEvent();
}
public double getWindDirection() {
return this.getDouble(WIND_DIRECTION, Math.PI / 2);
}
public double getLaunchAltitude() {
return this.getDouble(LAUNCH_ALTITUDE, 0);
}
@ -1364,4 +1341,11 @@ public abstract class ApplicationPreferences implements ChangeSource, ORPreferen
}
}
}
@Override
public void stateChanged(EventObject e) {
if (e.getSource() == averageWindModel) {
storeWindModelState();
}
}
}

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.getAverageWindModel().setAverage(prefs.getAverageWindModel().getAverage());
defaults.getAverageWindModel().setStandardDeviation(prefs.getAverageWindModel().getStandardDeviation());
defaults.getAverageWindModel().setTurbulenceIntensity(prefs.getAverageWindModel().getTurbulenceIntensity());
defaults.setLaunchLatitude(prefs.getDouble(SIMCONDITION_SITE_LAT, defaults.getLaunchLatitude()));
defaults.setLaunchLongitude(prefs.getDouble(SIMCONDITION_SITE_LON, defaults.getLaunchLongitude()));
@ -59,9 +58,9 @@ public class DefaultSimulationOptionFactory {
public void saveDefault(SimulationOptions newDefaults) {
prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getWindSpeedAverage());
prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getWindSpeedDeviation());
prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getWindTurbulenceIntensity());
prefs.putDouble(SIMCONDITION_WIND_SPEED, newDefaults.getAverageWindModel().getAverage());
prefs.putDouble(SIMCONDITION_WIND_STDDEV, newDefaults.getAverageWindModel().getStandardDeviation());
prefs.putDouble(SIMCONDITION_WIND_TURB, newDefaults.getAverageWindModel().getTurbulenceIntensity());
prefs.putDouble(SIMCONDITION_SITE_LAT, newDefaults.getLaunchLatitude());
prefs.putDouble(SIMCONDITION_SITE_LON, newDefaults.getLaunchLongitude());

View File

@ -6,6 +6,9 @@ import java.util.EventObject;
import java.util.List;
import java.util.Random;
import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
import info.openrocket.core.models.wind.WindModel;
import info.openrocket.core.models.wind.WindModelType;
import info.openrocket.core.preferences.ApplicationPreferences;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -54,12 +57,8 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
private double launchRodLength = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_LENGTH, 1);
private boolean launchIntoWind = preferences.getBoolean(ApplicationPreferences.LAUNCH_INTO_WIND, true);
private double launchRodAngle = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_ANGLE, 0);
private double windDirection = preferences.getDouble(ApplicationPreferences.WIND_DIRECTION, Math.PI / 2);
private double launchRodDirection = preferences.getDouble(ApplicationPreferences.LAUNCH_ROD_DIRECTION, Math.PI / 2);
private double windAverage = preferences.getDouble(ApplicationPreferences.WIND_AVERAGE, 2.0);
private double windTurbulence = preferences.getDouble(ApplicationPreferences.WIND_TURBULENCE, 0.1);
/*
* SimulationOptions maintains the launch site parameters as separate double values,
* and converts them into a WorldCoordinate when converting to SimulationConditions.
@ -81,7 +80,15 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
private List<EventListener> listeners = new ArrayList<>();
private WindModelType windModelType = WindModelType.AVERAGE;
private PinkNoiseWindModel averageWindModel;
private MultiLevelPinkNoiseWindModel multiLevelPinkNoiseWindModel;
public SimulationOptions() {
averageWindModel = new PinkNoiseWindModel(randomSeed);
averageWindModel.addChangeListener(e -> fireChangeEvent());
multiLevelPinkNoiseWindModel = new MultiLevelPinkNoiseWindModel();
multiLevelPinkNoiseWindModel.addChangeListener(e -> fireChangeEvent());
}
public double getLaunchRodLength() {
@ -120,6 +127,12 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
public double getLaunchRodDirection() {
if (launchIntoWind) {
double windDirection;
if (windModelType == WindModelType.AVERAGE) {
windDirection = averageWindModel.getDirection();
} else {
windDirection = multiLevelPinkNoiseWindModel.getWindDirection(0, launchAltitude);
}
this.setLaunchRodDirection(windDirection);
}
return launchRodDirection;
@ -133,57 +146,33 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
fireChangeEvent();
}
public double getWindSpeedAverage() {
return windAverage;
public WindModelType getWindModelType() {
return windModelType;
}
public void setWindSpeedAverage(double windAverage) {
if (MathUtil.equals(this.windAverage, windAverage))
return;
this.windAverage = MathUtil.max(windAverage, 0);
if (MathUtil.equals(this.windAverage, 0)) {
setWindTurbulenceIntensity(0);
public void setWindModelType(WindModelType windModelType) {
if (this.windModelType != windModelType) {
this.windModelType = windModelType;
fireChangeEvent();
}
fireChangeEvent();
}
public double getWindSpeedDeviation() {
return windAverage * windTurbulence;
}
public void setWindSpeedDeviation(double windDeviation) {
if (windAverage < 0.1) {
windAverage = 0.1;
public WindModel getWindModel() {
if (windModelType == WindModelType.AVERAGE) {
return averageWindModel;
} else if (windModelType == WindModelType.MULTI_LEVEL) {
return multiLevelPinkNoiseWindModel;
} else {
throw new IllegalArgumentException("Unknown wind model type: " + windModelType);
}
setWindTurbulenceIntensity(windDeviation / windAverage);
}
public double getWindTurbulenceIntensity() {
return windTurbulence;
public PinkNoiseWindModel getAverageWindModel() {
return averageWindModel;
}
public void setWindTurbulenceIntensity(double intensity) {
// Does not check equality so that setWindSpeedDeviation can be sure of event
// firing
this.windTurbulence = intensity;
fireChangeEvent();
}
public void setWindDirection(double direction) {
direction = MathUtil.reduce2Pi(direction);
if (launchIntoWind) {
this.setLaunchRodDirection(direction);
}
if (MathUtil.equals(this.windDirection, direction))
return;
this.windDirection = direction;
fireChangeEvent();
}
public double getWindDirection() {
return this.windDirection;
public MultiLevelPinkNoiseWindModel getMultiLevelWindModel() {
return multiLevelPinkNoiseWindModel;
}
public double getLaunchAltitude() {
@ -349,7 +338,16 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
public SimulationOptions clone() {
try {
SimulationOptions copy = (SimulationOptions) super.clone();
// Deep clone the wind models
copy.averageWindModel = this.averageWindModel.clone();
copy.multiLevelPinkNoiseWindModel = this.multiLevelPinkNoiseWindModel.clone();
copy.windModelType = this.windModelType;
// Create a new list for listeners
copy.listeners = new ArrayList<>();
return copy;
} catch (CloneNotSupportedException e) {
throw new BugException(e);
@ -361,6 +359,20 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
// only do it if one of the "important" (user specified) parameters has really
// changed.
boolean isChanged = false;
if (this.windModelType != src.windModelType) {
isChanged = true;
this.windModelType = src.windModelType;
}
if (!this.averageWindModel.equals(src.averageWindModel)) {
isChanged = true;
this.averageWindModel.loadFrom(src.averageWindModel);
}
if (!this.multiLevelPinkNoiseWindModel.equals(src.multiLevelPinkNoiseWindModel)) {
isChanged = true;
this.multiLevelPinkNoiseWindModel.loadFrom(src.multiLevelPinkNoiseWindModel);
}
if (this.launchAltitude != src.launchAltitude) {
isChanged = true;
this.launchAltitude = src.launchAltitude;
@ -405,18 +417,7 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
isChanged = true;
this.maximumAngle = src.maximumAngle;
}
if (this.windAverage != src.windAverage) {
isChanged = true;
this.windAverage = src.windAverage;
}
if (this.windDirection != src.windDirection) {
isChanged = true;
this.windDirection = src.windDirection;
}
if (this.windTurbulence != src.windTurbulence) {
isChanged = true;
this.windTurbulence = src.windTurbulence;
}
if (this.timeStep != src.timeStep) {
isChanged = true;
this.timeStep = src.timeStep;
@ -453,10 +454,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
MathUtil.equals(this.launchRodLength, o.launchRodLength) &&
MathUtil.equals(this.launchTemperature, o.launchTemperature) &&
MathUtil.equals(this.maximumAngle, o.maximumAngle) &&
MathUtil.equals(this.timeStep, o.timeStep) &&
MathUtil.equals(this.windAverage, o.windAverage) &&
MathUtil.equals(this.windTurbulence, o.windTurbulence) &&
MathUtil.equals(this.windDirection, o.windDirection));
MathUtil.equals(this.timeStep, o.timeStep)) &&
this.windModelType == o.windModelType &&
this.averageWindModel.equals(o.averageWindModel) &&
this.multiLevelPinkNoiseWindModel.equals(o.multiLevelPinkNoiseWindModel);
}
/**
@ -505,17 +506,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
conditions.setGeodeticComputation(getGeodeticComputation());
conditions.setRandomSeed(randomSeed);
PinkNoiseWindModel windModel = new PinkNoiseWindModel(randomSeed);
windModel.setAverage(getWindSpeedAverage());
windModel.setStandardDeviation(getWindSpeedDeviation());
windModel.setDirection(windDirection);
WindModel windModel = getWindModel().clone();
conditions.setWindModel(windModel);
conditions.setAtmosphericModel(getAtmosphericModel());
GravityModel gravityModel = new WGSGravityModel();
conditions.setGravityModel(gravityModel);
conditions.setAerodynamicCalculator(new BarrowmanCalculator());
@ -533,10 +527,10 @@ public class SimulationOptions implements ChangeSource, Cloneable, SimulationOpt
.concat(String.format(" launchRodLength: %f\n", launchRodLength))
.concat(String.format(" launchIntoWind: %b\n", launchIntoWind))
.concat(String.format(" launchRodAngle: %f\n", launchRodAngle))
.concat(String.format(" windDirection: %f\n", windDirection))
.concat(String.format(" launchRodDirection: %f\n", launchRodDirection))
.concat(String.format(" windAverage: %f\n", windAverage))
.concat(String.format(" windTurbulence: %f\n", windTurbulence))
.concat(String.format(" windModelType: %s\n", windModelType))
.concat(String.format(" pinkNoiseWindModel: %s\n", averageWindModel))
.concat(String.format(" multiLevelPinkNoiseWindModel: %s\n", multiLevelPinkNoiseWindModel))
.concat(String.format(" launchAltitude: %f\n", launchAltitude))
.concat(String.format(" launchLatitude: %f\n", launchLatitude))
.concat(String.format(" launchLongitude: %f\n", launchLongitude))

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 getAverageWindModel();
double getLaunchAltitude();

View File

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

View File

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

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

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

View File

@ -506,6 +506,15 @@ simedtdlg.lbl.ttip.Pressure = The atmospheric pressure at the launch site.
simedtdlg.lbl.Launchsite = Launch site
simedtdlg.lbl.Latitude = Latitude:
simedtdlg.lbl.ttip.Latitude = <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.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 = <html>Required for weather prediction and elevation models.
@ -555,6 +564,20 @@ simedtdlg.IntensityDesc.Medium = Medium
simedtdlg.IntensityDesc.High = High
simedtdlg.IntensityDesc.Veryhigh = Very high
simedtdlg.IntensityDesc.Extreme = Extreme
simedtdlg.lbl.WindModelSelection = Wind model to use:
simedtdlg.radio.AverageWind = Average
simedtdlg.radio.AverageWind.ttip = Model the wind as pink noise from the average wind speed and standard deviation.
simedtdlg.radio.MultiLevelWind = Multi-level
simedtdlg.radio.MultiLevelWind.ttip = Model the wind using speed and direction entries at various altitude levels.
simedtdlg.but.addWindLevel = Add level
simedtdlg.but.deleteWindLevel = Delete level
simedtdlg.but.visualizeWindLevels = Visualize levels
! WindLevelVisualizationDialog
WindLevelVisualizationDialog.title.WindLevelVisualization = Wind Level Visualization
WindLevelVisualizationDialog.lbl.WindSpeed = Wind speed
WindLevelVisualizationDialog.lbl.Altitude = Altitude
WindLevelVisualizationDialog.checkbox.ShowDirections = Show wind direction vectors
! SimulationConfigDialog
SimulationConfigDialog.tab.Settings = Settings

View File

@ -0,0 +1,203 @@
package info.openrocket.core.models.wind;
import info.openrocket.core.util.MathUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.ModID;
import info.openrocket.core.util.StateChangeListener;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
class MultiLevelWindModelTest {
private final static double EPSILON = MathUtil.EPSILON;
private static final double DELTA_T = PinkNoiseWindModel.DELTA_T;
private static final int SAMPLE_SIZE = 1000;
private MultiLevelPinkNoiseWindModel model;
@BeforeEach
void setUp() {
model = new MultiLevelPinkNoiseWindModel();
}
@Test
@DisplayName("Add and remove wind levels")
void testAddAndRemoveWindLevels() {
model.addWindLevel(100, 5, Math.PI / 4, 1);
model.addWindLevel(200, 10, Math.PI / 2, 1);
assertEquals(2, model.getLevels().size());
model.removeWindLevel(100);
assertEquals(1, model.getLevels().size());
assertEquals(200, model.getLevels().get(0).altitude, EPSILON);
model.removeWindLevel(0);
assertEquals(1, model.getLevels().size());
assertEquals(200, model.getLevels().get(0).altitude, EPSILON);
model.removeWindLevel(200);
assertTrue(model.getLevels().isEmpty());
}
@Test
@DisplayName("Adding duplicate altitude throws IllegalArgumentException")
void testAddDuplicateAltitude() {
model.addWindLevel(100, 5, Math.PI / 4, 1);
assertThrows(IllegalArgumentException.class, () -> model.addWindLevel(100, 10, Math.PI / 2, 1));
}
@Test
@DisplayName("Get wind velocity")
void testGetWindVelocity() {
model.addWindLevel(0, 5, 0, 1);
model.addWindLevel(1000, 10, Math.PI / 2, 2);
verifyWind(0, 5, 0, 1);
verifyWind(1000, 10, Math.PI / 2, 2);
}
@Test
@DisplayName("Interpolation between levels")
void testInterpolationBetweenLevels() {
// Test speed interpolation
model.addWindLevel(0, 5, 0, 0.1);
model.addWindLevel(1000, 10, 0, 0.3);
verifyWind(200, 6, 0, 0.14);
verifyWind(500, 7.5, 0, 0.2);
verifyWind(900, 9.5, 0, 0.28);
model.clearLevels();
// Test direction interpolation when speed vectors are parallel
model.addWindLevel(0, 5, 0, 0);
model.addWindLevel(1000, 5, Math.PI, 0);
verifyWind(200, 3, 0, EPSILON);
verifyWind(501, 0, Math.PI, 0.01);
verifyWind(900, 4, Math.PI, EPSILON);
model.clearLevels();
// Test direction interpolation when speed vectors are not parallel
model.addWindLevel(0, 5, 0, 0);
model.addWindLevel(1000, 5, Math.PI / 2, 0);
verifyWind(200, 4.1231056256, 0.2449786631, EPSILON);
verifyWind(500, 3.5355339059, Math.PI / 4, EPSILON);
verifyWind(800, 4.1231056256, 1.3258176637, EPSILON);
}
@Test
@DisplayName("Extrapolation outside levels")
void testExtrapolationOutsideLevels() {
model.addWindLevel(100, 5, 0, 1.4);
model.addWindLevel(200, 10, Math.PI / 2, 2.2);
verifyWind(0, 5, 0, 1.4);
verifyWind(300, 10, Math.PI / 2, 2.2);
verifyWind(1000, 10, Math.PI / 2, 2.2);
}
@Test
@DisplayName("Resort levels")
void testSortLevels() {
model.addWindLevel(200, 10, Math.PI / 2, 1);
model.addWindLevel(100, 5, Math.PI / 4, 1);
model.addWindLevel(300, 15, 3 * Math.PI / 4, 1);
model.sortLevels();
List<MultiLevelPinkNoiseWindModel.LevelWindModel> levels = model.getLevels();
assertEquals(3, levels.size());
assertEquals(100, levels.get(0).altitude, EPSILON);
assertEquals(200, levels.get(1).altitude, EPSILON);
assertEquals(300, levels.get(2).altitude, EPSILON);
}
@Test
@DisplayName("Clone model")
void testClone() {
model.addWindLevel(100, 5, Math.PI / 4, 1);
model.addWindLevel(200, 10, Math.PI / 2, 2);
MultiLevelPinkNoiseWindModel clonedModel = model.clone();
assertNotSame(model, clonedModel);
assertEquals(model, clonedModel);
clonedModel.addWindLevel(300, 15, 3 * Math.PI / 4, 1);
assertNotEquals(model, clonedModel);
}
@Test
@DisplayName("Load from another model")
void testLoadFrom() {
model.addWindLevel(100, 5, Math.PI / 4, 2);
model.addWindLevel(200, 10, Math.PI / 2, 1);
MultiLevelPinkNoiseWindModel newModel = new MultiLevelPinkNoiseWindModel();
newModel.loadFrom(model);
assertEquals(model, newModel);
}
@Test
@DisplayName("Get ModID")
void testGetModID() {
assertEquals(ModID.ZERO, model.getModID());
}
@Test
@DisplayName("Change listeners")
void testChangeListeners() {
final boolean[] listenerCalled = {false};
StateChangeListener listener = event -> listenerCalled[0] = true;
model.addChangeListener(listener);
model.fireChangeEvent();
assertTrue(listenerCalled[0]);
listenerCalled[0] = false;
model.removeChangeListener(listener);
model.fireChangeEvent();
assertFalse(listenerCalled[0]);
}
private void verifyWind(double altitude, double expectedSpeed, double expectedDirection, double standardDeviation) {
double[] speeds = new double[SAMPLE_SIZE];
double[] directions = new double[SAMPLE_SIZE];
for (int i = 0; i < SAMPLE_SIZE; i++) {
Coordinate velocity = model.getWindVelocity(i * DELTA_T, altitude);
speeds[i] = velocity.length();
directions[i] = Math.atan2(velocity.x, velocity.y);
}
double avgSpeed = Arrays.stream(speeds, 0, SAMPLE_SIZE).average().orElse(0.0);
double avgDirection = averageAngle(directions);
// Check average speed and direction
assertEquals(expectedSpeed, avgSpeed, standardDeviation, "Average wind speed at altitude " + altitude);
assertEquals(expectedDirection, avgDirection, EPSILON, "Average wind direction at altitude " + altitude);
// Check that some values are above and below the expected value
//assertTrue(IntStream.range(0, SAMPLE_SIZE).anyMatch(i -> speeds[i] >= expectedSpeed));
//assertTrue(IntStream.range(0, SAMPLE_SIZE).anyMatch(i -> speeds[i] <= expectedSpeed));
}
private double averageAngle(double[] angles) {
double sumSin = 0, sumCos = 0;
for (double angle : angles) {
sumSin += Math.sin(angle);
sumCos += Math.cos(angle);
}
return Math.atan2(sumSin, sumCos);
}
}

View File

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

View File

@ -69,6 +69,7 @@ public class FlightEventsTest extends BaseTestCase {
final Simulation sim = new Simulation(rocket);
sim.getOptions().setISAAtmosphere(true);
sim.getOptions().setTimeStep(0.05);
sim.getOptions ().getAverageWindModel().setAverage(0.1);
rocket.getSelectedConfiguration().setAllStages();
FlightConfigurationId fcid = rocket.getSelectedConfiguration().getFlightConfigurationID();
sim.setFlightConfigurationId(fcid);

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.MultiLevelPinkNoiseWindModel;
import info.openrocket.core.models.wind.PinkNoiseWindModel;
import info.openrocket.core.plugin.PluginModule;
import info.openrocket.core.preferences.ApplicationPreferences;
import info.openrocket.core.startup.Application;
import info.openrocket.core.startup.MockPreferences;
import info.openrocket.core.util.Coordinate;
import info.openrocket.core.util.MathUtil;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -60,16 +65,99 @@ public class SimulationConditionsTest {
assertEquals(Math.PI / 2, options.getLaunchRodDirection(), EPSILON);
assertEquals(0.0, options.getLaunchRodAngle(), EPSILON);
assertTrue(options.getLaunchIntoWind());
assertEquals(Math.PI / 2, options.getWindDirection(), EPSILON);
assertEquals(0.1, options.getWindTurbulenceIntensity(), EPSILON);
assertEquals(2.0, options.getWindSpeedAverage(), EPSILON);
assertEquals(0.2, options.getWindSpeedDeviation(), EPSILON);
assertEquals(Math.PI / 2, options.getAverageWindModel().getDirection(), EPSILON);
assertEquals(0.1, options.getAverageWindModel().getTurbulenceIntensity(), EPSILON);
assertEquals(2.0, options.getAverageWindModel().getAverage(), EPSILON);
assertEquals(0.2, options.getAverageWindModel().getStandardDeviation(), EPSILON);
assertEquals(0.05, options.getTimeStep(), EPSILON);
assertEquals(3 * Math.PI / 180, options.getMaximumStepAngle(), EPSILON);
}
@Test
@DisplayName("Compare PinkNoiseWindModel and MultiLevelPinkNoiseWindModel in SimulationConditions")
public void testWindModelComparison() {
SimulationConditions conditions = new SimulationConditions();
// Test PinkNoiseWindModel
PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
pinkNoiseModel.setAverage(5.0);
pinkNoiseModel.setStandardDeviation(1.0);
pinkNoiseModel.setDirection(Math.PI / 4); // 45 degrees
conditions.setWindModel(pinkNoiseModel);
Coordinate pinkNoiseVelocity = conditions.getWindModel().getWindVelocity(0, 100);
assertNotNull(pinkNoiseVelocity);
assertTrue(pinkNoiseVelocity.length() > 0);
// Test MultiLevelPinkNoiseWindModel
MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4, 1);
multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 2);
conditions.setWindModel(multiLevelModel);
Coordinate multiLevelVelocity = conditions.getWindModel().getWindVelocity(0, 100);
assertNotNull(multiLevelVelocity);
assertTrue(multiLevelVelocity.length() > 0);
// Compare behaviors
assertNotEquals(pinkNoiseVelocity, multiLevelVelocity);
}
@Test
@DisplayName("Test wind velocity consistency for MultiLevelPinkNoiseWindModel")
public void testMultiLevelWindModelConsistency() {
SimulationConditions conditions = new SimulationConditions();
MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
multiLevelModel.addWindLevel(0, 5.0, Math.PI / 4, 2);
multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 1);
conditions.setWindModel(multiLevelModel);
Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 500);
Coordinate velocity2 = conditions.getWindModel().getWindVelocity(0, 500);
assertEquals(velocity1, velocity2);
}
@Test
@DisplayName("Test wind velocity variation for PinkNoiseWindModel")
public void testPinkNoiseWindModelVariation() {
SimulationConditions conditions = new SimulationConditions();
PinkNoiseWindModel pinkNoiseModel = new PinkNoiseWindModel();
pinkNoiseModel.setAverage(5.0);
pinkNoiseModel.setStandardDeviation(1.0);
pinkNoiseModel.setDirection(Math.PI / 4);
conditions.setWindModel(pinkNoiseModel);
Coordinate velocity1 = conditions.getWindModel().getWindVelocity(0, 100);
Coordinate velocity2 = conditions.getWindModel().getWindVelocity(1, 100);
assertNotEquals(velocity1, velocity2);
}
@Test
@DisplayName("Test altitude dependence of MultiLevelPinkNoiseWindModel")
public void testMultiLevelWindModelAltitudeDependence() {
SimulationConditions conditions = new SimulationConditions();
MultiLevelPinkNoiseWindModel multiLevelModel = new MultiLevelPinkNoiseWindModel();
multiLevelModel.addWindLevel(0, 5.0, 0, 0);
multiLevelModel.addWindLevel(1000, 10.0, Math.PI / 2, 0);
conditions.setWindModel(multiLevelModel);
Coordinate velocityLow = conditions.getWindModel().getWindVelocity(0, 0);
Coordinate velocityHigh = conditions.getWindModel().getWindVelocity(0, 1000);
Coordinate velocityMid = conditions.getWindModel().getWindVelocity(0, 500);
assertNotEquals(velocityLow, velocityHigh);
assertTrue(velocityMid.length() > velocityLow.length() && velocityMid.length() < velocityHigh.length());
}
private static class PreferencesModule extends AbstractModule {
@Override
protected void configure() {

View File

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

View File

@ -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 (<docprefs>).
Added wind model settings (<wind mode="{average or multilevel}">), and windmodeltype to simulation conditions.

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

@ -0,0 +1,244 @@
package info.openrocket.swing.gui.simulation;
import info.openrocket.core.l10n.Translator;
import info.openrocket.core.models.wind.MultiLevelPinkNoiseWindModel;
import info.openrocket.core.startup.Application;
import info.openrocket.core.unit.Unit;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
import javax.swing.JPanel;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dialog;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.event.WindowListener;
import java.awt.geom.AffineTransform;
import java.util.Comparator;
import java.util.List;
public class WindLevelVisualizationDialog extends JDialog {
private static final Translator trans = Application.getTranslator();
private final WindLevelVisualization visualization;
private final JCheckBox showDirectionsCheckBox;
public WindLevelVisualizationDialog(Dialog owner, MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) {
super(owner, trans.get("WindLevelVisualizationDialog.title.WindLevelVisualization"), false);
visualization = new WindLevelVisualization(model, altitudeUnit, speedUnit);
visualization.setPreferredSize(new Dimension(400, 500));
JPanel contentPane = new JPanel(new BorderLayout());
contentPane.add(visualization, BorderLayout.CENTER);
// Use BorderLayout for the control panel
JPanel controlPanel = new JPanel(new BorderLayout());
controlPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5)); // Add some padding
// Checkbox on the left
showDirectionsCheckBox = new JCheckBox(trans.get("WindLevelVisualizationDialog.checkbox.ShowDirections"));
showDirectionsCheckBox.setSelected(true);
showDirectionsCheckBox.addActionListener(e -> {
visualization.setShowDirections(showDirectionsCheckBox.isSelected());
visualization.repaint();
});
controlPanel.add(showDirectionsCheckBox, BorderLayout.WEST);
// Close button on the right
JButton closeButton = new JButton(trans.get("button.close"));
closeButton.addActionListener(e -> dispose());
JPanel closeButtonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
closeButtonPanel.add(closeButton);
controlPanel.add(closeButtonPanel, BorderLayout.EAST);
contentPane.add(controlPanel, BorderLayout.SOUTH);
setContentPane(contentPane);
pack();
setLocationRelativeTo(owner);
setDefaultCloseOperation(HIDE_ON_CLOSE);
setAlwaysOnTop(true);
}
public void updateUnits(Unit altitudeUnit, Unit speedUnit) {
visualization.updateUnits(altitudeUnit, speedUnit);
}
private static class WindLevelVisualization extends JPanel {
private final MultiLevelPinkNoiseWindModel model;
private static final int MARGIN = 50;
private static final int ARROW_SIZE = 10;
private static final int TICK_LENGTH = 5;
private Unit altitudeUnit;
private Unit speedUnit;
private boolean showDirections = true;
public WindLevelVisualization(MultiLevelPinkNoiseWindModel model, Unit altitudeUnit, Unit speedUnit) {
this.model = model;
this.altitudeUnit = altitudeUnit;
this.speedUnit = speedUnit;
}
public void updateUnits(Unit altitudeUnit, Unit speedUnit) {
this.altitudeUnit = altitudeUnit;
this.speedUnit = speedUnit;
repaint();
}
public void setShowDirections(boolean showDirections) {
this.showDirections = showDirections;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2d = (Graphics2D) g;
int width = getWidth();
int height = getHeight();
// Enable antialiasing for smoother lines
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// Draw background
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, width, height);
List<MultiLevelPinkNoiseWindModel.LevelWindModel> levels = model.getLevels();
if (levels.isEmpty()) return;
// Sort levels before drawing
levels.sort(Comparator.comparingDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getAltitude));
double maxAltitude = levels.stream().mapToDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getAltitude).max().orElse(1000);
double maxSpeed = levels.stream().mapToDouble(MultiLevelPinkNoiseWindModel.LevelWindModel::getSpeed).max().orElse(10);
// Extend axis ranges by 10% for drawing
double extendedMaxAltitude = maxAltitude * 1.1;
double extendedMaxSpeed = maxSpeed * 1.1;
// Draw axes
drawAxes(g2d, width, height, maxSpeed, maxAltitude, extendedMaxSpeed, extendedMaxAltitude);
// Draw wind levels
for (int i = 0; i < levels.size(); i++) {
MultiLevelPinkNoiseWindModel.LevelWindModel level = levels.get(i);
int x = MARGIN + (int) (level.getSpeed() / extendedMaxSpeed * (width - 2 * MARGIN));
int y = height - MARGIN - (int) (level.getAltitude() / extendedMaxAltitude * (height - 2 * MARGIN));
// Draw point
g2d.setColor(Color.BLUE);
g2d.fillOval(x - 3, y - 3, 6, 6);
// Draw wind direction arrow
if (showDirections) {
drawWindArrow(g2d, x, y, level.getDirection());
}
// Draw connecting line if not the first point
if (i > 0) {
MultiLevelPinkNoiseWindModel.LevelWindModel prevLevel = levels.get(i - 1);
int prevX = MARGIN + (int) (prevLevel.getSpeed() / extendedMaxSpeed * (width - 2 * MARGIN));
int prevY = height - MARGIN - (int) (prevLevel.getAltitude() / extendedMaxAltitude * (height - 2 * MARGIN));
g2d.setColor(Color.GRAY);
g2d.drawLine(prevX, prevY, x, y);
}
}
}
private void drawAxes(Graphics2D g2d, int width, int height, double maxSpeed, double maxAltitude,
double extendedMaxSpeed, double extendedMaxAltitude) {
g2d.setColor(Color.BLACK);
// Draw X-axis
g2d.drawLine(MARGIN, height - MARGIN, width - MARGIN, height - MARGIN);
drawFilledArrowHead(g2d, width - MARGIN + (ARROW_SIZE-2), height - MARGIN, ARROW_SIZE, 0);
// Draw Y-axis
g2d.drawLine(MARGIN, height - MARGIN, MARGIN, MARGIN);
drawFilledArrowHead(g2d, MARGIN, MARGIN - (ARROW_SIZE-2), ARROW_SIZE, -Math.PI / 2);
// Draw max value ticks and labels
g2d.setFont(g2d.getFont().deriveFont(10f));
FontMetrics fm = g2d.getFontMetrics();
// X-axis max value
int xTickX = MARGIN + (int) ((maxSpeed / extendedMaxSpeed) * (width - 2 * MARGIN));
g2d.drawLine(xTickX, height - MARGIN, xTickX, height - MARGIN + TICK_LENGTH);
String xMaxLabel = speedUnit.toString(maxSpeed);
g2d.drawString(xMaxLabel, xTickX - fm.stringWidth(xMaxLabel) / 2, height - MARGIN + TICK_LENGTH + fm.getHeight());
// Y-axis max value
int yTickY = height - MARGIN - (int) ((maxAltitude / extendedMaxAltitude) * (height - 2 * MARGIN));
g2d.drawLine(MARGIN - TICK_LENGTH, yTickY, MARGIN, yTickY);
String yMaxLabel = altitudeUnit.toString(maxAltitude);
g2d.drawString(yMaxLabel, MARGIN - TICK_LENGTH - fm.stringWidth(yMaxLabel) - 2, yTickY + fm.getAscent() / 2);
// Draw axis labels
g2d.setFont(g2d.getFont().deriveFont(12f));
fm = g2d.getFontMetrics();
// X-axis label
String xLabel = trans.get("WindLevelVisualizationDialog.lbl.WindSpeed") + " (" + speedUnit.getUnit() + ")";
g2d.drawString(xLabel, width / 2 - fm.stringWidth(xLabel) / 2, height - 10);
// Y-axis label
String yLabel = trans.get("WindLevelVisualizationDialog.lbl.Altitude") + " (" + altitudeUnit.getUnit() + ")";
AffineTransform originalTransform = g2d.getTransform();
g2d.rotate(-Math.PI / 2);
g2d.drawString(yLabel, -height / 2 - fm.stringWidth(yLabel) / 2, MARGIN / 2);
g2d.setTransform(originalTransform);
}
private void drawFilledArrowHead(Graphics2D g, int x, int y, int arrowSize, double angle) {
int[] xPoints = new int[3];
int[] yPoints = new int[3];
xPoints[0] = x;
yPoints[0] = y;
xPoints[1] = x - (int) (arrowSize * Math.cos(angle - Math.PI / 6));
yPoints[1] = y - (int) (arrowSize * Math.sin(angle - Math.PI / 6));
xPoints[2] = x - (int) (arrowSize * Math.cos(angle + Math.PI / 6));
yPoints[2] = y - (int) (arrowSize * Math.sin(angle + Math.PI / 6));
g.fillPolygon(xPoints, yPoints, 3);
}
private void drawWindArrow(Graphics2D g, int x, int y, double direction) {
int directionVectorLength = 15;
int dx = (int) (directionVectorLength * Math.sin(direction));
int dy = (int) (directionVectorLength * Math.cos(direction));
int arrowSize = 10;
g.setColor(Color.RED);
// Draw the main line
g.drawLine(x, y, x + dx, y - dy);
int dx_arrow = (int) ((arrowSize-1) * Math.sin(direction));
int dy_arrow = (int) ((arrowSize-1) * Math.cos(direction));
// Draw filled arrow head
drawFilledArrowHead(g, x + dx + dx_arrow, y - dy - dy_arrow, arrowSize, direction - Math.PI/2);
}
}
@Override
public void dispose() {
for (WindowListener listener : getWindowListeners()) {
removeWindowListener(listener);
}
super.dispose();
}
}

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