Ejemplo n.º 1
0
    def wrapper(*args, **kwargs):
        form = get_form_from_request(request)
        if form is None:
            current_app.logger.warning(
                "Unsupported request method for unpacking 'start' and 'duration' from request."
            )
            return invalid_method(request.method)

        if "start" in form:
            start = parse_isodate_str(form["start"])
            if not start:
                current_app.logger.warning("Cannot parse 'start' value")
                return invalid_period()
            if start.tzinfo is None:
                current_app.logger.warning("Cannot parse timezone of 'start' value")
                return invalid_timezone(
                    "Start time should explicitly state a timezone."
                )
        else:
            current_app.logger.warning("Request missing 'start'.")
            return invalid_period()
        kwargs["start"] = start
        if "duration" in form:
            duration = parse_duration(form["duration"], start)
            if not duration:
                current_app.logger.warning("Cannot parse 'duration' value")
                return invalid_period()
        else:
            current_app.logger.warning("Request missing 'duration'.")
            return invalid_period()
        kwargs["duration"] = duration
        return fn(*args, **kwargs)
Ejemplo n.º 2
0
        def decorated_service(*args, **kwargs):
            form = get_form_from_request(request)
            if form is None:
                current_app.logger.warning(
                    "Unsupported request method for unpacking 'horizon' from request."
                )
                return invalid_method(request.method)

            rolling = True
            if "horizon" in form:
                horizon, rolling = parse_horizon(form["horizon"])
                if horizon is None:
                    current_app.logger.warning("Cannot parse 'horizon' value")
                    return invalid_horizon()
                elif ex_post is True:
                    if horizon > timedelta(hours=0):
                        extra_info = "Meter data must have a zero or negative horizon to indicate observations after the fact."
                        return invalid_horizon(extra_info)
            elif infer_missing is True:
                # A missing horizon is only accepted if the server can infer it
                if "start" in form and "duration" in form:
                    start = parse_isodate_str(form["start"])
                    duration = parse_duration(form["duration"], start)
                    if not start:
                        extra_info = "Cannot parse 'start' value."
                        current_app.logger.warning(extra_info)
                        return invalid_period(extra_info)
                    if start.tzinfo is None:
                        current_app.logger.warning(
                            "Cannot parse timezone of 'start' value"
                        )
                        return invalid_timezone(
                            "Start time should explicitly state a timezone."
                        )
                    if not duration:
                        extra_info = "Cannot parse 'duration' value."
                        current_app.logger.warning(extra_info)
                        return invalid_period(extra_info)
                    if current_app.config.get("FLEXMEASURES_MODE", "") == "play":
                        horizon = timedelta(hours=0)
                    else:
                        horizon = start + duration - server_now()
                    rolling = False
                else:
                    current_app.logger.warning(
                        "Request missing both 'horizon', 'start' and 'duration'."
                    )
                    extra_info = "Specify a 'horizon' value, or 'start' and 'duration' values so that the horizon can be inferred."
                    return invalid_horizon(extra_info)
            else:
                # Otherwise, a missing horizon is fine
                horizon = None

            kwargs["horizon"] = horizon
            if infer_missing is True:
                kwargs["rolling"] = rolling
            return fn(*args, **kwargs)
Ejemplo n.º 3
0
    def trigger_schedule(  # noqa: C901
        self,
        sensor: Sensor,
        start_of_schedule: datetime,
        unit: str,
        prior: datetime,
        roundtrip_efficiency: Optional[ur.Quantity] = None,
        **kwargs,
    ):
        """
        Trigger FlexMeasures to create a schedule.

        .. :quickref: Schedule; Trigger scheduling job

        The message should contain a flexibility model.

        **Example request A**

        This message triggers a schedule starting at 10.00am, at which the state of charge (soc) is 12.1 kWh.

        .. code-block:: json

            {
                "start": "2015-06-02T10:00:00+00:00",
                "soc-at-start": 12.1,
                "soc-unit": "kWh"
            }

        **Example request B**

        This message triggers a schedule starting at 10.00am, at which the state of charge (soc) is 12.1 kWh,
        with a target state of charge of 25 kWh at 4.00pm.
        The minimum and maximum soc are set to 10 and 25 kWh, respectively.
        Roundtrip efficiency for use in scheduling is set to 98%.

        .. code-block:: json

            {
                "start": "2015-06-02T10:00:00+00:00",
                "soc-at-start": 12.1,
                "soc-unit": "kWh",
                "soc-targets": [
                    {
                        "value": 25,
                        "datetime": "2015-06-02T16:00:00+00:00"
                    }
                ],
                "soc-min": 10,
                "soc-max": 25,
                "roundtrip-efficiency": 0.98
            }

        **Example response**

        This message indicates that the scheduling request has been processed without any error.
        A scheduling job has been created with some Universally Unique Identifier (UUID),
        which will be picked up by a worker.
        The given UUID may be used to obtain the resulting schedule: see /sensors/<id>/schedules/<uuid>.

        .. sourcecode:: json

            {
                "status": "PROCESSED",
                "schedule": "364bfd06-c1fa-430b-8d25-8f5a547651fb",
                "message": "Request has been processed."
            }

        :reqheader Authorization: The authentication token
        :reqheader Content-Type: application/json
        :resheader Content-Type: application/json
        :status 200: PROCESSED
        :status 400: INVALID_TIMEZONE, INVALID_DATETIME, INVALID_DOMAIN, INVALID_UNIT, PTUS_INCOMPLETE
        :status 401: UNAUTHORIZED
        :status 403: INVALID_SENDER
        :status 405: INVALID_METHOD
        """
        # todo: if a soc-sensor entity address is passed, persist those values to the corresponding sensor
        #       (also update the note in posting_data.rst about flexibility states not being persisted).

        # get value
        if "value" not in kwargs:
            return ptus_incomplete()
        try:
            value = float(kwargs.get("value"))  # type: ignore
        except ValueError:
            extra_info = "Request includes empty or ill-formatted value(s)."
            current_app.logger.warning(extra_info)
            return ptus_incomplete(extra_info)
        if unit == "kWh":
            value = value / 1000.0

        # Convert round-trip efficiency to dimensionless
        if roundtrip_efficiency is not None:
            roundtrip_efficiency = roundtrip_efficiency.to(
                ur.Quantity("dimensionless")).magnitude

        # get optional min and max SOC
        soc_min = kwargs.get("soc_min", None)
        soc_max = kwargs.get("soc_max", None)
        if soc_min is not None and unit == "kWh":
            soc_min = soc_min / 1000.0
        if soc_max is not None and unit == "kWh":
            soc_max = soc_max / 1000.0

        # set soc targets
        end_of_schedule = start_of_schedule + current_app.config.get(  # type: ignore
            "FLEXMEASURES_PLANNING_HORIZON")
        resolution = sensor.event_resolution
        soc_targets = pd.Series(
            np.nan,
            index=pd.date_range(
                start_of_schedule,
                end_of_schedule,
                freq=resolution,
                closed="right"
            ),  # note that target values are indexed by their due date (i.e. closed="right")
        )
        # todo: move deserialization of targets into TargetSchema
        for target in kwargs.get("targets", []):

            # get target value
            if "value" not in target:
                return ptus_incomplete("Target missing 'value' parameter.")
            try:
                target_value = float(target["value"])
            except ValueError:
                extra_info = "Request includes empty or ill-formatted soc target(s)."
                current_app.logger.warning(extra_info)
                return ptus_incomplete(extra_info)
            if unit == "kWh":
                target_value = target_value / 1000.0

            # get target datetime
            if "datetime" not in target:
                return invalid_datetime("Target missing datetime parameter.")
            else:
                target_datetime = target["datetime"]
                if target_datetime is None:
                    return invalid_datetime(
                        "Cannot parse target datetime string %s as iso date" %
                        target["datetime"])
                if target_datetime.tzinfo is None:
                    current_app.logger.warning(
                        "Cannot parse timezone of target 'datetime' value %s" %
                        target["datetime"])
                    return invalid_timezone(
                        "Target datetime should explicitly state a timezone.")
                if target_datetime > end_of_schedule:
                    return invalid_datetime(
                        f'Target datetime exceeds {end_of_schedule}. Maximum scheduling horizon is {current_app.config.get("FLEXMEASURES_PLANNING_HORIZON")}.'
                    )
                target_datetime = target_datetime.astimezone(
                    soc_targets.index.tzinfo
                )  # otherwise DST would be problematic

            # set target
            soc_targets.loc[target_datetime] = target_value

        job = create_scheduling_job(
            sensor.id,
            start_of_schedule,
            end_of_schedule,
            resolution=resolution,
            belief_time=prior,  # server time if no prior time was sent
            soc_at_start=value,
            soc_targets=soc_targets,
            soc_min=soc_min,
            soc_max=soc_max,
            roundtrip_efficiency=roundtrip_efficiency,
            enqueue=True,
        )

        response = dict(schedule=job.id)

        d, s = request_processed()
        return dict(**response, **d), s
Ejemplo n.º 4
0
def post_udi_event_response(unit: str, prior: datetime):

    if not has_assets():
        current_app.logger.info("User doesn't seem to have any assets.")

    form = get_form_from_request(request)

    if "datetime" not in form:
        return invalid_datetime("Missing datetime parameter.")
    else:
        datetime = parse_isodate_str(form.get("datetime"))
        if datetime is None:
            return invalid_datetime(
                "Cannot parse datetime string %s as iso date" %
                form.get("datetime"))
        if datetime.tzinfo is None:
            current_app.logger.warning(
                "Cannot parse timezone of 'datetime' value %s" %
                form.get("datetime"))
            return invalid_timezone(
                "Datetime should explicitly state a timezone.")

    # parse event/address info
    if "event" not in form:
        return invalid_domain("No event identifier sent.")
    try:
        ea = parse_entity_address(form.get("event"),
                                  entity_type="event",
                                  fm_scheme="fm0")
    except EntityAddressException as eae:
        return invalid_domain(str(eae))

    sensor_id = ea["asset_id"]
    event_id = ea["event_id"]
    event_type = ea["event_type"]

    if event_type not in ("soc", "soc-with-targets"):
        return unrecognized_event_type(event_type)

    # Look for the Sensor object
    sensor = Sensor.query.filter_by(id=sensor_id).one_or_none()
    if sensor is None or not can_access_asset(sensor):
        current_app.logger.warning("Cannot identify sensor via %s." % ea)
        return unrecognized_connection_group()
    if sensor.generic_asset.generic_asset_type.name not in (
            "battery",
            "one-way_evse",
            "two-way_evse",
    ):
        return invalid_domain(
            f"API version 1.3 only supports UDI events for batteries and Electric Vehicle Supply Equipment (EVSE). "
            f"Sensor ID:{sensor_id} does not belong to a battery or EVSE, but {p.a(sensor.generic_asset.generic_asset_type.description)}."
        )

    # unless on play, keep events ordered by entry date and ID
    if current_app.config.get("FLEXMEASURES_MODE") != "play":
        # do not allow new date to precede previous date
        if isinstance(sensor.generic_asset.get_attribute("soc_datetime"),
                      str) and datetime < datetime.fromisoformat(
                          sensor.generic_asset.get_attribute("soc_datetime")):
            msg = "The date of the requested UDI event (%s) is earlier than the latest known date (%s)." % (
                datetime,
                datetime.fromisoformat(
                    sensor.generic_asset.get_attribute("soc_datetime")),
            )
            current_app.logger.warning(msg)
            return invalid_datetime(msg)

        # check if udi event id is higher than existing
        if sensor.generic_asset.get_attribute("soc_udi_event_id") is not None:
            if sensor.generic_asset.get_attribute(
                    "soc_udi_event_id") >= event_id:
                return outdated_event_id(
                    event_id,
                    sensor.generic_asset.get_attribute("soc_udi_event_id"))

    # get value
    if "value" not in form:
        return ptus_incomplete()
    try:
        value = float(form.get("value"))
    except ValueError:
        extra_info = "Request includes empty or ill-formatted value(s)."
        current_app.logger.warning(extra_info)
        return ptus_incomplete(extra_info)
    if unit == "kWh":
        value = value / 1000.0

    # get optional efficiency
    roundtrip_efficiency = form.get("roundtrip_efficiency", None)

    # get optional min and max SOC
    soc_min = form.get("soc_min", None)
    soc_max = form.get("soc_max", None)
    if soc_min is not None and unit == "kWh":
        soc_min = soc_min / 1000.0
    if soc_max is not None and unit == "kWh":
        soc_max = soc_max / 1000.0

    # set soc targets
    start_of_schedule = datetime
    end_of_schedule = datetime + current_app.config.get(
        "FLEXMEASURES_PLANNING_HORIZON")
    resolution = sensor.event_resolution
    soc_targets = pd.Series(
        np.nan,
        index=pd.date_range(
            start_of_schedule,
            end_of_schedule,
            freq=resolution,
            closed="right"
        ),  # note that target values are indexed by their due date (i.e. closed="right")
    )

    if event_type == "soc-with-targets":
        if "targets" not in form:
            return incomplete_event(
                event_id,
                event_type,
                "Cannot process event %s with missing targets." %
                form.get("event"),
            )
        for target in form.get("targets"):

            # get target value
            if "value" not in target:
                return ptus_incomplete("Target missing value parameter.")
            try:
                target_value = float(target["value"])
            except ValueError:
                extra_info = "Request includes empty or ill-formatted target value(s)."
                current_app.logger.warning(extra_info)
                return ptus_incomplete(extra_info)
            if unit == "kWh":
                target_value = target_value / 1000.0

            # get target datetime
            if "datetime" not in target:
                return invalid_datetime("Target missing datetime parameter.")
            else:
                target_datetime = parse_isodate_str(target["datetime"])
                if target_datetime is None:
                    return invalid_datetime(
                        "Cannot parse target datetime string %s as iso date" %
                        target["datetime"])
                if target_datetime.tzinfo is None:
                    current_app.logger.warning(
                        "Cannot parse timezone of target 'datetime' value %s" %
                        target["datetime"])
                    return invalid_timezone(
                        "Target datetime should explicitly state a timezone.")
                if target_datetime > end_of_schedule:
                    return invalid_datetime(
                        f'Target datetime exceeds {end_of_schedule}. Maximum scheduling horizon is {current_app.config.get("FLEXMEASURES_PLANNING_HORIZON")}.'
                    )
                target_datetime = target_datetime.astimezone(
                    soc_targets.index.tzinfo
                )  # otherwise DST would be problematic

            # set target
            soc_targets.loc[target_datetime] = target_value

    create_scheduling_job(
        sensor_id,
        start_of_schedule,
        end_of_schedule,
        resolution=resolution,
        belief_time=prior,  # server time if no prior time was sent
        soc_at_start=value,
        soc_targets=soc_targets,
        soc_min=soc_min,
        soc_max=soc_max,
        roundtrip_efficiency=roundtrip_efficiency,
        job_id=form.get("event"),
        enqueue=True,
    )

    # Store new soc info as GenericAsset attributes
    sensor.generic_asset.set_attribute("soc_datetime", datetime.isoformat())
    sensor.generic_asset.set_attribute("soc_udi_event_id", event_id)
    sensor.generic_asset.set_attribute("soc_in_mwh", value)

    db.session.commit()
    return request_processed()
Ejemplo n.º 5
0
def post_udi_event_response(unit):  # noqa: C901

    if not has_assets():
        current_app.logger.info("User doesn't seem to have any assets.")

    form = get_form_from_request(request)

    # check datetime, or use server_now
    if "datetime" not in form:
        return invalid_datetime("Missing datetime parameter.")
    else:
        datetime = parse_isodate_str(form.get("datetime"))
        if datetime is None:
            return invalid_datetime(
                "Cannot parse datetime string %s as iso date" %
                form.get("datetime"))
        if datetime.tzinfo is None:
            current_app.logger.warning(
                "Cannot parse timezone of 'datetime' value %s" %
                form.get("datetime"))
            return invalid_timezone(
                "Datetime should explicitly state a timezone.")

    # parse event/address info
    if "event" not in form:
        return invalid_domain("No event identifier sent.")
    try:
        ea = parse_entity_address(form.get("event"),
                                  entity_type="event",
                                  fm_scheme="fm0")
    except EntityAddressException as eae:
        return invalid_domain(str(eae))

    sensor_id = ea["asset_id"]
    event_id = ea["event_id"]
    event_type = ea["event_type"]

    if event_type != "soc":
        return unrecognized_event(event_id, event_type)

    # Look for the Sensor object
    sensor = Sensor.query.filter(Sensor.id == sensor_id).one_or_none()
    if sensor is None or not can_access_asset(sensor):
        current_app.logger.warning("Cannot identify sensor via %s." % ea)
        return unrecognized_connection_group()
    if sensor.generic_asset.generic_asset_type.name != "battery":
        return invalid_domain(
            "API version 1.2 only supports UDI events for batteries. "
            "Sensor ID:%s does not belong to a battery." % sensor_id)

    # unless on play, keep events ordered by entry date and ID
    if current_app.config.get("FLEXMEASURES_MODE") != "play":
        # do not allow new date to be after last date
        if (isinstance(sensor.generic_asset.get_attribute("soc_datetime"), str)
                and datetime.fromisoformat(
                    sensor.generic_asset.get_attribute("soc_datetime")) >=
                datetime):
            msg = "The date of the requested UDI event (%s) is earlier than the latest known date (%s)." % (
                datetime,
                datetime.fromisoformat(
                    sensor.generic_asset.get_attribute("soc_datetime")),
            )
            current_app.logger.warning(msg)
            return invalid_datetime(msg)

        # check if udi event id is higher than existing
        soc_udi_event_id = sensor.generic_asset.get_attribute(
            "soc_udi_event_id")
        if soc_udi_event_id is not None and soc_udi_event_id >= event_id:
            return outdated_event_id(event_id, soc_udi_event_id)

    # get value
    if "value" not in form:
        return ptus_incomplete()
    value = form.get("value")
    if unit == "kWh":
        value = value / 1000.0

    # Store new soc info as GenericAsset attributes
    sensor.generic_asset.set_attribute("soc_datetime", datetime.isoformat())
    sensor.generic_asset.set_attribute("soc_udi_event_id", event_id)
    sensor.generic_asset.set_attribute("soc_in_mwh", value)

    db.session.commit()
    return request_processed("Request has been processed.")
Ejemplo n.º 6
0
def post_udi_event_response(unit):  # noqa: C901

    if not has_assets():
        current_app.logger.info("User doesn't seem to have any assets.")

    form = get_form_from_request(request)

    # check datetime, or use server_now
    if "datetime" not in form:
        return invalid_datetime("Missing datetime parameter.")
    else:
        datetime = parse_isodate_str(form.get("datetime"))
        if datetime is None:
            return invalid_datetime(
                "Cannot parse datetime string %s as iso date" % form.get("datetime")
            )
        if datetime.tzinfo is None:
            current_app.logger.warning(
                "Cannot parse timezone of 'datetime' value %s" % form.get("datetime")
            )
            return invalid_timezone("Datetime should explicitly state a timezone.")

    # parse event/address info
    if "event" not in form:
        return invalid_domain("No event identifier sent.")
    try:
        ea = parse_entity_address(form.get("event"), entity_type="event")
    except EntityAddressException as eae:
        return invalid_domain(str(eae))

    asset_id = ea["asset_id"]
    event_id = ea["event_id"]
    event_type = ea["event_type"]

    if event_type != "soc":
        return unrecognized_event(event_id, event_type)

    # get asset
    asset: Asset = Asset.query.filter_by(id=asset_id).one_or_none()
    if asset is None or not can_access_asset(asset):
        current_app.logger.warning("Cannot identify asset via %s." % ea)
        return unrecognized_connection_group()
    if asset.asset_type_name != "battery":
        return invalid_domain(
            "API version 1.2 only supports UDI events for batteries. Asset ID:%s is not a battery."
            % asset_id
        )

    # unless on play, keep events ordered by entry date and ID
    if current_app.config.get("FLEXMEASURES_MODE") != "play":
        # do not allow new date to be after last date
        if asset.soc_datetime is not None:
            if asset.soc_datetime >= datetime:
                msg = (
                    "The date of the requested UDI event (%s) is earlier than the latest known date (%s)."
                    % (datetime, asset.soc_datetime)
                )
                current_app.logger.warning(msg)
                return invalid_datetime(msg)

        # check if udi event id is higher than existing
        if asset.soc_udi_event_id is not None:
            if asset.soc_udi_event_id >= event_id:
                return outdated_event_id(event_id, asset.soc_udi_event_id)

    # get value
    if "value" not in form:
        return ptus_incomplete()
    value = form.get("value")
    if unit == "kWh":
        value = value / 1000.0

    # store new soc in asset
    asset.soc_datetime = datetime
    asset.soc_udi_event_id = event_id
    asset.soc_in_mwh = value

    db.session.commit()
    return request_processed("Request has been processed.")