diff --git a/.gitignore b/.gitignore index 6345bf61..15196518 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ docs/mkdocs.yml # ignore .env in root project for vscode .env + +# locust auth files +locust_auth.json diff --git a/locust/README.md b/locust/README.md new file mode 100644 index 00000000..c65d7c86 --- /dev/null +++ b/locust/README.md @@ -0,0 +1,86 @@ +# Tomorrow Now GAP Load Testing Using Locust + +## Description + +Load test using Locust. + +- Python based class +- Easy to generate scenario test using python +- Nice UI and charts that updates in real time + + +## Authentication Config + +Create a json file under locust directory called `locust_auth.json`. +Below is the sample: + +``` +[ + { + "username": "YOUR_USERNAME", + "password": "YOUR_PASSWORD", + "wait_time_start": null, + "wait_time_end": null + } +] +``` + +We can configure `wait_time_start` and `wait_time_end` for each user. If it is null, then the wait_time by default is a constant 1 second. + + +## Usage: Virtual env + +1. Create virtual environment +``` +mkvirtualenv tn_locust +``` + +Or activate existing virtual environment +``` +workon tn_locust +``` + +2. Install locust +``` +pip3 install locust +``` + +3. Run locust master +``` +locust -f weather --class-picker +``` + +There are currently 4 task types: +- `rand_var`: Random attributes length +- `rand_out`: Random output_type +- `rand_date`: Random date range +- `rand_all`: Random all + +These types are represented as task tag, so we can filter out the task that we only want to run by using parameter in the command line. + +For example, we want to run task with random attributes length: +``` +locust -f weather --class-picker --tags rand_var +``` + +The tags can also be configured in web ui for each UserClass. +Web UI is available on http://localhost:8089/ + + +## Usage: Docker Compose + +TODO: docker compose for running locust + + +## Using Locust Web UI + +TODO: add screenshots. + +To start a new test: +1. Pick one or more the User class +2. (Optional) Configure tags in User class +3. Set number of users +4. Set ramp up +5. Set the host +6. (Advanced Options) Set maximum run time +7. Click Start diff --git a/locust/common/__init__.py b/locust/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/locust/common/api.py b/locust/common/api.py new file mode 100644 index 00000000..12cf6a3d --- /dev/null +++ b/locust/common/api.py @@ -0,0 +1,109 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: API Class for Locust Load Testing +""" + + +class ApiTaskTag: + """Represent the tag for a task.""" + + RANDOM_VAR = 'rand_var' + RANDOM_OUTPUT = 'rand_out' + RANDOM_DATE = 'rand_date' + RANDOM_ALL = 'rand_all' + + +class ApiWeatherGroupMode: + """Represents how to group the API requests.""" + + BY_PRODUCT_TYPE = 1 + BY_OUTPUT_TYPE = 2 + BY_ATTRIBUTE_LENGTH = 3 + BY_DATE_COUNT = 4 + BY_QUERY_TYPE = 5 + + @staticmethod + def as_list(): + """Return the enum as list.""" + return [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_OUTPUT_TYPE, + ApiWeatherGroupMode.BY_ATTRIBUTE_LENGTH, + ApiWeatherGroupMode.BY_DATE_COUNT, + ApiWeatherGroupMode.BY_QUERY_TYPE, + ] + + +class Api: + """Provides api call to TNGAP.""" + + def __init__(self, client, user): + """Initialize the class.""" + self.client = client + self.user = user + + def get_weather_request_name( + self, group_modes, product_type, output_type, attributes, + start_date, end_date, lat=None, lon=None, bbox=None, + location_name=None, default_name=None): + """Return request name.""" + names = [] + for mode in ApiWeatherGroupMode.as_list(): + if mode not in group_modes: + continue + + name = '' + if mode == ApiWeatherGroupMode.BY_PRODUCT_TYPE: + name = product_type + elif mode == ApiWeatherGroupMode.BY_OUTPUT_TYPE: + name = output_type + elif mode == ApiWeatherGroupMode.BY_ATTRIBUTE_LENGTH: + name = f'ATTR{len(attributes)}' + elif mode == ApiWeatherGroupMode.BY_DATE_COUNT: + name = f'DT{(end_date - start_date).days}' + elif mode == ApiWeatherGroupMode.BY_QUERY_TYPE: + name = 'point' + if bbox is not None: + name = 'bbox' + elif location_name is not None: + name = 'loc' + + if name: + names.append(name) + + return default_name if len(names) == 0 else '_'.join(names) + + def weather( + self, product_type, output_type, attributes, start_date, end_date, + lat=None, lon=None, bbox=None, location_name=None, group_modes=None + ): + """Call weather API.""" + if group_modes is None: + group_modes = [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_OUTPUT_TYPE + ] + request_name = self.get_weather_request_name( + group_modes, product_type, output_type, attributes, + start_date, end_date, lat=lat, lon=lon, bbox=bbox, + location_name=location_name, default_name='weather' + ) + attributes_str = ','.join(attributes) + url = ( + f'/api/v1/measurement/?lat={lat}&lon={lon}&bbox={bbox}&' + + f'location_name={location_name}&attributes={attributes_str}&' + + f'start_date={start_date}&end_date={end_date}&' + + f'product={product_type}&output_type={output_type}' + ) + + headers = { + 'Authorization': self.user['auth'], + 'user-agent': ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' + ) + } + + self.client.get(url, headers=headers, name=request_name) diff --git a/locust/common/auth.py b/locust/common/auth.py new file mode 100644 index 00000000..9d8d14a0 --- /dev/null +++ b/locust/common/auth.py @@ -0,0 +1,44 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Auth for Locust Load Testing +""" + +import json +import random +from base64 import b64encode +from locust import between, constant + + +def basic_auth(username, password): + """Encode username and password as basic auth.""" + token = b64encode( + f"{username}:{password}".encode('utf-8')).decode("ascii") + return f'Basic {token}' + + +class AuthConfig: + """Auth users from config json file.""" + + DEFAULT_WAIT_TIME = 1 # 1 second + + def __init__(self, file_path='/mnt/locust/locust_auth.json'): + """Initialize the class.""" + with open(file_path, 'r') as json_file: + self.users = json.load(json_file) + + def get_user(self): + """Get random user.""" + user = random.choice(self.users) + wait_time = constant(self.DEFAULT_WAIT_TIME) + if user['wait_time_start'] and user['wait_time_end']: + wait_time = between( + user['wait_time_start'], user['wait_time_end']) + return { + 'auth': basic_auth(user['username'], user['password']), + 'wait_time': wait_time + } + + +auth_config = AuthConfig('locust_auth.json') diff --git a/locust/common/base_user.py b/locust/common/base_user.py new file mode 100644 index 00000000..8b1ae6fc --- /dev/null +++ b/locust/common/base_user.py @@ -0,0 +1,177 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Base Class for Locust Load Testing +""" + +import random +import datetime +from locust import HttpUser, tag, task + +from common.auth import auth_config +from common.api import Api, ApiTaskTag, ApiWeatherGroupMode + + +class BaseUserScenario(HttpUser): + """Base User scenario for testing API.""" + + product_type = '' + attributes = [] + output_types = [ + 'json', + 'csv', + 'netcdf' + ] + + # dates + min_date = datetime.date(2013, 1, 1) + max_date = datetime.date(2023, 12, 31) + min_rand_dates = 1 + max_rand_dates = 30 + default_dates = ( + datetime.date(2019, 3, 1), + datetime.date(2019, 3, 15), + ) + + # TODO: generate random? + # location + point = (-1.404244, 35.008688,) + bbox = '' + location_name = None + + # output + default_output_type = 'csv' + + def on_start(self): + """Set the test.""" + self.api = Api(self.client, auth_config.get_user()) + + def wait_time(self): + """Get wait_time in second.""" + return self.api.user['wait_time'](self) + + def _random_date(self, start_date, end_date): + """Generate random date.""" + # Calculate the difference in days between the two dates + delta = end_date - start_date + # Generate a random number of days to add to the start date + random_days = random.randint(0, delta.days) + # Return the new random date + return start_date + datetime.timedelta(days=random_days) + + def _random_attributes(self, attributes): + """Generate random selection of attributes.""" + # Choose a random number of attributes to select + # (at least 1, up to the total length of the list) + num_to_select = random.randint(1, len(attributes)) + # Randomly sample the attributes + selected_attributes = random.sample(attributes, num_to_select) + return selected_attributes + + def get_random_date_range(self): + """Generate random date range.""" + days_count = random.randint( + self.min_rand_dates, self.max_rand_dates) + rand_date = self._random_date( + self.min_date, self.max_date + ) + return ( + rand_date, + rand_date + datetime.timedelta(days=days_count) + ) + + def get_random_attributes(self): + """Generate random attributes.""" + return self._random_attributes(self.attributes) + + def get_random_output_type(self): + """Generate random output type.""" + return random.sample(self.output_types, 1)[0] + + + @tag(ApiTaskTag.RANDOM_VAR) + @task + def random_variables(self): + """Test with random variables.""" + group_modes = [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_ATTRIBUTE_LENGTH + ] + + # test with random variables + self.api.weather( + self.product_type, + self.default_output_type, + self.get_random_attributes(), + self.default_dates[0], + self.default_dates[1], + lat=self.point[0], + lon=self.point[1], + group_modes=group_modes + ) + + @tag(ApiTaskTag.RANDOM_DATE) + @task + def random_date(self): + """Test with random date.""" + group_modes = [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_DATE_COUNT + ] + dates1 = self.get_random_date_range() + + # test with random variables + self.api.weather( + self.product_type, + self.default_output_type, + self.attributes[0:3], + dates1[0], + dates1[1], + lat=self.point[0], + lon=self.point[1], + group_modes=group_modes + ) + + @tag(ApiTaskTag.RANDOM_OUTPUT) + @task + def random_output(self): + """Test with random output_type.""" + group_modes = [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_OUTPUT_TYPE + ] + # test with random variables + self.api.weather( + self.product_type, + self.get_random_output_type(), + self.attributes[0:3], + self.default_dates[0], + self.default_dates[1], + lat=self.point[0], + lon=self.point[1], + group_modes=group_modes + ) + + @tag(ApiTaskTag.RANDOM_ALL) + @task + def random_all(self): + """Test with random all.""" + group_modes = [ + ApiWeatherGroupMode.BY_PRODUCT_TYPE, + ApiWeatherGroupMode.BY_OUTPUT_TYPE, + ApiWeatherGroupMode.BY_ATTRIBUTE_LENGTH, + ApiWeatherGroupMode.BY_DATE_COUNT + ] + dates1 = self.get_random_date_range() + # test with random variables + self.api.weather( + self.product_type, + self.get_random_output_type(), + self.get_random_attributes(), + dates1[0], + dates1[1], + lat=self.point[0], + lon=self.point[1], + group_modes=group_modes + ) diff --git a/locust/location/__init__.py b/locust/location/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/locust/weather/__init__.py b/locust/weather/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/locust/weather/arable.py b/locust/weather/arable.py new file mode 100644 index 00000000..26b44f68 --- /dev/null +++ b/locust/weather/arable.py @@ -0,0 +1,48 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Arable User Class for Locust Load Testing +""" + +import datetime + +from common.base_user import BaseUserScenario + + +class ArableWeatherUser(BaseUserScenario): + """User scenario that accessing Arable ground observation data.""" + + product_type = 'arable_ground_observation' + attributes = [ + 'total_evapotranspiration_flux', + 'max_relative_humidity', + 'max_day_temperature', + 'mean_relative_humidity', + 'mean_day_temperature', + 'min_relative_humidity', + 'min_day_temperature', + 'precipitation_total', + 'precipitation', + 'sea_level_pressure', + 'wind_heading', + 'wind_speed', + 'wind_speed_max', + 'wind_speed_min' + ] + + # dates + min_date = datetime.date(2024, 10, 1) + max_date = datetime.datetime.now().date() + min_rand_dates = 1 + max_rand_dates = 30 + default_dates = ( + min_date, + min_date + datetime.timedelta(days=30), + ) + + # location + point = (-1.404244, 35.008688,) + + # output + default_output_type = 'csv' diff --git a/locust/weather/cbam.py b/locust/weather/cbam.py new file mode 100644 index 00000000..d3f79fe8 --- /dev/null +++ b/locust/weather/cbam.py @@ -0,0 +1,99 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: CBAM Class for Locust Load Testing +""" + +import datetime + +from common.base_user import BaseUserScenario + + +class CBAMWeatherUser(BaseUserScenario): + """User scenario that accessing CBAM historical data.""" + + product_type = 'cbam_historical_analysis' + attributes = [ + 'min_temperature', + 'min_day_temperature', + 'total_rainfall', + 'max_day_temperature', + 'min_night_temperature', + 'total_solar_irradiance', + 'average_solar_irradiance', + 'max_night_temperature', + 'max_temperature', + 'total_evapotranspiration_flux' + ] + + # dates + min_date = datetime.date(2013, 1, 1) + max_date = datetime.date(2023, 12, 31) + min_rand_dates = 1 + max_rand_dates = 90 + + # location + point = (-1.404244, 35.008688,) + + # output + default_output_type = 'netcdf' + + +class CBAMBiasAdjustWeatherUser(BaseUserScenario): + """User scenario that accessing CBAM Bias Adjust historical data.""" + + product_type = 'cbam_historical_analysis_bias_adjust' + attributes = [ + 'min_temperature', + 'total_rainfall', + 'total_solar_irradiance', + 'max_temperature' + ] + + # dates + min_date = datetime.date(2013, 1, 1) + max_date = datetime.date(2023, 12, 31) + min_rand_dates = 1 + max_rand_dates = 90 + + # location + point = (-1.404244, 35.008688,) + + # output + default_output_type = 'netcdf' + + +class CBAMShortTermWeatherUser(BaseUserScenario): + """User scenario that accessing CBAM Short-term forecast data.""" + + product_type = 'cbam_shortterm_forecast' + attributes = [ + 'total_rainfall', + 'total_evapotranspiration_flux', + 'max_temperature', + 'min_temperature', + 'precipitation_probability', + 'humidity_maximum', + 'humidity_minimum', + 'wind_speed_avg', + 'solar_radiation' + ] + + # dates + min_date = datetime.datetime.now().date() + max_date = ( + datetime.datetime.now().date() + datetime.timedelta(days=14) + ) + min_rand_dates = 1 + max_rand_dates = 14 + default_dates = ( + min_date, + max_date, + ) + + # location + point = (-0.02378, 35.008688,) + + # output + default_output_type = 'netcdf' diff --git a/locust/weather/disdrometer.py b/locust/weather/disdrometer.py new file mode 100644 index 00000000..c070b692 --- /dev/null +++ b/locust/weather/disdrometer.py @@ -0,0 +1,49 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Disdrometer User Class for Locust Load Testing +""" + +import datetime + +from common.base_user import BaseUserScenario + + +class DisdrometerWeatherUser(BaseUserScenario): + """User scenario that accessing Disdrometer data.""" + + product_type = 'disdrometer_ground_observation' + attributes = [ + 'atmospheric_pressure', + 'depth_of_water', + 'electrical_conductivity_of_precipitation', + 'electrical_conductivity_of_water', + 'lightning_distance', + 'shortwave_radiation', + 'soil_moisture_content', + 'soil_temperature', + 'surface_air_temperature', + 'wind_speed', + 'wind_gusts', + 'precipitation_total', + 'precipitation', + 'relative_humidity', + 'wind_heading' + ] + + # dates + min_date = datetime.date(2024, 10, 1) + max_date = datetime.datetime.now().date() + min_rand_dates = 1 + max_rand_dates = 30 + default_dates = ( + min_date, + min_date + datetime.timedelta(days=30), + ) + + # location + point = (0.00271, 34.596908,) + + # output + default_output_type = 'csv' diff --git a/locust/weather/salient.py b/locust/weather/salient.py new file mode 100644 index 00000000..75ba40d4 --- /dev/null +++ b/locust/weather/salient.py @@ -0,0 +1,57 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Salient User Class for Locust Load Testing +""" + +import datetime + +from common.base_user import BaseUserScenario + + +class SalientWeatherUser(BaseUserScenario): + """User scenario that accessing Salient forecast data.""" + + product_type = 'salient_seasonal_forecast' + attributes = [ + 'temperature', + 'temperature_clim', + 'temperature_anom', + 'precipitation', + 'precipitation_clim', + 'precipitation_anom', + 'min_temperature', + 'min_temperature_clim', + 'min_temperature_anom', + 'max_temperature', + 'max_temperature_clim', + 'max_temperature_anom', + 'relative_humidty', + 'relative_humidty_clim', + 'relative_humidty_anom', + 'solar_radiation', + 'solar_radiation_clim', + 'solar_radiation_anom', + 'wind_speed', + 'wind_speed_clim', + 'wind_speed_anom', + ] + + # dates + min_date = datetime.datetime.now().date() + max_date = ( + datetime.datetime.now().date() + datetime.timedelta(days=90) + ) + min_rand_dates = 1 + max_rand_dates = 90 + default_dates = ( + min_date, + max_date, + ) + + # location + point = (-0.625, 33.38,) + + # output + default_output_type = 'netcdf' diff --git a/locust/weather/tahmo.py b/locust/weather/tahmo.py new file mode 100644 index 00000000..ae0f81a4 --- /dev/null +++ b/locust/weather/tahmo.py @@ -0,0 +1,41 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Tahmo User Class for Locust Load Testing +""" + +import datetime + +from common.base_user import BaseUserScenario + + +class TahmoWeatherUser(BaseUserScenario): + """User scenario that accessing Tahmo ground observation data.""" + + product_type = 'tahmo_ground_observation' + attributes = [ + 'precipitation', + 'solar_radiation', + 'max_relative_humidity', + 'min_relative_humidity', + 'average_air_temperature', + 'max_air_temperature', + 'min_air_temperature' + ] + + # dates + min_date = datetime.date(2019, 1, 1) + max_date = datetime.date(2023, 12, 31) + min_rand_dates = 1 + max_rand_dates = 30 + default_dates = ( + datetime.date(2019, 3, 1), + datetime.date(2019, 3, 15), + ) + + # location + point = (-1.404244, 35.008688,) + + # output + default_output_type = 'csv' diff --git a/locust/weather/windborne.py b/locust/weather/windborne.py new file mode 100644 index 00000000..f7db75b9 --- /dev/null +++ b/locust/weather/windborne.py @@ -0,0 +1,6 @@ +# coding=utf-8 +""" +Tomorrow Now GAP. + +.. note:: Windborne User Class for Locust Load Testing +"""