From a482ae5cc376d0d10e895d942565d79405dc29ba Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Mon, 14 Jul 2025 11:05:07 -0700 Subject: [PATCH 01/12] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 493db3e3..8efe899d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ __pycache__ *.js *.png +*.ipynb From fb31ae94c4faa5b53073dedd81e22c5f9f6de250 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Thu, 7 Aug 2025 14:27:15 -0700 Subject: [PATCH 02/12] initial commit for Geologic timescale annotation --- pyleoclim/data/GTS_updated.csv | 101 +++++++ pyleoclim/utils/plotting.py | 466 ++++++++++++++++++++++++++++++++- 2 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 pyleoclim/data/GTS_updated.csv diff --git a/pyleoclim/data/GTS_updated.csv b/pyleoclim/data/GTS_updated.csv new file mode 100644 index 00000000..0d014c37 --- /dev/null +++ b/pyleoclim/data/GTS_updated.csv @@ -0,0 +1,101 @@ +Period,Epoch,Stage,UpperBoundary,LowerBoundary,Average,Rperiod,Gperiod,Bperiod,Repoch,Gepoch,Bepoch,Rstage,Gstage,Bstage,Period_Abbrev,Epoch_Abbrev,Stage_Abbrev,Period_Color,Epoch_Color,Stage_Color +Quaternary,Holocene,Holocene,0.0,0.0117,0.00585,0.976470588235294,0.976470588235294,0.498039215686275,0.996078431372549,0.949019607843137,0.87843137254902,0.996078431372549,0.949019607843137,0.87843137254902,Q,H,,#f8f87f,#fef1e0,#fef1e0 +Quaternary,Pleistocene,Upper Pleistocene,0.0117,0.129,0.07035,0.976470588235294,0.976470588235294,0.498039215686275,1.0,0.949019607843137,0.682352941176471,1.0,0.949019607843137,0.827450980392157,Q,P,,#f8f87f,#fff1ae,#fff1d3 +Quaternary,Pleistocene,Chibanian,0.129,0.774,0.4515,0.976470588235294,0.976470588235294,0.498039215686275,1.0,0.949019607843137,0.682352941176471,1.0,0.949019607843137,0.780392156862745,Q,P,,#f8f87f,#fff1ae,#fff1c6 +Quaternary,Pleistocene,Calabrian,0.774,1.8,1.287,0.976470588235294,0.976470588235294,0.498039215686275,1.0,0.949019607843137,0.682352941176471,1.0,0.949019607843137,0.729411764705882,Q,P,,#f8f87f,#fff1ae,#fff1b9 +Quaternary,Pleistocene,Gelasian,1.8,2.58,2.19,0.976470588235294,0.976470588235294,0.498039215686275,1.0,0.949019607843137,0.682352941176471,1.0,0.929411764705882,0.701960784313725,Q,P,,#f8f87f,#fff1ae,#ffecb2 +Neogene,Pliocene,Piacenzian,2.58,3.6,3.09,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.6,1.0,1.0,0.749019607843137,N,Pli,,#ffe618,#ffff99,#ffffbe +Neogene,Pliocene,Zanclean,3.6,5.33,4.465,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.6,1.0,1.0,0.701960784313725,N,Pli,,#ffe618,#ffff99,#ffffb2 +Neogene,Miocene,Messinian,5.33,7.25,6.29,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.450980392156863,N,M,,#ffe618,#ffff00,#ffff73 +Neogene,Miocene,Tortonian,7.25,11.63,9.44,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.4,N,M,,#ffe618,#ffff00,#ffff66 +Neogene,Miocene,Serravallian,11.63,13.82,12.725,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.349019607843137,N,M,,#ffe618,#ffff00,#ffff58 +Neogene,Miocene,Langhian,13.82,15.99,14.905,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.301960784313725,N,M,,#ffe618,#ffff00,#ffff4c +Neogene,Miocene,Burdigalian,15.99,20.45,18.22,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.254901960784314,N,M,,#ffe618,#ffff00,#ffff41 +Neogene,Miocene,Aquitanian,20.45,23.04,21.745,1.0,0.901960784313726,0.0980392156862745,1.0,1.0,0.0,1.0,1.0,0.2,N,M,,#ffe618,#ffff00,#ffff33 +Paleogene,Oligocene,Chattian,23.04,27.29,25.165,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.752941176470588,0.47843137254902,0.996078431372549,0.901960784313726,0.666666666666667,Pg,O,,#fc9a51,#fcbf7a,#fee6aa +Paleogene,Oligocene,Rupelian,27.29,33.9,30.595,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.752941176470588,0.47843137254902,0.996078431372549,0.850980392156863,0.603921568627451,Pg,O,,#fc9a51,#fcbf7a,#fed99a +Paleogene,Eocene,Priabonian,33.9,37.71,35.805,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.705882352941177,0.423529411764706,0.992156862745098,0.803921568627451,0.631372549019608,Pg,E,,#fc9a51,#fcb46c,#fccda1 +Paleogene,Eocene,Bartonian,37.71,41.03,39.37,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.705882352941177,0.423529411764706,0.992156862745098,0.752941176470588,0.568627450980392,Pg,E,,#fc9a51,#fcb46c,#fcbf90 +Paleogene,Eocene,Lutetian,41.03,48.07,44.55,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.705882352941177,0.423529411764706,0.988235294117647,0.705882352941177,0.509803921568627,Pg,E,,#fc9a51,#fcb46c,#fbb481 +Paleogene,Eocene,Ypresian,48.07,56.0,52.035,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.705882352941177,0.423529411764706,0.988235294117647,0.654901960784314,0.450980392156863,Pg,E,,#fc9a51,#fcb46c,#fba773 +Paleogene,Paleocene,Thanetian,56.0,59.24,57.62,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.654901960784314,0.372549019607843,0.992156862745098,0.749019607843137,0.435294117647059,Pg,P,,#fc9a51,#fca75e,#fcbe6f +Paleogene,Paleocene,Selandian,59.24,61.66,60.45,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.654901960784314,0.372549019607843,0.996078431372549,0.749019607843137,0.396078431372549,Pg,P,,#fc9a51,#fca75e,#febe65 +Paleogene,Paleocene,Danian,61.66,66.0,63.83,0.992156862745098,0.603921568627451,0.32156862745098,0.992156862745098,0.654901960784314,0.372549019607843,0.992156862745098,0.705882352941177,0.384313725490196,Pg,P,,#fc9a51,#fca75e,#fcb461 +Cretaceous,Upper,Maastrichtian,66.0,72.17,69.085,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.949019607843137,0.980392156862745,0.549019607843137,K,,M,#7fc54d,#a6d84a,#f1f98b +Cretaceous,Upper,Campanian,72.17,83.65,77.91,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.901960784313726,0.956862745098039,0.498039215686275,K,,C,#7fc54d,#a6d84a,#e6f37f +Cretaceous,Upper,Santonian,83.65,85.7,84.675,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.850980392156863,0.937254901960784,0.454901960784314,K,,S,#7fc54d,#a6d84a,#d9ee74 +Cretaceous,Upper,Coniacian,85.7,89.39,87.545,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.8,0.913725490196078,0.407843137254902,K,,Cn,#7fc54d,#a6d84a,#cce868 +Cretaceous,Upper,Turonian,89.39,93.9,91.645,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.749019607843137,0.890196078431372,0.364705882352941,K,,T,#7fc54d,#a6d84a,#bee25c +Cretaceous,Upper,Cenomanian,93.9,100.5,97.2,0.498039215686275,0.776470588235294,0.305882352941176,0.650980392156863,0.847058823529412,0.290196078431373,0.701960784313725,0.870588235294118,0.325490196078431,K,,C,#7fc54d,#a6d84a,#b2de52 +Cretaceous,Lower,Albian,100.5,113.2,106.85,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.8,0.917647058823529,0.592156862745098,K,,A,#7fc54d,#8bcd56,#cce997 +Cretaceous,Lower,Aptian,113.2,121.4,117.3,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.749019607843137,0.894117647058824,0.541176470588235,K,,Ap,#7fc54d,#8bcd56,#bee489 +Cretaceous,Lower,Barremian,121.4,126.5,123.95,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.701960784313725,0.874509803921569,0.498039215686275,K,,B,#7fc54d,#8bcd56,#b2df7f +Cretaceous,Lower,Hauterivian,126.5,132.6,129.55,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.650980392156863,0.850980392156863,0.458823529411765,K,,H,#7fc54d,#8bcd56,#a6d975 +Cretaceous,Lower,Valanginian,132.6,137.7,135.15,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.6,0.827450980392157,0.415686274509804,K,,V,#7fc54d,#8bcd56,#99d36a +Cretaceous,Lower,Berriasian,137.7,143.1,140.4,0.498039215686275,0.776470588235294,0.305882352941176,0.549019607843137,0.803921568627451,0.341176470588235,0.549019607843137,0.803921568627451,0.376470588235294,K,,B,#7fc54d,#8bcd56,#8bcd5f +Jurassic,Upper,Tithonian,143.1,149.24,146.17,0.203921568627451,0.698039215686274,0.788235294117647,0.701960784313725,0.890196078431372,0.933333333333333,0.850980392156863,0.945098039215686,0.968627450980392,J,,,#34b1c9,#b2e2ed,#d9f0f6 +Jurassic,Upper,Kimmeridgian,149.24,154.78,152.01,0.203921568627451,0.698039215686274,0.788235294117647,0.701960784313725,0.890196078431372,0.933333333333333,0.8,0.925490196078431,0.956862745098039,J,,,#34b1c9,#b2e2ed,#ccebf3 +Jurassic,Upper,Oxfordian,154.78,161.53,158.155,0.203921568627451,0.698039215686274,0.788235294117647,0.701960784313725,0.890196078431372,0.933333333333333,0.749019607843137,0.905882352941176,0.945098039215686,J,,,#34b1c9,#b2e2ed,#bee6f0 +Jurassic,Middle,Callovian,161.53,165.29,163.41,0.203921568627451,0.698039215686274,0.788235294117647,0.501960784313725,0.811764705882353,0.847058823529412,0.749019607843137,0.905882352941176,0.898039215686275,J,,,#34b1c9,#7fcfd8,#bee6e5 +Jurassic,Middle,Bathonian,165.29,168.17,166.73,0.203921568627451,0.698039215686274,0.788235294117647,0.501960784313725,0.811764705882353,0.847058823529412,0.701960784313725,0.886274509803922,0.890196078431372,J,,,#34b1c9,#7fcfd8,#b2e2e2 +Jurassic,Middle,Bajocian,168.17,170.9,169.535,0.203921568627451,0.698039215686274,0.788235294117647,0.501960784313725,0.811764705882353,0.847058823529412,0.650980392156863,0.866666666666667,0.87843137254902,J,,,#34b1c9,#7fcfd8,#a6dde0 +Jurassic,Middle,Aalenian,170.9,174.7,172.8,0.203921568627451,0.698039215686274,0.788235294117647,0.501960784313725,0.811764705882353,0.847058823529412,0.603921568627451,0.850980392156863,0.866666666666667,J,,,#34b1c9,#7fcfd8,#9ad9dd +Jurassic,Lower,Toarcian,174.7,184.2,179.45,0.203921568627451,0.698039215686274,0.788235294117647,0.258823529411765,0.682352941176471,0.815686274509804,0.6,0.807843137254902,0.890196078431372,J,,,#34b1c9,#42aed0,#99cee2 +Jurassic,Lower,Pliensbachian,184.2,192.9,188.55,0.203921568627451,0.698039215686274,0.788235294117647,0.258823529411765,0.682352941176471,0.815686274509804,0.501960784313725,0.772549019607843,0.866666666666667,J,,,#34b1c9,#42aed0,#7fc4dd +Jurassic,Lower,Sinemurian,192.9,199.46,196.18,0.203921568627451,0.698039215686274,0.788235294117647,0.258823529411765,0.682352941176471,0.815686274509804,0.403921568627451,0.737254901960784,0.847058823529412,J,,,#34b1c9,#42aed0,#67bbd8 +Jurassic,Lower,Hettangian,199.46,201.36,200.41,0.203921568627451,0.698039215686274,0.788235294117647,0.258823529411765,0.682352941176471,0.815686274509804,0.305882352941176,0.701960784313725,0.827450980392157,J,,,#34b1c9,#42aed0,#4db2d3 +Triassic,Upper,Rhaetian,201.36,205.74,203.55,0.505882352941176,0.168627450980392,0.572549019607843,0.741176470588235,0.549019607843137,0.764705882352941,0.890196078431372,0.725490196078431,0.858823529411765,T,,,#802a91,#bc8bc2,#e2b8db +Triassic,Upper,Norian,205.74,227.3,216.52,0.505882352941176,0.168627450980392,0.572549019607843,0.741176470588235,0.549019607843137,0.764705882352941,0.83921568627451,0.666666666666667,0.827450980392157,T,,,#802a91,#bc8bc2,#d6aad3 +Triassic,Upper,Carnian,227.3,237.0,232.15,0.505882352941176,0.168627450980392,0.572549019607843,0.741176470588235,0.549019607843137,0.764705882352941,0.788235294117647,0.607843137254902,0.796078431372549,T,,,#802a91,#bc8bc2,#c99bcb +Triassic,Middle,Ladinian,237.0,241.46,239.23,0.505882352941176,0.168627450980392,0.572549019607843,0.694117647058824,0.407843137254902,0.694117647058824,0.788235294117647,0.513725490196078,0.749019607843137,T,,,#802a91,#b168b1,#c982be +Triassic,Middle,Anisian,241.46,246.7,244.08,0.505882352941176,0.168627450980392,0.572549019607843,0.694117647058824,0.407843137254902,0.694117647058824,0.737254901960784,0.458823529411765,0.717647058823529,T,,,#802a91,#b168b1,#bb75b6 +Triassic,Lower,Olenekian,246.7,249.88,248.29,0.505882352941176,0.168627450980392,0.572549019607843,0.596078431372549,0.223529411764706,0.6,0.690196078431373,0.317647058823529,0.647058823529412,T,,,#802a91,#983999,#b050a5 +Triassic,Lower,Induan,249.88,251.9,250.89,0.505882352941176,0.168627450980392,0.572549019607843,0.596078431372549,0.223529411764706,0.6,0.643137254901961,0.274509803921569,0.623529411764706,T,,,#802a91,#983999,#a4469f +Permian,Lopingian,Changhsingian,251.9,254.24,253.07,0.941176470588235,0.250980392156863,0.156862745098039,0.843137254901961,0.654901960784314,0.580392156862745,0.988235294117647,0.752941176470588,0.698039215686274,P,,,#ef4027,#d7a793,#fbbfb1 +Permian,Lopingian,Wuchiapingian,254.24,259.55,256.895,0.941176470588235,0.250980392156863,0.156862745098039,0.843137254901961,0.654901960784314,0.580392156862745,0.988235294117647,0.705882352941177,0.635294117647059,P,,,#ef4027,#d7a793,#fbb4a2 +Permian,Guadalupian,Capitanian,259.55,264.34,261.945,0.941176470588235,0.250980392156863,0.156862745098039,0.984313725490196,0.454901960784314,0.36078431372549,0.984313725490196,0.603921568627451,0.52156862745098,P,,,#ef4027,#fb745b,#fb9a84 +Permian,Guadalupian,Wordian,264.34,269.21,266.775,0.941176470588235,0.250980392156863,0.156862745098039,0.984313725490196,0.454901960784314,0.36078431372549,0.984313725490196,0.552941176470588,0.462745098039216,P,,,#ef4027,#fb745b,#fb8c76 +Permian,Guadalupian,Roadian,269.21,274.37,271.79,0.941176470588235,0.250980392156863,0.156862745098039,0.984313725490196,0.454901960784314,0.36078431372549,0.984313725490196,0.501960784313725,0.411764705882353,P,,,#ef4027,#fb745b,#fb7f69 +Permian,Cisuralian,Kungurian,274.37,283.3,278.835,0.941176470588235,0.250980392156863,0.156862745098039,0.937254901960784,0.345098039215686,0.270588235294118,0.890196078431372,0.529411764705882,0.462745098039216,P,,,#ef4027,#ee5745,#e28676 +Permian,Cisuralian,Artinskian,283.3,290.51,286.905,0.941176470588235,0.250980392156863,0.156862745098039,0.937254901960784,0.345098039215686,0.270588235294118,0.890196078431372,0.482352941176471,0.407843137254902,P,,,#ef4027,#ee5745,#e27b68 +Permian,Cisuralian,Sakmarian,290.51,293.52,292.015,0.941176470588235,0.250980392156863,0.156862745098039,0.937254901960784,0.345098039215686,0.270588235294118,0.890196078431372,0.435294117647059,0.36078431372549,P,,,#ef4027,#ee5745,#e26f5b +Permian,Cisuralian,Asselian,293.52,298.89,296.205,0.941176470588235,0.250980392156863,0.156862745098039,0.937254901960784,0.345098039215686,0.270588235294118,0.890196078431372,0.388235294117647,0.313725490196078,P,,,#ef4027,#ee5745,#e2624f +Carboniferous,Upper,Gzhelian,298.89,303.68,301.285,0.403921568627451,0.647058823529412,0.6,0.749019607843137,0.815686274509804,0.729411764705882,0.8,0.831372549019608,0.780392156862745,C,,,#67a599,#bed0b9,#ccd4c6 +Carboniferous,Upper,Kasimovian,303.68,307.02,305.35,0.403921568627451,0.647058823529412,0.6,0.749019607843137,0.815686274509804,0.729411764705882,0.749019607843137,0.815686274509804,0.772549019607843,C,,,#67a599,#bed0b9,#bed0c4 +Carboniferous,Middle,Moscovian,307.02,315.15,311.085,0.403921568627451,0.647058823529412,0.6,0.650980392156863,0.780392156862745,0.717647058823529,0.701960784313725,0.796078431372549,0.725490196078431,C,,,#67a599,#a6c6b6,#b2cbb8 +Carboniferous,Lower,Bashkirian,315.15,323.4,319.275,0.403921568627451,0.647058823529412,0.6,0.549019607843137,0.745098039215686,0.705882352941177,0.6,0.76078431372549,0.709803921568627,C,,,#67a599,#8bbdb4,#99c1b4 +Carboniferous,Upper,Serpukhovian,323.4,330.34,326.87,0.403921568627451,0.647058823529412,0.6,0.701960784313725,0.745098039215686,0.423529411764706,0.749019607843137,0.76078431372549,0.419607843137255,C,,,#67a599,#b2bd6c,#bec16b +Carboniferous,Middle,Visean,330.34,346.73,338.535,0.403921568627451,0.647058823529412,0.6,0.6,0.705882352941177,0.423529411764706,0.650980392156863,0.725490196078431,0.423529411764706,C,,,#67a599,#99b46c,#a6b86c +Carboniferous,Lower,Tournaisian,346.73,359.3,353.015,0.403921568627451,0.647058823529412,0.6,0.501960784313725,0.670588235294118,0.423529411764706,0.549019607843137,0.690196078431373,0.423529411764706,C,,,#67a599,#7fab6c,#8bb06c +Devonian,Upper,Famennian,359.3,371.1,365.2,0.796078431372549,0.549019607843137,0.215686274509804,0.945098039215686,1.0,0.615686274509804,0.949019607843137,0.929411764705882,0.772549019607843,D,,,#cb8b37,#f0ff9d,#f1ecc4 +Devonian,Upper,Frasnian,371.1,378.9,375.0,0.796078431372549,0.549019607843137,0.215686274509804,0.945098039215686,1.0,0.615686274509804,0.949019607843137,0.929411764705882,0.67843137254902,D,,,#cb8b37,#f0ff9d,#f1ecad +Devonian,Middle,Givetian,378.9,385.3,382.1,0.796078431372549,0.549019607843137,0.215686274509804,0.945098039215686,0.784313725490196,0.407843137254902,0.945098039215686,0.882352941176471,0.52156862745098,D,,,#cb8b37,#f0c768,#f0e184 +Devonian,Middle,Eifelian,385.3,394.3,389.8,0.796078431372549,0.549019607843137,0.215686274509804,0.945098039215686,0.784313725490196,0.407843137254902,0.945098039215686,0.835294117647059,0.462745098039216,D,,,#cb8b37,#f0c768,#f0d576 +Devonian,Lower,Emsian,394.3,410.5,402.4,0.796078431372549,0.549019607843137,0.215686274509804,0.898039215686275,0.674509803921569,0.301960784313725,0.898039215686275,0.815686274509804,0.458823529411765,D,,,#cb8b37,#e5ac4c,#e5d075 +Devonian,Lower,Pragian,410.5,412.4,411.45,0.796078431372549,0.549019607843137,0.215686274509804,0.898039215686275,0.674509803921569,0.301960784313725,0.898039215686275,0.768627450980392,0.407843137254902,D,,,#cb8b37,#e5ac4c,#e5c368 +Devonian,Lower,Lochkovian,412.4,419.0,415.7,0.796078431372549,0.549019607843137,0.215686274509804,0.898039215686275,0.674509803921569,0.301960784313725,0.898039215686275,0.717647058823529,0.352941176470588,D,,,#cb8b37,#e5ac4c,#e5b659 +Silurian,Pridoli,Pridoli,419.0,422.73,420.865,0.701960784313725,0.882352941176471,0.713725490196078,0.901960784313726,0.96078431372549,0.882352941176471,0.901960784313726,0.96078431372549,0.882352941176471,S,,,#b2e1b5,#e6f4e1,#e6f4e1 +Silurian,Ludlow,Ludfordian,422.73,425.01,423.87,0.701960784313725,0.882352941176471,0.713725490196078,0.749019607843137,0.901960784313726,0.811764705882353,0.850980392156863,0.941176470588235,0.874509803921569,S,,,#b2e1b5,#bee6cf,#d9efdf +Silurian,Ludlow,Gorstian,425.01,426.74,425.875,0.701960784313725,0.882352941176471,0.713725490196078,0.749019607843137,0.901960784313726,0.811764705882353,0.8,0.925490196078431,0.866666666666667,S,,,#b2e1b5,#bee6cf,#ccebdd +Silurian,Wenlock,Homerian,426.74,430.62,428.68,0.701960784313725,0.882352941176471,0.713725490196078,0.701960784313725,0.882352941176471,0.76078431372549,0.8,0.92156862745098,0.819607843137255,S,,,#b2e1b5,#b2e1c1,#ccead1 +Silurian,Wenlock,Sheinwoodian,430.62,432.93,431.775,0.701960784313725,0.882352941176471,0.713725490196078,0.701960784313725,0.882352941176471,0.76078431372549,0.749019607843137,0.901960784313726,0.764705882352941,S,,,#b2e1b5,#b2e1c1,#bee6c2 +Silurian,Llandovery,Telychian,432.93,438.59,435.76,0.701960784313725,0.882352941176471,0.713725490196078,0.6,0.843137254901961,0.701960784313725,0.749019607843137,0.901960784313726,0.811764705882353,S,,,#b2e1b5,#99d7b2,#bee6cf +Silurian,Llandovery,Aeronian,438.59,440.49,439.54,0.701960784313725,0.882352941176471,0.713725490196078,0.6,0.843137254901961,0.701960784313725,0.701960784313725,0.882352941176471,0.76078431372549,S,,,#b2e1b5,#99d7b2,#b2e1c1 +Silurian,Llandovery,Rhuddanian,440.49,443.07,441.78,0.701960784313725,0.882352941176471,0.713725490196078,0.6,0.843137254901961,0.701960784313725,0.650980392156863,0.862745098039216,0.709803921568627,S,,,#b2e1b5,#99d7b2,#a6dcb4 +Ordovician,Upper,Hirnantian,443.07,445.21,444.14,0.0,0.572549019607843,0.43921568627451,0.498039215686275,0.792156862745098,0.576470588235294,0.650980392156863,0.858823529411765,0.670588235294118,O,,,#009170,#7fca92,#a6dbab +Ordovician,Upper,Katian,445.21,452.75,448.98,0.0,0.572549019607843,0.43921568627451,0.498039215686275,0.792156862745098,0.576470588235294,0.6,0.83921568627451,0.623529411764706,O,,,#009170,#7fca92,#99d69f +Ordovician,Upper,Sandbian,452.75,458.18,455.465,0.0,0.572549019607843,0.43921568627451,0.498039215686275,0.792156862745098,0.576470588235294,0.549019607843137,0.815686274509804,0.580392156862745,O,,,#009170,#7fca92,#8bd093 +Ordovician,Middle,Darriwilian,458.18,469.42,463.8,0.0,0.572549019607843,0.43921568627451,0.301960784313725,0.705882352941177,0.494117647058824,0.454901960784314,0.776470588235294,0.611764705882353,O,,,#009170,#4cb47e,#74c59c +Ordovician,Middle,Dapingian,469.42,471.26,470.34,0.0,0.572549019607843,0.43921568627451,0.301960784313725,0.705882352941177,0.494117647058824,0.4,0.752941176470588,0.572549019607843,O,,,#009170,#4cb47e,#66bf91 +Ordovician,Lower,Floian,471.26,477.08,474.17,0.0,0.572549019607843,0.43921568627451,0.101960784313725,0.615686274509804,0.435294117647059,0.254901960784314,0.690196078431373,0.529411764705882,O,,,#009170,#199d6f,#41b086 +Ordovician,Lower,Tremadocian,477.08,486.85,481.965,0.0,0.572549019607843,0.43921568627451,0.101960784313725,0.615686274509804,0.435294117647059,0.2,0.662745098039216,0.494117647058824,O,,,#009170,#199d6f,#33a97e +Cambrian,Furongian,Stage 10,486.85,491.0,488.925,0.498039215686275,0.627450980392157,0.337254901960784,0.701960784313725,0.87843137254902,0.584313725490196,0.901960784313726,0.96078431372549,0.788235294117647,Cm,,,#7fa055,#b2e094,#e6f4c9 +Cambrian,Furongian,Jiangshanian,491.0,494.2,492.6,0.498039215686275,0.627450980392157,0.337254901960784,0.701960784313725,0.87843137254902,0.584313725490196,0.850980392156863,0.941176470588235,0.733333333333333,Cm,,,#7fa055,#b2e094,#d9efba +Cambrian,Furongian,Paibian,494.2,497.0,495.6,0.498039215686275,0.627450980392157,0.337254901960784,0.701960784313725,0.87843137254902,0.584313725490196,0.8,0.92156862745098,0.682352941176471,Cm,,,#7fa055,#b2e094,#cceaae +Cambrian,Miaolingian,Guzhangian,497.0,500.5,498.75,0.498039215686275,0.627450980392157,0.337254901960784,0.650980392156863,0.811764705882353,0.525490196078431,0.8,0.874509803921569,0.666666666666667,Cm,,,#7fa055,#a6cf85,#ccdfaa +Cambrian,Miaolingian,Drumian,500.5,504.5,502.5,0.498039215686275,0.627450980392157,0.337254901960784,0.650980392156863,0.811764705882353,0.525490196078431,0.749019607843137,0.850980392156863,0.615686274509804,Cm,,,#7fa055,#a6cf85,#bed99d +Cambrian,Miaolingian,Stage 5,504.5,509.0,506.75,0.498039215686275,0.627450980392157,0.337254901960784,0.650980392156863,0.811764705882353,0.525490196078431,0.701960784313725,0.831372549019608,0.572549019607843,Cm,,,#7fa055,#a6cf85,#b2d491 +Cambrian,Series 2,Stage 4,509.0,514.5,511.75,0.498039215686275,0.627450980392157,0.337254901960784,0.6,0.752941176470588,0.470588235294118,0.701960784313725,0.792156862745098,0.556862745098039,Cm,,,#7fa055,#99bf78,#b2ca8d +Cambrian,Series 2,Stage 3,514.5,521.0,517.75,0.498039215686275,0.627450980392157,0.337254901960784,0.6,0.752941176470588,0.470588235294118,0.650980392156863,0.772549019607843,0.513725490196078,Cm,,,#7fa055,#99bf78,#a6c482 +Cambrian,Terreneuvian,Stage 2,521.0,529.0,525.0,0.498039215686275,0.627450980392157,0.337254901960784,0.549019607843137,0.690196078431373,0.423529411764706,0.650980392156863,0.729411764705882,0.501960784313725,Cm,,,#7fa055,#8bb06c,#a6b97f +Cambrian,Terreneuvian,Fortunian,529.0,538.8,533.9,0.498039215686275,0.627450980392157,0.337254901960784,0.549019607843137,0.690196078431373,0.423529411764706,0.6,0.709803921568627,0.458823529411765,Cm,,,#7fa055,#8bb06c,#99b475 diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index bbf1ce4f..eb50c4f6 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -13,9 +13,13 @@ import numpy as np import pandas as pd import collections.abc +import copy +from collections import defaultdict +from pathlib import Path from ..utils import lipdutils +DATA_DIR = Path(__file__).parents[1].joinpath("data").resolve() # import pandas as pd # from matplotlib.patches import Rectangle @@ -822,6 +826,7 @@ def make_annotation_ax(fig, ax, loc='overlay', ax_d[ax_name] = make_phantom_ax(ax_d[ax_name]) ax_d[ax_name].set_facecolor((1, 1, 1, 0)) + return ax_d @@ -884,11 +889,17 @@ def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend new_colors = [] new_alphas = [] + xlims = ax.get_xlim() + for ik, _ts in enumerate(intervals): if isinstance(color, list) is True: c = color[ik] else: c = color + + if '#' in c: + c = mpl.colors.to_rgba(c) + new_colors.append(c) if isinstance(alpha, list) is True: @@ -903,6 +914,17 @@ def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend label = '' new_labels.append(label) + if xlims[0] < xlims[1]: + if _ts[1] > xlims[1]: + _ts[1] = xlims[1] + if _ts[0] < xlims[0]: + _ts[0] = xlims[0] + else: + if _ts[1] < xlims[1]: + _ts[1] = xlims[1] + if _ts[0] > xlims[0]: + _ts[0] = xlims[0] + ax.axvspan(_ts[0], _ts[1], facecolor=c, alpha=a) return ax @@ -1242,7 +1264,7 @@ def make_scalar_mappable(cmap=None, hue_vect=None, n=None, norm_kwargs=None): return ax_sm -import copy + def consolidate_legends(ax, split_btwn=True, hue='relation', style='exp_type', size=None, colorbar=False): @@ -1437,3 +1459,445 @@ def keep_center_colormap(cmap, vmin, vmax, center=0): def tidy_labels(label): ''' Tidy up the label string''' return label.rstrip().lstrip().rstrip(',').lstrip(',') + + +def check_text_fits_in_span(fig, ax, interval, label, fontsize=12, verbose=False): + """ + Checks if the given text can fit within the interval [x1, x2] when plotted. + + Parameters + ---------- + ax : matplotlib.axes.Axes + The axis where the span is plotted. + + x1, x2 : float + The interval in which the text should fit. + + text : str + The text to check. + + fontsize : int, optional + Font size to test the fitting, default is 12. + + Returns + ------- + bool + True if text fits within [x1, x2], False otherwise. + """ + if verbose is True: + print('fontsize', fontsize, 'label', label, 'interval', interval) + fig.canvas.draw() # Ensure text positioning updates + renderer = fig.canvas.get_renderer() + + # text_width1 = get_label_width(ax, label, buffer=0., fontsize=fontsize, verbose=verbose) + + # Create a test text object (invisible) + test_text = ax.text(0, 0, label, ha='center', va='center', alpha=0, **{'fontsize': fontsize}) + + # Get text bounding box in display (pixel) coordinates + bbox = test_text.get_window_extent(renderer=renderer) + + # Convert bbox from display units to data units + bbox_data = ax.transData.inverted().transform(bbox) + text_x_min, _ = bbox_data[0] + text_x_max, _ = bbox_data[1] + + text_width = np.abs(text_x_max - text_x_min) # Text width in data coordinates + test_text.remove() # Clean up test text + + # Compare text width with the available span width + x1, x2 = interval + span_width = np.abs(x2 - x1) + if verbose is True: + # print('get_label_width version', 'text width', text_width1, 'span width', span_width, + # 'fits?', text_width1 <= span_width) + print('native version', 'text width', text_width, 'span width', span_width, + 'fits?', text_width <= span_width) + + return text_width <= span_width + + +def add_geol_labels(fig, _ax, ldf, key='Periods', y_gts=None, + fontsize=10, allow_abbreviations=True, orientation='north', verbose=False): + ax = _ax[key] + xlims = ax.get_xlim() + + ax_outside_labels = None + + no_fits = defaultdict(list) + for i in range(len(ldf)): + ageStart = ldf.Start[i] + ageEnd = ldf.End[i] + ageMean = .5 * (ldf.Start[i] + ldf.End[i]) + ageColor = ldf.Color[i] + + ageHandle = ldf.Name[i] + + ylims = ax.get_ylim() + if y_gts is None: + h = np.diff(ax.get_ylim())[0] + y_gts = .5 * h # ylims[0] + + if xlims[0] < xlims[1]: + left_age = min([ageStart, ageEnd]) + right_age = max([ageStart, ageEnd]) + + if left_age < xlims[0]: + left_age = xlims[0] + fall_back_bound_ageMean = right_age - .5 * np.abs(right_age - left_age) + time_value = max([ageMean, fall_back_bound_ageMean]) + + elif right_age > xlims[1]: + right_age = xlims[1] + fall_back_bound_ageMean = left_age + .5 * np.abs(right_age - left_age) + time_value = min([ageMean, fall_back_bound_ageMean]) + else: + time_value = ageMean + + else: + left_age = max([ageStart, ageEnd]) + right_age = min([ageStart, ageEnd]) + + if left_age > xlims[0]: + left_age = xlims[0] + fall_back_bound_ageMean = right_age + .5 * np.abs(right_age - left_age) + time_value = min([ageMean, fall_back_bound_ageMean]) + elif right_age < xlims[1]: + right_age = xlims[1] + fall_back_bound_ageMean = left_age - .5 * np.abs(right_age - left_age) + time_value = max([ageMean, fall_back_bound_ageMean]) + else: + time_value = ageMean + + if isinstance(ageHandle, str) == False: + ageHandle = '' + text_obj = ax.text(time_value, y_gts, ageHandle, + ha='center', + va='center', + fontsize=fontsize, + rotation=0) + if verbose is True: + print(ageHandle, time_value, y_gts, 'left', left_age, 'right', right_age) + fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, + verbose=verbose) # check_fits(ax, rect, text_obj) + if fit_bool == False: + text_obj.remove() + + if allow_abbreviations == False: + no_fits['handle'].append(ageHandle) + no_fits['time_value'].append(time_value) + if ax_outside_labels is None: + # _ax=pyleo.utils.plotting.make_annotation_ax(fig, _ax, ax_name = 'outside_labels', height=.06, + # loc='above', v_offset=.02,zorder=-2) + ax_outside_labels = True + # _ax['outside_labels'].spines['top'].set_visible(False) + + else: + ageHandle = ldf.Abbrev[i] + fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, + verbose=verbose) # check_fits(ax, rect, text_obj) + if fit_bool == True: + if isinstance(ageHandle, str) == False: + ageHandle = '' + text_obj = ax.text(time_value, y_gts, ageHandle, + # fontname='TeX Gyre Heros', + ha='center', + va='center', + fontsize=fontsize, + rotation=0) + else: + try: + text_obj.remove() + except: + if verbose is True: + print('text_obj already removed') + + if ax_outside_labels is not None: + if verbose is True: + print('adding outside labels', _ax['outside_labels'].get_ylim()) + if orientation == 'south': + valign = 'top' + else: + valign = 'bottom' + orientation = 'north' + _ax['outside_labels'] = label_intervals( + fig, _ax['outside_labels'], + no_fits['handle'], no_fits['time_value'], + orientation=orientation, baseline=1, height=0.35, buffer=0.12, + linestyle_kwargs={'color': 'gray'}, text_kwargs={'fontsize': fontsize, 'va': valign}) + # + + +def set_placement(loc, epochs, periods, stages): + label_d = {'epochs': epochs, 'periods': periods, 'stages': stages} + ik = 0 + for key in ['stages', 'epochs', 'periods']: + if label_d[key] is True: + label_d[key] = ik + if loc == 'above': + ik += 1 + if loc == 'below': + ik -= 1 + + stages = label_d['stages'] + epochs = label_d['epochs'] + periods = label_d['periods'] + return epochs, periods, stages + + +def get_v_offsets(loc, height, num_offsets, v_offset_0=None): + v_offset_d = {} + if loc == 'above': + v_offset_0 = v_offset_0 if v_offset_0 is not None else .01 + v_offset_d['v_offset_0'] = v_offset_0 + for ik in range(num_offsets): + v_offset_d[ik] = v_offset_0 # + ik*height + + else: + v_offset_0 = v_offset_0 if v_offset_0 is not None else -.01 + v_offset_d['v_offset_0'] = v_offset_0 + for ik in range(num_offsets): + v_offset_d[-ik] = v_offset_0 # - ik*height + + return v_offset_d # {'v_offset_0': v_offset_0, 'inner':v_offset_inner, 'outer':v_offset_outer} + + +def summarize_geol(grp): + """ + Function to summarize the geologic time scale data. + """ + group_name = [col for col in ['Period', 'Epoch', 'Stage'] if col in grp.columns][0] + color_var = group_name + '_Color' + abbrev_var = group_name + '_Abbrev' + return pd.Series({ + 'Name': grp[group_name].iloc[0], + 'End': grp['UpperBoundary'].min(), + 'Start': grp['LowerBoundary'].max(), + 'Abbrev': grp[abbrev_var].iloc[0], + 'Color': grp[color_var].iloc[0] + }) + + +def add_GTS(fig, ax, epochs, periods=False, stages=False, allow_abbreviations=True, location='below', height=.045, + v_offset=None, fontsize=10, verbose=False, zorder=-2): + """ + Add the Geologic Time Scale (GTS) to a matplotlib figure. + Parameters + ---------- + fig : matplotlib.figure.Figure + The figure to which the GTS will be added. + ax : matplotlib.axes.Axes + The axes to which the GTS will be added. + epochs : bool + If True, add epochs to the GTS. + periods : bool + If True, add periods to the GTS. + stages : bool + If True, add stages to the GTS. + allow_abbreviations : bool + If True, allow abbreviations for the GTS labels. + location : str + The location of the GTS labels, either 'above' or 'below'. + height : float + The height of the GTS labels. + v_offset : float, optional + The vertical offset for the GTS labels. If None, a default value will be used. + fontsize : int + The font size for the GTS labels. + verbose : bool + If True, print additional information for debugging. + zorder : int + The z-order for the GTS labels, determining their drawing order relative to other elements. + Returns + ------- + fig : matplotlib.figure.Figure + The figure with the GTS added. + ax : matplotlib.axes.Axes + The axes with the GTS added. + Notes + ----- + This function reads the Geologic Time Scale data from a CSV file named 'GTS_updated.csv'. + The CSV file should contain columns for 'Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', + 'Epoch_Abbrev', 'Stage_Abbrev', 'Period_Color', 'Epoch_Color', and 'Stage_Color'. + The function highlights intervals for stages, epochs, and periods based on the boundaries defined in the CSV file. + The function also adds labels for stages, epochs, and periods to the specified axes. + If the `allow_abbreviations` parameter is set to False, the full names of stages, epochs, and periods will be used. + + Examples + -------- + .. jupyter-execute:: + + import pyleoclim as pyleo + import matplotlib.pyplot as plt + + ts_18 = pyleo.utils.load_dataset('cenogrid_d18O') + ts_13 = pyleo.utils.load_dataset('cenogrid_d13C') + ms = pyleo.MultipleSeries([ts_18, ts_13], label='Cenogrid', time_unit='ma BP') + + fig, ax = ms.stackplot(figsize=(8, 5),linewidth=0.5, fill_between_alpha=0) + + for ik, ax_name in enumerate(ax.keys()): + ax[ax_name].invert_xaxis() + ax[0].invert_yaxis() + + fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, epochs=True, periods=True, stages=True, + allow_abbreviations=False, location='below', height=0.04, + v_offset=0.01, fontsize=10) + """ + + GT_csv = 'GTS_updated.csv' + gt_path = DATA_DIR.joinpath(f"{GT_csv}") + GTS_df = pd.read_csv(gt_path) + GTS_df[['UpperBoundary', 'LowerBoundary']] = GTS_df[['UpperBoundary', 'LowerBoundary']].apply(pd.to_numeric, + errors='coerce') + + num_offsets = epochs + periods + stages + offsets = get_v_offsets(location, height, num_offsets, v_offset) + height_num = epochs + periods + stages + 1 + if allow_abbreviations is False: + height = height_num * height + offsets['v_offset_0'] + ax = make_annotation_ax(fig, ax, ax_name='outside_labels', height=height, + loc=location, v_offset=offsets['v_offset_0'], zorder=zorder) + ax['outside_labels'].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + + xlims = ax['x_axis'].get_xlim() + print(xlims) + epochs_loc, periods_loc, stages_loc = set_placement(location, epochs, periods, stages) + for loc in [stages_loc, epochs_loc, periods_loc]: + if loc is False: + continue + if loc == stages_loc: + stage_ax_name = -stages_loc + 1000 # 'Stages' + ax =make_annotation_ax(fig, ax, ax_name=stage_ax_name, height=height, + loc=location, v_offset=offsets[stages_loc], zorder=zorder) + ax[stage_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + ax[stage_ax_name].set_xlim(xlims) + ax[stage_ax_name].grid(visible=False) + + ldf = GTS_df[['Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', + 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Stage').apply( + summarize_geol).sort_values('End').reset_index() + ldf = ldf.loc[ldf.End <= max(xlims)] + intervals = ldf[['End', 'Start']].values.reshape(-1, 2) + ax[stage_ax_name] = hightlight_intervals(ax[stage_ax_name], intervals, color=ldf['Color'].values, alpha=1) + add_geol_labels(fig, ax, ldf, key=stage_ax_name, y_gts=.5, fontsize=fontsize, + allow_abbreviations=allow_abbreviations, verbose=verbose) + ax[stage_ax_name].grid(False) + + elif loc == epochs_loc: + epoch_ax_name = -epochs_loc + 1000 # 'Epochs' + ax = make_annotation_ax(fig, ax, ax_name=epoch_ax_name, height=height, + loc=location, v_offset=offsets[epochs_loc], + zorder=zorder) + ax[epoch_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + ax[epoch_ax_name].set_xlim(xlims) + + ldf = GTS_df[['Epoch', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', + 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Epoch').apply( + summarize_geol).sort_values('End').reset_index() + ldf = ldf.loc[ldf.End <= max(xlims)] + intervals = ldf[['End', 'Start']].values.reshape(-1, 2) + ax[epoch_ax_name] = hightlight_intervals(ax[epoch_ax_name], intervals, color=ldf['Color'].values, alpha=1) + add_geol_labels(fig, ax, ldf, key=epoch_ax_name, y_gts=.45, fontsize=fontsize, + allow_abbreviations=allow_abbreviations, orientation='north', verbose=verbose) + ax[epoch_ax_name].grid(False) + + elif loc == periods_loc: + period_ax_name = -periods_loc + 1000 # 'Periods' + ax = make_annotation_ax(fig, ax, ax_name=period_ax_name, height=height, + loc=location, v_offset=offsets[periods_loc], + zorder=zorder) + ax[period_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + ax[period_ax_name].set_xlim(xlims) + ax[period_ax_name].grid(visible=False) + + ldf = GTS_df[['Period', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', + 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Period').apply( + summarize_geol).sort_values('End').reset_index() + ldf = ldf.loc[ldf.End <= max(xlims)] + intervals = ldf[['End', 'Start']].values.reshape(-1, 2) + ax[period_ax_name] = hightlight_intervals(ax[period_ax_name], intervals, color=ldf['Color'].values, alpha=1) + add_geol_labels(fig, ax, ldf, key=period_ax_name, y_gts=.5, fontsize=fontsize, + allow_abbreviations=allow_abbreviations, verbose=verbose) + ax[period_ax_name].grid(False) + + return fig, ax + +# def get_label_width(ax, label, buffer=0., fontsize=10., verbose=False): +# renderer = ax.figure.canvas.get_renderer() +# text = ax.text(0, 0, label, **dict(fontsize=float(fontsize))) +# bbox = text.get_window_extent(renderer=renderer) +# text.remove() +# +# # Convert pixel bbox to data coordinates +# data_bbox = ax.transData.inverted().transform(bbox) +# width = np.abs(data_bbox[1][0] - data_bbox[0][0]) +# +# if verbose is True: +# print(f"Label: {label}, Width: {width}, Buffer: {buffer}") +# return width + buffer + +# def calculate_overlapping_sets(fig, ax, labels, x_locs, fontsize, buffer=0.1, verbose=False): +# # calls `check_text_fits_in_span` +# +# overlapping_sets = [] +# current_set = {0} # Start with the first label +# n_labels = len(labels) +# xlims = ax.get_xlim() +# x_loc = [xlims[0]] +# x_loc += x_locs +# # x_locs = [].extend(x_locs) +# x_loc.append(xlims[1]) +# x_locs = x_loc +# +# # check if left fits in its slot. if left fits, but middle +# for i in range(2, n_labels): +# label_in_question = labels[i - 1] +# interval = (x_locs[i - 1], x_locs[i + 1]) +# # Check if label fits in the interval between its neighbor stems +# fits = check_text_fits_in_span(fig, ax, interval, label_in_question, fontsize=fontsize) +# if verbose is True: +# print('center', label_in_question, fits, interval) +# +# # left +# #if time 0 is on right +# label_in_question_r = labels[i - 2] +# interval_r = (x_locs[i - 2], x_locs[i - 1]) +# fits_right = check_text_fits_in_span(fig, ax, interval_r, label_in_question_r, fontsize=fontsize) +# interval_l = (x_locs[i - 1], x_locs[i]) +# fits_left = check_text_fits_in_span(fig, ax, interval_l, label_in_question_r, fontsize=fontsize) +# if verbose is True: +# print('before label', label_in_question_r, '(left)', fits_left, interval_l, +# '(right)', fits_right, interval_r) +# +# label_in_question_l = labels[i] +# interval_l = (x_locs[i], x_locs[i + 1]) +# fits_left = check_text_fits_in_span(fig, ax, interval_l, label_in_question_l, fontsize=fontsize) +# interval_r = (x_locs[i - 1], x_locs[i]) +# fits_right = check_text_fits_in_span(fig, ax, interval_r, label_in_question_l, fontsize=fontsize) +# if verbose is True: +# print('after label', label_in_question_l, '(left)', fits_left, interval_l, +# '(right)', fits_right, interval_r) +# +# interval = (x_locs[i], x_locs[i + 1]) +# fits_right = check_text_fits_in_span(fig, ax, interval, labels[i], fontsize=fontsize) +# # print('fits right', labels[i], fits_right, interval) +# +# interval = (x_locs[i - 1], x_locs[i]) +# fits_left = check_text_fits_in_span(fig, ax, interval, labels[i - 1], fontsize=fontsize) +# if verbose is True: +# print(label_in_question, 'fits', fits, 'fits left', fits_left, 'fits right', fits_right) +# +# # If neither label fits within their interval, they overlap +# if not fits_left or not fits_right: +# current_set.add(i) +# else: +# overlapping_sets.append(sorted(list(current_set))) +# current_set = {i} +# +# # Append last set +# overlapping_sets.append(sorted(list(current_set))) +# if verbose is True: +# print('overlapping sets', overlapping_sets) +# return overlapping_sets + From 952dbf239424514c86cd7021c6c2ec98e2b29212 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Wed, 1 Oct 2025 13:13:52 -0700 Subject: [PATCH 03/12] Fix typos and improve type checks in highlight_intervals Corrected the function name from 'hightlight_intervals' to 'highlight_intervals' and updated all references. Improved type checking for intervals, color, alpha, and labels to accept both lists and numpy arrays. Enhanced color handling to support named CSS4 colors and fixed logic for interval boundary checks. --- pyleoclim/utils/plotting.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index eb50c4f6..aeb58216 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -833,7 +833,7 @@ def make_annotation_ax(fig, ax, loc='overlay', import matplotlib.patches as mpatches -def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend=True): +def highlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend=True): ''' Hightlights intervals This function highlights intervals. @@ -875,12 +875,12 @@ def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend ax=pyleo.utils.plotting.make_annotation_ax(fig, ax, ax_name = 'highlighted_intervals', zorder=-1) intervals = [[3, 8], [12, 18], [30, 31], [40,43], [49, 60], [60, 65]] - ax['highlighted_intervals'] = pyleo.utils.plotting.hightlight_intervals(ax['highlighted_intervals'], intervals, + ax['highlighted_intervals'] = pyleo.utils.plotting.highlight_intervals(ax['highlighted_intervals'], intervals, color='g', alpha=.1) ''' - if isinstance(intervals[0], list) is False: + if isinstance(intervals[0], (list, np.ndarray)) is False: intervals = [intervals] handles = [] @@ -890,25 +890,26 @@ def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend new_alphas = [] xlims = ax.get_xlim() - for ik, _ts in enumerate(intervals): - if isinstance(color, list) is True: + if isinstance(color, (list, np.ndarray)) is True: c = color[ik] else: c = color - if '#' in c: + if c in mpl.colors.CSS4_COLORS: + c = mpl.colors.CSS4_COLORS[c] + elif isinstance(c, str) and c.startswith('#'): c = mpl.colors.to_rgba(c) new_colors.append(c) - if isinstance(alpha, list) is True: + if isinstance(alpha, (list, np.ndarray)) is True: a = alpha[ik] else: a = alpha new_alphas.append(a) - if isinstance(labels, list) is True: + if isinstance(labels, (list, np.ndarray)) is True: label = labels[ik] else: label = '' @@ -920,6 +921,7 @@ def hightlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend if _ts[0] < xlims[0]: _ts[0] = xlims[0] else: + print(_ts[0], _ts[1], xlims[0], xlims[1]) if _ts[1] < xlims[1]: _ts[1] = xlims[1] if _ts[0] > xlims[0]: From 77c749ed166bdc11e23e7fcc9cf09e6fe8c236b1 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Wed, 1 Oct 2025 13:23:42 -0700 Subject: [PATCH 04/12] Refactor and modernize geologic time scale plotting Replaces legacy CSV-based GTS plotting with a new system that loads the ICS chart from RDF (Turtle) using rdflib, and provides robust DataFrame-based utilities for geologic time scale annotation. Adds functions to load and customize GTS data, determine label placement, and render colored interval bars with smart label fitting and abbreviation support. The new approach is more flexible, supports up-to-date ICS data, and improves maintainability. --- pyleoclim/utils/plotting.py | 1027 ++++++++++++++++++++++++----------- 1 file changed, 710 insertions(+), 317 deletions(-) diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index aeb58216..ca4d00c4 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -18,6 +18,13 @@ from pathlib import Path from ..utils import lipdutils +from rdflib import Graph, Namespace, URIRef, Literal +from rdflib.namespace import SKOS, RDF +import pandas as pd +import matplotlib.colors as mcolors +import matplotlib.patches as mpatches + + DATA_DIR = Path(__file__).parents[1].joinpath("data").resolve() @@ -830,7 +837,6 @@ def make_annotation_ax(fig, ax, loc='overlay', return ax_d -import matplotlib.patches as mpatches def highlight_intervals(ax, intervals, labels=None, color='g', alpha=.3, legend=True): @@ -1462,369 +1468,756 @@ def tidy_labels(label): ''' Tidy up the label string''' return label.rstrip().lstrip().rstrip(',').lstrip(',') +TIME = Namespace("http://www.w3.org/2006/time#") +SCHEMA = Namespace("https://schema.org/") +GTS = Namespace("http://resource.geosciml.org/ontology/timescale/gts#") -def check_text_fits_in_span(fig, ax, interval, label, fontsize=12, verbose=False): - """ - Checks if the given text can fit within the interval [x1, x2] when plotted. +def _first(it): + for x in it: + return x + return None - Parameters - ---------- - ax : matplotlib.axes.Axes - The axis where the span is plotted. +def _localname(term): + s = str(term) + if "#" in s: + return s.rsplit("#", 1)[1] + return s.rstrip("/").rsplit("/", 1)[-1] - x1, x2 : float - The interval in which the text should fit. - - text : str - The text to check. - - fontsize : int, optional - Font size to test the fitting, default is 12. - - Returns - ------- - bool - True if text fits within [x1, x2], False otherwise. +def _find_ns_for_local(graph: Graph, local: str): """ - if verbose is True: - print('fontsize', fontsize, 'label', label, 'interval', interval) - fig.canvas.draw() # Ensure text positioning updates - renderer = fig.canvas.get_renderer() - - # text_width1 = get_label_width(ax, label, buffer=0., fontsize=fontsize, verbose=verbose) - - # Create a test text object (invisible) - test_text = ax.text(0, 0, label, ha='center', va='center', alpha=0, **{'fontsize': fontsize}) - - # Get text bounding box in display (pixel) coordinates - bbox = test_text.get_window_extent(renderer=renderer) + Find a predicate in the graph whose localname == `local` and + return its namespace base as an rdflib Namespace. Fallback to None. + """ + for s, p, o in graph.triples((None, None, None)): + if isinstance(p, URIRef) and _localname(p) == local: + base = str(p)[: -len(local)] + return Namespace(base) + return None + +def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl") -> pd.DataFrame: + g = Graph() + g.parse(ttl_path_or_url, format="turtle") + + # Resolve namespaces robustly + ischart_ns = _find_ns_for_local(g, "inMYA") # e.g., https://w3id.org/isc/isc2020# + if ischart_ns is None: + # Hard fallback (current ICS charts use this) + ischart_ns = Namespace("https://w3id.org/isc/isc2020#") + + # Rank mapping to your requested Rank labels + rank_map = { + "Eon": "Eon", "Eonothem": "Eon", + "Era": "Era", "Erathem": "Era", + "Period": "Period", "System": "Period", + "Epoch": "Epoch", "Series": "Epoch", + "Age": "Stage", "Stage": "Stage", + "Subepoch": "Subepoch", "Subseries": "Subepoch", + "Substage": "Substage", "Subperiod": "Subperiod", + } - # Convert bbox from display units to data units - bbox_data = ax.transData.inverted().transform(bbox) - text_x_min, _ = bbox_data[0] - text_x_max, _ = bbox_data[1] + rows = [] + + # An interval concept typically has skos:prefLabel and gts:rank + for subj in set(g.subjects(SKOS.prefLabel, None)): + rank_uri = _first(g.objects(subj, GTS.rank)) + if rank_uri is None: + continue # skip non-interval resources + + # Type + rank_local = _localname(rank_uri) + Rank = rank_map.get(rank_local, rank_local) + + # Name + name_lit = _first(g.objects(subj, SKOS.prefLabel)) + Name = str(name_lit) if name_lit is not None else "" + + # Abbrev: skos:notation (typed literal is fine) + notation = _first(g.objects(subj, SKOS.notation)) + Abbrev = str(notation) if notation is not None else "" + + # Color (hex) if present + col = _first(g.objects(subj, SCHEMA.color)) + Color = str(col) if col is not None else "" + + # Helper to read a boundary node (blank node or URI) → inMYA float + def boundary_ma(node_pred): + node = _first(g.objects(subj, node_pred)) + if node is None: + return None + # find a predicate with localname 'inMYA' under this node + val = None + for p, o in g.predicate_objects(node): + if _localname(p) == "inMYA": + val = o + break + try: + return float(val) if val is not None else None + except Exception: + return None + + start_ma = boundary_ma(TIME.hasBeginning) + end_ma = boundary_ma(TIME.hasEnd) + + # UpperBoundary (younger/smaller Ma), LowerBoundary (older/larger Ma) + UpperBoundary = LowerBoundary = None + if start_ma is not None and end_ma is not None: + UpperBoundary = min(start_ma, end_ma) + LowerBoundary = max(start_ma, end_ma) + elif start_ma is not None: + UpperBoundary = start_ma + elif end_ma is not None: + UpperBoundary = end_ma + + # Keep intervals only + if not Name or (UpperBoundary is None and LowerBoundary is None): + continue - text_width = np.abs(text_x_max - text_x_min) # Text width in data coordinates - test_text.remove() # Clean up test text + rows.append({ + "Rank": Rank, + "Name": Name, + "Abbrev": Abbrev, + "Color": Color, + "UpperBoundary": UpperBoundary, + "LowerBoundary": LowerBoundary + }) - # Compare text width with the available span width - x1, x2 = interval - span_width = np.abs(x2 - x1) - if verbose is True: - # print('get_label_width version', 'text width', text_width1, 'span width', span_width, - # 'fits?', text_width1 <= span_width) - print('native version', 'text width', text_width, 'span width', span_width, - 'fits?', text_width <= span_width) + df = pd.DataFrame(rows).dropna(subset=["Name", "Rank"]) + # Optional: order types, youngest first within each type + type_order = ["Eon", "Era", "Period", "Epoch", "Stage", "Subepoch", "Substage", "Subperiod"] + df["Rank"] = pd.Categorical(df["Rank"], categories=type_order + sorted(set(df["Rank"]) - set(type_order)), ordered=True) + df = df.sort_values(["Rank", "UpperBoundary"], na_position="last").reset_index(drop=True) + return df - return text_width <= span_width +def apply_custom_traits(df, specs, target_col="Abbrev"): + """ + Apply custom overrides to a DataFrame by matching on traits. + Parameters + ---------- + df : pandas.DataFrame + Must include the target column and all columns named in specs. + specs : list of dict + Each dict has at least the target_col key plus one or more + trait columns used for matching. + Example: {"Type":"Period", "Name":"Cambrian", "Abbrev":"Cam."} + target_col : str, default "Abbrev" + The column to update. -def add_geol_labels(fig, _ax, ldf, key='Periods', y_gts=None, - fontsize=10, allow_abbreviations=True, orientation='north', verbose=False): - ax = _ax[key] - xlims = ax.get_xlim() + Returns + ------- + pandas.DataFrame + Copy of df with updates applied. + """ + df = df.copy() + if not isinstance(specs, list): + specs = [specs] + for spec in specs: + if target_col not in spec: + raise ValueError(f"Spec {spec} missing '{target_col}' key") + + # Start with all True, then AND each condition + mask = pd.Series(True, index=df.index) + for col, val in spec.items(): + if col == target_col: + continue + mask &= df[col] == val + + df.loc[mask, target_col] = spec[target_col] + return df + +def text_loc(fig, ax, rect, label_text, width, yloc): + ''' + Determine if the label fits inside or outside the bar. - ax_outside_labels = None + Parameters + ---------- + fig : matplotlib.figure.Figure + The figure object. + ax : matplotlib.axes.Axes + The axis where the bar is plotted. + rect : matplotlib.patches.Rectangle + The rectangle (bar) object. + label_text : str + The text label to check. + width : float + The width of the bar in data coordinates. + yloc : float + The y location of the bar in data coordinates. - no_fits = defaultdict(list) - for i in range(len(ldf)): - ageStart = ldf.Start[i] - ageEnd = ldf.End[i] - ageMean = .5 * (ldf.Start[i] + ldf.End[i]) - ageColor = ldf.Color[i] + Returns + ------- + str + 'inside' if the label fits inside the bar, 'outside' otherwise. - ageHandle = ldf.Name[i] + ''' - ylims = ax.get_ylim() - if y_gts is None: - h = np.diff(ax.get_ylim())[0] - y_gts = .5 * h # ylims[0] - if xlims[0] < xlims[1]: - left_age = min([ageStart, ageEnd]) - right_age = max([ageStart, ageEnd]) - - if left_age < xlims[0]: - left_age = xlims[0] - fall_back_bound_ageMean = right_age - .5 * np.abs(right_age - left_age) - time_value = max([ageMean, fall_back_bound_ageMean]) - - elif right_age > xlims[1]: - right_age = xlims[1] - fall_back_bound_ageMean = left_age + .5 * np.abs(right_age - left_age) - time_value = min([ageMean, fall_back_bound_ageMean]) - else: - time_value = ageMean + text = ax.text(yloc, rect.get_y() + rect.get_height() / 2, label_text, + va='center', ha='left', fontstretch='expanded') - else: - left_age = max([ageStart, ageEnd]) - right_age = min([ageStart, ageEnd]) - - if left_age > xlims[0]: - left_age = xlims[0] - fall_back_bound_ageMean = right_age + .5 * np.abs(right_age - left_age) - time_value = min([ageMean, fall_back_bound_ageMean]) - elif right_age < xlims[1]: - right_age = xlims[1] - fall_back_bound_ageMean = left_age - .5 * np.abs(right_age - left_age) - time_value = max([ageMean, fall_back_bound_ageMean]) - else: - time_value = ageMean - - if isinstance(ageHandle, str) == False: - ageHandle = '' - text_obj = ax.text(time_value, y_gts, ageHandle, - ha='center', - va='center', - fontsize=fontsize, - rotation=0) - if verbose is True: - print(ageHandle, time_value, y_gts, 'left', left_age, 'right', right_age) - fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, - verbose=verbose) # check_fits(ax, rect, text_obj) - if fit_bool == False: - text_obj.remove() - - if allow_abbreviations == False: - no_fits['handle'].append(ageHandle) - no_fits['time_value'].append(time_value) - if ax_outside_labels is None: - # _ax=pyleo.utils.plotting.make_annotation_ax(fig, _ax, ax_name = 'outside_labels', height=.06, - # loc='above', v_offset=.02,zorder=-2) - ax_outside_labels = True - # _ax['outside_labels'].spines['top'].set_visible(False) + # Transform data width into display coords + renderer = fig.canvas.get_renderer() + bar_disp = ax.transData.transform((width, 0)) - ax.transData.transform((0, 0)) + bar_width_pixels = bar_disp[0] - else: - ageHandle = ldf.Abbrev[i] - fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, - verbose=verbose) # check_fits(ax, rect, text_obj) - if fit_bool == True: - if isinstance(ageHandle, str) == False: - ageHandle = '' - text_obj = ax.text(time_value, y_gts, ageHandle, - # fontname='TeX Gyre Heros', - ha='center', - va='center', - fontsize=fontsize, - rotation=0) - else: - try: - text_obj.remove() - except: - if verbose is True: - print('text_obj already removed') - - if ax_outside_labels is not None: - if verbose is True: - print('adding outside labels', _ax['outside_labels'].get_ylim()) - if orientation == 'south': - valign = 'top' - else: - valign = 'bottom' - orientation = 'north' - _ax['outside_labels'] = label_intervals( - fig, _ax['outside_labels'], - no_fits['handle'], no_fits['time_value'], - orientation=orientation, baseline=1, height=0.35, buffer=0.12, - linestyle_kwargs={'color': 'gray'}, text_kwargs={'fontsize': fontsize, 'va': valign}) - # - - -def set_placement(loc, epochs, periods, stages): - label_d = {'epochs': epochs, 'periods': periods, 'stages': stages} - ik = 0 - for key in ['stages', 'epochs', 'periods']: - if label_d[key] is True: - label_d[key] = ik - if loc == 'above': - ik += 1 - if loc == 'below': - ik -= 1 - - stages = label_d['stages'] - epochs = label_d['epochs'] - periods = label_d['periods'] - return epochs, periods, stages - - -def get_v_offsets(loc, height, num_offsets, v_offset_0=None): - v_offset_d = {} - if loc == 'above': - v_offset_0 = v_offset_0 if v_offset_0 is not None else .01 - v_offset_d['v_offset_0'] = v_offset_0 - for ik in range(num_offsets): - v_offset_d[ik] = v_offset_0 # + ik*height + # Get label width in pixels + bbox = text.get_window_extent(renderer=renderer) + label_width_pixels = bbox.width + if label_width_pixels < bar_width_pixels: + loc = 'inside' else: - v_offset_0 = v_offset_0 if v_offset_0 is not None else -.01 - v_offset_d['v_offset_0'] = v_offset_0 - for ik in range(num_offsets): - v_offset_d[-ik] = v_offset_0 # - ik*height - - return v_offset_d # {'v_offset_0': v_offset_0, 'inner':v_offset_inner, 'outer':v_offset_outer} - - -def summarize_geol(grp): - """ - Function to summarize the geologic time scale data. - """ - group_name = [col for col in ['Period', 'Epoch', 'Stage'] if col in grp.columns][0] - color_var = group_name + '_Color' - abbrev_var = group_name + '_Abbrev' - return pd.Series({ - 'Name': grp[group_name].iloc[0], - 'End': grp['UpperBoundary'].min(), - 'Start': grp['LowerBoundary'].max(), - 'Abbrev': grp[abbrev_var].iloc[0], - 'Color': grp[color_var].iloc[0] - }) - - -def add_GTS(fig, ax, epochs, periods=False, stages=False, allow_abbreviations=True, location='below', height=.045, - v_offset=None, fontsize=10, verbose=False, zorder=-2): - """ - Add the Geologic Time Scale (GTS) to a matplotlib figure. + loc = 'outside' + text.remove() + return loc + +def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', label_pref='full', + allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=12, edgecolor='k', + edgewidth=0, alpha=1, + gts_url = "https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl"): + ''' + Adds the Geologic Time Scale (GTS) to the plot. Parameters ---------- fig : matplotlib.figure.Figure - The figure to which the GTS will be added. - ax : matplotlib.axes.Axes - The axes to which the GTS will be added. - epochs : bool - If True, add epochs to the GTS. - periods : bool - If True, add periods to the GTS. - stages : bool - If True, add stages to the GTS. - allow_abbreviations : bool - If True, allow abbreviations for the GTS labels. - location : str - The location of the GTS labels, either 'above' or 'below'. - height : float - The height of the GTS labels. + The figure object where the GTS will be added. + ax : dict + The axis where the GTS will be plotted. + GTS_df : pd.DataFrame, optional + DataFrame containing the GTS data with columns for 'Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary'. + If None, the function will load the ICS chart from the provided URL. + Ranks : list of str, optional + List of ranks to include (e.g., ['Period', 'Epoch', 'Stage']). If None, defaults to ['Period', 'Epoch', 'Stage']. + time_units : str, optional + Time units of the GTS data. Supported: 'Ma' (default), 'ka', 'Ga'. + location : str, optional + Specifies whether to place the GTS above or below the plot. Default is 'above'. + label_pref : str, optional + Preference for labels: 'full' (default) for full names, 'abbrev' for abbreviations. + allow_abbreviations : bool, optional + If True, allows abbreviations for labels that don't fit. Default is True. + ax_name : str, optional + Name of the axis to create for the GTS. Default is 'gts'. v_offset : float, optional - The vertical offset for the GTS labels. If None, a default value will be used. - fontsize : int - The font size for the GTS labels. - verbose : bool - If True, print additional information for debugging. - zorder : int - The z-order for the GTS labels, determining their drawing order relative to other elements. + Vertical offset for the GTS labels. Default is 0. + height : float, optional + Height of the GTS annotation area. Default is 0.05. + text_color : str or None, optional + Color of the text labels. If None, the function will choose a contrasting color based on the bar color. Default is None. + fontsize : int, optional + Font size for the GTS labels. Default is 12. + edgecolor : str, optional + Color of the bar edges. Default is 'k' (black). + edgewidth : float, optional + Width of the bar edges. Default is 0. + alpha : float, optional + Transparency of the bars. Default is 1 (opaque). + gts_url : str, optional + URL to load the ICS chart if GTS_df is None. Default is the ICS chart URL. Returns ------- fig : matplotlib.figure.Figure - The figure with the GTS added. - ax : matplotlib.axes.Axes - The axes with the GTS added. - Notes - ----- - This function reads the Geologic Time Scale data from a CSV file named 'GTS_updated.csv'. - The CSV file should contain columns for 'Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', - 'Epoch_Abbrev', 'Stage_Abbrev', 'Period_Color', 'Epoch_Color', and 'Stage_Color'. - The function highlights intervals for stages, epochs, and periods based on the boundaries defined in the CSV file. - The function also adds labels for stages, epochs, and periods to the specified axes. - If the `allow_abbreviations` parameter is set to False, the full names of stages, epochs, and periods will be used. + The figure object with the GTS added. + ax : dict + The axis dictionary with the GTS axis added. + Examples -------- + .. jupyter-execute:: import pyleoclim as pyleo - import matplotlib.pyplot as plt ts_18 = pyleo.utils.load_dataset('cenogrid_d18O') ts_13 = pyleo.utils.load_dataset('cenogrid_d13C') ms = pyleo.MultipleSeries([ts_18, ts_13], label='Cenogrid', time_unit='ma BP') - fig, ax = ms.stackplot(figsize=(8, 5),linewidth=0.5, fill_between_alpha=0) + fig, ax = ms.stackplot(figsize=(10, 5),linewidth=0.5, fill_between_alpha=0) + ax[0].invert_yaxis() # d18O is traditionally inverted for ik, ax_name in enumerate(ax.keys()): + ax[ax_name].grid(visible=False) ax[ax_name].invert_xaxis() - ax[0].invert_yaxis() - fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, epochs=True, periods=True, stages=True, - allow_abbreviations=False, location='below', height=0.04, - v_offset=0.01, fontsize=10) - """ + GTS_df = pyleo.utils.load_ics_chart_to_df() + + GTS_df = pyleo.utils.apply_custom_traits(GTS_df, {'Rank':'Epoch', 'Name':'Pliocene', 'Abbrev':'Plio'}, target_col="Abbrev") + fig, ax = pyleo.utils.add_GTS(fig, ax, time_units='Ma', GTS_df=GTS_df, location='above', label_pref='full', + allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=12, edgecolor='k', + edgewidth=0, alpha=1) - GT_csv = 'GTS_updated.csv' - gt_path = DATA_DIR.joinpath(f"{GT_csv}") - GTS_df = pd.read_csv(gt_path) - GTS_df[['UpperBoundary', 'LowerBoundary']] = GTS_df[['UpperBoundary', 'LowerBoundary']].apply(pd.to_numeric, - errors='coerce') - - num_offsets = epochs + periods + stages - offsets = get_v_offsets(location, height, num_offsets, v_offset) - height_num = epochs + periods + stages + 1 - if allow_abbreviations is False: - height = height_num * height + offsets['v_offset_0'] - ax = make_annotation_ax(fig, ax, ax_name='outside_labels', height=height, - loc=location, v_offset=offsets['v_offset_0'], zorder=zorder) - ax['outside_labels'].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + ''' + + if GTS_df is not None: + assert isinstance(GTS_df, pd.DataFrame) + assert all(col in GTS_df.columns for col in ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']), "GTS_df must contain columns: ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']" + assert GTS_df['UpperBoundary'].dtype in [np.float64, np.float32, np.int64, np.int32], "GTS_df 'UpperBoundary' must be numeric" + assert GTS_df['LowerBoundary'].dtype in [np.float64, np.float32, np.int64, np.int32], "GTS_df 'LowerBoundary' must be numeric" + duration = GTS_df['LowerBoundary']-GTS_df['UpperBoundary'] + assert all(duration >= 0), "GTS_df 'LowerBoundary' must be greater than (further back in time) or equal to 'UpperBoundary (more modern)'" + + if GTS_df is None: + GTS_df = load_ics_chart_to_df(gts_url) + if time_units not in ['Ma', 'Mya', 'My']: + if time_units in ['ka', 'kya', 'ky']: + GTS_df['UpperBoundary'] = GTS_df['UpperBoundary'] * 1e3 + GTS_df['LowerBoundary'] = GTS_df['LowerBoundary'] * 1e3 + elif time_units in ['Ga', 'Gya', 'Gy']: + GTS_df['UpperBoundary'] = GTS_df['UpperBoundary'] * 1e-3 + GTS_df['LowerBoundary'] = GTS_df['LowerBoundary'] * 1e-3 + else: + raise ValueError(f"Unsupported time_units '{time_units}'. Supported: 'Ma', 'ka', 'Ga'.") + + if Ranks is None: + Ranks = ['Period', 'Epoch', 'Stage'] xlims = ax['x_axis'].get_xlim() - print(xlims) - epochs_loc, periods_loc, stages_loc = set_placement(location, epochs, periods, stages) - for loc in [stages_loc, epochs_loc, periods_loc]: - if loc is False: - continue - if loc == stages_loc: - stage_ax_name = -stages_loc + 1000 # 'Stages' - ax =make_annotation_ax(fig, ax, ax_name=stage_ax_name, height=height, - loc=location, v_offset=offsets[stages_loc], zorder=zorder) - ax[stage_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) - ax[stage_ax_name].set_xlim(xlims) - ax[stage_ax_name].grid(visible=False) - - ldf = GTS_df[['Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', - 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Stage').apply( - summarize_geol).sort_values('End').reset_index() - ldf = ldf.loc[ldf.End <= max(xlims)] - intervals = ldf[['End', 'Start']].values.reshape(-1, 2) - ax[stage_ax_name] = hightlight_intervals(ax[stage_ax_name], intervals, color=ldf['Color'].values, alpha=1) - add_geol_labels(fig, ax, ldf, key=stage_ax_name, y_gts=.5, fontsize=fontsize, - allow_abbreviations=allow_abbreviations, verbose=verbose) - ax[stage_ax_name].grid(False) - - elif loc == epochs_loc: - epoch_ax_name = -epochs_loc + 1000 # 'Epochs' - ax = make_annotation_ax(fig, ax, ax_name=epoch_ax_name, height=height, - loc=location, v_offset=offsets[epochs_loc], - zorder=zorder) - ax[epoch_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) - ax[epoch_ax_name].set_xlim(xlims) - - ldf = GTS_df[['Epoch', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', - 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Epoch').apply( - summarize_geol).sort_values('End').reset_index() - ldf = ldf.loc[ldf.End <= max(xlims)] - intervals = ldf[['End', 'Start']].values.reshape(-1, 2) - ax[epoch_ax_name] = hightlight_intervals(ax[epoch_ax_name], intervals, color=ldf['Color'].values, alpha=1) - add_geol_labels(fig, ax, ldf, key=epoch_ax_name, y_gts=.45, fontsize=fontsize, - allow_abbreviations=allow_abbreviations, orientation='north', verbose=verbose) - ax[epoch_ax_name].grid(False) - - elif loc == periods_loc: - period_ax_name = -periods_loc + 1000 # 'Periods' - ax = make_annotation_ax(fig, ax, ax_name=period_ax_name, height=height, - loc=location, v_offset=offsets[periods_loc], - zorder=zorder) - ax[period_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) - ax[period_ax_name].set_xlim(xlims) - ax[period_ax_name].grid(visible=False) - - ldf = GTS_df[['Period', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', - 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Period').apply( - summarize_geol).sort_values('End').reset_index() - ldf = ldf.loc[ldf.End <= max(xlims)] - intervals = ldf[['End', 'Start']].values.reshape(-1, 2) - ax[period_ax_name] = hightlight_intervals(ax[period_ax_name], intervals, color=ldf['Color'].values, alpha=1) - add_geol_labels(fig, ax, ldf, key=period_ax_name, y_gts=.5, fontsize=fontsize, - allow_abbreviations=allow_abbreviations, verbose=verbose) - ax[period_ax_name].grid(False) + # expects upper to be smaller than lower + need_to_swap = False + if xlims[0] > xlims[1]: + need_to_swap = True + + upper_lim = min(xlims) + lower_lim = max(xlims) + xlims = (upper_lim, lower_lim) + + # filter to only the range of interest + sub_df = GTS_df[GTS_df['Rank'].isin(Ranks)] + sub_df = sub_df[(sub_df['UpperBoundary'] < lower_lim) & (sub_df['LowerBoundary'] > upper_lim)] + sub_df['UpperBoundary'] = sub_df['UpperBoundary'].apply(lambda x: max([x, upper_lim])) + sub_df['LowerBoundary'] = sub_df['LowerBoundary'].apply(lambda x: min([x, lower_lim])) + sub_df['duration'] = np.abs(sub_df['UpperBoundary'] - sub_df['LowerBoundary']) + sub_df.sort_values('LowerBoundary', ascending=False, inplace=True) + + num_offsets = len(Ranks) + zorder = 10 + + ax = pyleo.utils.plotting.make_annotation_ax(fig, ax, ax_name=ax_name, height=height * num_offsets, + loc=location, v_offset=v_offset, zorder=zorder) + ax[ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + + for ik, ax_key in enumerate(ax.keys()): + ax[ax_key].grid(visible=False) + ax[ax_key].set_xlim(xlims) + + k = 0 + text_color_flag = text_color + for unit, sub_sub_df in sub_df.groupby('Rank'): + for i, row in sub_sub_df.iterrows(): + color = row.Color + (r, g, b) = mcolors.to_rgb(color) + width = row.duration + y_loc = k * height + rects = ax[ax_name].barh(y_loc, width=width, left=row.UpperBoundary, height=height, color=color, + edgecolor=edgecolor, linewidth=edgewidth, alpha=alpha) + + # Render label to get its size in pixels + abbrev_loc = text_loc(fig, ax[ax_name], rects[0], row.Abbrev, width, y_loc) + full_label_loc = text_loc(fig, ax[ax_name], rects[0], row.Name, width, y_loc) + + loc = 'outside' + label = '' + if label_pref == 'abbrev': + loc = abbrev_loc + label = row.Abbrev + else: + if full_label_loc == 'inside': + loc = full_label_loc + label = row.Name + elif allow_abbreviations is True: + if abbrev_loc == 'inside': + loc = abbrev_loc + label = row.Abbrev + + if loc == 'inside': + if text_color_flag is None: + text_color = 'w' if r * g * b < 0.5 else 'darkgrey' + if b < .50: + if r * g > 0.85: + text_color = 'darkgrey' + elif r < .5: + if g * b > 0.85: + text_color = 'darkgrey' + + ax[ax_name].bar_label(rects, labels=[label], label_type='center', color=text_color, fontsize=fontsize) + + k += 1 + + if location == 'below': + ax[ax_name].invert_yaxis() + + for ik, ax_key in enumerate(ax.keys()): + ax[ax_key].grid(False) + if need_to_swap is True: + ax[ax_key].invert_xaxis() return fig, ax + +# def check_text_fits_in_span(fig, ax, interval, label, fontsize=12, verbose=False): +# """ +# Checks if the given text can fit within the interval [x1, x2] when plotted. +# +# Parameters +# ---------- +# ax : matplotlib.axes.Axes +# The axis where the span is plotted. +# +# x1, x2 : float +# The interval in which the text should fit. +# +# text : str +# The text to check. +# +# fontsize : int, optional +# Font size to test the fitting, default is 12. +# +# Returns +# ------- +# bool +# True if text fits within [x1, x2], False otherwise. +# """ +# if verbose is True: +# print('fontsize', fontsize, 'label', label, 'interval', interval) +# fig.canvas.draw() # Ensure text positioning updates +# renderer = fig.canvas.get_renderer() +# +# # text_width1 = get_label_width(ax, label, buffer=0., fontsize=fontsize, verbose=verbose) +# +# # Create a test text object (invisible) +# test_text = ax.text(0, 0, label, ha='center', va='center', alpha=0, **{'fontsize': fontsize}) +# +# # Get text bounding box in display (pixel) coordinates +# bbox = test_text.get_window_extent(renderer=renderer) +# +# # Convert bbox from display units to data units +# bbox_data = ax.transData.inverted().transform(bbox) +# text_x_min, _ = bbox_data[0] +# text_x_max, _ = bbox_data[1] +# +# text_width = np.abs(text_x_max - text_x_min) # Text width in data coordinates +# test_text.remove() # Clean up test text +# +# # Compare text width with the available span width +# x1, x2 = interval +# span_width = np.abs(x2 - x1) +# if verbose is True: +# # print('get_label_width version', 'text width', text_width1, 'span width', span_width, +# # 'fits?', text_width1 <= span_width) +# print('native version', 'text width', text_width, 'span width', span_width, +# 'fits?', text_width <= span_width) +# +# return text_width <= span_width + + +# first attempt at managing outside labels +# def add_geol_labels(fig, _ax, ldf, key='Periods', y_gts=None, +# fontsize=10, allow_abbreviations=True, orientation='north', verbose=False): +# ax = _ax[key] +# xlims = ax.get_xlim() +# +# ax_outside_labels = None +# +# no_fits = defaultdict(list) +# for i in range(len(ldf)): +# ageStart = ldf.Start[i] +# ageEnd = ldf.End[i] +# ageMean = .5 * (ldf.Start[i] + ldf.End[i]) +# ageColor = ldf.Color[i] +# +# ageHandle = ldf.Name[i] +# +# ylims = ax.get_ylim() +# if y_gts is None: +# h = np.diff(ax.get_ylim())[0] +# y_gts = .5 * h # ylims[0] +# +# if xlims[0] < xlims[1]: +# left_age = min([ageStart, ageEnd]) +# right_age = max([ageStart, ageEnd]) +# +# if left_age < xlims[0]: +# left_age = xlims[0] +# fall_back_bound_ageMean = right_age - .5 * np.abs(right_age - left_age) +# time_value = max([ageMean, fall_back_bound_ageMean]) +# +# elif right_age > xlims[1]: +# right_age = xlims[1] +# fall_back_bound_ageMean = left_age + .5 * np.abs(right_age - left_age) +# time_value = min([ageMean, fall_back_bound_ageMean]) +# else: +# time_value = ageMean +# +# else: +# left_age = max([ageStart, ageEnd]) +# right_age = min([ageStart, ageEnd]) +# +# if left_age > xlims[0]: +# left_age = xlims[0] +# fall_back_bound_ageMean = right_age + .5 * np.abs(right_age - left_age) +# time_value = min([ageMean, fall_back_bound_ageMean]) +# elif right_age < xlims[1]: +# right_age = xlims[1] +# fall_back_bound_ageMean = left_age - .5 * np.abs(right_age - left_age) +# time_value = max([ageMean, fall_back_bound_ageMean]) +# else: +# time_value = ageMean +# +# if isinstance(ageHandle, str) == False: +# ageHandle = '' +# text_obj = ax.text(time_value, y_gts, ageHandle, +# ha='center', +# va='center', +# fontsize=fontsize, +# rotation=0) +# if verbose is True: +# print(ageHandle, time_value, y_gts, 'left', left_age, 'right', right_age) +# fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, +# verbose=verbose) # check_fits(ax, rect, text_obj) +# if fit_bool == False: +# text_obj.remove() +# +# if allow_abbreviations == False: +# no_fits['handle'].append(ageHandle) +# no_fits['time_value'].append(time_value) +# if ax_outside_labels is None: +# # _ax=pyleo.utils.plotting.make_annotation_ax(fig, _ax, ax_name = 'outside_labels', height=.06, +# # loc='above', v_offset=.02,zorder=-2) +# ax_outside_labels = True +# # _ax['outside_labels'].spines['top'].set_visible(False) +# +# else: +# ageHandle = ldf.Abbrev[i] +# fit_bool = check_text_fits_in_span(fig, ax, [left_age, right_age], ageHandle, +# verbose=verbose) # check_fits(ax, rect, text_obj) +# if fit_bool == True: +# if isinstance(ageHandle, str) == False: +# ageHandle = '' +# text_obj = ax.text(time_value, y_gts, ageHandle, +# # fontname='TeX Gyre Heros', +# ha='center', +# va='center', +# fontsize=fontsize, +# rotation=0) +# else: +# try: +# text_obj.remove() +# except: +# if verbose is True: +# print('text_obj already removed') +# +# if ax_outside_labels is not None: +# if verbose is True: +# print('adding outside labels', _ax['outside_labels'].get_ylim()) +# if orientation == 'south': +# valign = 'top' +# else: +# valign = 'bottom' +# orientation = 'north' +# _ax['outside_labels'] = label_intervals( +# fig, _ax['outside_labels'], +# no_fits['handle'], no_fits['time_value'], +# orientation=orientation, baseline=1, height=0.35, buffer=0.12, +# linestyle_kwargs={'color': 'gray'}, text_kwargs={'fontsize': fontsize, 'va': valign}) +# # +# + +# def set_placement(loc, epochs, periods, stages): +# label_d = {'epochs': epochs, 'periods': periods, 'stages': stages} +# ik = 0 +# for key in ['stages', 'epochs', 'periods']: +# if label_d[key] is True: +# label_d[key] = ik +# if loc == 'above': +# ik += 1 +# if loc == 'below': +# ik -= 1 +# +# stages = label_d['stages'] +# epochs = label_d['epochs'] +# periods = label_d['periods'] +# return epochs, periods, stages +# + +# def get_v_offsets(loc, height, num_offsets, v_offset_0=None): +# v_offset_d = {} +# if loc == 'above': +# v_offset_0 = v_offset_0 if v_offset_0 is not None else .01 +# v_offset_d['v_offset_0'] = v_offset_0 +# for ik in range(num_offsets): +# v_offset_d[ik] = v_offset_0 # + ik*height +# +# else: +# v_offset_0 = v_offset_0 if v_offset_0 is not None else -.01 +# v_offset_d['v_offset_0'] = v_offset_0 +# for ik in range(num_offsets): +# v_offset_d[-ik] = v_offset_0 # - ik*height +# +# return v_offset_d # {'v_offset_0': v_offset_0, 'inner':v_offset_inner, 'outer':v_offset_outer} + + +# def summarize_geol(grp): +# """ +# Function to summarize the geologic time scale data. +# """ +# group_name = [col for col in ['Period', 'Epoch', 'Stage'] if col in grp.columns][0] +# color_var = group_name + '_Color' +# abbrev_var = group_name + '_Abbrev' +# return pd.Series({ +# 'Name': grp[group_name].iloc[0], +# 'End': grp['UpperBoundary'].min(), +# 'Start': grp['LowerBoundary'].max(), +# 'Abbrev': grp[abbrev_var].iloc[0], +# 'Color': grp[color_var].iloc[0] +# }) + + +# def add_GTS(fig, ax, epochs, periods=False, stages=False, allow_abbreviations=True, location='below', height=.045, +# v_offset=None, fontsize=10, verbose=False, zorder=-2): +# """ +# Add the Geologic Time Scale (GTS) to a matplotlib figure. +# Parameters +# ---------- +# fig : matplotlib.figure.Figure +# The figure to which the GTS will be added. +# ax : matplotlib.axes.Axes +# The axes to which the GTS will be added. +# epochs : bool +# If True, add epochs to the GTS. +# periods : bool +# If True, add periods to the GTS. +# stages : bool +# If True, add stages to the GTS. +# allow_abbreviations : bool +# If True, allow abbreviations for the GTS labels. +# location : str +# The location of the GTS labels, either 'above' or 'below'. +# height : float +# The height of the GTS labels. +# v_offset : float, optional +# The vertical offset for the GTS labels. If None, a default value will be used. +# fontsize : int +# The font size for the GTS labels. +# verbose : bool +# If True, print additional information for debugging. +# zorder : int +# The z-order for the GTS labels, determining their drawing order relative to other elements. +# Returns +# ------- +# fig : matplotlib.figure.Figure +# The figure with the GTS added. +# ax : matplotlib.axes.Axes +# The axes with the GTS added. +# Notes +# ----- +# This function reads the Geologic Time Scale data from a CSV file named 'GTS_updated.csv'. +# The CSV file should contain columns for 'Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', +# 'Epoch_Abbrev', 'Stage_Abbrev', 'Period_Color', 'Epoch_Color', and 'Stage_Color'. +# The function highlights intervals for stages, epochs, and periods based on the boundaries defined in the CSV file. +# The function also adds labels for stages, epochs, and periods to the specified axes. +# If the `allow_abbreviations` parameter is set to False, the full names of stages, epochs, and periods will be used. +# +# Examples +# -------- +# .. jupyter-execute:: +# +# import pyleoclim as pyleo +# import matplotlib.pyplot as plt +# +# ts_18 = pyleo.utils.load_dataset('cenogrid_d18O') +# ts_13 = pyleo.utils.load_dataset('cenogrid_d13C') +# ms = pyleo.MultipleSeries([ts_18, ts_13], label='Cenogrid', time_unit='ma BP') +# +# fig, ax = ms.stackplot(figsize=(8, 5),linewidth=0.5, fill_between_alpha=0) +# +# for ik, ax_name in enumerate(ax.keys()): +# ax[ax_name].invert_xaxis() +# ax[0].invert_yaxis() +# +# fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, epochs=True, periods=True, stages=True, +# allow_abbreviations=False, location='below', height=0.04, +# v_offset=0.01, fontsize=10) +# """ +# +# GT_csv = 'GTS_updated.csv' +# gt_path = DATA_DIR.joinpath(f"{GT_csv}") +# GTS_df = pd.read_csv(gt_path) +# GTS_df[['UpperBoundary', 'LowerBoundary']] = GTS_df[['UpperBoundary', 'LowerBoundary']].apply(pd.to_numeric, +# errors='coerce') +# +# num_offsets = epochs + periods + stages +# offsets = get_v_offsets(location, height, num_offsets, v_offset) +# height_num = epochs + periods + stages + 1 +# if allow_abbreviations is False: +# height = height_num * height + offsets['v_offset_0'] +# ax = make_annotation_ax(fig, ax, ax_name='outside_labels', height=height, +# loc=location, v_offset=offsets['v_offset_0'], zorder=zorder) +# ax['outside_labels'].spines[['top', 'bottom', 'left', 'right']].set_visible(False) +# +# xlims = ax['x_axis'].get_xlim() +# print(xlims) +# epochs_loc, periods_loc, stages_loc = set_placement(location, epochs, periods, stages) +# for loc in [stages_loc, epochs_loc, periods_loc]: +# if loc is False: +# continue +# if loc == stages_loc: +# stage_ax_name = -stages_loc + 1000 # 'Stages' +# ax =make_annotation_ax(fig, ax, ax_name=stage_ax_name, height=height, +# loc=location, v_offset=offsets[stages_loc], zorder=zorder) +# ax[stage_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) +# ax[stage_ax_name].set_xlim(xlims) +# ax[stage_ax_name].grid(visible=False) +# +# ldf = GTS_df[['Stage', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', +# 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Stage').apply( +# summarize_geol).sort_values('End').reset_index() +# ldf = ldf.loc[ldf.End <= max(xlims)] +# intervals = ldf[['End', 'Start']].values.reshape(-1, 2) +# ax[stage_ax_name] = hightlight_intervals(ax[stage_ax_name], intervals, color=ldf['Color'].values, alpha=1) +# add_geol_labels(fig, ax, ldf, key=stage_ax_name, y_gts=.5, fontsize=fontsize, +# allow_abbreviations=allow_abbreviations, verbose=verbose) +# ax[stage_ax_name].grid(False) +# +# elif loc == epochs_loc: +# epoch_ax_name = -epochs_loc + 1000 # 'Epochs' +# ax = make_annotation_ax(fig, ax, ax_name=epoch_ax_name, height=height, +# loc=location, v_offset=offsets[epochs_loc], +# zorder=zorder) +# ax[epoch_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) +# ax[epoch_ax_name].set_xlim(xlims) +# +# ldf = GTS_df[['Epoch', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', +# 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Epoch').apply( +# summarize_geol).sort_values('End').reset_index() +# ldf = ldf.loc[ldf.End <= max(xlims)] +# intervals = ldf[['End', 'Start']].values.reshape(-1, 2) +# ax[epoch_ax_name] = hightlight_intervals(ax[epoch_ax_name], intervals, color=ldf['Color'].values, alpha=1) +# add_geol_labels(fig, ax, ldf, key=epoch_ax_name, y_gts=.45, fontsize=fontsize, +# allow_abbreviations=allow_abbreviations, orientation='north', verbose=verbose) +# ax[epoch_ax_name].grid(False) +# +# elif loc == periods_loc: +# period_ax_name = -periods_loc + 1000 # 'Periods' +# ax = make_annotation_ax(fig, ax, ax_name=period_ax_name, height=height, +# loc=location, v_offset=offsets[periods_loc], +# zorder=zorder) +# ax[period_ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) +# ax[period_ax_name].set_xlim(xlims) +# ax[period_ax_name].grid(visible=False) +# +# ldf = GTS_df[['Period', 'UpperBoundary', 'LowerBoundary', 'Period_Abbrev', 'Epoch_Abbrev', 'Stage_Abbrev', +# 'Period_Color', 'Epoch_Color', 'Stage_Color']].groupby('Period').apply( +# summarize_geol).sort_values('End').reset_index() +# ldf = ldf.loc[ldf.End <= max(xlims)] +# intervals = ldf[['End', 'Start']].values.reshape(-1, 2) +# ax[period_ax_name] = hightlight_intervals(ax[period_ax_name], intervals, color=ldf['Color'].values, alpha=1) +# add_geol_labels(fig, ax, ldf, key=period_ax_name, y_gts=.5, fontsize=fontsize, +# allow_abbreviations=allow_abbreviations, verbose=verbose) +# ax[period_ax_name].grid(False) +# +# return fig, ax + # def get_label_width(ax, label, buffer=0., fontsize=10., verbose=False): # renderer = ax.figure.canvas.get_renderer() # text = ax.text(0, 0, label, **dict(fontsize=float(fontsize))) From 29a0cbf0591760d883e996f27a8eca508e9b3008 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Wed, 1 Oct 2025 13:33:22 -0700 Subject: [PATCH 05/12] Refactor GTS utilities to datasets module Moved load_ics_chart_to_df and apply_custom_traits from plotting.py to the datasets module. Updated imports and function calls accordingly to improve code organization and modularity. --- pyleoclim/utils/plotting.py | 170 ++---------------------------------- 1 file changed, 7 insertions(+), 163 deletions(-) diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index ca4d00c4..033fa3ba 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -18,8 +18,8 @@ from pathlib import Path from ..utils import lipdutils -from rdflib import Graph, Namespace, URIRef, Literal -from rdflib.namespace import SKOS, RDF +from ..utils import datasets + import pandas as pd import matplotlib.colors as mcolors import matplotlib.patches as mpatches @@ -1468,162 +1468,6 @@ def tidy_labels(label): ''' Tidy up the label string''' return label.rstrip().lstrip().rstrip(',').lstrip(',') -TIME = Namespace("http://www.w3.org/2006/time#") -SCHEMA = Namespace("https://schema.org/") -GTS = Namespace("http://resource.geosciml.org/ontology/timescale/gts#") - -def _first(it): - for x in it: - return x - return None - -def _localname(term): - s = str(term) - if "#" in s: - return s.rsplit("#", 1)[1] - return s.rstrip("/").rsplit("/", 1)[-1] - -def _find_ns_for_local(graph: Graph, local: str): - """ - Find a predicate in the graph whose localname == `local` and - return its namespace base as an rdflib Namespace. Fallback to None. - """ - for s, p, o in graph.triples((None, None, None)): - if isinstance(p, URIRef) and _localname(p) == local: - base = str(p)[: -len(local)] - return Namespace(base) - return None - -def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl") -> pd.DataFrame: - g = Graph() - g.parse(ttl_path_or_url, format="turtle") - - # Resolve namespaces robustly - ischart_ns = _find_ns_for_local(g, "inMYA") # e.g., https://w3id.org/isc/isc2020# - if ischart_ns is None: - # Hard fallback (current ICS charts use this) - ischart_ns = Namespace("https://w3id.org/isc/isc2020#") - - # Rank mapping to your requested Rank labels - rank_map = { - "Eon": "Eon", "Eonothem": "Eon", - "Era": "Era", "Erathem": "Era", - "Period": "Period", "System": "Period", - "Epoch": "Epoch", "Series": "Epoch", - "Age": "Stage", "Stage": "Stage", - "Subepoch": "Subepoch", "Subseries": "Subepoch", - "Substage": "Substage", "Subperiod": "Subperiod", - } - - rows = [] - - # An interval concept typically has skos:prefLabel and gts:rank - for subj in set(g.subjects(SKOS.prefLabel, None)): - rank_uri = _first(g.objects(subj, GTS.rank)) - if rank_uri is None: - continue # skip non-interval resources - - # Type - rank_local = _localname(rank_uri) - Rank = rank_map.get(rank_local, rank_local) - - # Name - name_lit = _first(g.objects(subj, SKOS.prefLabel)) - Name = str(name_lit) if name_lit is not None else "" - - # Abbrev: skos:notation (typed literal is fine) - notation = _first(g.objects(subj, SKOS.notation)) - Abbrev = str(notation) if notation is not None else "" - - # Color (hex) if present - col = _first(g.objects(subj, SCHEMA.color)) - Color = str(col) if col is not None else "" - - # Helper to read a boundary node (blank node or URI) → inMYA float - def boundary_ma(node_pred): - node = _first(g.objects(subj, node_pred)) - if node is None: - return None - # find a predicate with localname 'inMYA' under this node - val = None - for p, o in g.predicate_objects(node): - if _localname(p) == "inMYA": - val = o - break - try: - return float(val) if val is not None else None - except Exception: - return None - - start_ma = boundary_ma(TIME.hasBeginning) - end_ma = boundary_ma(TIME.hasEnd) - - # UpperBoundary (younger/smaller Ma), LowerBoundary (older/larger Ma) - UpperBoundary = LowerBoundary = None - if start_ma is not None and end_ma is not None: - UpperBoundary = min(start_ma, end_ma) - LowerBoundary = max(start_ma, end_ma) - elif start_ma is not None: - UpperBoundary = start_ma - elif end_ma is not None: - UpperBoundary = end_ma - - # Keep intervals only - if not Name or (UpperBoundary is None and LowerBoundary is None): - continue - - rows.append({ - "Rank": Rank, - "Name": Name, - "Abbrev": Abbrev, - "Color": Color, - "UpperBoundary": UpperBoundary, - "LowerBoundary": LowerBoundary - }) - - df = pd.DataFrame(rows).dropna(subset=["Name", "Rank"]) - # Optional: order types, youngest first within each type - type_order = ["Eon", "Era", "Period", "Epoch", "Stage", "Subepoch", "Substage", "Subperiod"] - df["Rank"] = pd.Categorical(df["Rank"], categories=type_order + sorted(set(df["Rank"]) - set(type_order)), ordered=True) - df = df.sort_values(["Rank", "UpperBoundary"], na_position="last").reset_index(drop=True) - return df - -def apply_custom_traits(df, specs, target_col="Abbrev"): - """ - Apply custom overrides to a DataFrame by matching on traits. - - Parameters - ---------- - df : pandas.DataFrame - Must include the target column and all columns named in specs. - specs : list of dict - Each dict has at least the target_col key plus one or more - trait columns used for matching. - Example: {"Type":"Period", "Name":"Cambrian", "Abbrev":"Cam."} - target_col : str, default "Abbrev" - The column to update. - - Returns - ------- - pandas.DataFrame - Copy of df with updates applied. - """ - df = df.copy() - if not isinstance(specs, list): - specs = [specs] - for spec in specs: - if target_col not in spec: - raise ValueError(f"Spec {spec} missing '{target_col}' key") - - # Start with all True, then AND each condition - mask = pd.Series(True, index=df.index) - for col, val in spec.items(): - if col == target_col: - continue - mask &= df[col] == val - - df.loc[mask, target_col] = spec[target_col] - return df def text_loc(fig, ax, rect, label_text, width, yloc): ''' @@ -1740,10 +1584,10 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', ax[ax_name].grid(visible=False) ax[ax_name].invert_xaxis() - GTS_df = pyleo.utils.load_ics_chart_to_df() + GTS_df = pyleo.utils.datasets.load_ics_chart_to_df() - GTS_df = pyleo.utils.apply_custom_traits(GTS_df, {'Rank':'Epoch', 'Name':'Pliocene', 'Abbrev':'Plio'}, target_col="Abbrev") - fig, ax = pyleo.utils.add_GTS(fig, ax, time_units='Ma', GTS_df=GTS_df, location='above', label_pref='full', + GTS_df = pyleo.utils.plotting.datasets.apply_custom_traits(GTS_df, {'Rank':'Epoch', 'Name':'Pliocene', 'Abbrev':'Plio'}, target_col="Abbrev") + fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, time_units='Ma', GTS_df=GTS_df, location='above', label_pref='full', allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=12, edgecolor='k', edgewidth=0, alpha=1) @@ -1758,7 +1602,7 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', assert all(duration >= 0), "GTS_df 'LowerBoundary' must be greater than (further back in time) or equal to 'UpperBoundary (more modern)'" if GTS_df is None: - GTS_df = load_ics_chart_to_df(gts_url) + GTS_df = datasets.load_ics_chart_to_df(gts_url) if time_units not in ['Ma', 'Mya', 'My']: if time_units in ['ka', 'kya', 'ky']: GTS_df['UpperBoundary'] = GTS_df['UpperBoundary'] * 1e3 @@ -1793,7 +1637,7 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', num_offsets = len(Ranks) zorder = 10 - ax = pyleo.utils.plotting.make_annotation_ax(fig, ax, ax_name=ax_name, height=height * num_offsets, + ax = make_annotation_ax(fig, ax, ax_name=ax_name, height=height * num_offsets, loc=location, v_offset=v_offset, zorder=zorder) ax[ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) From e481d6f492c06d5b508926a783403f6d092d8979 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Wed, 1 Oct 2025 13:33:36 -0700 Subject: [PATCH 06/12] Add ICS chart loader and trait override utilities Introduces `load_ics_chart_to_df` to parse ICS stratigraphic charts in Turtle format into DataFrames, including robust namespace handling and rank normalization. Adds `apply_custom_traits` to allow custom overrides of DataFrame columns based on trait matching. These utilities facilitate integration and customization of geologic timescale data. --- pyleoclim/utils/datasets.py | 160 ++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/pyleoclim/utils/datasets.py b/pyleoclim/utils/datasets.py index 804d731f..bc2d9140 100644 --- a/pyleoclim/utils/datasets.py +++ b/pyleoclim/utils/datasets.py @@ -5,6 +5,8 @@ import pyleoclim as pyleo import yaml import pandas as pd +from rdflib import Graph, Namespace, URIRef, Literal +from rdflib.namespace import SKOS, RDF from ..utils import jsonutils DATA_DIR = Path(__file__).parents[1].joinpath("data").resolve() @@ -171,3 +173,161 @@ def load_dataset(name): raise RuntimeError(f"Unable to load dataset with file extension {metadata['file_extension']}.") return ts + + +TIME = Namespace("http://www.w3.org/2006/time#") +SCHEMA = Namespace("https://schema.org/") +GTS = Namespace("http://resource.geosciml.org/ontology/timescale/gts#") + +def _first(it): + for x in it: + return x + return None + +def _localname(term): + s = str(term) + if "#" in s: + return s.rsplit("#", 1)[1] + return s.rstrip("/").rsplit("/", 1)[-1] + +def _find_ns_for_local(graph: Graph, local: str): + """ + Find a predicate in the graph whose localname == `local` and + return its namespace base as an rdflib Namespace. Fallback to None. + """ + for s, p, o in graph.triples((None, None, None)): + if isinstance(p, URIRef) and _localname(p) == local: + base = str(p)[: -len(local)] + return Namespace(base) + return None + +def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl") -> pd.DataFrame: + g = Graph() + g.parse(ttl_path_or_url, format="turtle") + + # Resolve namespaces robustly + ischart_ns = _find_ns_for_local(g, "inMYA") # e.g., https://w3id.org/isc/isc2020# + if ischart_ns is None: + # Hard fallback (current ICS charts use this) + ischart_ns = Namespace("https://w3id.org/isc/isc2020#") + + # Rank mapping to your requested Rank labels + rank_map = { + "Eon": "Eon", "Eonothem": "Eon", + "Era": "Era", "Erathem": "Era", + "Period": "Period", "System": "Period", + "Epoch": "Epoch", "Series": "Epoch", + "Age": "Stage", "Stage": "Stage", + "Subepoch": "Subepoch", "Subseries": "Subepoch", + "Substage": "Substage", "Subperiod": "Subperiod", + } + + rows = [] + + # An interval concept typically has skos:prefLabel and gts:rank + for subj in set(g.subjects(SKOS.prefLabel, None)): + rank_uri = _first(g.objects(subj, GTS.rank)) + if rank_uri is None: + continue # skip non-interval resources + + # Type + rank_local = _localname(rank_uri) + Rank = rank_map.get(rank_local, rank_local) + + # Name + name_lit = _first(g.objects(subj, SKOS.prefLabel)) + Name = str(name_lit) if name_lit is not None else "" + + # Abbrev: skos:notation (typed literal is fine) + notation = _first(g.objects(subj, SKOS.notation)) + Abbrev = str(notation) if notation is not None else "" + + # Color (hex) if present + col = _first(g.objects(subj, SCHEMA.color)) + Color = str(col) if col is not None else "" + + # Helper to read a boundary node (blank node or URI) → inMYA float + def boundary_ma(node_pred): + node = _first(g.objects(subj, node_pred)) + if node is None: + return None + # find a predicate with localname 'inMYA' under this node + val = None + for p, o in g.predicate_objects(node): + if _localname(p) == "inMYA": + val = o + break + try: + return float(val) if val is not None else None + except Exception: + return None + + start_ma = boundary_ma(TIME.hasBeginning) + end_ma = boundary_ma(TIME.hasEnd) + + # UpperBoundary (younger/smaller Ma), LowerBoundary (older/larger Ma) + UpperBoundary = LowerBoundary = None + if start_ma is not None and end_ma is not None: + UpperBoundary = min(start_ma, end_ma) + LowerBoundary = max(start_ma, end_ma) + elif start_ma is not None: + UpperBoundary = start_ma + elif end_ma is not None: + UpperBoundary = end_ma + + # Keep intervals only + if not Name or (UpperBoundary is None and LowerBoundary is None): + continue + + rows.append({ + "Rank": Rank, + "Name": Name, + "Abbrev": Abbrev, + "Color": Color, + "UpperBoundary": UpperBoundary, + "LowerBoundary": LowerBoundary + }) + + df = pd.DataFrame(rows).dropna(subset=["Name", "Rank"]) + # Optional: order types, youngest first within each type + type_order = ["Eon", "Era", "Period", "Epoch", "Stage", "Subepoch", "Substage", "Subperiod"] + df["Rank"] = pd.Categorical(df["Rank"], categories=type_order + sorted(set(df["Rank"]) - set(type_order)), ordered=True) + df = df.sort_values(["Rank", "UpperBoundary"], na_position="last").reset_index(drop=True) + return df + +def apply_custom_traits(df, specs, target_col="Abbrev"): + """ + Apply custom overrides to a DataFrame by matching on traits. + + Parameters + ---------- + df : pandas.DataFrame + Must include the target column and all columns named in specs. + specs : list of dict + Each dict has at least the target_col key plus one or more + trait columns used for matching. + Example: {"Type":"Period", "Name":"Cambrian", "Abbrev":"Cam."} + target_col : str, default "Abbrev" + The column to update. + + Returns + ------- + pandas.DataFrame + Copy of df with updates applied. + """ + df = df.copy() + if not isinstance(specs, list): + specs = [specs] + for spec in specs: + if target_col not in spec: + raise ValueError(f"Spec {spec} missing '{target_col}' key") + + # Start with all True, then AND each condition + mask = pd.Series(True, index=df.index) + for col, val in spec.items(): + if col == target_col: + continue + mask &= df[col] == val + + df.loc[mask, target_col] = spec[target_col] + return df From 95a9e3695accbc1faf4c11584f4d5d696eaf3a2f Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Thu, 2 Oct 2025 13:56:09 -0700 Subject: [PATCH 07/12] Add time_units parameter to load_ics_chart_to_df The load_ics_chart_to_df function now accepts a time_units argument to allow conversion of boundary values to different time units (Ma, ka, Ga, or years). This enhances flexibility for users needing geological time data in various units. --- pyleoclim/utils/datasets.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pyleoclim/utils/datasets.py b/pyleoclim/utils/datasets.py index bc2d9140..92017b66 100644 --- a/pyleoclim/utils/datasets.py +++ b/pyleoclim/utils/datasets.py @@ -201,7 +201,7 @@ def _find_ns_for_local(graph: Graph, local: str): return Namespace(base) return None -def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl") -> pd.DataFrame: +def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl", time_units='Ma') -> pd.DataFrame: g = Graph() g.parse(ttl_path_or_url, format="turtle") @@ -293,6 +293,20 @@ def boundary_ma(node_pred): type_order = ["Eon", "Era", "Period", "Epoch", "Stage", "Subepoch", "Substage", "Subperiod"] df["Rank"] = pd.Categorical(df["Rank"], categories=type_order + sorted(set(df["Rank"]) - set(type_order)), ordered=True) df = df.sort_values(["Rank", "UpperBoundary"], na_position="last").reset_index(drop=True) + + if time_units not in ['Ma', 'Mya', 'My']: + if time_units in ['a', 'ya', 'y', 'yr']: + df['UpperBoundary'] = df['UpperBoundary'] * 1e6 + df['LowerBoundary'] = df['LowerBoundary'] * 1e6 + elif time_units in ['ka', 'kya', 'ky', 'kyr']: + df['UpperBoundary'] = df['UpperBoundary'] * 1e3 + df['LowerBoundary'] = df['LowerBoundary'] * 1e3 + elif time_units in ['Ga', 'Gya', 'Gy', 'Gyr']: + df['UpperBoundary'] = df['UpperBoundary'] * 1e-3 + df['LowerBoundary'] = df['LowerBoundary'] * 1e-3 + else: + raise ValueError(f"Unsupported time_units '{time_units}'. Supported: 'Ma', 'ka', 'Ga'.") + return df def apply_custom_traits(df, specs, target_col="Abbrev"): From 10b5b57bd005c6173c0dfc8faed15ccd63e39a36 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Thu, 2 Oct 2025 13:56:33 -0700 Subject: [PATCH 08/12] Add and enhance add_GTS function for geologic time scale Introduces the add_GTS function to plot the Geologic Time Scale (GTS) on Matplotlib axes, with options for label preferences, time units, rank order, and improved font size handling. Refactors argument names for clarity, adds new parameters (e.g., zorder, reverse_rank_order), and improves axis handling and label fitting logic. --- pyleoclim/utils/plotting.py | 79 +++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index 033fa3ba..b54250cb 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -4,7 +4,7 @@ Plotting utilities, leveraging Matplotlib. """ -__all__ = ['set_style', 'closefig', 'savefig'] +__all__ = ['set_style', 'closefig', 'savefig', 'add_GTS'] import matplotlib.pyplot as plt import pathlib @@ -16,6 +16,7 @@ import copy from collections import defaultdict from pathlib import Path +import re from ..utils import lipdutils from ..utils import datasets @@ -1503,10 +1504,12 @@ def text_loc(fig, ax, rect, label_text, width, yloc): renderer = fig.canvas.get_renderer() bar_disp = ax.transData.transform((width, 0)) - ax.transData.transform((0, 0)) bar_width_pixels = bar_disp[0] + bar_height_pixels = bar_disp[1] # Get label width in pixels bbox = text.get_window_extent(renderer=renderer) label_width_pixels = bbox.width + label_height_pixels = bbox.height if label_width_pixels < bar_width_pixels: loc = 'inside' @@ -1515,9 +1518,9 @@ def text_loc(fig, ax, rect, label_text, width, yloc): text.remove() return loc -def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', label_pref='full', - allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=12, edgecolor='k', - edgewidth=0, alpha=1, +def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', label_pref='full', + allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=None, edgecolor='k', + edgewidth=0, alpha=1,zorder=10,reverse_rank_order=False, gts_url = "https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl"): ''' Adds the Geologic Time Scale (GTS) to the plot. @@ -1530,14 +1533,14 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', GTS_df : pd.DataFrame, optional DataFrame containing the GTS data with columns for 'Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary'. If None, the function will load the ICS chart from the provided URL. - Ranks : list of str, optional + ranks : list of str, optional List of ranks to include (e.g., ['Period', 'Epoch', 'Stage']). If None, defaults to ['Period', 'Epoch', 'Stage']. time_units : str, optional Time units of the GTS data. Supported: 'Ma' (default), 'ka', 'Ga'. location : str, optional Specifies whether to place the GTS above or below the plot. Default is 'above'. label_pref : str, optional - Preference for labels: 'full' (default) for full names, 'abbrev' for abbreviations. + Preference for labels: 'full' (default) for full names, 'abbrev' for abbreviations, 'none' for no labels (only colors). allow_abbreviations : bool, optional If True, allows abbreviations for labels that don't fit. Default is True. ax_name : str, optional @@ -1556,6 +1559,8 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', Width of the bar edges. Default is 0. alpha : float, optional Transparency of the bars. Default is 1 (opaque). + zorder : int, optional + Z-order for the GTS axis. Default is 10. gts_url : str, optional URL to load the ICS chart if GTS_df is None. Default is the ICS chart URL. Returns @@ -1593,6 +1598,13 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', ''' + if isinstance(ax, dict) is False: + ax = {0: ax} + if fontsize is None: + pattern = re.compile(r".*(labelsize|fontsize).*") + fontsize_options = mpl.rcParams.find_all(pattern) + fontsize = min([value for value in fontsize_options.values() if isinstance(value, (int, float))]) + if GTS_df is not None: assert isinstance(GTS_df, pd.DataFrame) assert all(col in GTS_df.columns for col in ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']), "GTS_df must contain columns: ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']" @@ -1602,21 +1614,16 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', assert all(duration >= 0), "GTS_df 'LowerBoundary' must be greater than (further back in time) or equal to 'UpperBoundary (more modern)'" if GTS_df is None: - GTS_df = datasets.load_ics_chart_to_df(gts_url) - if time_units not in ['Ma', 'Mya', 'My']: - if time_units in ['ka', 'kya', 'ky']: - GTS_df['UpperBoundary'] = GTS_df['UpperBoundary'] * 1e3 - GTS_df['LowerBoundary'] = GTS_df['LowerBoundary'] * 1e3 - elif time_units in ['Ga', 'Gya', 'Gy']: - GTS_df['UpperBoundary'] = GTS_df['UpperBoundary'] * 1e-3 - GTS_df['LowerBoundary'] = GTS_df['LowerBoundary'] * 1e-3 - else: - raise ValueError(f"Unsupported time_units '{time_units}'. Supported: 'Ma', 'ka', 'Ga'.") + GTS_df = datasets.load_ics_chart_to_df(gts_url, time_units=time_units) - if Ranks is None: - Ranks = ['Period', 'Epoch', 'Stage'] + if ranks is None: + ranks = ['Period', 'Epoch', 'Stage'] + + if 'x_axis' in ax.keys(): + xlims = ax['x_axis'].get_xlim() + else: + xlims = ax[0].get_xlim() - xlims = ax['x_axis'].get_xlim() # expects upper to be smaller than lower need_to_swap = False if xlims[0] > xlims[1]: @@ -1627,45 +1634,50 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', xlims = (upper_lim, lower_lim) # filter to only the range of interest - sub_df = GTS_df[GTS_df['Rank'].isin(Ranks)] + sub_df = GTS_df[GTS_df['Rank'].isin(ranks)] sub_df = sub_df[(sub_df['UpperBoundary'] < lower_lim) & (sub_df['LowerBoundary'] > upper_lim)] sub_df['UpperBoundary'] = sub_df['UpperBoundary'].apply(lambda x: max([x, upper_lim])) sub_df['LowerBoundary'] = sub_df['LowerBoundary'].apply(lambda x: min([x, lower_lim])) sub_df['duration'] = np.abs(sub_df['UpperBoundary'] - sub_df['LowerBoundary']) sub_df.sort_values('LowerBoundary', ascending=False, inplace=True) - num_offsets = len(Ranks) - zorder = 10 + num_ranks = len(ranks) - ax = make_annotation_ax(fig, ax, ax_name=ax_name, height=height * num_offsets, + # create the annotation axis + ax = make_annotation_ax(fig, ax, ax_name=ax_name, height=height * num_ranks, loc=location, v_offset=v_offset, zorder=zorder) ax[ax_name].spines[['top', 'bottom', 'left', 'right']].set_visible(False) + # set limits and grid to make sure x-axis is increasing (will reverse at end if needed) for ik, ax_key in enumerate(ax.keys()): ax[ax_key].grid(visible=False) ax[ax_key].set_xlim(xlims) k = 0 - text_color_flag = text_color - for unit, sub_sub_df in sub_df.groupby('Rank'): - for i, row in sub_sub_df.iterrows(): + text_color_flag = text_color # None means auto, otherwise use specified color + for unit, unit_df in sub_df.groupby('Rank'): + for i, row in unit_df.iterrows(): color = row.Color (r, g, b) = mcolors.to_rgb(color) - width = row.duration - y_loc = k * height + width = row.duration # width of the bar in data coords + y_loc = k * height # y location of the bar rects = ax[ax_name].barh(y_loc, width=width, left=row.UpperBoundary, height=height, color=color, edgecolor=edgecolor, linewidth=edgewidth, alpha=alpha) + if label_pref == 'none': + continue + # Render label to get its size in pixels abbrev_loc = text_loc(fig, ax[ax_name], rects[0], row.Abbrev, width, y_loc) full_label_loc = text_loc(fig, ax[ax_name], rects[0], row.Name, width, y_loc) - loc = 'outside' - label = '' - if label_pref == 'abbrev': + + loc, label = 'outside', '' # default to outside with no label + if label_pref in ['abbrev', 'abbreviation']: loc = abbrev_loc label = row.Abbrev else: + # full label preferred, but if it doesn't fit, try abbrev if allowed if full_label_loc == 'inside': loc = full_label_loc label = row.Name @@ -1688,7 +1700,7 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', k += 1 - if location == 'below': + if reverse_rank_order is True: ax[ax_name].invert_yaxis() for ik, ax_key in enumerate(ax.keys()): @@ -1696,8 +1708,9 @@ def add_GTS(fig, ax, GTS_df=None, Ranks=None, time_units='Ma',location='above', if need_to_swap is True: ax[ax_key].invert_xaxis() - return fig, ax + return fig, ax +# could test with [p.get_width() for p in ax['gts'].patches] # def check_text_fits_in_span(fig, ax, interval, label, fontsize=12, verbose=False): # """ From 15df381907c03f7d3888eadcc70947d29e67eb63 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Fri, 3 Oct 2025 11:38:49 -0700 Subject: [PATCH 09/12] Make rdflib import optional in ICS chart loader Refactored load_ics_chart_to_df to import rdflib only within the function and handle ImportError by falling back to loading a local CSV. This improves compatibility for users without rdflib installed and avoids unnecessary imports when not needed. --- pyleoclim/utils/datasets.py | 223 +++++++++++++++++++----------------- 1 file changed, 121 insertions(+), 102 deletions(-) diff --git a/pyleoclim/utils/datasets.py b/pyleoclim/utils/datasets.py index e3b81204..a9066ff1 100644 --- a/pyleoclim/utils/datasets.py +++ b/pyleoclim/utils/datasets.py @@ -5,8 +5,6 @@ import pyleoclim as pyleo import yaml import pandas as pd -from rdflib import Graph, Namespace, URIRef, Literal -from rdflib.namespace import SKOS, RDF from ..utils import jsonutils DATA_DIR = Path(__file__).parents[1].joinpath("data").resolve() @@ -217,9 +215,7 @@ def load_dataset(name): return ts -TIME = Namespace("http://www.w3.org/2006/time#") -SCHEMA = Namespace("https://schema.org/") -GTS = Namespace("http://resource.geosciml.org/ontology/timescale/gts#") + def _first(it): for x in it: @@ -232,105 +228,127 @@ def _localname(term): return s.rsplit("#", 1)[1] return s.rstrip("/").rsplit("/", 1)[-1] -def _find_ns_for_local(graph: Graph, local: str): - """ - Find a predicate in the graph whose localname == `local` and - return its namespace base as an rdflib Namespace. Fallback to None. - """ - for s, p, o in graph.triples((None, None, None)): - if isinstance(p, URIRef) and _localname(p) == local: - base = str(p)[: -len(local)] - return Namespace(base) - return None + def load_ics_chart_to_df(ttl_path_or_url="https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl", time_units='Ma') -> pd.DataFrame: - g = Graph() - g.parse(ttl_path_or_url, format="turtle") - - # Resolve namespaces robustly - ischart_ns = _find_ns_for_local(g, "inMYA") # e.g., https://w3id.org/isc/isc2020# - if ischart_ns is None: - # Hard fallback (current ICS charts use this) - ischart_ns = Namespace("https://w3id.org/isc/isc2020#") - - # Rank mapping to your requested Rank labels - rank_map = { - "Eon": "Eon", "Eonothem": "Eon", - "Era": "Era", "Erathem": "Era", - "Period": "Period", "System": "Period", - "Epoch": "Epoch", "Series": "Epoch", - "Age": "Stage", "Stage": "Stage", - "Subepoch": "Subepoch", "Subseries": "Subepoch", - "Substage": "Substage", "Subperiod": "Subperiod", - } - - rows = [] - - # An interval concept typically has skos:prefLabel and gts:rank - for subj in set(g.subjects(SKOS.prefLabel, None)): - rank_uri = _first(g.objects(subj, GTS.rank)) - if rank_uri is None: - continue # skip non-interval resources - - # Type - rank_local = _localname(rank_uri) - Rank = rank_map.get(rank_local, rank_local) - - # Name - name_lit = _first(g.objects(subj, SKOS.prefLabel)) - Name = str(name_lit) if name_lit is not None else "" - - # Abbrev: skos:notation (typed literal is fine) - notation = _first(g.objects(subj, SKOS.notation)) - Abbrev = str(notation) if notation is not None else "" - - # Color (hex) if present - col = _first(g.objects(subj, SCHEMA.color)) - Color = str(col) if col is not None else "" - - # Helper to read a boundary node (blank node or URI) → inMYA float - def boundary_ma(node_pred): - node = _first(g.objects(subj, node_pred)) - if node is None: - return None - # find a predicate with localname 'inMYA' under this node - val = None - for p, o in g.predicate_objects(node): - if _localname(p) == "inMYA": - val = o - break - try: - return float(val) if val is not None else None - except Exception: - return None - - start_ma = boundary_ma(TIME.hasBeginning) - end_ma = boundary_ma(TIME.hasEnd) - - # UpperBoundary (younger/smaller Ma), LowerBoundary (older/larger Ma) - UpperBoundary = LowerBoundary = None - if start_ma is not None and end_ma is not None: - UpperBoundary = min(start_ma, end_ma) - LowerBoundary = max(start_ma, end_ma) - elif start_ma is not None: - UpperBoundary = start_ma - elif end_ma is not None: - UpperBoundary = end_ma - - # Keep intervals only - if not Name or (UpperBoundary is None and LowerBoundary is None): - continue - - rows.append({ - "Rank": Rank, - "Name": Name, - "Abbrev": Abbrev, - "Color": Color, - "UpperBoundary": UpperBoundary, - "LowerBoundary": LowerBoundary - }) - - df = pd.DataFrame(rows).dropna(subset=["Name", "Rank"]) + + try: + from rdflib import Graph, Namespace, URIRef, Literal + from rdflib.namespace import SKOS, RDF + + print("Using rdflib to load GTS information from ICS") + TIME = Namespace("http://www.w3.org/2006/time#") + SCHEMA = Namespace("https://schema.org/") + GTS = Namespace("http://resource.geosciml.org/ontology/timescale/gts#") + + def _find_ns_for_local(graph, local): + """ + Find a predicate in the graph whose localname == `local` and + return its namespace base as an rdflib Namespace. Fallback to None. + """ + for s, p, o in graph.triples((None, None, None)): + if isinstance(p, URIRef) and _localname(p) == local: + base = str(p)[: -len(local)] + return Namespace(base) + return None + + g = Graph() + g.parse(ttl_path_or_url, format="turtle") + + # Resolve namespaces robustly + ischart_ns = _find_ns_for_local(g, "inMYA") # e.g., https://w3id.org/isc/isc2020# + if ischart_ns is None: + # Hard fallback (current ICS charts use this) + ischart_ns = Namespace("https://w3id.org/isc/isc2020#") + print("Warning: could not find 'inMYA' predicate in graph; using fallback namespace https://w3id.org/isc/isc2020#") + + # Rank mapping to your requested Rank labels + rank_map = { + "Eon": "Eon", "Eonothem": "Eon", + "Era": "Era", "Erathem": "Era", + "Period": "Period", "System": "Period", + "Epoch": "Epoch", "Series": "Epoch", + "Age": "Stage", "Stage": "Stage", + "Subepoch": "Subepoch", "Subseries": "Subepoch", + "Substage": "Substage", "Subperiod": "Subperiod", + } + + rows = [] + + # An interval concept typically has skos:prefLabel and gts:rank + for subj in set(g.subjects(SKOS.prefLabel, None)): + rank_uri = _first(g.objects(subj, GTS.rank)) + if rank_uri is None: + continue # skip non-interval resources + + # Type + rank_local = _localname(rank_uri) + Rank = rank_map.get(rank_local, rank_local) + + # Name + name_lit = _first(g.objects(subj, SKOS.prefLabel)) + Name = str(name_lit) if name_lit is not None else "" + + # Abbrev: skos:notation (typed literal is fine) + notation = _first(g.objects(subj, SKOS.notation)) + Abbrev = str(notation) if notation is not None else "" + + # Color (hex) if present + col = _first(g.objects(subj, SCHEMA.color)) + Color = str(col) if col is not None else "" + + # Helper to read a boundary node (blank node or URI) → inMYA float + def boundary_ma(node_pred): + node = _first(g.objects(subj, node_pred)) + if node is None: + return None + # find a predicate with localname 'inMYA' under this node + val = None + for p, o in g.predicate_objects(node): + if _localname(p) == "inMYA": + val = o + break + try: + return float(val) if val is not None else None + except Exception: + return None + + start_ma = boundary_ma(TIME.hasBeginning) + end_ma = boundary_ma(TIME.hasEnd) + + # UpperBoundary (younger/smaller Ma), LowerBoundary (older/larger Ma) + UpperBoundary = LowerBoundary = None + if start_ma is not None and end_ma is not None: + UpperBoundary = min(start_ma, end_ma) + LowerBoundary = max(start_ma, end_ma) + elif start_ma is not None: + UpperBoundary = start_ma + elif end_ma is not None: + UpperBoundary = end_ma + + # Keep intervals only + if not Name or (UpperBoundary is None and LowerBoundary is None): + continue + + rows.append({ + "Rank": Rank, + "Name": Name, + "Abbrev": Abbrev, + "Color": Color, + "UpperBoundary": UpperBoundary, + "LowerBoundary": LowerBoundary + }) + + df = pd.DataFrame(rows).dropna(subset=["Name", "Rank"]) + except ImportError as e: + print("Could not import rdflib; please install it to load GTS information from ICS. Loading from local CSV") + df = pd.read_csv(DATA_DIR.joinpath("i-c-stratigraphy_2024_11_24.csv"), skiprows=11 ) + df['UpperBoundary'] = pd.to_numeric(df['UpperBoundary'], errors='coerce') + df['LowerBoundary'] = pd.to_numeric(df['LowerBoundary'], errors='coerce') + df['Color'] = df['Color'].astype(str) + # Clean up + + # Optional: order types, youngest first within each type type_order = ["Eon", "Era", "Period", "Epoch", "Stage", "Subepoch", "Substage", "Subperiod"] df["Rank"] = pd.Categorical(df["Rank"], categories=type_order + sorted(set(df["Rank"]) - set(type_order)), ordered=True) @@ -343,6 +361,7 @@ def boundary_ma(node_pred): elif time_units in ['ka', 'kya', 'ky', 'kyr']: df['UpperBoundary'] = df['UpperBoundary'] * 1e3 df['LowerBoundary'] = df['LowerBoundary'] * 1e3 + print('converted to ka') elif time_units in ['Ga', 'Gya', 'Gy', 'Gyr']: df['UpperBoundary'] = df['UpperBoundary'] * 1e-3 df['LowerBoundary'] = df['LowerBoundary'] * 1e-3 From e90f2ac233ce3a876c71eb10d9ac85aa61929a07 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Fri, 3 Oct 2025 11:39:04 -0700 Subject: [PATCH 10/12] Refactor add_GTS: rank order, duration, and axis logic Changed default reverse_rank_order to True and updated docstring to clarify rank ordering. Fixed duration calculation to use .values for numpy arrays. Modified rank iteration to follow the order in the ranks list. Adjusted y-axis inversion logic to depend on location instead of reverse_rank_order. --- pyleoclim/utils/plotting.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/pyleoclim/utils/plotting.py b/pyleoclim/utils/plotting.py index b54250cb..017b3315 100644 --- a/pyleoclim/utils/plotting.py +++ b/pyleoclim/utils/plotting.py @@ -1520,8 +1520,9 @@ def text_loc(fig, ax, rect, label_text, width, yloc): def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', label_pref='full', allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=None, edgecolor='k', - edgewidth=0, alpha=1,zorder=10,reverse_rank_order=False, + edgewidth=0, alpha=1,zorder=10,reverse_rank_order=True, gts_url = "https://raw.githubusercontent.com/i-c-stratigraphy/chart/refs/heads/main/chart.ttl"): + ''' Adds the Geologic Time Scale (GTS) to the plot. Parameters @@ -1534,7 +1535,7 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', DataFrame containing the GTS data with columns for 'Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary'. If None, the function will load the ICS chart from the provided URL. ranks : list of str, optional - List of ranks to include (e.g., ['Period', 'Epoch', 'Stage']). If None, defaults to ['Period', 'Epoch', 'Stage']. + List of ranks to include (e.g., ['Period', 'Epoch', 'Stage']). If None, defaults to ['Period', 'Epoch', 'Stage']. Ranks are ordered from outer-most to inner-most. time_units : str, optional Time units of the GTS data. Supported: 'Ma' (default), 'ka', 'Ga'. location : str, optional @@ -1563,6 +1564,7 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', Z-order for the GTS axis. Default is 10. gts_url : str, optional URL to load the ICS chart if GTS_df is None. Default is the ICS chart URL. + Returns ------- fig : matplotlib.figure.Figure @@ -1585,16 +1587,8 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', fig, ax = ms.stackplot(figsize=(10, 5),linewidth=0.5, fill_between_alpha=0) ax[0].invert_yaxis() # d18O is traditionally inverted - for ik, ax_name in enumerate(ax.keys()): - ax[ax_name].grid(visible=False) - ax[ax_name].invert_xaxis() - - GTS_df = pyleo.utils.datasets.load_ics_chart_to_df() - - GTS_df = pyleo.utils.plotting.datasets.apply_custom_traits(GTS_df, {'Rank':'Epoch', 'Name':'Pliocene', 'Abbrev':'Plio'}, target_col="Abbrev") - fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, time_units='Ma', GTS_df=GTS_df, location='above', label_pref='full', - allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05, text_color=None, fontsize=12, edgecolor='k', - edgewidth=0, alpha=1) + fig, ax = pyleo.utils.plotting.add_GTS(fig, ax, time_units='Ma', location='above', label_pref='full', + allow_abbreviations=True, ax_name='gts', v_offset=0, height=.05) ''' @@ -1610,7 +1604,7 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', assert all(col in GTS_df.columns for col in ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']), "GTS_df must contain columns: ['Rank', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary']" assert GTS_df['UpperBoundary'].dtype in [np.float64, np.float32, np.int64, np.int32], "GTS_df 'UpperBoundary' must be numeric" assert GTS_df['LowerBoundary'].dtype in [np.float64, np.float32, np.int64, np.int32], "GTS_df 'LowerBoundary' must be numeric" - duration = GTS_df['LowerBoundary']-GTS_df['UpperBoundary'] + duration = GTS_df['LowerBoundary'].values-GTS_df['UpperBoundary'].values assert all(duration >= 0), "GTS_df 'LowerBoundary' must be greater than (further back in time) or equal to 'UpperBoundary (more modern)'" if GTS_df is None: @@ -1655,7 +1649,8 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', k = 0 text_color_flag = text_color # None means auto, otherwise use specified color - for unit, unit_df in sub_df.groupby('Rank'): + for unit in ranks: + unit_df = sub_df[sub_df['Rank']==unit].copy() for i, row in unit_df.iterrows(): color = row.Color (r, g, b) = mcolors.to_rgb(color) @@ -1700,7 +1695,8 @@ def add_GTS(fig, ax, GTS_df=None, ranks=None, time_units='Ma',location='above', k += 1 - if reverse_rank_order is True: + # if reverse_rank_order is True: + if location in ['above', 'top']: ax[ax_name].invert_yaxis() for ik, ax_key in enumerate(ax.keys()): From 2edc0f315ae543d182f58431c8661175da93612f Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Fri, 3 Oct 2025 11:39:20 -0700 Subject: [PATCH 11/12] Add ICS chronostratigraphic chart CSV data Added i-c-stratigraphy_2024_11_24.csv containing the International Chronostratigraphic Chart (ICS) with boundaries, colors, and abbreviations for geologic time units. Data is sourced from the ICS chart.ttl as of 2024-11-24. --- .../data/i-c-stratigraphy_2024_11_24.csv | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 pyleoclim/data/i-c-stratigraphy_2024_11_24.csv diff --git a/pyleoclim/data/i-c-stratigraphy_2024_11_24.csv b/pyleoclim/data/i-c-stratigraphy_2024_11_24.csv new file mode 100644 index 00000000..aab00ec9 --- /dev/null +++ b/pyleoclim/data/i-c-stratigraphy_2024_11_24.csv @@ -0,0 +1,190 @@ +# International Chronostratigraphic Chart (ICS) +# Constructed from the Turtle file at: https://github.com/i-c-stratigraphy/chart/blob/main/chart.ttl +# Date of last update of Turtle file: 2024-11-24 +# Source: International Commission on Stratigraphy (ICS), chart.ttl (SKOS/RDF ontology) +# +# Notes: +# - Units: All boundary values are in millions of years ago (Ma). +# - Columns included: 'Type', 'Name', 'Abbrev', 'Color', 'UpperBoundary', 'LowerBoundary'. +# - Colors: official ICS chart hexadecimal colors. +# - Abbreviations: from ICS skos:notation or custom overrides. +# +Rank,Name,Abbrev,Color,UpperBoundary,LowerBoundary +Eon,Phanerozoic,PH,#9AD9DD,0.0,538.8 +Eon,Proterozoic,PR,#F73563,538.8,2500.0 +Eon,Archean,AR,#F0047F,2500.0,4031.0 +Eon,Hadean,HA,#AE027E,4031.0,4567.0 +Era,Cenozoic,CZ,#F2F91D,0.0,66.0 +Era,Mesozoic,MZ,#67C5CA,66.0,251.902 +Era,Paleozoic,PZ,#99C08D,251.902,538.8 +Era,Neoproterozoic,NP,#FEB342,538.8,1000.0 +Era,Mesoproterozoic,MP,#FDB462,1000.0,1600.0 +Era,Paleoproterozoic,PP,#F74370,1600.0,2500.0 +Era,Neoarchean,NA,#F99BC1,2500.0,2800.0 +Era,Mesoarchean,MA,#F768A9,2800.0,3200.0 +Era,Paleoarchean,PA,#F4449F,3200.0,3600.0 +Era,Eoarchean,EA,#DA037F,3600.0,4031.0 +Period,Quaternary,Q,#F9F97F,0.0,2.58 +Period,Neogene,N,#FFE619,2.58,23.04 +Period,Paleogene,E,#FD9A52,23.04,66.0 +Period,Cretaceous,K,#7FC64E,66.0,143.1 +Period,Jurassic,J,#34B2C9,143.1,201.4 +Period,Triassic,T,#812B92,201.4,251.902 +Period,Permian,P,#F04028,251.902,298.9 +Period,Carboniferous,C,#67A599,298.9,358.86 +Period,Devonian,D,#CB8C37,358.86,419.62 +Period,Silurian,S,#B3E1B6,419.62,443.1 +Period,Ordovician,O,#009270,443.1,486.85 +Period,Cambrian,Ep,#7FA056,486.85,538.8 +Period,Ediacaran,NP3,#FED96A,538.8,635.0 +Period,Cryogenian,NP2,#FECC5C,635.0,720.0 +Period,Tonian,NP1,#FEBF4E,720.0,1000.0 +Period,Stenian,MP3,#FED99A,1000.0,1200.0 +Period,Ectasian,MP2,#FDCC8A,1200.0,1400.0 +Period,Calymmian,MP1,#FDC07A,1400.0,1600.0 +Period,Statherian,PP4,#F875A7,1600.0,1800.0 +Period,Orosirian,PP3,#F76898,1800.0,2050.0 +Period,Rhyacian,PP2,#F75B89,2050.0,2300.0 +Period,Siderian,PP1,#F74F7C,2300.0,2500.0 +Epoch,Holocene,Q2,#FEEBD2,0.0,0.0117 +Epoch,Pleistocene,Q1,#FFEFAF,0.0117,2.58 +Epoch,Pliocene,N2,#FFFF99,2.58,5.333 +Epoch,Miocene,N1,#FFFF00,5.333,23.04 +Epoch,Oligocene,E3,#FEC07A,23.04,33.9 +Epoch,Eocene,E2,#FDB46C,33.9,56.0 +Epoch,Paleocene,E1,#FDA75F,56.0,66.0 +Epoch,Late,K2,#A6D84A,66.0,100.5 +Epoch,Early,K1,#8CCD57,100.5,143.1 +Epoch,Late Jurassic,J3,#B3E3EE,143.1,161.5 +Epoch,Middle Jurassic,J2,#80CFD8,161.5,174.7 +Epoch,Early Jurassic,J1,#42AED0,174.7,201.4 +Epoch,Late Triassic,T3,#BD8CC3,201.4,237.0 +Epoch,Middle Triassic,T2,#B168B1,237.0,246.7 +Epoch,Early Triassic,T1,#983999,246.7,251.902 +Epoch,Lopingian,P3,#FBA794,251.902,259.51 +Epoch,Guadalupian,P2,#FB745C,259.51,274.4 +Epoch,Cisuralian,P1,#EF5845,274.4,298.9 +Epoch,Late Pennsylvanian,C2c6c7,#BFD0BA,298.9,307.0 +Epoch,Middle Pennsylvanian,C2c5,#A6C7B7,307.0,315.2 +Epoch,Early Pennsylvanian,C2c4,#8CBEB4,315.2,323.4 +Epoch,Late Mississippian,C1c3,#B3BE6C,323.4,330.3 +Epoch,Middle Mississippian,C1c2,#99B46C,330.3,346.7 +Epoch,Early Mississippian,C1c1,#80AB6C,346.7,358.86 +Epoch,Late Devonian,D3,#F1E19D,358.86,382.31 +Epoch,Middle Devonian,D2,#F1C868,382.31,393.47 +Epoch,Early Devonian,D1,#E5AC4D,393.47,419.62 +Epoch,Ludlow,S3,#BFE6CF,419.62,426.7 +Epoch,Wenlock,S2,#B3E1C2,426.7,432.9 +Epoch,Llandovery,S1,#99D7B3,432.9,443.1 +Epoch,Late Ordovician,O3,#7FCA93,443.1,458.2 +Epoch,Middle Ordovician,O2,#4DB47E,458.2,471.3 +Epoch,Early Ordovician,O1,#1A9D6F,471.3,486.85 +Epoch,Furongian,Ep4,#B3E095,486.85,497.0 +Epoch,Miaolingian,Ep3,#A6CF86,497.0,506.5 +Epoch,Series 2,Ep2,#99C078,506.5,521.0 +Epoch,Terreneuvian,Ep1,#8CB06C,521.0,538.8 +Stage,Meghalayan,q7,#FDEDEC,0.0,0.0042 +Stage,Northgrippian,q6,#FDECE4,0.0042,0.0082 +Stage,Greenlandian,q5,#FEECDB,0.0082,0.0117 +Stage,Late,q4,#FFF2D3,0.0117,0.129 +Stage,Chibanian,q3,#FFF2C7,0.129,0.774 +Stage,Calabrian,q2,#FFF2BA,0.774,1.8 +Stage,Gelasian,q1,#FFEDB3,1.8,2.58 +Stage,Piacenzian,n8,#FFFFBF,2.58,3.6 +Stage,Zanclean,n7,#FFFFB3,3.6,5.333 +Stage,Messinian,n6,#FFFF73,5.333,7.246 +Stage,Tortonian,n5,#FFFF66,7.246,11.63 +Stage,Serravallian,n4,#FFFF59,11.63,13.82 +Stage,Langhian,n3,#FFFF4D,13.82,15.98 +Stage,Burdigalian,n2,#FFFF41,15.98,20.45 +Stage,Aquitanian,n1,#FFFF33,20.45,23.03 +Stage,Chattian,e9,#FEE6AA,23.04,27.3 +Stage,Rupelian,e8,#FED99A,27.3,33.9 +Stage,Priabonian,e7,#FDCDA1,33.9,37.71 +Stage,Bartonian,e6,#FDC091,37.71,41.03 +Stage,Lutetian,e5,#FDB482,41.03,48.07 +Stage,Ypresian,e4,#FCA773,48.07,56.0 +Stage,Thanetian,e3,#FDBF6F,56.0,59.24 +Stage,Selandian,e2,#FEBF65,59.24,61.66 +Stage,Danian,e1,#FDB462,61.66,66.0 +Stage,Maastrichtian,k6,#F2FA8C,66.0,72.2 +Stage,Campanian,k5,#E6F47F,72.2,83.6 +Stage,Santonian,k4,#D9EF74,83.6,85.7 +Stage,Coniacian,k3,#CCE968,85.7,89.8 +Stage,Turonian,k2,#BFE35D,89.8,93.9 +Stage,Cenomanian,k1,#B3DE53,93.9,100.5 +Stage,Albian,b6,#CCEA97,100.5,113.2 +Stage,Aptian,b5,#BFE48A,113.2,121.4 +Stage,Barremian,b4,#B3DF7F,121.4,125.77 +Stage,Hauterivian,b3,#A6D975,125.77,132.6 +Stage,Valanginian,b2,#99D36A,132.6,137.05 +Stage,Berriasian,b1,#8CCD60,137.05,143.1 +Stage,Tithonian,j7,#D9F1F7,143.1,149.2 +Stage,Kimmeridgian,j6,#CCECF4,149.2,154.8 +Stage,Oxfordian,j5,#BFE7F1,154.8,161.5 +Stage,Callovian,j4,#BFE7E5,161.5,165.3 +Stage,Bathonian,j3,#B3E2E3,165.3,168.2 +Stage,Bajocian,j2,#A6DDE0,168.2,170.9 +Stage,Aalenian,j1,#9AD9DD,170.9,174.7 +Stage,Toarcian,I4,#99CEE3,174.7,184.2 +Stage,Pliensbachian,I3,#80C5DD,184.2,192.9 +Stage,Sinemurian,I2,#67BCD8,192.9,199.5 +Stage,Hettangian,I1,#4EB3D3,199.5,201.4 +Stage,Rhaetian,t7,#E3B9DB,201.4,205.7 +Stage,Norian,t6,#D6AAD3,205.7,227.3 +Stage,Carnian,t5,#C99BCB,227.3,237.0 +Stage,Ladinian,t4,#C983BF,237.0,241.464 +Stage,Anisian,t3,#BC75B7,241.464,246.7 +Stage,Olenekian,t2,#B051A5,246.7,249.9 +Stage,Induan,t1,#A4469F,249.9,251.902 +Stage,Changhsingian,p9,#FCC0B2,251.902,254.14 +Stage,Wuchiapingian,p8,#FCB4A2,254.14,259.51 +Stage,Capitanian,p7,#FB9A85,259.51,264.28 +Stage,Wordian,p6,#FB8D76,264.28,266.9 +Stage,Roadian,p5,#FB8069,266.9,274.4 +Stage,Kungurian,p4,#E38776,274.4,283.3 +Stage,Artinskian,p3,#E37B68,283.3,290.1 +Stage,Sakmarian,p2,#E36F5C,290.1,293.52 +Stage,Asselian,p1,#E36350,293.52,298.9 +Stage,Gzhelian,c7,#CCD4C7,298.9,303.7 +Stage,Kasimovian,c6,#BFD0C5,303.7,307.0 +Stage,Moscovian,c5,#B3CBB9,307.0,315.2 +Stage,Bashkirian,c4,#99C2B5,315.2,323.4 +Stage,Serpukhovian,c3,#BFC26B,323.4,330.3 +Stage,Visean,c2,#A6B96C,330.3,346.7 +Stage,Tournaisian,c1,#8CB06C,346.7,358.86 +Stage,Famennian,d7,#F2EDB3,358.86,372.15 +Stage,Frasnian,d6,#F2EDAD,372.15,382.31 +Stage,Givetian,d5,#F1E185,382.31,387.95 +Stage,Eifelian,d4,#F1D576,387.95,393.47 +Stage,Emsian,d3,#E5D075,393.47,410.62 +Stage,Pragian,d2,#E5C468,410.62,413.02 +Stage,Lochkovian,d1,#E5B75A,413.02,419.62 +Stage,Pridoli,S4,#E6F5E1,419.62,422.7 +Stage,Ludfordian,s7,#D9F0DF,422.7,425.0 +Stage,Gorstian,s6,#CCECDD,425.0,426.7 +Stage,Homerian,s5,#CCEBD1,426.7,430.6 +Stage,Sheinwoodian,s4,#BFE6C3,430.6,432.9 +Stage,Telychian,s3,#BFE6CF,432.9,438.6 +Stage,Aeronian,s2,#B3E1C2,438.6,440.5 +Stage,Rhuddanian,s1,#A6DCB5,440.5,443.1 +Stage,Hirnantian,o7,#A6DBAB,443.1,445.2 +Stage,Katian,o6,#99D69F,445.2,452.8 +Stage,Sandbian,o5,#8CD094,452.8,458.2 +Stage,Darriwilian,o4,#74C69C,458.2,469.4 +Stage,Dapingian,o3,#66C092,469.4,471.3 +Stage,Floian,o2,#41B087,471.3,477.1 +Stage,Tremadocian,o1,#33A97E,477.1,486.85 +Stage,Stage 10,ep10,#E6F5C9,486.85,491.0 +Stage,Jiangshanian,ep9,#D9F0BB,491.0,494.2 +Stage,Paibian,ep8,#CCEBAE,494.2,497.0 +Stage,Guzhangian,ep7,#CCDFAA,497.0,500.5 +Stage,Drumian,ep6,#BFD99D,500.5,504.5 +Stage,Wuliuan,ep5,#B3D492,504.5,506.5 +Stage,Stage 4,ep4,#B3CA8E,506.5,514.5 +Stage,Stage 3,ep2,#A6C583,514.5,521.0 +Stage,Stage 2,ep3,#A6BA80,521.0,529.0 +Stage,Fortunian,ep1,#99B575,529.0,538.8 +Sub-Period,Pennsylvanian,C2,#7EBCC6,298.9,323.4 +Sub-Period,Mississippian,C1,#678F66,323.4,358.86 +Super-Eon,Precambrian,PE,#F74370,538.8,4567.0 From d2ac440f5cc12d275473cab7be3c118b1be290d2 Mon Sep 17 00:00:00 2001 From: Jordan Landers Date: Fri, 3 Oct 2025 11:39:34 -0700 Subject: [PATCH 12/12] Add example for plotting with Geologic Time Scale Added a usage example in the plot method docstring demonstrating how to plot the CENOGRID d18O timeseries with Geologic Time Scale annotation using add_GTS. --- pyleoclim/core/series.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pyleoclim/core/series.py b/pyleoclim/core/series.py index 276ece67..a7da9b64 100644 --- a/pyleoclim/core/series.py +++ b/pyleoclim/core/series.py @@ -1118,6 +1118,19 @@ def plot(self, figsize=[10, 4], fig, ax = ts.plot(color='k', savefig_settings={'path': 'ts_plot3.png'}); pyleo.closefig(fig) pyleo.savefig(fig,path='ts_plot3.png') + + Plot the CENOGRID d18O timeseries with added Geologic Time Scale annotation + + .. jupyter-execute:: + + import pyleoclim as pyleo + + ts_18 = pyleo.utils.load_dataset('cenogrid_d18O') + fig, ax = ts_18.plot(figsize=(10, 4),linewidth=0.5) + ax.invert_yaxis() # d18O is traditionally inverted + + fig, ax = pyleo.add_GTS(fig, ax, ranks=['Period', 'Epoch'], location='above') + ''' # generate default axis labels time_label, value_label = self.make_labels()