Example #1
0
def _is_valid_reading(sensor_data: typing.Dict[str, typing.Any]) -> bool:
    if sensor_data["last_seen"] < timestamp() - (60 * 60):
        # Out of date / maybe dead
        return False
    if sensor_data["channel_flags"] != "Normal":
        # Flagged for an unusually high reading
        return False
    try:
        pm25 = float(sensor_data["pm2.5"])
    except (TypeError, ValueError):
        return False
    if math.isnan(pm25):
        # Purpleair can occasionally return NaN.
        # I wonder if this is a bug on their end.
        return False
    if pm25 <= 0 or pm25 > 1000:
        # Something is very wrong
        return False
    try:
        humidity = float(sensor_data["humidity"])
    except (TypeError, ValueError):
        return False
    if math.isnan(humidity):
        return False
    latitude = sensor_data["latitude"]
    longitude = sensor_data["longitude"]
    if latitude is None or longitude is None:
        return False

    return True
Example #2
0
    def maybe_notify(self) -> bool:
        if not self.is_in_send_window:
            return False

        if self.last_alert_sent_at >= timestamp() - self.FREQUENCY:
            return False

        curr_pm25 = self.zipcode.pm25
        curr_aqi_level = Pm25.from_measurement(curr_pm25)
        curr_aqi = pm25_to_aqi(curr_pm25)

        # Only send if the pm25 changed a level since the last time we sent this alert.
        last_aqi_level = Pm25.from_measurement(self.last_pm25)
        if curr_aqi_level == last_aqi_level:
            return False

        # Do not alert clients who received an alert recently unless AQI has changed markedly.
        was_alerted_recently = self.last_alert_sent_at > timestamp() - (60 *
                                                                        60 * 6)
        last_aqi = self.last_aqi
        if (was_alerted_recently and last_aqi and curr_aqi
                and abs(curr_aqi - last_aqi) < 20):
            return False

        message = (
            "Air quality in {city} {zipcode} has changed to {curr_aqi_level} (AQI {curr_aqi}).\n"
            "\n"
            'Reply "M" for Menu or "E" to end alerts.').format(
                city=self.zipcode.city.name,
                zipcode=self.zipcode.zipcode,
                curr_aqi_level=curr_aqi_level.display,
                curr_aqi=curr_aqi,
            )
        if not self.send_message(message):
            return False

        self.last_alert_sent_at = timestamp()
        self.last_pm25 = curr_pm25
        self.num_alerts_sent += 1
        db.session.commit()

        self.log_event(EventType.ALERT,
                       zipcode=self.zipcode.zipcode,
                       pm25=curr_pm25)

        return True
Example #3
0
 def disable_alerts(self, is_automatic=False):
     if self.alerts_disabled_at == 0:
         self.alerts_disabled_at = timestamp()
         db.session.commit()
         self.log_event(
             EventType.UNSUBSCRIBE_AUTO
             if is_automatic else EventType.UNSUBSCRIBE,
             zipcode=self.zipcode.zipcode,
         )
Example #4
0
 def get_activity_counts(self):
     windows = [1, 2, 3, 4, 5, 6, 7, 30]
     curr_time = timestamp()
     counts = {window: 0 for window in windows}
     for client in self.filter_phones().all():
         for window in windows:
             ts = curr_time - (window * 24 * 60 * 60)
             if client.last_activity_at > ts or client.last_alert_sent_at > ts:
                 counts[window] += 1
     return counts
Example #5
0
def admin_bulk_sms():
    if not current_user.can_send_sms:
        return redirect(url_for("admin_summary"))
    form = BulkSMSForm(last_active_at=now())
    if form.validate_on_submit():
        bulk_send.delay(form.data["message"],
                        form.data["last_active_at"].timestamp())
        flash("Sent!")
        return redirect(url_for("admin_summary"))
    return render_template(
        "bulk_sms.html",
        form=form,
        num_inactive=Client.query.filter_inactive_since(timestamp()).count(),
    )
Example #6
0
 def get_or_create(
         self, identifier: str,
         type_code: ClientIdentifierType) -> typing.Tuple["Client", bool]:
     client = self.filter_by(identifier=identifier,
                             type_code=type_code).first()
     if not client:
         client = Client(identifier=identifier,
                         type_code=type_code,
                         last_activity_at=timestamp())
         db.session.add(client)
         db.session.commit()
         was_created = True
     else:
         was_created = False
     return client, was_created
Example #7
0
def _metrics_sync():
    updates = []
    ts = timestamp()

    zipcodes_to_sensors = collections.defaultdict(list)
    for zipcode_id, latest_reading, distance in (
            Sensor.query.join(SensorZipcodeRelation).filter(
                Sensor.updated_at > ts - (30 * 60)).with_entities(
                    SensorZipcodeRelation.zipcode_id,
                    Sensor.latest_reading,
                    SensorZipcodeRelation.distance,
                ).all()):
        zipcodes_to_sensors[zipcode_id].append((latest_reading, distance))

    for zipcode_id, sensor_tuples in zipcodes_to_sensors.items():
        readings: typing.List[float] = []
        closest_reading = float("inf")
        farthest_reading = 0.0
        for reading, distance in sorted(sensor_tuples, key=lambda s: s[1]):
            if (len(readings) < DESIRED_NUM_READINGS
                    or distance < DESIRED_READING_DISTANCE_KM):
                readings.append(reading)
                closest_reading = min(distance, closest_reading)
                farthest_reading = max(distance, farthest_reading)
            else:
                break

        if readings:
            pm25 = round(sum(readings) / len(readings), ndigits=3)
            num_sensors = len(readings)
            min_sensor_distance = round(closest_reading, ndigits=3)
            max_sensor_distance = round(farthest_reading, ndigits=3)
            updates.append({
                "id": zipcode_id,
                "pm25": pm25,
                "pm25_updated_at": ts,
                "num_sensors": num_sensors,
                "min_sensor_distance": min_sensor_distance,
                "max_sensor_distance": max_sensor_distance,
            })

    logger.info("Updating %s zipcodes", len(updates))
    for mappings in chunk_list(updates, batch_size=5000):
        db.session.bulk_update_mappings(Zipcode, mappings)
        db.session.commit()
Example #8
0
 def log_request(self, zipcode: Zipcode):
     request = Request.query.filter_by(
         client_id=self.id,
         zipcode_id=zipcode.id,
     ).first()
     now = timestamp()
     if request is None:
         request = Request(
             client_id=self.id,
             zipcode_id=zipcode.id,
             count=1,
             first_ts=now,
             last_ts=now,
         )
         db.session.add(request)
     else:
         request.count += 1
         request.last_ts = now
     db.session.commit()
Example #9
0
def admin_bulk_sms():
    if not current_user.can_send_sms:
        return redirect(url_for("admin_summary"))
    form = BulkSMSForm(last_active_at=now(), include_unsubscribed=False)
    if form.validate_on_submit():
        bulk_send.delay(
            message=form.data["message"],
            last_active_at=form.data["last_active_at"].timestamp(),
            locale=form.data["locale"],
            include_unsubscribed=form.data["include_unsubscribed"],
            is_feedback_request=form.data["is_feedback_request"],
        )
        flash("Sent!")
        return redirect(url_for("admin_summary"))
    return render_template(
        "bulk_sms.html",
        form=form,
        num_inactive=Client.query.filter_inactive_since(
            timestamp(), form.data["include_unsubscribed"]).count(),
    )
Example #10
0
def _get_purpleair_sensors_data() -> typing.List[typing.Dict[str, typing.Any]]:
    logger = get_celery_logger()
    try:
        response_dict = call_purpleair_sensors_api().json()
    except (requests.RequestException, json.JSONDecodeError) as e:
        # Send an email to an admin if data lags by more than 30 minutes.
        # Otherwise, just log a warning as most of these errors are
        # transient. In the future we might choose to retry on transient
        # failures, but it's not urgent since we will rerun the sync
        # every ten minutes anyway.
        last_updated_at = Sensor.query.get_last_updated_at()
        seconds_since_last_update = timestamp() - last_updated_at
        if seconds_since_last_update > 30 * 60:
            level = logging.ERROR
        else:
            level = logging.WARNING
        logger.log(
            level,
            "%s updating purpleair data: %s",
            type(e).__name__,
            e,
            exc_info=True,
        )
        return []
    else:
        fields = response_dict["fields"]
        channel_flags = response_dict["channel_flags"]
        data = []
        for sensor_data in response_dict["data"]:
            sensor_data = dict(zip(fields, sensor_data))
            try:
                sensor_data["channel_flags"] = channel_flags[
                    sensor_data["channel_flags"]]
            except KeyError:
                pass
            data.append(sensor_data)
        return data
Example #11
0
def _is_valid_reading(sensor_data: typing.Dict[str, typing.Any]) -> bool:
    if sensor_data.get("DEVICE_LOCATIONTYPE") != "outside":
        return False
    if sensor_data.get("ParentID"):
        return False
    if sensor_data.get("LastSeen", 0) < timestamp() - (60 * 60):
        # Out of date / maybe dead
        return False
    if sensor_data.get("Flag"):
        # Flagged for an unusually high reading
        return False
    try:
        pm25 = float(sensor_data.get("PM2_5Value", 0))
    except (TypeError, ValueError):
        return False
    if pm25 <= 0 or pm25 > 1000:
        # Something is very wrong
        return False
    latitude = sensor_data.get("Lat")
    longitude = sensor_data.get("Lon")
    if latitude is None or longitude is None:
        return False

    return True
Example #12
0
 def mark_seen(self, locale: str):
     self.last_activity_at = timestamp()
     if self.locale != locale:
         self.locale = locale
     db.session.commit()
Example #13
0
 def get_share_window(self) -> typing.Tuple[int, int]:
     ts = timestamp()
     share_window_start = ts - 60 * 15
     share_window_end = ts - 60 * 5
     return share_window_start, share_window_end
Example #14
0
 def filter_eligible_for_sending(self) -> "ClientQuery":
     cutoff = timestamp() - Client.FREQUENCY
     return (self.filter_phones().options(joinedload(
         Client.zipcode)).filter(Client.alerts_disabled_at == 0).filter(
             Client.last_alert_sent_at < cutoff).filter(
                 Client.zipcode_id.isnot(None)))
Example #15
0
 def mark_seen(self):
     self.last_activity_at = timestamp()
     db.session.commit()
Example #16
0
    def maybe_notify(self) -> bool:
        if not self.is_in_send_window:
            return False

        alert_frequency = self.alert_frequency * 60 * 60
        if self.last_alert_sent_at >= timestamp() - alert_frequency:
            return False

        curr_pm25 = self.get_current_pm25()
        curr_aqi_level = self.get_current_pm25_level()
        curr_aqi = self.get_current_aqi()

        # Only send if the pm25 changed a level since the last time we sent this alert.
        last_aqi_level = self.get_last_pm25_level()
        if curr_aqi_level == last_aqi_level:
            return False

        alert_threshold = self.alert_threshold

        # If the current AQI is below the alert threshold, and the last AQI was
        # at the alerting threshold or below, we won't send the alert.
        # For example, if the user sets their threshold at UNHEALTHY, they won't
        # be notified when the AQI transitions from UNHEALTHY to UNHEALHY FOR SENSITIVE GROUPS
        # or from UNHEALTHY to MODERATE, but will be notified if the AQI transitions from
        # VERY UNHEALTHY to UNHEALTHY.
        if curr_aqi_level < alert_threshold and last_aqi_level <= alert_threshold:
            return False

        # If the current AQI is at the alert threshold but the last AQI was under it,
        # don't send the alert because we haven't crossed the threshold yet.
        if curr_aqi_level == alert_threshold and last_aqi_level < alert_threshold:
            return False

        # Do not alert clients who received an alert recently unless AQI has changed markedly.
        was_alerted_recently = self.last_alert_sent_at > timestamp() - (60 *
                                                                        60 * 6)
        last_aqi = self.get_last_aqi()
        if was_alerted_recently and abs(curr_aqi - last_aqi) < 50:
            return False

        message = gettext(
            'AQI is now %(curr_aqi)s in zipcode %(zipcode)s (level: %(curr_aqi_level)s).\n\nReply "M" for Menu or "E" to end alerts.',
            zipcode=self.zipcode.zipcode,
            curr_aqi_level=curr_aqi_level.display,
            curr_aqi=curr_aqi,
        )
        if not self.send_message(message):
            return False

        self.last_alert_sent_at = timestamp()
        self.last_pm25 = self.zipcode.pm25
        self.last_pm_cf_1 = self.zipcode.pm_cf_1
        self.last_humidity = self.zipcode.humidity
        self.num_alerts_sent += 1
        db.session.commit()

        self.log_event(EventType.ALERT,
                       zipcode=self.zipcode.zipcode,
                       pm25=curr_pm25)

        return True
Example #17
0
 def pm25_stale_cutoff(cls) -> float:
     """Timestamp before which pm25 measurements are considered stale."""
     return timestamp() - (60 * 60)
Example #18
0
 def timestamp(self) -> int:
     return timestamp()