diff --git a/flexmeasures_openweathermap/cli/commands.py b/flexmeasures_openweathermap/cli/commands.py index 3d38fcf..674e6a3 100644 --- a/flexmeasures_openweathermap/cli/commands.py +++ b/flexmeasures_openweathermap/cli/commands.py @@ -11,8 +11,9 @@ from .schemas.weather_sensor import WeatherSensorSchema from ..utils.modeling import ( get_or_create_weather_station, + get_weather_station_by_asset_id, ) -from ..utils.locating import get_locations +from ..utils.locating import get_locations, get_location_by_asset_id from ..utils.filing import make_file_path from ..utils.owm import ( save_forecasts_in_db, @@ -21,7 +22,6 @@ ) from ..sensor_specs import mapping - """ TODO: allow to also pass an asset ID or name for the weather station (instead of location) to both commands? See https://github.com/SeitaBV/flexmeasures-openweathermap/issues/2 @@ -39,16 +39,21 @@ required=True, help=f"Name of the sensor. Has to be from the supported list ({supported_sensors_list})", ) -# @click.option("--generic-asset-id", required=False, help="The asset id of the weather station (you can also give its location).") +@click.option( + "--asset-id", + required=False, + type=int, + help="The asset id of the weather station (you can also give its location).", +) @click.option( "--latitude", - required=True, + required=False, type=float, help="Latitude of where you want to measure.", ) @click.option( "--longitude", - required=True, + required=False, type=float, help="Longitude of where you want to measure.", ) @@ -69,9 +74,26 @@ def add_weather_sensor(**args): f"[FLEXMEASURES-OWM] Please correct the following errors:\n{errors}.\n Use the --help flag to learn more." ) raise click.Abort + if args["asset_id"] is not None: + weather_station = get_weather_station_by_asset_id(args["asset_id"]) + elif args["latitude"] is not None and args["longitude"] is not None: + weather_station = get_or_create_weather_station( + args["latitude"], args["longitude"] + ) + else: + raise Exception( + "Arguments are missing to register a weather sensor. Provide either '--asset-id' or ('--latitude' and '--longitude')." + ) - weather_station = get_or_create_weather_station(args["latitude"], args["longitude"]) - + sensor = Sensor.query.filter( + Sensor.name == args["name"].lower(), + Sensor.generic_asset == weather_station, + ).one_or_none() + if sensor: + click.echo( + f"[FLEXMEASURES-OWM] A '{args['name']}' weather sensor already exists at this weather station (the station's ID is {weather_station.id})." + ) + return fm_sensor_specs = get_supported_sensor_spec(args["name"]) fm_sensor_specs["generic_asset"] = weather_station fm_sensor_specs["timezone"] = args["timezone"] @@ -95,12 +117,18 @@ def add_weather_sensor(**args): @click.option( "--location", type=str, - required=True, + required=False, help='Measurement location(s). "latitude,longitude" or "top-left-latitude,top-left-longitude:' 'bottom-right-latitude,bottom-right-longitude." The first format defines one location to measure.' " The second format defines a region of interest with several (>=4) locations" ' (see also the "method" and "num_cells" parameters for details on how to use this feature).', ) +@click.option( + "--asset-id", + type=int, + required=False, + help="ID of a weather station asset - forecasts will be gotten for its location. If present, --location will be ignored.", +) @click.option( "--store-in-db/--store-as-json-files", default=True, @@ -125,7 +153,7 @@ def add_weather_sensor(**args): help="Name of the region (will create sub-folder if you store json files).", ) @task_with_status_report("get-openweathermap-forecasts") -def collect_weather_data(location, store_in_db, num_cells, method, region): +def collect_weather_data(location, asset_id, store_in_db, num_cells, method, region): """ Collect weather forecasts from the OpenWeatherMap API. This will be done for one or more locations, for which we first identify relevant weather stations. @@ -139,7 +167,14 @@ def collect_weather_data(location, store_in_db, num_cells, method, region): raise Exception( "[FLEXMEASURES-OWM] Setting OPENWEATHERMAP_API_KEY not available." ) - locations = get_locations(location, num_cells, method) + if asset_id is not None: + locations = [get_location_by_asset_id(asset_id)] + elif location is not None: + locations = get_locations(location, num_cells, method) + else: + raise Warning( + "[FLEXMEASURES-OWM] Pass either location or asset-id to get weather forecasts." + ) # Save the results if store_in_db: diff --git a/flexmeasures_openweathermap/cli/schemas/weather_sensor.py b/flexmeasures_openweathermap/cli/schemas/weather_sensor.py index 5642e3f..1ea8543 100644 --- a/flexmeasures_openweathermap/cli/schemas/weather_sensor.py +++ b/flexmeasures_openweathermap/cli/schemas/weather_sensor.py @@ -1,16 +1,13 @@ from marshmallow import ( Schema, validates, - validates_schema, ValidationError, fields, validate, ) import pytz -from flexmeasures import Sensor -from ...utils.modeling import get_or_create_weather_station from ...utils.owm import get_supported_sensor_spec, get_supported_sensors_str @@ -22,8 +19,13 @@ class WeatherSensorSchema(Schema): name = fields.Str(required=True) timezone = fields.Str() - latitude = fields.Float(required=True, validate=validate.Range(min=-90, max=90)) - longitude = fields.Float(required=True, validate=validate.Range(min=-180, max=180)) + asset_id = fields.Int(required=False, allow_none=True) + latitude = fields.Float( + required=False, validate=validate.Range(min=-90, max=90), allow_none=True + ) + longitude = fields.Float( + required=False, validate=validate.Range(min=-180, max=180), allow_none=True + ) @validates("name") def validate_name_is_supported(self, name: str): @@ -33,22 +35,6 @@ def validate_name_is_supported(self, name: str): f"Weather sensors with name '{name}' are not supported by flexmeasures-openweathermap. For now, the following is supported: [{get_supported_sensors_str()}]" ) - @validates_schema(skip_on_field_errors=False) - def validate_name_is_unique_in_weather_station(self, data, **kwargs): - if "name" not in data or "latitude" not in data or "longitude" not in data: - return # That's a different validation problem - weather_station = get_or_create_weather_station( - data["latitude"], data["longitude"] - ) - sensor = Sensor.query.filter( - Sensor.name == data["name"].lower(), - Sensor.generic_asset == weather_station, - ).one_or_none() - if sensor: - raise ValidationError( - f"A '{data['name']}' weather sensor already exists at this weather station (the station's ID is {weather_station.id})." - ) - @validates("timezone") def validate_timezone(self, timezone: str): try: diff --git a/flexmeasures_openweathermap/cli/tests/test_get_forecasts.py b/flexmeasures_openweathermap/cli/tests/test_get_forecasts.py index 104a497..9b999f3 100644 --- a/flexmeasures_openweathermap/cli/tests/test_get_forecasts.py +++ b/flexmeasures_openweathermap/cli/tests/test_get_forecasts.py @@ -66,4 +66,4 @@ def test_get_weather_forecasts_no_close_sensors( assert ( "Reported task get-openweathermap-forecasts status as True" in result.output ) - assert "No sufficiently close weather sensor found" in caplog.text + assert "no sufficiently close weather sensor found" in caplog.text diff --git a/flexmeasures_openweathermap/conftest.py b/flexmeasures_openweathermap/conftest.py index c06951d..ce074cb 100644 --- a/flexmeasures_openweathermap/conftest.py +++ b/flexmeasures_openweathermap/conftest.py @@ -5,7 +5,8 @@ from flask_sqlalchemy import SQLAlchemy from flexmeasures.app import create as create_flexmeasures_app from flexmeasures.conftest import db, fresh_db # noqa: F401 -from flexmeasures import Asset, AssetType, Sensor +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures.data.models.time_series import Sensor from flexmeasures_openweathermap import WEATHER_STATION_TYPE_NAME @@ -42,10 +43,10 @@ def add_weather_sensors_fresh_db(fresh_db) -> Dict[str, Sensor]: # noqa: F811 def create_weather_sensors(db: SQLAlchemy): # noqa: F811 """Add a weather station asset with two weather sensors.""" - weather_station_type = AssetType(name=WEATHER_STATION_TYPE_NAME) + weather_station_type = GenericAssetType(name=WEATHER_STATION_TYPE_NAME) db.session.add(weather_station_type) - weather_station = Asset( + weather_station = GenericAsset( name="Test weather station", generic_asset_type=weather_station_type, latitude=33.4843866, diff --git a/flexmeasures_openweathermap/utils/locating.py b/flexmeasures_openweathermap/utils/locating.py index 1be8d06..3ae9a02 100644 --- a/flexmeasures_openweathermap/utils/locating.py +++ b/flexmeasures_openweathermap/utils/locating.py @@ -6,7 +6,8 @@ from flask import current_app from flexmeasures.utils.grid_cells import LatLngGrid, get_cell_nums -from flexmeasures import Asset, Sensor +from flexmeasures import Sensor +from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.utils import flexmeasures_inflection from .. import WEATHER_STATION_TYPE_NAME @@ -92,7 +93,7 @@ def find_weather_sensor_by_location( n=1, ) if weather_sensor is not None: - weather_station: Asset = weather_sensor.generic_asset + weather_station: GenericAsset = weather_sensor.generic_asset if abs( location[0] - weather_station.location[0] ) > max_degree_difference_for_nearest_weather_sensor or abs( @@ -100,7 +101,7 @@ def find_weather_sensor_by_location( > max_degree_difference_for_nearest_weather_sensor ): current_app.logger.warning( - f"[FLEXMEASURES-OWM] No sufficiently close weather sensor found (within {max_degree_difference_for_nearest_weather_sensor} {flexmeasures_inflection.pluralize('degree', max_degree_difference_for_nearest_weather_sensor)} distance) for measuring {sensor_name}! We're looking for: {location}, closest available: ({weather_station.location})" + f"[FLEXMEASURES-OWM] We found a weather station, but no sufficiently close weather sensor found (within {max_degree_difference_for_nearest_weather_sensor} {flexmeasures_inflection.pluralize('degree', max_degree_difference_for_nearest_weather_sensor)} distance) for measuring {sensor_name}! We're looking for: {location}, closest available: ({weather_station.location})" ) else: current_app.logger.warning( @@ -108,3 +109,19 @@ def find_weather_sensor_by_location( % sensor_name ) return weather_sensor + + +def get_location_by_asset_id(asset_id: int) -> Tuple[float, float]: + """Get location for forecasting by passing an asset id""" + asset = GenericAsset.query.filter( + GenericAsset.generic_asset_type_id == asset_id + ).one_or_none() + if asset.generic_asset_type.name != WEATHER_STATION_TYPE_NAME: + raise Exception( + f"Asset {asset} does not seem to be a weather station we should use ― we expect an asset with type '{WEATHER_STATION_TYPE_NAME}'." + ) + if asset is None: + raise Exception( + "[FLEXMEASURES-OWM] No asset found for the given asset id %s." % asset_id + ) + return (asset.latitude, asset.longitude) diff --git a/flexmeasures_openweathermap/utils/modeling.py b/flexmeasures_openweathermap/utils/modeling.py index 40992d4..c8ee4ba 100644 --- a/flexmeasures_openweathermap/utils/modeling.py +++ b/flexmeasures_openweathermap/utils/modeling.py @@ -1,7 +1,8 @@ from packaging import version from flask import current_app -from flexmeasures import Asset, AssetType, Source, __version__ as flexmeasures_version +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures import Source, __version__ as flexmeasures_version from flexmeasures.data import db from flexmeasures.data.services.data_sources import get_or_create_source @@ -38,13 +39,13 @@ def get_or_create_owm_data_source_for_derived_data() -> Source: ) -def get_or_create_weather_station_type() -> AssetType: +def get_or_create_weather_station_type() -> GenericAssetType: """Make sure a weather station type exists""" - weather_station_type = AssetType.query.filter( - AssetType.name == WEATHER_STATION_TYPE_NAME, + weather_station_type = GenericAssetType.query.filter( + GenericAssetType.name == WEATHER_STATION_TYPE_NAME, ).one_or_none() if weather_station_type is None: - weather_station_type = AssetType( + weather_station_type = GenericAssetType( name=WEATHER_STATION_TYPE_NAME, description="A weather station with various sensors.", ) @@ -52,17 +53,17 @@ def get_or_create_weather_station_type() -> AssetType: return weather_station_type -def get_or_create_weather_station(latitude: float, longitude: float) -> Asset: +def get_or_create_weather_station(latitude: float, longitude: float) -> GenericAsset: """Make sure a weather station exists at this location.""" station_name = current_app.config.get( "WEATHER_STATION_NAME", DEFAULT_WEATHER_STATION_NAME ) - weather_station = Asset.query.filter( - Asset.latitude == latitude, Asset.longitude == longitude + weather_station = GenericAsset.query.filter( + GenericAsset.latitude == latitude, GenericAsset.longitude == longitude ).one_or_none() if weather_station is None: weather_station_type = get_or_create_weather_station_type() - weather_station = Asset( + weather_station = GenericAsset( name=station_name, generic_asset_type=weather_station_type, latitude=latitude, @@ -70,3 +71,20 @@ def get_or_create_weather_station(latitude: float, longitude: float) -> Asset: ) db.session.add(weather_station) return weather_station + + +def get_weather_station_by_asset_id(asset_id: int) -> GenericAsset: + weather_station = GenericAsset.query.filter( + GenericAsset.generic_asset_type_id == asset_id + ).one_or_none() + if weather_station is None: + raise Exception( + f"[FLEXMEASURES-OWM] Weather station is not present for the given asset id '{asset_id}'." + ) + + if weather_station.latitude is None or weather_station.longitude is None: + raise Exception( + f"[FLEXMEASURES-OWM] Weather station {weather_station} is missing location information [Latitude, Longitude]." + ) + + return weather_station