Repository: tskit-dev/msprime (language: python)

Issue:
Update `from_demes()` once demes-0.1.3 is released
Demes 0.1.3 will introduce some breaking changes (e.g. pulses will now specify sources and proportions as lists, instead of source and proportions as single values). We're quite close there to hitting all the milestones to get 0.1.3 out the door (https://github.com/popsim-consortium/demes-python/milestone/3), so this issue is just to make sure we are on top of it. (The requirements also has "demes>=0.1.2", so demes-0.1.3 will end up not working with the current msprime version if the user has demes-0.1.3 installed.)

The changes in msprime should only require updating `Demography.from_demes()`, specifically a few simple changes in lines here: https://github.com/tskit-dev/msprime/blob/7fbaab3b031f7208cd82b79763e924260f412723/msprime/demography.py#L1984



Discussion / hints:
Sounds good @apragsdale. I'm currently hoping to get msprime 1.1 out in a few weeks (conditioned on tskit 0.4.0 and getting the pedigree stuff wrapped up). I'll put this into the 1.1 milestone for now, so we don't forget about it (in case the demes release is out in time).
Great! Thanks @jeromekelleher. I think it's quite doable, as it's just two "small" issues over in Demes to resolve. I'll ping @grahamgower and see if we can make it in time for msprime-1.1.
Thanks @apragsdale for opening this. I think we should make the next demes-python release version 0.2.0, because of this breaking change. msprime 1.0.x already requires demes<0.2 in the setup.cfg, so the next demes release shouldn't cause big problems (fingers crossed).
Does this need doing now that version 0.2.0 of demes is out @apragsdale? We can push out a quick point release here with any updates?

Looks like we should just be able to wait though?
I have already started on those updates to msprime for demes 0.2 and been using them locally, so it will be quick to get a PR together for you two to have a look at. If 1.1 is close, I don't know if it's so important to rush out a point release, but it could be nice to have if 1.1 is a bit further off
Cool, let's see where we are after the PR goes in. Probably simplest to pop out a quick point release, it's no big deal.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6af8d4dbe..380098220 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## [1.0.4] - 2021-12-01
+
+**New features**:
+
+- Support for Demes 0.2.0, which introduces a change to how pulse
+  sources and proportions are specified.
+  ({pr}`1936`, {issue}`1930`, {user}`apragsdale`)
+
 ## [1.0.3] - 2021-11-12
 
 This is a bugfix release recommended for all users.
diff --git a/msprime/demography.py b/msprime/demography.py
index bfa4005b4..da1294f35 100644
--- a/msprime/demography.py
+++ b/msprime/demography.py
@@ -1982,12 +1982,14 @@ def get_growth_rate(epoch):
         # Add pulses in reverse order, so that pulses with the same time
         # correspond to the correct backwards-time mass migration ordering.
         for pulse in reversed(events.pop("pulses")):
-            demography.add_mass_migration(
-                time=pulse.time,
-                source=pulse.dest,
-                dest=pulse.source,
-                proportion=pulse.proportion,
-            )
+            sequential_props = _proportions_to_sequential(pulse.proportions)
+            for prop, source in zip(sequential_props, pulse.sources):
+                demography.add_mass_migration(
+                    time=pulse.time,
+                    source=pulse.dest,
+                    dest=source,
+                    proportion=prop,
+                )
         for merger in events.pop("mergers"):
             demography.add_admixture(
                 time=merger.time,
@@ -2710,9 +2712,9 @@ def epoch_resolve(deme, time):
             for lm in lineage_movements:
                 if (lm.source, lm.dest, lm.proportion) in pulses:
                     b.add_pulse(
-                        source=resolved[lm.dest].name,
+                        sources=[resolved[lm.dest].name],
                         dest=resolved[lm.source].name,
-                        proportion=lm.proportion,
+                        proportions=[lm.proportion],
                         time=time,
                     )
 
diff --git a/requirements/CI-complete/requirements.txt b/requirements/CI-complete/requirements.txt
index 99fb24e0a..64a2c772e 100644
--- a/requirements/CI-complete/requirements.txt
+++ b/requirements/CI-complete/requirements.txt
@@ -1,7 +1,7 @@
 bintrees==2.2.0
 codecov==2.1.12
 daiquiri==3.0.1
-demes==0.1.2
+demes==0.2.0
 hypothesis==6.23.2
 mypy==0.910 
 newick==1.3.1
diff --git a/requirements/CI-docs/requirements.txt b/requirements/CI-docs/requirements.txt
index ab7839cb3..2c6aa4df5 100644
--- a/requirements/CI-docs/requirements.txt
+++ b/requirements/CI-docs/requirements.txt
@@ -1,6 +1,6 @@
 daiquiri==3.0.1
-demes==0.1.2
-demesdraw==0.1.4
+demes==0.2.0
+demesdraw==0.2.0
 jupyter-book==0.12.1
 matplotlib==3.5.0
 newick==1.3.1
diff --git a/requirements/development.txt b/requirements/development.txt
index de254ed4c..869e99431 100644
--- a/requirements/development.txt
+++ b/requirements/development.txt
@@ -4,7 +4,7 @@ bintrees
 codecov
 coverage
 daiquiri
-demes>=0.1.2
+demes>=0.2.0
 demesdraw
 flake8
 hypothesis
diff --git a/setup.cfg b/setup.cfg
index 11d9a37e0..7fdd5a2a9 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -49,7 +49,7 @@ install_requires =
     numpy
     newick>=1.3.0
     tskit>=0.3.5
-    demes<0.2
+    demes>=0.2
 setup_requires =
     numpy
     setuptools


Test patch:
diff --git a/requirements/CI-tests-conda/requirements.txt b/requirements/CI-tests-conda/requirements.txt
index c97ad5885..125381044 100644
--- a/requirements/CI-tests-conda/requirements.txt
+++ b/requirements/CI-tests-conda/requirements.txt
@@ -5,4 +5,4 @@ jsonschema<4.0
 gsl
 tskit==0.3.7
 stdpopsim==0.1.2
-demes==0.1.2
+demes==0.2.0
diff --git a/requirements/CI-tests-pip/requirements.txt b/requirements/CI-tests-pip/requirements.txt
index a688a52d6..406b1c2b2 100644
--- a/requirements/CI-tests-pip/requirements.txt
+++ b/requirements/CI-tests-pip/requirements.txt
@@ -1,6 +1,6 @@
 bintrees==2.2.0
 daiquiri==3.0.1
-demes==0.1.2
+demes==0.2.0
 hypothesis==6.29.0
 newick==1.3.0
 numpy==1.21.4
diff --git a/tests/test_demes.py b/tests/test_demes.py
index a88a5f931..a7a602057 100644
--- a/tests/test_demes.py
+++ b/tests/test_demes.py
@@ -286,14 +286,14 @@ def test_pulses(self):
             epochs:
               - start_size: 2000
         pulses:
-          - source: X
+          - sources: [X]
             dest: A
             time: 500
-            proportion: 0.1
-          - source: A
+            proportions: [0.1]
+          - sources: [A]
             dest: X
             time: 100
-            proportion: 0.2
+            proportions: [0.2]
         """
         d = self.from_yaml(yaml)
         assert d.num_populations == 2
@@ -340,10 +340,10 @@ def test_pulses_ordering(self):
           - name: D
           - name: E
         pulses:
-          - {source: A, dest: B, time: 100, proportion: 0.1}
-          - {source: B, dest: C, time: 100, proportion: 0.2}
-          - {source: C, dest: D, time: 100, proportion: 0.3}
-          - {source: A, dest: E, time: 200, proportion: 0.4}
+          - {sources: [A], dest: B, time: 100, proportions: [0.1]}
+          - {sources: [B], dest: C, time: 100, proportions: [0.2]}
+          - {sources: [C], dest: D, time: 100, proportions: [0.3]}
+          - {sources: [A], dest: E, time: 200, proportions: [0.4]}
         """
         d = self.from_yaml(yaml)
         assert d.num_populations == 5
@@ -367,6 +367,24 @@ def test_pulses_ordering(self):
         assert d.events[3].dest == "A"
         assert d.events[3].proportion == 0.4
 
+    def test_multipulse(self):
+        b = demes.Builder(defaults=dict(epoch=dict(start_size=1)))
+        b.add_deme("a")
+        b.add_deme("b")
+        b.add_deme("c")
+        b.add_pulse(sources=["a", "b"], proportions=[0.1, 0.2], dest="c", time=1)
+        g = b.resolve()
+        d = msprime.Demography.from_demes(g)
+        assert len(d.events) == 2
+        assert isinstance(d.events[0], msprime.MassMigration)
+        assert isinstance(d.events[1], msprime.MassMigration)
+        assert d.events[0].source == "c"
+        assert d.events[0].dest == "a"
+        assert d.events[0].proportion == 0.1
+        assert d.events[1].source == "c"
+        assert d.events[1].dest == "b"
+        assert d.events[1].proportion == 0.2 / (1 - 0.1)
+
     def test_merger(self):
         yaml = """\
         time_units: generations
@@ -865,9 +883,9 @@ def test_pulse(self):
         assert len(graph.pulses) == 1
         pulse = graph.pulses[0]
         assert pulse.time == 100
-        assert pulse.source == "pop_1"
+        assert pulse.sources[0] == "pop_1"
         assert pulse.dest == "pop_0"
-        assert pulse.proportion == 0.1
+        assert pulse.proportions[0] == 0.1
 
     @pytest.mark.filterwarnings(
         # demes warns about multiple pulses with the same time
@@ -884,21 +902,21 @@ def test_pulse_ordering(self):
         assert len(graph.migrations) == 0
         assert len(graph.pulses) == 4
         pulses = graph.pulses
-        assert pulses[0].source == "pop_4"
+        assert pulses[0].sources[0] == "pop_4"
         assert pulses[0].dest == "pop_3"
-        assert pulses[0].proportion == 0.4
+        assert pulses[0].proportions[0] == 0.4
         assert pulses[0].time == 200
-        assert pulses[1].source == "pop_3"
+        assert pulses[1].sources[0] == "pop_3"
         assert pulses[1].dest == "pop_2"
-        assert pulses[1].proportion == 0.3
+        assert pulses[1].proportions[0] == 0.3
         assert pulses[1].time == 100
-        assert pulses[2].source == "pop_2"
+        assert pulses[2].sources[0] == "pop_2"
         assert pulses[2].dest == "pop_1"
-        assert pulses[2].proportion == 0.2
+        assert pulses[2].proportions[0] == 0.2
         assert pulses[2].time == 100
-        assert pulses[3].source == "pop_1"
+        assert pulses[3].sources[0] == "pop_1"
         assert pulses[3].dest == "pop_0"
-        assert pulses[3].proportion == 0.1
+        assert pulses[3].proportions[0] == 0.1
         assert pulses[3].time == 100
 
     def test_bad_pulse(self):
@@ -1018,6 +1036,49 @@ def test_census_event_is_ignored(self):
         demog.add_census(100)
         demog.to_demes()
 
+    @pytest.mark.filterwarnings(
+        # demes warns about multiple pulses with the same time
+        "ignore:Multiple pulses:UserWarning"
+    )
+    def test_multipulse_roundtrip_two_sources(self):
+        b = demes.Builder(defaults=dict(epoch=dict(start_size=1)))
+        b.add_deme("a")
+        b.add_deme("b")
+        b.add_deme("c")
+        b.add_pulse(sources=["a", "b"], proportions=[0.1, 0.2], dest="c", time=1)
+        g = b.resolve()
+        d = msprime.Demography.from_demes(g)
+        g2 = d.to_demes()
+        assert len(g2.pulses) == 2
+        assert g2.pulses[0].sources[0] == "b"
+        assert g2.pulses[0].proportions[0] == 0.2 / (1 - 0.1)
+        assert g2.pulses[1].sources[0] == "a"
+        assert g2.pulses[1].proportions[0] == 0.1
+
+    @pytest.mark.filterwarnings(
+        # demes warns about multiple pulses with the same time
+        "ignore:Multiple pulses:UserWarning"
+    )
+    def test_multipulse_roundtrip_three_sources(self):
+        b = demes.Builder(defaults=dict(epoch=dict(start_size=1)))
+        b.add_deme("a")
+        b.add_deme("b")
+        b.add_deme("c")
+        b.add_deme("d")
+        b.add_pulse(
+            sources=["a", "b", "c"], proportions=[0.1, 0.2, 0.3], dest="d", time=1
+        )
+        g = b.resolve()
+        d = msprime.Demography.from_demes(g)
+        g2 = d.to_demes()
+        assert len(g2.pulses) == 3
+        assert g2.pulses[0].sources[0] == "c"
+        assert g2.pulses[0].proportions[0] == 0.3 / (1 - 0.1 - 0.2)
+        assert g2.pulses[1].sources[0] == "b"
+        assert g2.pulses[1].proportions[0] == 0.2 / (1 - 0.1)
+        assert g2.pulses[2].sources[0] == "a"
+        assert g2.pulses[2].proportions[0] == 0.1
+
 
 # class TestRoundTrip:
 #    def remove_selfing_and_cloning(self, graph):