diff --git a/CHANGELOG.md b/CHANGELOG.md index 078c524..de78ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm The first release is [3.0.0] because this is actually the third major version (completely rewritten) of the software. ## [Unreleased] +### Added +- Add support for signals with multiple-value acf ([#9](https://github.com/AresValley/Artemis/pull/9)). This breaks the backward compatibility because the database changed structure. + ### Fixed - The audio buttons are of the same dimension also for high resolution screens ([#13](https://github.com/AresValley/Artemis/pull/13)) - An audio sample can be paused and a different one can be played without a program crash ([#12](https://github.com/AresValley/Artemis/pull/12)) diff --git a/src/acfvalue.py b/src/acfvalue.py new file mode 100644 index 0000000..aff004a --- /dev/null +++ b/src/acfvalue.py @@ -0,0 +1,56 @@ +from constants import Constants + + +class ACFValue: + """Handle complex/multiple ACF values.""" + + def __init__(self, value): + """Given a string describing an acf value, build an object with the + following attrributes: + - is_numeric: whether the value is a number or a string; + - numeric_value: the numeric value (if any, zero otherwise); + - unknown: whether the value is unknown.""" + if value == Constants.UNKNOWN: + self._value = value + self._description = "" + self._string = self._value + self.is_numeric = False + self.unknown = True + self.numeric_value = 0.0 + else: + self.unknown = False + if Constants.ACF_SEPARATOR in value: + description, acf_value = value.split(Constants.ACF_SEPARATOR) + self._description = description + self._value = acf_value + self._string = f"{self._description}: {self._value}" + else: + self._description = "" + self._value = value + self._string = self._value + try: + self.numeric_value = float(self._value) + except Exception: + self.is_numeric = False + self.numeric_value = 0.0 + else: + self.is_numeric = True + self._string += " ms" + + @classmethod + def list_from_series(cls, series): + """Parse all acf values from the database. + + Accept an iterable of ACFValues. + Return a list of lists of ACFValues.""" + entries = [] + for entry in series: + entries.append([ + cls(value.strip()) for value in entry.split(Constants.FIELD_SEPARATOR) + ]) + return entries + + @staticmethod + def concat_strings(acf_list_values): + """Concatenate a list of ACFValues to be displayed.""" + return '\n'.join(s._string for s in acf_list_values) diff --git a/src/artemis.py b/src/artemis.py index ab67473..65eec88 100644 --- a/src/artemis.py +++ b/src/artemis.py @@ -19,7 +19,7 @@ from PyQt5 import uic from PyQt5.QtCore import (QFileInfo, Qt, pyqtSlot,) - +from acfvalue import ACFValue from audio_player import AudioPlayer from weatherdata import ForecastData from download_window import DownloadWindow @@ -410,10 +410,12 @@ class Artemis(QMainWindow, Ui_MainWindow): if answer == QMessageBox.Yes: self.download_db() else: + # Avoid a crash if there are duplicated signals self.db = self.db.groupby(level=0).first() self.signal_names = self.db.index self.total_signals = len(self.signal_names) self.db.fillna(Constants.UNKNOWN, inplace=True) + self.db[Signal.ACF] = ACFValue.list_from_series(self.db[Signal.ACF]) self.db[Signal.WIKI_CLICKED] = False self.update_status_tip(self.total_signals) self.signals_list.clear() @@ -530,7 +532,9 @@ class Artemis(QMainWindow, Ui_MainWindow): self.mode_lab.setText(current_signal.at[Signal.MODE]) self.modul_lab.setText(current_signal.at[Signal.MODULATION]) self.loc_lab.setText(current_signal.at[Signal.LOCATION]) - self.acf_lab.setText(current_signal.at[Signal.ACF]) + self.acf_lab.setText( + ACFValue.concat_strings(current_signal.at[Signal.ACF]) + ) self.description_text.setText(current_signal.at[Signal.DESCRIPTION]) for cat, cat_lab in zip(category_code, self.category_labels): if cat == '0': diff --git a/src/artemis.ui b/src/artemis.ui index bdcf1c7..7297fdc 100644 --- a/src/artemis.ui +++ b/src/artemis.ui @@ -4537,9 +4537,9 @@ Inactive ACF - + - + @@ -4631,6 +4631,53 @@ Inactive + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 12 + 75 + true + + + + Include variable ACF + + + true + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/src/constants.py b/src/constants.py index e875826..30bc4b7 100644 --- a/src/constants.py +++ b/src/constants.py @@ -88,7 +88,8 @@ class Database: Signal.MODE, Signal.INF_BAND, Signal.SUP_BAND, - Signal.CATEGORY_CODE) + Signal.CATEGORY_CODE, + Signal.ACF,) class ForecastColors: @@ -181,6 +182,7 @@ class Constants: NOT_AVAILABLE = "spectrumnotavailable.png" NOT_SELECTED = "nosignalselected.png" FIELD_SEPARATOR = ";" + ACF_SEPARATOR = " - " DEFAULT_IMGS_FOLDER = os.path.join(":", "pics", "default_pics") DEFAULT_NOT_SELECTED = os.path.join(DEFAULT_IMGS_FOLDER, NOT_SELECTED) DEFAULT_NOT_AVAILABLE = os.path.join(DEFAULT_IMGS_FOLDER, NOT_AVAILABLE) diff --git a/src/filters.py b/src/filters.py index fff8f0f..3a16c1c 100644 --- a/src/filters.py +++ b/src/filters.py @@ -5,7 +5,6 @@ The only class exposed is Filters which provides the following methods: - reset(): to reset all the applied filters; - refresh(): used when the theme is changed.""" -from collections import namedtuple from functools import partial import webbrowser @@ -729,6 +728,7 @@ class ACFFilter(_BaseFilter): self.apply_remove_btn.set_slave_filters( simple_ones=[ self._owner.include_undef_acf, + self._owner.include_variable_acf, self._owner.acf_spinbox, self._owner.acf_confidence ] @@ -761,6 +761,8 @@ class ACFFilter(_BaseFilter): uncheck_and_emit(self.apply_remove_btn) if self._owner.include_undef_acf.isChecked(): self._owner.include_undef_acf.setChecked(False) + if self._owner.include_variable_acf.isChecked(): + self._owner.include_variable_acf.setChecked(False) self._owner.acf_spinbox.setValue(50) self._owner.acf_confidence.setValue(0) @@ -768,21 +770,23 @@ class ACFFilter(_BaseFilter): """Evalaute if the signal matches the acf filters.""" if not self.apply_remove_btn.isChecked(): return True - signal_acf = self._owner.db.at[signal_name, Signal.ACF] - if signal_acf == Constants.UNKNOWN: + signal_acf_list = self._owner.db.at[signal_name, Signal.ACF] + if signal_acf_list[0].unknown: # Unknown acf are the only acf of the signal. if self._owner.include_undef_acf.isChecked(): return True else: return False else: - signal_acf = safe_cast(signal_acf.rstrip("ms"), float) tolerance = self._owner.acf_spinbox.value() * self._owner.acf_confidence.value() / 100 upper_limit = self._owner.acf_spinbox.value() + tolerance lower_limit = self._owner.acf_spinbox.value() - tolerance - if signal_acf <= upper_limit and signal_acf >= lower_limit: - return True - else: - return False + for v in signal_acf_list: + if v.is_numeric: + if lower_limit <= v.numeric_value <= upper_limit: + return True + elif self._owner.include_variable_acf.isChecked(): + return True + return False def refresh(self): """Extend _BaseFilter.refresh.""" @@ -794,57 +798,48 @@ class Filters(QObject): """Global filter class. Provides the information about all the filters. Its only public attribute - is filters, which is a namedtuple containing instances of all the filters. + is filters, which is a dictionary containing instances of all the filters. The only exposed methods are reset(), ok(signal_name) and refresh(). The class also connects the apply and reset buttons to the relevant functions.""" - _FiltersTuple = namedtuple( - "_FiltersTuple", - [ - "freq_filter", - "band_filter", - "cat_filter", - "mode_filter", - "modulation_filter", - "location_filter", - "acf_filter", - ] - ) - def __init__(self, owner): super().__init__() - self.filters = self._FiltersTuple( - FreqFilter(owner), - BandFilter(owner), - CatFilter(owner), - ModeFilter(owner), - ModulationFilter(owner), - LocFilter(owner), - ACFFilter(owner), - ) + self.filters = { + "freq_filter": FreqFilter(owner), + "band_filter": BandFilter(owner), + "cat_filter": CatFilter(owner), + "mode_filter": ModeFilter(owner), + "modulation_filter": ModulationFilter(owner), + "location_filter": LocFilter(owner), + "acf_filter": ACFFilter(owner), + } self._owner = owner - self._owner.reset_filters_btn.clicked.connect(self.reset) + self._owner.reset_filters_btn.clicked.connect(self._reset) # Connect Apply and Reset buttons clicks to functions. - for f in self.filters: + for f in self._values: f.apply_remove_btn.clicked.connect(self._display_signals) f.reset_btn.clicked.connect(f.reset) + @property + def _values(self): + return self.filters.values() + @pyqtSlot() def _display_signals(self): self._owner.display_signals() @pyqtSlot() - def reset(self): + def _reset(self): """Reset all the filters.""" - for f in self.filters: + for f in self._values: f.reset() def ok(self, signal_name): """Check whether all the filters are passed.""" - return all(f._ok(signal_name) for f in self.filters) + return all(f._ok(signal_name) for f in self._values) def refresh(self): """Refresh the relevant widgets when changing theme.""" - for f in self.filters: + for f in self._values: f.refresh()