class Meta: """ Holds meta-information about model. """ # Delegated vars = delegate_to('model') aux = delegate_to('model') params = delegate_to('model') compile_diff_fn = delegate_to('compiler') compile_aux_fn = delegate_to('compiler') # Computed variables diff_fn = lazy(lambda self: self.compile_diff_fn()) aux_fn = lazy(lambda self: self.compile_aux_fn()) vars_size = lazy(lambda self: sum(v.size for v in self.vars.values())) aux_size = lazy(lambda self: sum(v.size for v in self.aux.values())) params_size = lazy(lambda self: sum(v.size for v in self.params.values())) t0 = 0.0 tf = 10.0 steps = 100 y0 = property( lambda self: self.compiler.vectorize_vars(self.model.var_values())) @lazy def compiler(self): m = self.model return Compiler(m.vars, m.aux, m.equations, dtype=m.dtype) def __init__(self, model): self.model = model def unvectorize_vars(self, y): """ Convert vector state to a dictionary. """ map = self.compiler.var_map() return {name: y[idx] for name, idx in map.items()} def read_var(self, name, src): """ Read named var from source array. """ idx = self.compiler.var_index(name) return src[idx] def read_aux(self, name, src): """ Read named auxiliary term from source array. """ idx = self.compiler.aux_index(name) return src[idx]
class CollectionProxy(Sequence): """ Acts as a proxy for a immutable collection of objects: i.e., attributes are augmented after iteration. """ _args = lazy(lambda x: (x._user, x._values, x._perms, x._rules, x._kwargs)) def __init__(self, obj, user, values, perms, rules, kwargs): self._obj = obj self._user = user self._values = values self._perms = perms self._rules = rules self._kwargs = kwargs def __len__(self): return len(self._obj) def __getitem__(self, idx): if isinstance(idx, slice): return CollectionProxy(self._obj[idx], *self._args) return Proxy(self._obj[idx], *self._args) def __iter__(self): args = self._args for obj in self._obj: yield Proxy(obj, *args) def __bool__(self): return bool(self._obj)
class Json(BaseElement): """ Stores JSON data. Hyperpython stores the JSON objects as data instead of text blobs. Users can introspect the data content in the .data attribute. """ _json_data = lazy(lambda self: json.dumps(self.data)) def __init__(self, data): self.data = data def __repr__(self): return "Json(%r)" % self.data def __html__(self): return self._json_data def dump(self, file): json.dump(self.data, file) def json(self): return self.data def copy(self): return Json(self.data)
def _patch(): from django.contrib.auth import get_user_model from ej.utils.functional import deprecate_lazy from sidekick import lazy, property, placeholder as _ # # Patch user model # user = get_user_model() # Enhanced querysets user.approved_comments = property(_.comments.approved()) user.rejected_comments = property(_.comments.rejected()) user.pending_comments = property(_.comments.pending()) # Statistics user.n_conversations = lazy(_.conversations.count()) user.n_total_comments = lazy(_.comments.count()) user.n_approved_comments = lazy(_.approved_comments.count()) user.n_pending_comments = lazy(_.pending_comments.count()) user.n_rejected_comments = lazy(_.rejected_comments.count()) user.n_votes = lazy(_.votes.count()) user.n_final_votes = lazy(_.votes.exclude(choice=Choice.SKIP).count()) # Deprecation warnings user.n_comments = deprecate_lazy( _.approved_comments.count(), 'The "n_comments" attribute was deprecated in favor of "n_approved_comments"\n.' "This property will be removed in future versions of EJ.", )
def _patch(): from django.contrib.auth import get_user_model from sidekick import lazy, property, placeholder as _ # # Patch user model # user = get_user_model() # Enhanced querysets user.approved_comments = property(_.comments.approved()) user.n_rejected_comments = property(_.comments.rejected()) # Statistics user.n_conversations = lazy(_.conversations.count()) user.n_comments = lazy(_.approved_comments.count()) user.n_rejected_comments = lazy(_.rejected_comments.count()) user.n_votes = lazy(_.approved_comments.count())
class ConversationProgress(ProgressBase): """ Tracks activity in conversation. """ conversation = models.OneToOneField( "ej_conversations.Conversation", related_name="progress", on_delete=models.CASCADE, ) conversation_level = models.EnumField(ConversationLevel, default=CommenterLevel.NONE) max_conversation_level = models.EnumField(ConversationLevel, default=CommenterLevel.NONE) # Non de-normalized fields: conversations n_votes = delegate_to("conversation") n_comments = delegate_to("conversation") n_rejected_comments = delegate_to("conversation") n_participants = delegate_to("conversation") n_favorites = delegate_to("conversation") n_tags = delegate_to("conversation") # Clusterization n_clusters = delegate_to("conversation") n_stereotypes = delegate_to("conversation") # Gamification n_endorsements = delegate_to("conversation") # Signals level_achievement_signal = lazy( lambda _: signals.conversation_level_achieved, shared=True) class Meta: verbose_name_plural = _("Conversation progress list") def __str__(self): return __('Progress for "{conversation}"').format( conversation=self.conversation) def compute_score(self): """ Compute the total number of points for user contribution. Conversation score is based on the following rules: * Vote: 1 points * Accepted comment: 2 points * Rejected comment: -3 points * Endorsement created: 3 points Returns: Total score (int) """ return (self.score_bias + self.n_votes + 2 * self.n_comments - 3 * self.n_rejected_comments + 3 * self.n_endorsements)
class ItemStats: """ A class that gathers basic statistics about a item. """ @lazy def filtered(self): data = self.data return data[data.index != self.skip_value] @lazy def skip(self): data = self.data filtered_data = data[data == self.skip_value] return self.total - self._pivot(filtered_data, aggfunc='count') missing = lazy(this.total - this.count) count = lazy(this._simple('count')) count_valid = lazy(this._simple('count', True)) average = lazy(this._simple('mean')) average_valid = lazy(this._simple('mean', True)) def __init__(self, data, *, total, cols=('item', 'value'), skip_value=0): self._cache = {} self.total = total self.skip_value = skip_value self.data = df = pd.DataFrame() item_col, value_col = cols df['item'] = data[item_col] df['value'] = data[value_col] def __len__(self): return len(self.data) def _get_data(self, filter=False): return self.filtered if filter else self.data def _pivot(self, data, **kwargs): return data.pivot_table('value', index='item', **kwargs) def _simple(self, aggfunc, filter=False): data = self._get_data(filter) return self._pivot(data, aggfunc=aggfunc)
def deprecate_lazy(func, msg): """ Like sidekick.lazy, but shows a deprecation message before computing function. """ func = extract_function(func) def deprecated_func(self): log.warning(msg) return func(self) return lazy(deprecated_func)
class DatabaseConf(EnvironmentConf): """ Configure the database. See also: https://docs.djangoproject.com/en/2.0/ref/settings/#databases """ DATABASE_DEFAULT = env('sqlite:///local/db/db.sqlite3', type='db_url', name='DJANGO_DB_URL') DATABASES = lazy(lambda self: { 'default': dict(TEST={}, **self.DATABASE_DEFAULT), }) # Derived inspections USING_SQLITE = lazy(this.is_using_db('sqlite')) USING_POSTGRESQL = lazy(this.is_using_db('postgresql')) USING_MYSQL = lazy(this.is_using_db('mysql')) def is_using_db(self, db, which='default'): return db in self.DATABASES[which]['ENGINE']
class CommentQueue(TimeStampedModel): """ Represents a pre-computed priority queue for non-voted comments. """ conversation = ConversationRef() user = UserRef() comments = models.TextField( blank=True, validators=[validate_comma_separated_integer_list], ) comments_list = lazy(lambda self: list(map(int, self.comments))) class Meta: unique_together = [('conversation', 'user')]
def _patch_conversation_app(): from ej.components import register_menu from ej_conversations.models import Conversation from django.utils.translation import ugettext as _ from sidekick import delegate_to, lazy from hyperpython import a not_given = object() def get_clusterization(conversation, default=not_given): """ Initialize a clusterization object for the given conversation, if it does not exist. """ try: return conversation.clusterization except (AttributeError, Clusterization.DoesNotExist): if default is not_given: mgm, _ = Clusterization.objects.get_or_create( conversation=conversation) return mgm else: return default Conversation.get_clusterization = get_clusterization Conversation._clusterization = lazy(get_clusterization) Conversation.clusters = delegate_to("_clusterization") @register_menu("conversations:detail-admin") def _detail_links(request, conversation): if request.user.has_perm("ej.can_edit_conversation", conversation): return [ a(_("Edit groups"), href=conversation.url("cluster:edit")), a(_("Manage personas"), href=conversation.url("cluster:stereotype-votes")), ] else: return [] @register_menu("conversations:detail-actions") def _detail_links(request, conversation): return [a(_("Opinion groups"), href=conversation.url("cluster:index"))]
class QuerysetSequence(collections.abc.Sequence): @staticmethod def prepare(queryset): return queryset _prepared = lazy(lambda self: self.prepare(self.queryset)) def __init__(self, queryset): self.queryset = queryset def __iter__(self): return iter(self._prepared) def __len__(self): return len(self._prepared) def __getitem__(self, item): return self._prepared[item] def __repr__(self): return "%s(%s)" % (type(self).__name__, list(self))
class QuerysetSequence(collections.Sequence): """ Base class for sequences tyeps fed by query sets instances. """ @staticmethod def prepare(queryset): return queryset _prepared = lazy(lambda self: self.prepare(self.queryset)) def __init__(self, queryset): self.queryset = queryset def __iter__(self): return iter(self._prepared) def __len__(self): return len(self._prepared) def __getitem__(self, item): return self._prepared[item] def __repr__(self): return '%s(%s)' % (type(self).__name__, list(self))
class UserProgress(ProgressBase): """ Tracks global user evolution. """ user = models.OneToOneField(settings.AUTH_USER_MODEL, related_name="progress", on_delete=models.CASCADE) commenter_level = models.EnumField(CommenterLevel, default=CommenterLevel.NONE) max_commenter_level = models.EnumField(CommenterLevel, default=CommenterLevel.NONE) host_level = models.EnumField(HostLevel, default=HostLevel.NONE) max_host_level = models.EnumField(HostLevel, default=HostLevel.NONE) profile_level = models.EnumField(ProfileLevel, default=ProfileLevel.NONE) max_profile_level = models.EnumField(ProfileLevel, default=ProfileLevel.NONE) # Non de-normalized fields: conversations app n_conversations = delegate_to("user") n_comments = delegate_to("user") n_rejected_comments = delegate_to("user") n_votes = delegate_to("user") # Gamification app n_endorsements = delegate_to("user") n_given_opinion_bridge_powers = delegate_to("user") n_given_minority_activist_powers = delegate_to("user") # Level of conversations def _level_checker(*args): *_, lvl = args # ugly trick to make static analysis happy return lazy(lambda p: p.user.conversations.filter( progress__conversation_level=lvl).count()) n_conversation_lvl_1 = _level_checker(ConversationLevel.ALIVE) n_conversation_lvl_2 = _level_checker(ConversationLevel.ENGAGING) n_conversation_lvl_3 = _level_checker(ConversationLevel.NOTEWORTHY) n_conversation_lvl_4 = _level_checker(ConversationLevel.ENGAGING) del _level_checker # Aggregators total_conversation_score = delegate_to("user") total_participation_score = delegate_to("user") # Signals level_achievement_signal = lazy(lambda _: signals.user_level_achieved, shared=True) n_trophies = 0 class Meta: verbose_name_plural = _("User progress list") def __str__(self): return __("Progress for {user}").format(user=self.user) def compute_score(self): """ Compute the total number of points earned by user. User score is based on the following rules: * Vote: 10 points * Accepted comment: 30 points * Rejected comment: -30 points * Endorsement received: 15 points * Opinion bridge: 50 points * Minority activist: 50 points * Plus the total score of created conversations. Returns: Total score (int) """ return (self.score_bias + 10 * self.n_votes + 30 * self.n_comments - 30 * self.n_rejected_comments + 15 * self.n_endorsements + 50 * self.n_given_opinion_bridge_powers + 50 * self.n_given_minority_activist_powers + self.total_conversation_score)
def _level_checker(*args): *_, lvl = args # ugly trick to make static analysis happy return lazy(lambda p: p.user.conversations.filter( progress__conversation_level=lvl).count())
class Model( WithDataModelMixin, WithInfoMixin, WithResultsMixin, WithParamsMixin, WithRegionDemography, metaclass=ModelMeta, ): """ Base class for all models. """ meta: Meta class Meta: model_name = "Model" data_aliases = {} # Initial values state: np.ndarray = None initial_cases: float = sk.lazy(lambda self: self._initial_cases()) initial_infected: float = sk.lazy(lambda self: self._initial_infected()) # Initial time date: datetime.date = None time: float = 0.0 iter: int = sk.property(lambda m: len(m.data)) dates: pd.DatetimeIndex = sk.property(lambda m: m.to_dates(m.times)) times: pd.Index = sk.property(lambda m: m.data.index) # Common epidemiological parameters R0: float = param_property("R0", default=2.0) K = sk.property(not_implemented) duplication_time = property(lambda self: np.log(2) / self.K) # Special accessors clinical: Clinical = property(lambda self: Clinical(self)) clinical_model: type = None clinical_params: Mapping = MappingProxyType({}) disease: Disease = None disease_params: DiseaseParams = None @property def ui(self) -> "UIProperty": try: from pydemic_ui.model import UIProperty except ImportError as ex: log.warn(f"Could not import pydemic_ui.model: {ex}") msg = ( "must have pydemic-ui installed to access the model.ui attribute.\n" "Please 'pip install pydemic-ui' before proceeding'") raise RuntimeError(msg) return UIProperty(self) def __init__(self, params=None, *, run=None, name=None, date=None, clinical=None, disease=None, **kwargs): self.name = name or f"{type(self).__name__} model" self.date = pd.to_datetime(date or today()) self.disease = get_disease(disease) self._initialized = False # Fix demography demography_opts = WithRegionDemography._init_from_dict(self, kwargs) self.disease_params = self.disease.params(**demography_opts) # Init other mixins WithParamsMixin.__init__(self, params, keywords=kwargs) WithInfoMixin.__init__(self) WithResultsMixin.__init__(self) WithDataModelMixin.__init__(self) if clinical: clinical = dict(clinical) self.clinical_model = clinical.pop("model", None) self.clinical_params = clinical for k, v in kwargs.items(): if hasattr(self, k): try: setattr(self, k, v) except AttributeError: name = type(self).__name__ msg = f"cannot set '{k}' attribute in '{name}' model" raise AttributeError(msg) else: raise TypeError(f"invalid arguments: {k}") if run is not None: self.run(run) def __str__(self): return self.name def _initial_cases(self): raise NotImplementedError("must be implemented in subclass") def _initial_infected(self): raise NotImplementedError("must be implemented in subclass") def epidemic_model_name(self): """ Return the epidemic model name. """ return self.meta.model_name # # Pickling and copying # # noinspection PyUnresolvedReferences def copy(self, **kwargs): """ Copy instance possibly setting new values for attributes. Keyword Args: All keyword arguments are used to reset attributes in the copy. Examples: >>> m.copy(R0=1.0, name="Stable") <SIR(name="Stable")> """ cls = type(self) data = self.__dict__.copy() params = data.pop("_params") data.pop("_results_cache") new = object.__new__(cls) for k in list(kwargs): if k in data: data[k] = kwargs.pop(k) new._params = copy(params) new._results_cache = {} new.__dict__.update(copy(data)) for k, v in kwargs.items(): setattr(new, k, v) return new def split(self, n=None, **kwargs) -> "ModelGroup": """ Create n copies of model, each one may override a different set of parameters and return a ModelGroup. Args: n: Number of copies in the resulting list. It can also be a sequence of dictionaries with arguments to pass to the .copy() constructor. Keyword Args: Keyword arguments are passed to the `.copy()` method of the model. If the keyword is a sequence, it applies the n-th component of the sequence to the corresponding n-th model. """ from ..model_group import ModelGroup if n is None: for k, v in kwargs.items(): if not isinstance(v, str) and isinstance(v, Sequence): n = len(v) break else: raise TypeError( "cannot determine the group size from arguments") if isinstance(n, int): options = [{} for _ in range(n)] else: options = [dict(d) for d in n] n: int = len(options) # Merge option dicts for k, v in kwargs.items(): if not isinstance(v, str) and isinstance(v, Sequence): xs = v m = len(xs) if m != n: raise ValueError(f"sizes do not match: " f"{k} should be a sequence of {n} " f"items, got {m}") for opt, x in zip(options, xs): opt.setdefault(k, x) else: for opt in options: opt.setdefault(k, v) # Fix name for opt in options: try: name = opt["name"] except KeyError: pass else: opt["name"] = name.format(n=n, **opt) return ModelGroup(self.copy(**opt) for opt in options) def split_children(self, options=MappingProxyType({}), **kwargs) -> "ModelGroup": """ Similar to split, but split into the children of the given class. Args: options: A mapping between region or region id """ from ..model_group import ModelGroup if self.region is None: raise ValueError("model is not bound to a region") for k in self._params: if k not in kwargs: kwargs[k] = self.get_param(k) for attr in ("disease", ): kwargs.setdefault(attr, getattr(self, attr)) return ModelGroup.from_children(self.region, type(self), options, **kwargs) def reset(self, date: Union[datetime.date, float] = None, **kwargs): """ Return a copy of the model setting the state to the final state. If a positional "date" argument is given, reset to the state to the one in the specified date. Args: date (float or date): An optional float or datetime selecting the desired date. Keyword Args: Additional keyword arguments are handled the same way as the :method:`copy` method. """ if date is None: date = self.date time = self.time elif isinstance(date, (float, int)): time = float(date) date = self.to_date(date) else: time: float = self.to_time(date) kwargs["data"] = self.data.loc[[time]] kwargs["date"] = date kwargs["state"] = kwargs["data"].iloc[0].values kwargs["time"] = 1 return self.copy(**kwargs) def trim_dates(self, start=0, end=None): """ Trim data in model to the given interval specified by start and end dates or times. Args: start (int or date): Starting date. If not given, start at zero. end (int or date): End date. If not given, select up to the final date. """ start = int(start or 0) end = int(end or self.time) new = self.copy( date=self.to_date(start), data=self.data.iloc[start:end].reset_index(drop=True), time=end - start, state=self.data.iloc[end].values, ) return new # # Initial conditions # def set_ic(self, state=None, **kwargs): """ Set initial conditions. """ if self.state is None: if state is None: state = self.initial_state(**kwargs) self.state = np.array(state, dtype=float) alias = self.meta.data_aliases for k, v in list(kwargs.items()): if k in alias: del kwargs[k] kwargs[alias[k]] = v components = extract_keys(self.meta.variables, kwargs) for k, v in components.items(): idx = self.meta.get_variable_index(k) self.state[idx] = v return self def set_data(self, data): """ Force a dataframe into simulation state. """ data = data.copy() data.columns = [self.meta.data_aliases.get(c, c) for c in data.columns] self.set_ic(state=data.iloc[0]) self.data = data.reset_index(drop=True) self.time = len(data) - 1 self.date = data.index[-1] self.state[:] = data.iloc[-1] self.info["observed.dates"] = data.index[[0, -1]] self._initialized = True return self def set_cases_from_region(self: T) -> T: """ Set the number of cases from region. """ self.set_cases() return self def set_cases(self: T, curves=None, adjust_R0=False, save_observed=False) -> T: """ Initialize model from a dataframe with the deaths and cases curve. This curve is usually the output of disease.epidemic_curve(region), and is automatically retrieved if not passed explicitly and the region of the model is set. Args: curves: Dataframe with cumulative ["cases", "deaths"] columns. If not given, or None, fetches from disease.epidemic_curves(info) adjust_R0: If true, adjust R0 from the observed cases. save_observed: If true, save the cases curves into the model.info["observed.cases"] key. """ if curves is None: warnings.warn("omitting curves from set_cases will be deprecated.") if self.region is None or self.disease is None: msg = 'must provide both "region" and "disease" or an explicit cases ' "curve." raise ValueError(msg) curves = self.region.pydemic.epidemic_curve(self.disease) if adjust_R0: warnings.warn("adjust_R0 argument is deprecated") method = "RollingOLS" if adjust_R0 is True else adjust_R0 Re, _ = value = fit.estimate_R0(self, curves, Re=True, method=method) assert np.isfinite(Re), f"invalid value for R0: {value}" self.R0 = Re # Save notification it in the info dictionary for reference if "cases_observed" in curves: tf = curves.index[-1] rate = curves.loc[tf, "cases_observed"] / curves.loc[tf, "cases"] else: rate = 1.0 self.info["observed.notification_rate"] = rate # Save simulation state from data model = self.epidemic_model_name() curve = fit.cases(curves) data = fit.epidemic_curve(model, curve, self) self.set_data(data) self.initial_cases = curve.iloc[0] if adjust_R0: self.R0 /= self["susceptible:final"] / self.population self.info["observed.R0"] = self.R0 # Optionally save cases curves into the info dictionary if save_observed: key = "observed.curves" if save_observed is True else save_observed df = curves.rename(columns={"cases": "cases_raw"}) df["cases"] = curve self.info[key] = df return self def adjust_R0(self, method="RollingOLS"): curves = self["cases"] self.R0, _ = fit.estimate_R0(self, curves, method=method) self.info["observed.R0"] = self.R0 def initial_state(self, cases=None, **kwargs): """ Create the default initial vector for model. """ if cases is not None: kwargs.setdefault("population", self.population) return formulas.initial_state(self.epidemic_model_name(), cases, self, **kwargs) return self._initial_state() def infect(self, n=1, column="infectious"): """ Convert 'n' susceptible individuals to infectious. """ last = self.data.index[-1] n = min(n, self.data.loc[last, "susceptible"]) self.data.loc[last, column] += n self.data.loc[last, "susceptible"] -= n return self def _initial_state(self): raise NotImplementedError def initialize(self): """ Force initialization. """ if not self._initialized: self.set_ic() self.data = make_dataframe(self) self._initialized = True # # Running simulation # def run(self: T, time) -> T: """ Runs the model for the given duration. """ steps = int(time) self.initialize() if time == 0: return _, *shape = self.data.shape ts = self.time + 1.0 + np.arange(steps) data = np.zeros((steps, *shape)) date = self.date if self.info.get("event.simulation_start") is None: self.info.save_event("simulation_start") self.run_to_fill(data, ts) extra = pd.DataFrame(data, columns=self.data.columns, index=ts) self.data = pd.concat([self.data, extra]) self.date = date + time * DAY self.time = ts[-1] self.state = data[-1] return self def run_to_fill(self: T, data, times) -> T: """ Run simulation to fill pre-allocated array of data. """ raise NotImplementedError def run_until(self, condition: Callable[["Model"], bool]): """ Run until stop condition is satisfied. Args: condition: A function that receives a model and return True if stop criteria is satisfied. """ raise NotImplementedError # # Utility methods # def to_dates(self, times: Sequence[int], start_date=None) -> pd.DatetimeIndex: """ Convert an array of numerical times to dates. Args: times: Sequence of times. start_date: Starting date. If not given, uses the starting date for simulation. """ dates: pd.DatetimeIndex if isinstance(times, pd.DatetimeIndex): return times if start_date is None: start_date = self.date - self.time * DAY # noinspection PyTypeChecker return pd.to_datetime(times, unit="D", origin=start_date) def to_date(self, time: Union[float, int]) -> datetime.date: """ Convert a single instant to the corresponding datetime """ return pd.to_datetime(time - self.time, unit="D", origin=self.date) def to_times(self, dates: Sequence, start_date=None) -> np.ndarray: """ Convert an array of numerical times to dates. Args: dates: Sequence of dates. start_date: Starting date. If not given, uses the starting date for simulation. """ if start_date is None: start_date = self.date - self.time * DAY data = [(date - start_date).days for date in dates] return np.array(data) if data else np.array([], dtype=int) def to_time(self, date, start_date=None) -> float: """ Convert date to time. """ if start_date is None: return self.to_time(date, self.date) - self.time return float((date - start_date).days) def get_times(self, idx=None): """ Get times possibly sliced by an index. """ if idx is None: return self.times else: return self.times[idx] def get_data_time(self, idx): times = self.get_times(idx) return pd.Series(times, index=times) def get_data_date(self, idx): times = self.get_times(idx) dates = self.to_dates(times) return pd.Series(dates, index=times) def get_data_cases(self, idx): raise NotImplementedError # # Plotting and showing information # def plot( self, components=None, *, ax=None, logy=False, show=False, dates=False, legend=True, grid=True, ): """ Plot the result of simulation. """ ax = ax or plt.gca() kwargs = {"logy": logy, "ax": ax, "grid": grid, "legend": legend} def get_column(col): if dates: col += ":dates" data = self[col] return data components = self.meta.variables if components is None else components for col in components: data = get_column(col) data.plot(**kwargs) if show: plt.show()
class HospitalizationWithOverflow(HospitalizationWithDelay): """ Hospitals have a maximum number of ICU and regular clinical beds. Death rates increase when this number overflows. """ hospitalization_overflow_bias = param_property(default=0.0) icu_capacity = param_property() hospital_capacity = param_property() icu_occupancy = param_property(default=0.75) hospital_occupancy = param_property(default=0.75) icu_surge_capacity = sk.lazy(_.icu_capacity * (1 - _.icu_occupancy)) hospital_surge_capacity = sk.lazy(_.hospital_capacity * (1 - _.hospital_occupancy)) def __init__(self, *args, occupancy=None, **kwargs): if occupancy is not None: kwargs.setdefault("icu_occupancy", occupancy) kwargs.setdefault("hospital_occupancy", occupancy) super().__init__(*args, **kwargs) def _icu_capacity(self): if self.region is not None: capacity = self.region.icu_capacity if np.isfinite(capacity): return capacity return self.population def _hospital_capacity(self): if self.region is not None: capacity = self.region.hospital_capacity if np.isfinite(capacity): return capacity return self.population # # Data methods # # Deaths def get_data_deaths(self, idx): return self["natural_deaths", idx] + self["overflow_deaths", idx] def get_data_natural_deaths(self, idx): """ The number of deaths assuming healthcare system in full capacity. """ return super().get_data_deaths(idx) def get_data_overflow_deaths(self, idx): """ The number of deaths caused by overflowing the healthcare system. """ return self["icu_overflow_deaths", idx] + self["hospital_overflow_deaths", idx] def get_data_icu_overflow_deaths(self, idx): """ The number of deaths caused by overflowing ICUs. """ # We just want to comput the excess deaths, so we discount the # contribution from natural ICUFR that is computed in natural deaths scale = 1 - self.ICUFR area = cumtrapz(self["critical_overflow"] * scale, self.times, initial=0) data = pd.Series(area / self.critical_period, index=self.times) return sliced(data, idx) def get_data_hospital_overflow_deaths(self, idx): """ The number of deaths caused by overflowing regular hospital beds. """ area = cumtrapz(self["severe_overflow"], self.times, initial=0) cases = area / self.severe_period ratio = (self.Qcr / self.Qsv) * self.hospitalization_overflow_bias deaths = cases * min(ratio, 1) data = pd.Series(deaths, index=self.times) return sliced(data, idx) def get_data_overflow_death_rate(self, idx): """ Daily number of additional deaths due to overflowing the healthcare system. """ return self["overflow_deaths", idx].diff().fillna(0) def get_data_icu_overflow_death_rate(self, idx): """ Daily number of additional deaths due to overflowing the ICU capacity. """ return self["icu_overflow_deaths", idx].diff().fillna(0) def get_data_hospital_overflow_death_rate(self, idx): """ Daily number of additional deaths due to overflowing hospital capacity. """ return self["hospital_overflow_deaths", idx].diff().fillna(0) # Severe/hospitalizations dynamics def get_data_severe_overflow(self, idx): """ The number of severe cases that are not being treated in a hospital facility. """ data = np.maximum(self["severe", idx] - self.hospital_surge_capacity, 0) return pd.Series(data, index=sliced(self.times, idx)) def get_data_hospitalized_cases(self, idx): area = cumtrapz(self["hospitalized"], self.times, initial=0) data = pd.Series(area / self.severe_period, index=self.times) return sliced(data, idx) def get_data_hospitalized(self, idx): demand = self["severe", idx] data = np.minimum(demand, self.hospital_surge_capacity) return pd.Series(data, index=sliced(self.times, idx)) # Critical/ICU dynamics def get_data_critical_overflow(self, idx): """ The number of critical cases that are not being treated in an ICU facility. """ data = np.maximum(self["critical", idx] - self.icu_surge_capacity, 0) return pd.Series(data, index=sliced(self.times, idx)) def get_data_icu_cases(self, idx): area = cumtrapz(self["icu"], self.times, initial=0) data = pd.Series(area / self.critical_period, index=self.times) return sliced(data, idx) def get_data_icu(self, idx): demand = self["hospitalized", idx] data = np.minimum(demand, self.icu_surge_capacity) return pd.Series(data, index=sliced(self.times, idx)) # Aliases get_data_icu_overflow = get_data_critical_overflow get_data_hospital_overflow = get_data_severe_overflow # Capacities def get_data_hospital_capacity(self, idx): return self._get_param("hospital_capacity", idx) def get_data_hospital_surge_capacity(self, idx): return self._get_param("hospital_surge_capacity", idx) def get_data_icu_capacity(self, idx): return self._get_param("icu_capacity", idx) def get_data_icu_surge_capacity(self, idx): return self._get_param("icu_surge_capacity", idx) def _get_param(self, name, idx, value=None): data = self["infectious", idx] * 0 data.name = name data += getattr(self, name) if value is None else value return data # # Results methods # def overflow_date(self, col, value=0.0): """ Get date in which column assumes a value greater than value. """ for date, x in self[f"{col}:dates"].iteritems(): if x > value: return date return None # TODO: convert to events def get_results_value_dates__icu_overflow(self): return self.overflow_date("critical", self.icu_surge_capacity) def get_results_value_dates__hospital_overflow(self): return self.overflow_date("severe", self.hospital_surge_capacity) def get_info_value_event__icu_overflow(self): date = self.overflow_date("critical") return Event.from_model(self, "icu_overflow", date) def get_info_value_event__hospital_overflow(self): date = self.overflow_date("severe") return Event.from_model(self, "hospital_overflow", date)
class ResourceInfo: """ Stores all information about a resource that is necessary to build the corresponding serializer and viewset classes. """ model_name = lazy(lambda self: self.model.__name__) @property def field_names(self): yield from (f.name for f in self.model._meta.fields) @property def related_field_names(self): yield from (name for name, model in self.related_models) @property def related_models(self): for name in self.fields: if name in self.properties: pass try: field = self.meta.get_field(name) except FieldDoesNotExist: continue if field.related_model: yield name, field.related_model @lazy def property_methods(self): ns = {} for name, property in self.properties.items(): ns[name] = SerializerMethodField() ns['get_' + name] = property_method(property, name) return ns @lazy def queryset(self): """ Default queryset for the resource. """ fields = self.related_field_names qs = self.model._default_manager.select_related(*fields) return self.update_queryset(qs) @lazy def action_methods(self): """ Methods registered with the @rest_api.action() decorator. """ return viewset_actions(self.actions) @lazy def detail_actions(self): return { k: v for k, v in self.actions.items() if v['args'].get('detail') } @lazy def serializer_hook_methods(self): methods = {} if 'save' in self.hooks: save_hook = wrap_request_instance_method(self.hooks['save']) methods['save_hook'] = save_hook return methods @lazy def viewset_hook_methods(self): methods = {} for hook in ('delete', 'query'): if hook in self.hooks: hook_method = wrap_request_instance_method(self.hooks[hook]) methods[hook + '_hook'] = hook_method return methods def __init__( self, model: Model, # Fields fields=None, exclude=(), # Urls and views base_url=None, base_name=None, # Viewset options viewset_base=RestAPIBaseViewSet, update_queryset=lambda x: x, # Serializer options serializer_base=RestAPISerializer, # Other options inline=False, lookup_field='pk'): self.model = model self.meta = model._meta self._model_fields = {f.name: f for f in self.meta.fields} self.inline = inline self.lookup_field = lookup_field # Field info fields = list(fields or fields_from_model(model)) for field in exclude: if field in fields: fields.remove(field) self.fields = [] for f in fields: self.add_field(f) # Url info self.base_url = base_url or natural_base_url(model) self.base_name = base_name or natural_base_url(model) # Hooks self.actions = {} self.properties = {} self.hooks = {} # Viewsets self.viewset_base = viewset_base self.update_queryset = update_queryset # Serializer self.serializer_base = serializer_base def copy(self): """ Return a copy of itelf. """ return copy(self) def add_hook(self, hook, function): if hook not in ('save', 'delete', 'query'): raise ValueError(f'invalid hook: {hook}') self.hooks[hook] = function def add_field(self, name, check=True): """ Register a new field name. """ if check: self.meta.get_field(name) if name not in self.fields: self.fields.append(name) def add_property(self, name, method): """ Register a property with the provided name. Args: name: Property name. method: A function f(obj) -> value that receives a model instance and return the corresponding property value. """ self.add_field(name, check=False) self.properties[name] = method def add_action(self, name, method, **kwargs): """ Register an action with the given name. """ if 'list' in kwargs and 'detail' in kwargs: msg = ('cannot specify both "list" and "detail" parameters ' 'simultaneously') raise TypeError(msg) elif 'list' in kwargs: kwargs['detail'] = not kwargs.pop('list') kwargs.setdefault('detail', True) self.actions[name] = {'method': method, 'args': kwargs} # # Info # def full_base_name(self, version): """ Base name with version information. """ return '%s-%s' % (version, self.base_name) def extra_kwargs(self, version): """ extra_kwargs applied on the serializer for references to the resource model. """ return { 'view_name': self.full_base_name(version) + '-detail', 'lookup_field': self.lookup_field, }
class Clusterization(TimeStampedModel): """ Manages clusterization tasks for a given conversation. """ conversation = models.OneToOneField( "ej_conversations.Conversation", on_delete=models.CASCADE, related_name="clusterization", ) cluster_status = EnumField(ClusterStatus, default=ClusterStatus.PENDING_DATA) pending_comments = models.ManyToManyField( "ej_conversations.Comment", related_name="pending_in_clusterizations", editable=False, blank=True, ) pending_votes = models.ManyToManyField( "ej_conversations.Vote", related_name="pending_in_clusterizations", editable=False, blank=True, ) unprocessed_comments = property(lambda self: self.pending_comments.count()) unprocessed_votes = property(lambda self: self.pending_votes.count()) comments = delegate_to("conversation") users = delegate_to("conversation") votes = delegate_to("conversation") owner = delegate_to("conversation", name="author") @property def stereotypes(self): return Stereotype.objects.filter(clusters__in=self.clusters.all()) @property def stereotype_votes(self): return StereotypeVote.objects.filter(comment__in=self.comments.all()) # # Statistics and annotated values # n_clusters = lazy(this.clusters.count()) n_stereotypes = lazy(this.stereotypes.count()) n_stereotype_votes = lazy(this.stereotype_votes.count()) objects = ClusterizationManager() class Meta: ordering = ["conversation_id"] def __str__(self): clusters = self.clusters.count() return f"{self.conversation} ({clusters} clusters)" def get_absolute_url(self): return self.conversation.url("cluster:index") def update_clusterization(self, force=False, atomic=False): """ Update clusters if necessary, unless force=True, in which it unconditionally updates the clusterization. """ if force or rules.test_rule("ej.must_update_clusterization", self): log.info(f"[clusters] updating cluster: {self.conversation}") if self.clusters.count() == 0: if self.cluster_status == ClusterStatus.ACTIVE: self.cluster_status = ClusterStatus.PENDING_DATA self.save() return with use_transaction(atomic=atomic): try: self.clusters.clusterize_from_votes() except ValueError: return self.pending_comments.all().delete() self.pending_votes.all().delete() if self.cluster_status == ClusterStatus.PENDING_DATA: self.cluster_status = ClusterStatus.ACTIVE x = self.id y = self.conversation_id self.save()
class ConversationProgress(ProgressBase): """ Tracks activity in conversation. """ conversation = models.OneToOneField("ej_conversations.Conversation", related_name="progress", on_delete=models.CASCADE) conversation_level = models.EnumField( ConversationLevel, default=CommenterLevel.NONE, verbose_name=_("conversation level"), help_text=_("Measures the level of engagement for conversation."), ) max_conversation_level = models.EnumField( ConversationLevel, default=CommenterLevel.NONE, editable=False, help_text=_("maximum achieved conversation level"), ) # Non de-normalized fields: conversations n_final_votes = delegate_to("conversation") n_approved_comments = delegate_to("conversation") n_rejected_comments = delegate_to("conversation") n_participants = delegate_to("conversation") n_favorites = delegate_to("conversation") n_tags = delegate_to("conversation") # Clusterization n_clusters = delegate_to("conversation") n_stereotypes = delegate_to("conversation") # Gamification n_endorsements = 0 # FIXME: delegate_to("conversation") # Signals level_achievement_signal = lazy( lambda _: signals.conversation_level_achieved, shared=True) # Points VOTE_POINTS = 1 APPROVED_COMMENT_POINTS = 2 REJECTED_COMMENT_POINTS = -0.125 ENDORSEMENT_POINTS = 3 pts_final_votes = compute_points(1) pts_approved_comments = compute_points(2) pts_rejected_comments = compute_points(-0.125) pts_endorsements = compute_points(3) objects = ProgressQuerySet.as_manager() class Meta: verbose_name = _("Conversation score") verbose_name_plural = _("Conversation scores") def __str__(self): return __('Progress for "{conversation}"').format( conversation=self.conversation) def compute_score(self): """ Compute the total number of points for user contribution. Conversation score is based on the following rules: * Vote: 1 points * Accepted comment: 2 points * Rejected comment: -3 points * Endorsement created: 3 points Returns: Total score (int) """ return int( max( 0, self.score_bias + self.pts_final_votes + self.pts_approved_comments + self.pts_rejected_comments + self.pts_endorsements, ))
class HasPlayerMixin(GameWindow): """ Mixin that adds a player element into the class. """ #: Scaling for assets scaling = 1.0 #: Player theme player_theme = 'grey' #: Initial player position (measured in tiles) player_initial_tile = 4, 1 #: Dummy physics engine physics_engine = record(can_jump=lambda: True, update=lambda *args: None) #: Default player class player_class = lazy(lambda _: Player) @lazy def player(self): x, y = self.player_initial_tile x, y = int(64 * x + 32), int(64 * y + 32) player = self.player_class(self.player_theme, scaling=self.scaling, center_x=x, center_y=y) self.__dict__['player'] = player self.on_player_init(player) return player # # Base implementations for class hooks # def on_player_init(self, player): """ Hook called when player is first created with the player as single argument. """ # # Hooks and methods overrides # def get_viewport_focus(self): player = self.player return player.left, player.bottom, player.right, player.top def update_player(self, dt): """ Update player element after a time increment of dt. """ self.player.update_clock(dt) self.player.update_actions(self.commands, self.physics_engine) self.player.update() self.player.update_animation() def update_elements(self, dt): super().update_elements(dt) self.update_player(dt) def draw_player(self): """ Draw player on screen. """ return self.player.draw_sprites() def draw_elements(self): super().draw_elements() self.draw_player()
class Conversation(HasFavoriteMixin, TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return get_user_model().objects.filter( votes__comment__conversation=self).distinct() # Comment managers def _filter_comments(*args): *_, which = args status = getattr(Comment.STATUS, which) return property(lambda self: self.comments.filter(status=status)) approved_comments = _filter_comments("approved") rejected_comments = _filter_comments("rejected") pending_comments = _filter_comments("pending") del _filter_comments class Meta: ordering = ["created"] verbose_name = _("Conversation") verbose_name_plural = _("Conversations") permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = deprecate_lazy( this.n_approved_comments, "Conversation.n_comments was deprecated in favor of .n_approved_comments." ) n_approved_comments = lazy(this.approved_comments.count()) n_pending_comments = lazy(this.pending_comments.count()) n_rejected_comments = lazy(this.rejected_comments.count()) n_total_comments = lazy(this.comments.count().count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_final_votes = lazy(this.votes.exclude(choice=Choice.SKIP).count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_total_comments = lazy(this.user_comments.count()) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) n_user_final_votes = lazy( this.user_votes.exclude(choice=Choice.SKIP).count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) # Statistical methods vote_count = vote_count statistics = statistics statistics_for_user = statistics_for_user @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 # FIXME: endorsements def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if self.__dict__.get("for_user", user) != user or self.__dict__.get( "request", request) != request: raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if self.is_promoted and self.author_id is not None and not self.author.has_perm( can_edit, self): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which="conversation:detail", board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) if comment.status == comment.STATUS.approved and author != self.author: comment_moderated.send( Comment, comment=comment, moderator=comment.moderator, is_approved=True, author=comment.author, ) log.info("new comment: %s" % comment) return comment def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment return None def next_comment_with_id(self, user, comment_id=None): """ Returns a comment with id if user didn't vote yet, otherwhise return a random comment. """ if comment_id: try: return self.approved_comments.exclude(votes__author=user).get( id=comment_id) except Exception as e: pass return self.next_comment(user)
class Conversation(TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return (get_user_model().objects.filter( votes__comment__conversation=self).distinct()) @property def approved_comments(self): return self.comments.filter(status=Comment.STATUS.approved) class Meta: ordering = ["created"] permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = lazy( this.comments.filter(status=Comment.STATUS.approved).count()) n_pending_comments = lazy( this.comments.filter(status=Comment.STATUS.pending).count()) n_rejected_comments = lazy( this.comments.filter(status=Comment.STATUS.rejected).count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if (self.__dict__.get("for_user", user) != user or self.__dict__.get("request", request) != request): raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if (self.is_promoted and self.author_id is not None and not self.author.has_perm(can_edit, self)): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which, board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) log.info("new comment: %s" % comment) return comment def vote_count(self, which=None): """ Return the number of votes of a given type. """ kwargs = {"comment__conversation_id": self.id} if which is not None: kwargs["choice"] = which return Vote.objects.filter(**kwargs).count() def statistics(self, cache=True): """ Return a dictionary with basic statistics about conversation. """ if cache: try: return self._cached_statistics except AttributeError: self._cached_statistics = self.statistics(False) return self._cached_statistics return { # Vote counts "votes": self.votes.aggregate( agree=Count("choice", filter=Q(choice=Choice.AGREE)), disagree=Count("choice", filter=Q(choice=Choice.DISAGREE)), skip=Count("choice", filter=Q(choice=Choice.SKIP)), total=Count("choice"), ), # Comment counts "comments": self.comments.aggregate( approved=Count("status", filter=Q(status=Comment.STATUS.approved)), rejected=Count("status", filter=Q(status=Comment.STATUS.rejected)), pending=Count("status", filter=Q(status=Comment.STATUS.pending)), total=Count("status"), ), # Participants count "participants": { "voters": (get_user_model().objects.filter( votes__comment__conversation_id=self.id).distinct().count( )), "commenters": (get_user_model().objects.filter( comments__conversation_id=self.id, comments__status=Comment.STATUS.approved, ).distinct().count()), }, } def statistics_for_user(self, user): """ Get information about user. """ max_votes = (self.comments.filter( status=Comment.STATUS.approved).exclude(author_id=user.id).count()) given_votes = (0 if user.id is None else (Vote.objects.filter(comment__conversation_id=self.id, author=user).count())) e = 1e-50 # for numerical stability return { "votes": given_votes, "missing_votes": max_votes - given_votes, "participation_ratio": given_votes / (max_votes + e), } def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment elif default is NOT_GIVEN: msg = _("No comments available for this user") raise Comment.DoesNotExist(msg) else: return default def is_favorite(self, user): """ Checks if conversation is favorite for the given user. """ return bool(self.favorites.filter(user=user).exists()) def make_favorite(self, user): """ Make conversation favorite for user. """ self.favorites.update_or_create(user=user) def remove_favorite(self, user): """ Remove favorite status for conversation """ if self.is_favorite(user): self.favorites.filter(user=user).delete() def toggle_favorite(self, user): """ Toggles favorite status of conversation. Return the final favorite status. """ try: self.favorites.get(user=user).delete() return False except ObjectDoesNotExist: self.make_favorite(user) return True
class HasPlatformsMixin(GameWindow): """ Mixin class for arcade.Window's that define methods for creating platforms and elements on screen. """ #: Configuration world_theme = 'blue' #: Scale factor scaling = 1.0 #: Platform list platforms = lazy(lambda _: arcade.SpriteList()) #: Decorations background_decorations = lazy(lambda _: arcade.SpriteList()) foreground_decorations = lazy(lambda _: arcade.SpriteList()) #: Geometric properties @lazy def scene_horizontal_end(self): return max(max(x.right for x in self.platforms), self.width) @lazy def scene_vertical_end(self): return max(max(x.top for x in self.platforms), self.height) + 128 # # Overrides # def draw_platforms(self): self.background_decorations.draw() self.platforms.draw() def draw_foreground_decorations(self): self.foreground_decorations.draw() def draw_elements(self): self.draw_platforms() super().draw_elements() def draw_foreground_elements(self): super().draw_foreground_elements() self.draw_foreground_decorations() # # Create elements # def create_ground(self, size, coords=(0, 0), end='default', height=1, roles=(Role.BACKGROUND, Role.OBJECT), **kwargs): """ Create a horizontal patch of ground. Args: size (int): Size of ground element in tiles. coords (int, int): Tuple with (x, y) coordinates of the first tile. end ({'default', 'sharp', 'round', None}): Style of ending tiles. height: Height in tiles. If height > 1, it generates extra solid ground tiles to fill the requested space. """ if end == 'default': endl, endr = 'gl', 'gr' elif end == 'sharp': endl, endr = 'sl', 'sr' elif end == 'round': endl, endr = 'rl', 'rr' elif end == 'None' or end is None: endl = endr = 'g' else: endl, endr = end # Remove smooth_ends parameter from constructor kwargs_ = kwargs.copy() kwargs_.pop('smooth_ends', None) role_fill, role_top = roles new = partial(self._get_tile, role=role_fill, **kwargs_) if height > 1: x, y = coords for j in range(height - 1): for i in range(size): pos = ((x + i) * 64 + 32, (y - j) * 64 - 32) self.__append(new('e1', position=pos)) return self.create_platform(size, coords, right=endr, left=endl, middle='g', single='gs', role=role_top, **kwargs) def create_platform(self, size, coords=(0, 0), smooth_ends=True, role=Role.PLATFORM, right='pr', left='pl', single='ps', middle='p', **kwargs): """ Create a platform. The main difference between platform and horizontal ground is that a platform does not collide with the Player when it is reaching it from bellow. A ground triggers a collision with player going from any direction. Args: size (int): Size of the platform (in number of tiles) coords (int, int): Position of initial tile. smooth_ends (bool): If True, render a rounded tile in both ends. role: Role given to platform tile. Defaults to Role.PLATFORM. single ({'ps'}): left ({'pl'}): right ({'pr'}): middle ({'p'}): Sprite used at each position on the platform. """ scale = self.scaling lst = [] x, y = coords x = (x * 64 + 32) * scale y = (y * 64 + 32) * scale dx = 64 * scale add = lambda x, **kwargs: lst.append( self._get_tile(x, role=role, **kwargs)) if size <= 0: raise ValueError('size must be positive') elif size == 1: add(single if smooth_ends else middle, position=(x, y)) elif size == 2: add(left if smooth_ends else middle, position=(x, y)) add(right if smooth_ends else middle, position=(x + dx, y)) else: add(left if smooth_ends else middle, position=(x, y)) for n in range(1, size - 1): add(middle, position=(x + n * dx, y)) pos = (x + (size - 1) * dx, y) add(right if smooth_ends else middle, position=pos) self.__extend(lst) return lst def create_ramp(self, direction, size, coords=(0, 0), fill=True, **kwargs): """ Creates a ramp that goes diagonally 'up' or 'down', according to the chosen direction. Args: direction ({'up', 'down'}): Vertical direction going from left to right. size (int): Number of elements in the diagonal. coords (int, int): Position of initial tile. fill: If True, fill with ground tiles. """ if direction == 'up': top = 'gl' u = 1 skip = 0 elif direction == 'down': top = 'gr' u = -1 skip = size - 1 else: raise TypeError("direction must be either 'up' or 'down'") bottom = 'e1' x, y = coords new = partial(self._get_tile, **kwargs) for i in range(size): tile = new(top, position=(x * 64 + 32, y * 64 + 32 * u)) self.platforms.append(tile) if i != skip: tile = new(bottom, position=(x * 64 + 32, (y - 1) * 64 + 32 * u)) self.background_decorations.append(tile) if fill: for j in range(0, skip + u * i - 1): pos = (x * 64 + 32, (y - j - 2) * 64 + u * 32) tile = new('e1', role=Role.BACKGROUND, position=pos) self.background_decorations.append(tile) x += 1 y += u def create_tower(self, height, width=1, coords=(0, 0), roles=(Role.OBJECT, Role.OBJECT), **kwargs): """ Creates a tower of blocks that climbs vertically up by the given height. Args: height (int): Vertical size in number of tiles. width (int): Width of the tower element. coords (int, int): Position of the base. """ x, y = coords kwargs.update(roles=roles, height=height) return self.create_ground(width, (x, y + height - 1), **kwargs) def create_block(self, name, coords=(0, 0), role=Role.OBJECT): """ Create a new block at given coordinates. """ sprite = get_sprite(f'other/block/{name}', scale=self.scaling, position=self.tile_to_position(*coords), role=role) self.__append(sprite) def create_arrow(self, name, coords=(0, 0), role=Role.BACKGROUND): """ Creates a new arrow """ x, y = coords x += 0.5 y += 0.4 sprite = get_sprite(f'other/arrows/{name}', scale=self.scaling, position=self.tile_to_position(x, y), role=role) self.__append(sprite) def create_fence(self, name='full', coords=(0, 0), role=Role.FOREGROUND): return self.create_object(f'other/fence/{name}', coords, role) def create_foreground(self, name, coords=(0, 0)): return self.create_object(name, coords, Role.FOREGROUND) def create_background(self, name, coords=(0, 0)): return self.create_object(name, coords, Role.BACKGROUND) def create_object(self, name, coords=(0, 0), role=Role.OBJECT): sprite = get_sprite(name, scale=self.scaling, role=role) x, y = self.tile_to_position(*coords) x = int(x + 32) y = int(y + sprite.height / 2) sprite.position = (x, y) self.__append(sprite) return sprite # # Auxiliary methods # def tile_to_position(self, i, j): return 64 * i, 64 * j def _get_tile(self, kind, color=None, scale=None, **kwargs): if color is None: color = self.world_theme if scale is None: scale = self.scaling return get_tile(kind, color, scale=scale, **kwargs) def __append(self, obj, which=None): if which: which.append(obj) elif getattr(obj, 'role', None) == Role.BACKGROUND: self.background_decorations.append(obj) elif getattr(obj, 'role', None) == Role.FOREGROUND: self.foreground_decorations.append(obj) else: self.platforms.append(obj) def __extend(self, objs, which=None): for obj in objs: self.__append(obj, which)
class HasScrollingCameraMixin(GameWindow): """ A basic game window that has a scrolling camera. """ #: The ratio of movement for background/foreground. #: ratio = 0 => no move, ratio = 1 => sync with the foreground parallax_ratio = 0.1 #: Tolerance of the reference point for the camera. It moves camera if #: the reference point (usually the player) moves beyond those margins #: Measured in pixels. viewport_margin_horizontal = 500 viewport_margin_vertical = 300 #: x, y coordinates for the start of viewport area viewport_horizontal_start = 0 viewport_vertical_start = 0 #: Automatically computed viewport end coordinates @property def viewport_horizontal_end(self): return self.viewport_horizontal_start + self.width @property def viewport_vertical_end(self): return self.viewport_vertical_start + self.height #: Min/max coordinates of the viewport in both directions scene_horizontal_start = 0 scene_horizontal_end = lazy(lambda _: _.width) scene_vertical_start = 0 scene_vertical_end = lazy(lambda _: _.height) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._viewport_focus = ( self.viewport_horizontal_start, self.viewport_vertical_start, self.viewport_horizontal_end, self.viewport_vertical_end, ) def move_with_parallax(self, obj, parallax=None, left=0, bottom=0): """ Move object relative to viewport using paralax effect. Args: obj: Displaced object parallax: Ratio between [0, 1] of paralax. If not given, uses the default parallax. left: bottom: Initial displacements of obj in respect with the current viewport. """ parallax = self.parallax_ratio if parallax is None else parallax viewport_x = self.viewport_horizontal_start viewport_y = self.viewport_vertical_start dx = obj[0].left - left - viewport_x * (1 - parallax) dy = obj[0].bottom - bottom - viewport_y * (1 - parallax) obj.move(round(-dx), round(-dy)) def move_with_viewport(self, obj, left=0, bottom=0): """ Move an object fixed with the background. """ self.move_with_parallax(obj, parallax=1.0, left=left, bottom=bottom) # # Register base implementations for class hooks # def on_viewport_changed(self): """ Hook that is executed when viewport is changed """ def get_viewport_focus(self): """ Return a bounding box of (x_min, y_min, x_max, y_max) with the region that the viewport should try to focus. """ return self.width, self.height, self.width, self.height # # Override base class methods # def update_elements(self, dt): super().update_elements(dt) self.update_viewport() def update_viewport(self): """ Update viewport to include the focused viewport area. """ xmin, ymin, xmax, ymax = self.get_viewport_focus() changed = False dx = self.viewport_margin_horizontal dy = self.viewport_margin_vertical v_xmin = self.viewport_horizontal_start v_xmax = self.viewport_horizontal_end v_ymin = self.viewport_vertical_start v_ymax = self.viewport_vertical_end # Check if player changed the viewport if xmin < v_xmin + dx: self.viewport_horizontal_start = \ max(self.scene_horizontal_start, xmin - dx) changed = True if xmax > v_xmax - dx: self.viewport_horizontal_start = \ min(self.scene_horizontal_end - self.width, xmax + dx - self.width) changed = True if ymin < v_ymin + dy: self.viewport_vertical_start = \ max(self.scene_vertical_start, ymin - dy) changed = True if ymax > v_ymax - dy: self.viewport_vertical_start = \ min(self.scene_vertical_end - self.width, ymax + dy - self.height) changed = True if changed: self.on_viewport_changed() arcade.set_viewport(round(self.viewport_horizontal_start), round(self.viewport_horizontal_end), round(self.viewport_vertical_start), round(self.viewport_vertical_end))
) comment = models.ForeignKey( 'ej_conversations.Comment', verbose_name=_('Comment'), related_name='stereotype_votes', on_delete=models.CASCADE, ) choice = EnumField(Choice, _('Choice')) stereotype = alias('author') objects = BoogieManager() def __str__(self): return f'StereotypeVote({self.author}, value={self.choice})' # # Auxiliary methods # def get_clusterization(conversation): try: return conversation.clusterization except Clusterization.DoesNotExist: mgm, _ = Clusterization.objects.get_or_create( conversation=conversation) return mgm Conversation.get_clusterization = get_clusterization Conversation._clusterization = lazy(get_clusterization) Conversation.clusters = delegate_to('_clusterization')
class ParticipationProgress(ProgressBase): """ Tracks user evolution in conversation. """ user = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="participation_progresses", on_delete=models.CASCADE, ) conversation = models.ForeignKey( "ej_conversations.Conversation", related_name="participation_progresses", on_delete=models.CASCADE, ) voter_level = models.EnumField(VoterLevel, default=VoterLevel.NONE) max_voter_level = models.EnumField(VoterLevel, default=VoterLevel.NONE) is_owner = models.BooleanField(default=False) is_focused = models.BooleanField(default=False) # Non de-normalized fields: conversations is_favorite = lazy( lambda p: p.conversation.favorites.filter(user=p.user).exists()) n_votes = lazy(lambda p: p.user.votes.filter(comment__conversation=p. conversation).count()) n_comments = lazy( lambda p: p.user.comments.filter(conversation=p.conversation).count()) n_rejected_comments = lazy(lambda p: p.user.rejected_comments.filter( conversation=p.conversation).count()) n_conversation_comments = delegate_to("conversation", name="n_comments") n_conversation_rejected_comments = delegate_to("conversation", name="n_rejected_comments") votes_ratio = lazy(this.n_votes / (this.n_conversation_comments + 1e-50)) # Gamification # n_endorsements = lazy(lambda p: Endorsement.objects.filter(comment__author=p.user).count()) # n_given_opinion_bridge_powers = delegate_to('user') # n_given_minority_activist_powers = delegate_to('user') n_endorsements = 0 n_given_opinion_bridge_powers = 0 n_given_minority_activist_powers = 0 # Leaderboard @lazy def n_conversation_scores(self): db = ParticipationProgress.objects return db.filter(conversation=self.conversation).count() @lazy def n_higher_scores(self): db = ParticipationProgress.objects return db.filter(conversation=self.conversation, score__gt=self.score).count() n_lower_scores = lazy(this.n_conversation_scores - this.n_higher_scores) # Signals level_achievement_signal = lazy( lambda _: signals.participation_level_achieved, shared=True) def __str__(self): msg = __("Progress for user: {user} at {conversation}") return msg.format(user=self.user, conversation=self.conversation) def sync(self): self.is_owner = self.conversation.author == self.user # You cannot receive a focused achievement in your own conversation! if not self.is_owner: n_comments = self.conversation.n_comments self.is_focused = n_comments == self.n_votes >= 20 return super().sync() def compute_score(self): """ Compute the total number of points earned by user. User score is based on the following rules: * Vote: 10 points * Accepted comment: 30 points * Rejected comment: -30 points * Endorsement received: 15 points * Opinion bridge: 50 points * Minority activist: 50 points * Plus the total score of created conversations. * Got a focused badge: 50 points. Returns: Total score (int) """ return (self.score_bias + 10 * self.n_votes + 30 * self.n_comments - 30 * self.n_rejected_comments + 15 * self.n_endorsements + 50 * self.n_given_opinion_bridge_powers + 50 * self.n_given_minority_activist_powers + 50 * self.is_focused)
class Disease(ABC): """ Basic interface that exposes information about specific diseases. """ # Attributes name: str = "" description: str = "" full_name: str = "" path: str = sk.lazy(lambda _: "diseases/" + _.name.lower().replace(" ", "-")) abspath: Path = sk.lazy(lambda _: db.DATABASES / _.path) _default_params = sk.lazy(lambda _: DiseaseParams(_)) # Constants MORTALITY_TABLE_DEFAULT = "default" MORTALITY_TABLE_ALIASES = {} MORTALITY_TABLE_DESCRIPTIONS = {} HOSPITALIZATION_TABLE_DEFAULT = "default" HOSPITALIZATION_TABLE_ALIASES = {} HOSPITALIZATION_TABLE_DESCRIPTIONS = {} PARAMS_BLACKLIST = frozenset({"to_json", "to_record", "to_dict", "params", "epidemic_curve"}) def __init__(self, name=None, description=None, full_name=None, path=None): self.name = name or self.name or type(self).__name__ self.full_name = full_name or self.full_name self.description = description or self.description or self.__doc__.strip() self.path = path or self.path def __str__(self): return self.full_name def __repr__(self): return f"{type(self).__name__}()" def __bool__(self): return True def mortality_table( self, source: str = None, qualified=False, extra=False, region=None ) -> QualDataT: """ Return the mortality table of the disease. The mortality table is stratified by age and has at least two columns, one with the CFR and other with the IFR per age group. Args: source (str): Select source/reference that collected this data. Different datasets may be available representing the best knowledge by different teams or assumptions about the disease. This argument is a string identifier for that version of the data. qualified (bool): If True, return a :cls:`Dataset` namedtuple with (data, source, notes) attributes. extra (bool): If True, display additional columns alongside with ["IRF", "CFR"]. The extra columns can be different for each dataset. """ df = self._read_dataset("mortality-table", source, qualified) return df if extra else df[["IFR", "CFR"]] def hospitalization_table( self, source=None, qualified=False, extra=False, region=None ) -> QualDataT: """ Return the hospitalization table of the disease. The hospitalization table is stratified by age and has two columns: "severe" and "critical". Each column represents the probability of developing either "severe" (requires clinical treatment) or "critical" (requires ICU) cases. In both cases, the probabilities are interpreted as the ratio between hospitalization and cases, *excluding* asymptomatic individuals Args: source (str): Select source/reference that collected this data. Different datasets may be available representing the best knowledge by different teams or assumptions about the disease. This argument is a string identifier for that version of the data. qualified (bool): If True, return a :cls:`Dataset` namedtuple with (data, source, notes) attributes. extra (bool): If True, display additional columns alongside with ["severe", "critical"]. The extra columns can be different for each dataset. """ df = self._read_dataset("hospitalization-table", source, qualified) return df if extra else df[["severe", "critical"]] def _read_dataset(self, which, source, qualified) -> QualDataT: """ Worker method for many data reader methods. """ attr = which.replace("-", "_").upper() aliases = getattr(self, attr + "_ALIASES") descriptions = getattr(self, attr + "_DESCRIPTIONS") source = source or getattr(self, attr + "_DEFAULT") if ":" in source: name, _, arg = source.partition(":") name = aliases.get(name, name) method = re.sub(r"[\s-]", "_", f"get_data_{which}_{name}") try: fn = getattr(self, method) except AttributeError: raise ValueError(f"invalid method source: {source!r}") else: data = fn(arg) description = fn.__doc__ else: name = aliases.get(source, source) description = descriptions.get(name, "").strip() data = read_table(f"{self.path}/{which}-{name}").copy() if qualified: return Dataset(data, source, description) return data def case_fatality_ratio(self, **kwargs) -> QualValueT: """ Compute the case fatality ratio with possible age distribution adjustments. Args: age_distribution: A age distribution or a string with a name of a valid Mundi region with known demography. If not given, uses the average world age distribution. source: Reference source used to provide the mortality table. """ return self._fatality_ratio("CFR", **kwargs) def infection_fatality_ratio(self, **kwargs) -> QualValueT: """ Compute the infection fatality ratio with possible age distribution adjustments. Args: age_distribution: A age distribution or a string with a name of a valid Mundi region with known demography. If not given, uses the average world age distribution. source: Reference source used to provide the mortality table. """ return self._fatality_ratio("IFR", **kwargs) def _fatality_ratio(self, col, age_distribution=None, source=None, region=None): table = self.mortality_table(source=source) if age_distribution is None and region: ages = mundi.region(region).age_distribution elif age_distribution is None: ages = world_age_distribution() else: ages = age_distribution return age_adjusted_average(ages, table[col]) def epidemic_curve( self, region, diff=False, smooth=False, real=False, keep_observed=False, window=14, trim_empty="left", keep_reversions=False, **kwargs, ) -> pd.DataFrame: """ Load epidemic curve for the given region. Args: region: Mundi r/egion or a string with region code. diff (bool): If diff=True, return the number of new daily cases instead of the accumulated epidemic curves. smooth (bool): If True, return a data frame with raw and smooth columns. The resulting dataframe adopts a MultiIndex created from the product of ["observed", "smooth"] x ["cases", "deaths"] real (bool, str): If True, estimate the real number of cases by trying to correct for some ascertainment rate. keep_observed (bool): If True, keep the raw observed cases under the "cases_observed" and "deaths_observed" columns. window (int): Size of the triangular smoothing window. keep_reversions (bool): If True, prevent the default behavior of cleaning data that violates a monotonic increasing behavior. trim_empty (str): Direction to trim rows with only null values. By default, it just trims the left hand side of the series (i.e., older entries). This value can be either 'left', 'right', 'both', or 'none'. """ data = self._epidemic_curve(mundi.region(region), **kwargs) data = trim_zeros(data, trim_empty) if not keep_reversions: data = force_monotonic(data) if real: method = "CFR" if real is True else real params = self.params(region=region) real = estimate_real_cases(data, params, method) if keep_observed: rename = {"cases": "cases_observed", "deaths": "deaths_observed"} data = pd.concat([real, data.rename(rename, axis=1)], axis=1) else: data = real if diff: values = np.diff(data, prepend=0, axis=0) data = pd.DataFrame(values, index=data.index, columns=data.columns) if smooth: columns = pd.MultiIndex.from_product([["observed", "smooth"], data.columns]) data = pd.concat([data, fit.smooth(data, window)], axis=1) data.columns = columns return data def _epidemic_curve(self, region, **kwargs): raise NotImplementedError # # Basic epidemiology # def R0(self, **kwargs) -> QualValueT: """ R0 for disease. """ return NotImplemented def rho(self, **kwargs): """ Relative transmissibility of asymptomatic cases. The default value is 1.0. This value makes the distinction between symptomatic and asymptomatic as a mere notification problem """ return 1.0 # # Clinical progression # # Probabilities def prob_severe(self, **kwargs) -> QualValueT: """ Probability that a case become severe enough to recommend hospitalization. Estimated from "severe cases / symptomatic cases". """ return self._prob_from_hospitalization_table("severe", **kwargs) def prob_critical(self, **kwargs) -> QualValueT: """ Probability that a case become severe enough to recommend intensive care treatment. Estimated from "critical cases / symptomatic cases". """ try: return self._prob_from_hospitalization_table("critical", **kwargs) except KeyError: pass try: return self.CFR(**kwargs) / self.icu_fatality_ratio(**kwargs) except RecursionError: model = type(self).__name__ raise ImproperlyConfigured( f""" {model} class must implement either prob_critical() or icu_fatality_ratio() methods. Otherwise, the default implementation creates a recursion between both methods.""" ) def _prob_from_hospitalization_table(self, col, **kwargs): """ Worker function for prob_critical and prob_severe methods. """ ages = set_age_distribution_default(kwargs, drop=True) values = self.hospitalization_table(**kwargs) return age_adjusted_average(ages, values[col]) def prob_aggravate_to_icu(self, **kwargs) -> QualValueT: """ Probability that an hospitalized patient require ICU. """ return self.prob_critical(**kwargs) / self.prob_severe(**kwargs) def prob_symptoms(self, **kwargs) -> QualDataT: """ Probability that disease develop symptomatic cases. """ e = 1e-50 ages = set_age_distribution_default(kwargs, drop=True) table = self.mortality_table(**kwargs) ratios = (table["IFR"] + e) / (table["CFR"] + e) return age_adjusted_average(ages, ratios) def hospitalization_overflow_bias(self, **kwargs) -> QualValueT: """ Increase in the fraction of critical patients when severe patients are not treated in a proper healthcare facility. The default implementation ignores this phenomenon and returns zero. """ return 0.0 # Durations def infectious_period(self, **kwargs) -> QualValueT: """ Period in which cases are infectious. """ return NotImplemented def incubation_period(self, **kwargs) -> QualValueT: """ Period between infection and symptoms onset. """ return NotImplemented def severe_period(self, **kwargs) -> QualValueT: """ Duration of the "severe" state of disease. The default implementation assumes it is the same as the hospitalization period. If this method has a different implementation, this parameter is interpreted as the duration of the severe state in the absence of hospitalization. """ return self.hospitalization_period(**kwargs) def critical_period(self, **kwargs) -> QualValueT: """ Duration of the "critical" state of disease. The default implementation assumes it is the same as the ICU period. If this method has a different implementation, this parameter is interpreted as the duration of the severe state in the absence of hospitalization. """ return self.icu_period(**kwargs) def icu_period(self, **kwargs) -> QualValueT: """ Duration of ICU treatment. The default implementation assumes this to be zero, meaning the disease never progress to a critical state or kills instantly if this state is reached. Obviously, most diseases would need to override this method. """ return 0.0 def hospitalization_period(self, **kwargs) -> QualValueT: """ Duration of hospitalization treatment. The default implementation assumes this to be zero, meaning the disease never progress to a severe state or kills instantly if this state is reached. Obviously, most diseases would need to override this method. """ return 0.0 # Delays def symptom_delay(self, **kwargs): """ Average duration between becoming infectious and symptom delay. """ return 0.0 def critical_delay(self, **kwargs) -> QualValueT: """ Average duration between symptom onset and necessity of ICU admission. """ return NotImplemented def severe_delay(self, **kwargs) -> QualValueT: """ Average duration between symptom onset and necessity of hospital admission. """ return NotImplemented def death_delay(self, **kwargs): """ Average duration between symptom onset and necessity of hospital admission. """ return self.critical_delay(**kwargs) + self.icu_period(**kwargs) # Derived values def gamma(self, **kwargs): """ The inverse of infectious period. """ return 1 / self.infectious_period(**kwargs) def sigma(self, **kwargs): """ The inverse of incubation period. """ return 1 / self.incubation_period(**kwargs) def hospital_fatality_ratio(self, **kwargs) -> QualValueT: """ Probability of death once requires hospitalization. """ # FIXME: make properly age-stratified return self.CFR(**kwargs) / self.prob_severe(**kwargs) def icu_fatality_ratio(self, **kwargs) -> QualValueT: """ Probability of death once requires intensive care. The default implementation assumes that death occurs only after reaching a critical state. """ # FIXME: make properly age-stratified return self.CFR(**kwargs) / self.prob_critical(**kwargs) # Aliases def Qs(self, **kwargs) -> QualValueT: """ Alias to "prob_symptoms". """ return self.prob_symptoms(**kwargs) def Qsv(self, **kwargs) -> QualValueT: """ Alias to "prob_severe". """ return self.prob_severe(**kwargs) def Qcr(self, **kwargs) -> QualValueT: """ Alias to "prob_critical". """ return self.prob_critical(**kwargs) def CFR(self, **kwargs) -> QualValueT: """ Alias to "case_fatality_ratio" """ return self.case_fatality_ratio(**kwargs) def IFR(self, **kwargs) -> QualValueT: """ Alias to "infection_fatality_ratio" """ return self.infection_fatality_ratio(**kwargs) def HFR(self, **kwargs) -> QualValueT: """ Alias to "hospital_fatality_ratio" """ return self.hospital_fatality_ratio(**kwargs) def ICUFR(self, **kwargs) -> QualValueT: """ Alias to "icu_fatality_ratio" """ return self.icu_fatality_ratio(**kwargs) # # Conversions to parameters # def params(self, *args, **kwargs) -> DiseaseParams: """ Wraps disease in a parameter namespace. This is useful and a safer alternative to use disease as an argument to several functions that expect params. """ if not args and not kwargs: return self._default_params return DiseaseParams(self, *args, **kwargs) def to_record(self, **kwargs) -> sk.record: """ Return a sidekick record object with all disease parameters. """ return sk.record(self.to_dict(**kwargs)) def to_dict(self, *, alias=False, transform=False, **kwargs) -> dict: """ Return a dict with all epidemiological parameters. """ methods = ( "R0", "rho", "case_fatality_ratio", "infection_fatality_ratio", "hospital_fatality_ratio", "icu_fatality_ratio", "infectious_period", "incubation_period", "hospitalization_period", "icu_period", "hospitalization_overflow_bias", "severe_period", "critical_period", "prob_symptoms", "prob_severe", "prob_critical", ) aliases = ( ("Qsv", "prob_severe"), ("Qcr", "prob_critical"), ("Qs", "prob_symptoms"), ("IFR", "infection_fatality_ratio"), ("CFR", "case_fatality_ratio"), ("HFR", "hospital_fatality_ratio"), ("ICUFR", "icu_fatality_ratio"), ) transforms = ( ("gamma", "infectious_period", (1 / X)), ("sigma", "incubation_period", (1 / X)), ) out = {} for method in methods: try: out[method] = getattr(self, method)(**kwargs) except Exception as e: name = type(e).__name__ warnings.warn(f"raised when calling disease.{method}():\n" f' {name}: "{e}" ') raise if alias: for k, v in aliases: if v in out: out[k] = out[v] if transform: for k, v, fn in transforms: if v in out: out[k] = fn(out[v]) return out def to_json(self, **kwargs) -> dict: """ Similar to :meth:`to_dict`, but converts non-compatible values such as series and dataframes to json. """ return to_json(self.to_dict(**kwargs))
class Comment(StatusModel, TimeStampedModel): """ A comment on a conversation. """ STATUS = Choices(("pending", _("awaiting moderation")), ("approved", _("approved")), ("rejected", _("rejected"))) STATUS_MAP = { "pending": STATUS.pending, "approved": STATUS.approved, "rejected": STATUS.rejected } conversation = models.ForeignKey("Conversation", related_name="comments", on_delete=models.CASCADE) author = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="comments", on_delete=models.CASCADE) content = models.TextField( _("Content"), max_length=252, validators=[MinLengthValidator(2), is_not_empty], help_text=_("Body of text for the comment"), ) rejection_reason = models.EnumField(RejectionReason, _("Rejection reason"), default=RejectionReason.USER_PROVIDED) rejection_reason_text = models.TextField( _("Rejection reason (free-form)"), blank=True, help_text=_( "You must provide a reason to reject a comment. Users will receive " "this feedback."), ) moderator = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="moderated_comments", on_delete=models.SET_NULL, blank=True, null=True, ) is_approved = property(lambda self: self.status == self.STATUS.approved) is_pending = property(lambda self: self.status == self.STATUS.pending) is_rejected = property(lambda self: self.status == self.STATUS.rejected) @property def has_rejection_explanation(self): return self.rejection_reason != RejectionReason.USER_PROVIDED or ( self.rejection_reason.USER_PROVIDED and self.rejection_reason_text) # # Annotations # author_name = lazy(lambda self: self.author.name, name="author_name") missing_votes = lazy( lambda self: self.conversation.users.count() - self.n_votes, name="missing_votes") agree_count = lazy(lambda self: votes_counter(self, choice=Choice.AGREE), name="agree_count") skip_count = lazy(lambda self: votes_counter(self, choice=Choice.SKIP), name="skip_count") disagree_count = lazy( lambda self: votes_counter(self, choice=Choice.DISAGREE), name="disagree_count") n_votes = lazy(lambda self: votes_counter(self), name="n_votes") @property def rejection_reason_display(self): if self.status == self.STATUS.approved: return _("Comment is approved") elif self.status == self.STATUS.pending: return _("Comment is pending moderation") elif self.rejection_reason_text: return self.rejection_reason_text elif self.rejection_reason is not None: return self.rejection_reason.description else: raise AssertionError objects = CommentQuerySet.as_manager() class Meta: unique_together = ("conversation", "content") def __str__(self): return self.content def clean(self): super().clean() if self.status == self.STATUS.rejected and not self.has_rejection_explanation: raise ValidationError({ "rejection_reason": _("Must give a reason to reject a comment") }) def vote(self, author, choice, commit=True): """ Cast a vote for the current comment. Vote must be one of 'agree', 'skip' or 'disagree'. >>> comment.vote(user, 'agree') # doctest: +SKIP """ choice = normalize_choice(choice) # We do not full_clean since the uniqueness constraint will only be # enforced when strictly necessary. vote = Vote(author=author, comment=self, choice=choice) vote.clean_fields() # Check if vote exists and if its existence represents an error is_changed = False try: saved_vote = Vote.objects.get(author=author, comment=self) except Vote.DoesNotExist: pass else: if saved_vote.choice == choice: commit = False elif saved_vote.choice == Choice.SKIP: vote.id = saved_vote.id vote.created = now() is_changed = True else: raise ValidationError("Cannot change user vote") # Send possibly saved vote if commit: vote.save() log.debug(f"Registered vote: {author} - {choice}") vote_cast.send( Comment, vote=vote, comment=self, choice=choice, is_update=is_changed, is_final=choice != Choice.SKIP, ) return vote def statistics(self, ratios=False): """ Return full voting statistics for comment. Args: ratios (bool): If True, also include 'agree_ratio', 'disagree_ratio', etc fields each original value. Ratios count the percentage of votes in each category. >>> comment.statistics() # doctest: +SKIP { 'agree': 42, 'disagree': 10, 'skip': 25, 'total': 67, 'missing': 102, } """ stats = { "agree": self.agree_count, "disagree": self.disagree_count, "skip": self.skip_count, "total": self.n_votes, "missing": self.missing_votes, } if ratios: e = 1e-50 # prevents ZeroDivisionErrors stats.update( agree_ratio=self.agree_count / (self.n_votes + e), disagree_ratio=self.disagree_count / (self.n_votes + e), skip_ratio=self.skip_count / (self.n_votes + e), missing_ratio=self.missing_votes / (self.missing_votes + self.n_votes + e), ) return stats
class WithParamsMixin(ABC): """ Basic interface for classes that have parameters """ name: str params: ParamsInfo params_data: pd.DataFrame disease_params: object _params: dict # Cache information in the params_info object as instance attributes. __all_params: frozenset = sk.lazy(_.meta.params.all) __primary_params: frozenset = sk.lazy(_.meta.params.primary) __alternative_params: frozenset = sk.lazy(_.meta.params.alternative) def __init__(self, params=None, keywords=None): self._params = {} if params is not None: self.set_params(params) extra = self.__all_params.intersection(keywords) if extra: self.set_params(extract_keys(extra, keywords)) for key in self.__primary_params - self._params.keys(): self.set_param(key, init_param(key, self, self.disease_params)) s1 = frozenset(self._params) s2 = self.__primary_params assert s1 == s2, f"Different param set: {s1} != {s2}" # # Parameters # def set_params(self, params=None, **kwargs): """ Set a collection of params. """ for k, v in kwargs: self.set_param(k, v) if params: NOT_GIVEN = object() for p in self.__primary_params: if p not in kwargs: value = get_param(p, params, default=NOT_GIVEN) if value is not NOT_GIVEN: self._params[p] = param_(value) def set_param(self, name, value, *, pdf=None, ref=None): """ Sets a parameter in the model, possibly assigning a distribution and reference. """ if name in self.__primary_params: cls = type(self).__name__ log.debug(f"{cls}.{name} = {value!r} ({self.name})") self._params[name] = param(value, pdf=pdf, ref=ref) elif name in self.__all_params: setattr(self, name, param(value).value) else: raise ValueError(f"{name} is an invalid param name") return self def get_param(self, name, param=False) -> Union[Number, Param]: """ Return the parameter with given name. Args: name: Parameter name. param: If True, return a :class:`Param` instance instead of a value. """ if param: try: return self._params[name] except KeyError: return param_(self.get_param(name)) try: return self._params[name].value except KeyError: pass if name in self.__all_params: return getattr(self, name) else: raise ValueError(f"invalid parameter name: {name!r}")