AbstractContactModel.java

/*-
 * #%L
 * MATSim Episim
 * %%
 * Copyright (C) 2020 matsim-org
 * %%
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.matsim.episim.model;

import org.matsim.api.core.v01.Scenario;
import org.matsim.core.config.Config;
import org.matsim.core.config.ConfigUtils;
import org.matsim.episim.*;
import org.matsim.episim.events.EpisimInfectionEvent;
import org.matsim.episim.events.EpisimPotentialInfectionEvent;
import org.matsim.episim.policy.Restriction;
import org.matsim.facilities.ActivityFacility;

import java.util.HashMap;
import java.time.DayOfWeek;
import java.util.Map;
import java.util.SplittableRandom;

import static org.matsim.episim.InfectionEventHandler.EpisimFacility;
import static org.matsim.episim.InfectionEventHandler.EpisimVehicle;


/**
 * Base implementation for interactions of persons during activities.
 */
public abstract class AbstractContactModel implements ContactModel {
	public static final String QUARANTINE_HOME = "quarantine_home";

	protected final Scenario scenario;
	protected final SplittableRandom rnd;
	protected final EpisimConfigGroup episimConfig;
	protected final EpisimReporting reporting;
	protected final TracingConfigGroup tracingConfig;

	/**
	 * Infections parameter instances for re-use. These are params that are always needed independent of the scenario.
	 */
	protected final EpisimConfigGroup.InfectionParams trParams;
	/**
	 * Home quarantine infection param.
	 */
	protected final EpisimConfigGroup.InfectionParams qhParams;
	/**
	 * See {@link TracingConfigGroup#getMinDuration()}
	 */
	protected final double trackingMinDuration;

	/**
	 * Infection probability calculation.
	 */
	protected final InfectionModel infectionModel;

	protected int iteration;
	protected DayOfWeek day;
	private Map<String, Restriction> restrictions;

	/**
	 * Count number of contacts per day.
	 */
	protected int numContacts = 0;

	/**
	 * Curfew compliance valid for the day.
	 */
	private double curfewCompliance;

	/**
	 * Map of each ActivityFacility with the corresponding subdistrict
	 */
	private final Map<String, String> subdistrictFacilities;


	AbstractContactModel(SplittableRandom rnd, Config config, InfectionModel infectionModel, EpisimReporting reporting, Scenario scenario) {
		this.rnd = rnd;
		this.episimConfig = ConfigUtils.addOrGetModule(config, EpisimConfigGroup.class);
		this.tracingConfig = ConfigUtils.addOrGetModule(config, TracingConfigGroup.class);
		this.infectionModel = infectionModel;
		this.reporting = reporting;
		this.trParams = episimConfig.selectInfectionParams("tr");
		this.qhParams = episimConfig.selectInfectionParams(QUARANTINE_HOME);
		this.trackingMinDuration = ConfigUtils.addOrGetModule(config, TracingConfigGroup.class).getMinDuration();
		this.scenario = scenario;

		subdistrictFacilities = new HashMap<>();
		if (episimConfig.getDistrictLevelRestrictions().equals(EpisimConfigGroup.DistrictLevelRestrictions.yes)
				&& scenario != null
				&& !scenario.getActivityFacilities().getFacilities().isEmpty()) {

			for (ActivityFacility facility : scenario.getActivityFacilities().getFacilities().values()) {
				String subdistrictAttributeName = episimConfig.getDistrictLevelRestrictionsAttribute();
				String subdistrict = (String) facility.getAttributes().getAttribute(subdistrictAttributeName);
				if (subdistrict != null) {
					this.subdistrictFacilities.put(facility.getId().toString(), subdistrict);
				}
			}
		}
	}

	AbstractContactModel(SplittableRandom rnd, Config config, InfectionModel infectionModel, EpisimReporting reporting) {
		this(rnd, config, infectionModel, reporting, null);

	}

	private static boolean hasDiseaseStatusRelevantForInfectionDynamics(EpisimPerson personWrapper) {
		switch (personWrapper.getDiseaseStatus()) {
			case susceptible:
			case contagious:
			case showingSymptoms:
				return true;

			case infectedButNotContagious:
			case recovered:
			case seriouslySick: // assume is in hospital
			case critical:
			case seriouslySickAfterCritical:
			case deceased:
				return false;

			default:
				throw new IllegalStateException("Unexpected value: " + personWrapper.getDiseaseStatus());
		}
	}

	/**
	 * This method checks whether person1 and person2 have relevant disease status for infection dynamics.
	 * If not or if both have the same disease status, the return value is false.
	 */
	static boolean personsCanInfectEachOther(EpisimPerson person1, EpisimPerson person2) {
		if (person1.getDiseaseStatus() == person2.getDiseaseStatus()) return false;
		// at least one of the persons must be susceptible
		if (person1.getDiseaseStatus() != EpisimPerson.DiseaseStatus.susceptible && person2.getDiseaseStatus() != EpisimPerson.DiseaseStatus.susceptible)
			return false;
		return (hasDiseaseStatusRelevantForInfectionDynamics(person1) && hasDiseaseStatusRelevantForInfectionDynamics(person2));
	}

	/**
	 * Attention: In order to re-use the underlying object, this function returns a buffer.
	 * Be aware that the old result will be overwritten, when the function is called multiple times.
	 */
	protected static StringBuilder getInfectionType(StringBuilder buffer, EpisimContainer<?> container, String leavingPersonsActivity,
													String otherPersonsActivity) {
		buffer.setLength(0);
		if (container instanceof EpisimFacility) {
			buffer.append(leavingPersonsActivity).append("_").append(otherPersonsActivity);
			return buffer;
		} else if (container instanceof EpisimVehicle) {
			buffer.append("pt");
			return buffer;
		} else {
			throw new RuntimeException("Infection situation is unknown");
		}
	}

	/**
	 * Get the relevant infection parameter based on container and activity and person.
	 */
	protected EpisimConfigGroup.InfectionParams getInfectionParams(EpisimContainer<?> container, EpisimPerson person, EpisimPerson.PerformedActivity activity) {
		if (container instanceof EpisimVehicle) {
			return trParams;
		} else if (container instanceof EpisimFacility) {
			EpisimConfigGroup.InfectionParams params = activity.params;

			// Select different infection params for home quarantined persons
			if (person.getQuarantineStatus() == EpisimPerson.QuarantineStatus.atHome && params.getContainerName().equals("home")) {
				return qhParams;
			}

			return params;
		} else
			throw new IllegalStateException("Don't know how to deal with container " + container);

	}

	protected void trackContactPerson(EpisimPerson personLeavingContainer, EpisimPerson otherPerson, double now, double jointTimeInContainer,
									  StringBuilder infectionType) {

		// Don't track certain activities
		if (infectionType.indexOf("pt") >= 0 || infectionType.indexOf("shop") >= 0) {
			return;
		}

		for (String act : tracingConfig.getIgnoredActivities()) {
			if (infectionType.indexOf(act) >= 0)
				return;
		}

		// don't track below threshold
		if (jointTimeInContainer < trackingMinDuration) {
			return;
		}

		personLeavingContainer.addTraceableContactPerson(otherPerson, now);
		otherPerson.addTraceableContactPerson(personLeavingContainer, now);
	}

	private boolean activityRelevantForInfectionDynamics(EpisimPerson person, EpisimContainer<?> container, Map<String,
			Restriction> restrictions, SplittableRandom rnd) {

		EpisimPerson.PerformedActivity act = container.getPerformedActivity(person.getPersonId());

		// Check if person is home quarantined
		if (person.getQuarantineStatus() == EpisimPerson.QuarantineStatus.atHome && !act.actType().startsWith("home"))
			return false;

		// enforce max group sizes
		Restriction r = restrictions.get(act.params.getContainerName());
		if (r.getMaxGroupSize() != null && r.getMaxGroupSize() > -1 && container.getMaxGroupSize() > 0 &&
				container.getMaxGroupSize() > r.getMaxGroupSize())
			return false;

		// reduce group size probabilistically
		Integer reducedGroupSize = r.getReducedGroupSize();
		if (reducedGroupSize != null && reducedGroupSize > -1 && reducedGroupSize != Integer.MAX_VALUE) {
			double current = (container.getPersons().size() * episimConfig.getSampleSize()) / container.getNumSpaces();

			// always false if current < reduced size
			boolean out = rnd.nextDouble() > reducedGroupSize / current;

			// don'T return true, other conditions might be false
			if (out) return false;
		}

		if (r.isClosed(container.getContainerId()))
			return false;

		return actIsRelevant(act.params, restrictions, rnd, container);
	}

	private boolean actIsRelevant(EpisimConfigGroup.InfectionParams params, Map<String, Restriction> restrictions, SplittableRandom rnd,EpisimContainer container) {

		Restriction r = restrictions.get(params.getContainerName());
		Double remainingFraction = r.getRemainingFraction();

		// Applies location based restriction, if applicable
		// So far, they are only applied for EpisimFacilities, not EpisimVehicles
		if (episimConfig.getDistrictLevelRestrictions().equals(EpisimConfigGroup.DistrictLevelRestrictions.yes) && container != null) {
			if (subdistrictFacilities.containsKey(container.getContainerId().toString())) {
				String subdistrict = subdistrictFacilities.get(container.getContainerId().toString());
				if (r.getLocationBasedRf().containsKey(subdistrict)) {
					remainingFraction = r.getLocationBasedRf().get(subdistrict);
				}
			}
		}

		// avoid use of rnd if outcome is known beforehand
		if (remainingFraction == 1)
			return true;
		if (remainingFraction == 0)
			return false;

		return rnd.nextDouble() < remainingFraction;

	}

	private boolean tripRelevantForInfectionDynamics(double time, EpisimPerson person, Map<String, Restriction> restrictions, SplittableRandom rnd) {

		if (person.getQuarantineStatus() != EpisimPerson.QuarantineStatus.no && person.getQuarantineStatus() != EpisimPerson.QuarantineStatus.testing)
			return false;

		EpisimPerson.PerformedActivity lastAct = person.getActivity(day, time % 86400);

		EpisimPerson.PerformedActivity nextAct = person.getNextActivity(day, time % 86400);

		// next activity is only considered if present
		return actIsRelevant(trParams, restrictions, rnd, null) &&
				(nextAct == null || actIsRelevant(nextAct.params, restrictions, rnd, null)) &&
				(actIsRelevant(lastAct.params, restrictions, rnd, null));

	}

	/**
	 * Checks whether person is relevant for tracking or for infection dynamics.  Currently, "relevant for infection dynamics" is a subset of "relevant for
	 * tracking".  However, I am not sure if this will always be the case.  kai, apr'20
	 *
	 * @noinspection BooleanMethodIsAlwaysInverted
	 */
	protected final boolean personRelevantForTrackingOrInfectionDynamics(double time, EpisimPerson person, EpisimContainer<?> container,
																		 Map<String, Restriction> restrictions, SplittableRandom rnd) {

		return personHasRelevantStatus(person) && checkPersonInContainer(time, person, container, restrictions, rnd);
	}

	protected final boolean personHasRelevantStatus(EpisimPerson person) {
		// Infected but not contagious persons are considered additionally
		return hasDiseaseStatusRelevantForInfectionDynamics(person) ||
				person.getDiseaseStatus() == EpisimPerson.DiseaseStatus.infectedButNotContagious;
	}

	/**
	 * Checks whether a person would be present in the container.
	 */
	protected final boolean checkPersonInContainer(double time, EpisimPerson person, EpisimContainer<?> container, Map<String, Restriction> restrictions, SplittableRandom rnd) {
		if (person.getQuarantineStatus() == EpisimPerson.QuarantineStatus.full) {
			return false;
		}

		// if activity participation was already handled, everything else is not relevant
		if (episimConfig.getActivityHandling() != EpisimConfigGroup.ActivityHandling.duringContact)
			return true;

		if (container instanceof EpisimFacility && activityRelevantForInfectionDynamics(person, container, restrictions, rnd)) {
			return true;
		}
		return container instanceof EpisimVehicle && tripRelevantForInfectionDynamics(time, person, restrictions, rnd);
	}

	/**
	 * Calculate the joint time persons have been in a container.
	 * This takes possible closing hours into account.
	 */
	protected double calculateJointTimeInContainer(double now, EpisimConfigGroup.InfectionParams act, double containerEnterTimeOfPersonLeaving, double containerEnterTimeOfOtherPerson) {
		Restriction r = getRestrictions().get(act.getContainerName());

		double max = Math.max(containerEnterTimeOfPersonLeaving, containerEnterTimeOfOtherPerson);

		// no closing hour set, or no compliance
		if (!r.hasClosingHours() || curfewCompliance == 0) {
			return now - max;
		} else if (episimConfig.getCalibrationParameter() != 1 && rnd.nextDouble() >= curfewCompliance) {
			return now - max;
		}

		double overlap = r.overlapWithClosingHour(max, now);
		if (overlap > 0) {
			double jointTime = now - max - overlap;
			// joint time can now be negative and will be set to 0
			return jointTime > 0 ? jointTime : 0;

		} else {
			return now - max;
		}
	}

	/**
	 * Set the iteration number and restrictions that are in place.
	 */
	@Override
	public void setRestrictionsForIteration(int iteration, Map<String, Restriction> restrictions) {
		this.iteration = iteration;
		this.day = EpisimUtils.getDayOfWeek(episimConfig, iteration);
		this.restrictions = restrictions;
		this.infectionModel.setIteration(iteration);
		this.curfewCompliance = EpisimUtils.findValidEntry(episimConfig.getCurfewCompliance(), 1.0,
				episimConfig.getStartDate().plusDays(iteration - 1));
		this.numContacts = 0;
	}

	public int getNumContacts() {
		return numContacts;
	}

	/**
	 * Sets the infection status of a person and reports the event.
	 */
	protected void infectPerson(EpisimPerson personWrapper, EpisimPerson infector, double now, StringBuilder infectionType,
								double prob, EpisimContainer<?> container) {

		if (personWrapper.getDiseaseStatus() != EpisimPerson.DiseaseStatus.susceptible) {
			throw new IllegalStateException("Person to be infected is not susceptible. Status is=" + personWrapper.getDiseaseStatus());
		}
		if (infector.getDiseaseStatus() != EpisimPerson.DiseaseStatus.contagious && infector.getDiseaseStatus() != EpisimPerson.DiseaseStatus.showingSymptoms) {
			throw new IllegalStateException("Infector is not contagious. Status is=" + infector.getDiseaseStatus());
		}
		if (personWrapper.getQuarantineStatus() == EpisimPerson.QuarantineStatus.full) {
			throw new IllegalStateException("Person to be infected is in full quarantine.");
		}
		if (infector.getQuarantineStatus() == EpisimPerson.QuarantineStatus.full) {
			throw new IllegalStateException("Infector is in ful quarantine.");
		}
		//		if (!personWrapper.getCurrentContainer().equals(infector.getCurrentContainer())) {
		//			throw new IllegalStateException("Person and infector are not in same container!");
		//		}

		// TODO: during iteration persons can get infected after 24h
		// this can lead to strange effects / ordering of events, because it is assumed one iteration is one day
		// now is overwritten to be at the end of day
		if (now >= EpisimUtils.getCorrectedTime(episimConfig.getStartOffset(), 24 * 60 * 60, iteration)) {
			now = EpisimUtils.getCorrectedTime(episimConfig.getStartOffset(), 24 * 60 * 60 - 1, iteration);
		}

		personWrapper.possibleInfection(
				new EpisimInfectionEvent(now, personWrapper.getPersonId(), infector.getPersonId(),
						container.getContainerId(), infectionType.toString(), container.getPersons().size(), infector.getVirusStrain(), prob,
						personWrapper.getAntibodies(infector.getVirusStrain()), personWrapper.getMaxAntibodies(infector.getVirusStrain()), personWrapper.getNumVaccinations())
		);

		// check infection immediately if there is only one thread
		if (episimConfig.getThreads() == 1)
			reporting.reportInfection(personWrapper.checkInfection());

	}

	protected void potentialInfection(EpisimPerson personWrapper, EpisimPerson infector, double now, StringBuilder infectionType,
	                                  double prob, EpisimContainer<?> container, double probUnVac, double rnd) {

		// for now, only filter vaccinated persons
		if (personWrapper.getVaccinationStatus() == EpisimPerson.VaccinationStatus.no)
			return;

		personWrapper.potentialInfection(
				new EpisimPotentialInfectionEvent(now, personWrapper.getPersonId(), infector.getPersonId(),
						container.getContainerId(), infectionType.toString(), container.getPersons().size(), infector.getVirusStrain(), prob, probUnVac,
						personWrapper.getAntibodies(infector.getVirusStrain()), rnd)
		);

	}


	public Map<String, Restriction> getRestrictions() {
		return restrictions;
	}

	@Override
	public void notifyEnterVehicle(EpisimPerson personEnteringVehicle, EpisimVehicle vehicle, double now) {
	}

	@Override
	public void notifyEnterFacility(EpisimPerson personEnteringFacility, EpisimFacility facility, double now) {
	}


}