Repository: ismip/ismip7-antarctic-ocean-forcing
Pull request #6: Reorganize bias-correction code
Author: login: xylar
Created: 2025-02-11T14:37:16Z | Merged: 2025-03-12T10:22:32Z

This merge moves bias-correction classes and functions into the i7aof package and updates the notebook accordingly.

It also lints the code to follow the PEP8 style conventions.

The missing cmocean dependency has been added.


Diff:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7fde3334..3401f7ab 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,4 +1,4 @@
-exclude: "docs|conda/meta.yaml"
+exclude: "docs|.*.ipynb"
 default_stages: [pre-commit]
 fail_fast: true
 
diff --git a/biascorr/src/region.py b/biascorr/src/region.py
deleted file mode 100644
index 95662fea..00000000
--- a/biascorr/src/region.py
+++ /dev/null
@@ -1,252 +0,0 @@
-import xarray as xr
-import matplotlib as mpl
-import matplotlib.pyplot as plt
-import numpy as np
-import cmocean as cmo
-
-from run import Run
-from run import dpm
-
-# Region class with functions for processing and plotting
-class Region:
-    def __init__(self, region,model,refmodel='UKESM1-0-LL',k0=0,k1=6000):
-        # Get volume CMIP
-        self.region = region
-        self.model = model # Reference model considered 'truth'
-        self.refmodel = refmodel # Model to be bias corrected
-        self.k0 = k0 # Start depth (top)
-        self.k1 = k1 # End depth (bottom)
-
-        # Read historical period of both refmodel and model to get started
-        self.get_historical()
-
-    def get_historical(self):
-        # Get data for refmodel over historical period
-        self.ref = Run(self.refmodel,self.region,'historical',1995,2015,k0=self.k0,k1=self.k1)
-
-        # Get data for model over historical period
-        self.prd = Run(self.model,self.region,'historical',1995,2015,k0=self.k0,k1=self.k1)
-
-        # Define the bias in the model with respect to the reference model
-        self.get_bias()
-
-    def get_future(self,run,y0,y1):
-        # Get data for model over some future period y0 - y1
-        self.fut = Run(self.model,self.region,run,y0,y1,k0=self.k0,k1=self.k1)
-
-        # Compute change in T and S between future and historical in T,S space of model
-        # Example: between y0 and y1, the water mass that historically had a temperature of X degrees and a salinity of Y psu
-        # has warmed by an amount of Z degrees.
-        self.get_delta()
-
-        # Construct deltaT and deltaS on the grid of the reference model for this future period
-        self.get_anom()
-
-    def plot_region_cmip(self,ii,jj):
-        plt.pcolormesh(self.lonc,self.latc,np.nansum(self.thkcello,axis=0),cmap='Blues',norm=mpl.colors.LogNorm(vmin=10,vmax=1e4))
-        plt.scatter(self.lonc[jj,ii],self.latc[jj,ii],50,c='tab:red')
-
-    def plot_volumes(self):
-        fig,ax = plt.subplots(1,3,figsize=(3*5,6),sharex=True,sharey=True)
-
-        ax[0].pcolormesh(self.ref.Sb,self.ref.Tb,self.prd.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13),cmap='Greys')
-        plt.colorbar(im,ax=ax[0],orientation='horizontal')
-        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')
-        ax[0].set_ylabel(self.region)
-
-        im = ax[1].pcolormesh(self.prd.Sb,self.prd.Tb,self.prd.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13))
-        plt.colorbar(im,ax=ax[1],orientation='horizontal')
-        ax[1].set_title(f'Present day {self.model} ({self.prd.y0}-{self.prd.y1})')
-        #ax[1].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
-
-        im = ax[2].pcolormesh(self.fut.Sb,self.fut.Tb,self.fut.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13),cmap='Oranges')
-        plt.colorbar(im,ax=ax[2],orientation='horizontal')
-        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
-        #ax[2].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
-
-    def plot_delta(self):
-        fig,ax = plt.subplots(1,5,figsize=(5*3.5,4.5),sharex=True,sharey=True)
-
-        im = ax[0].pcolormesh(self.ref.Sb,self.ref.Tb,self.ref.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13))
-        plt.colorbar(im,ax=ax[0],orientation='horizontal')
-        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')
-        ax[0].set_ylabel(self.region)
-
-        im = ax[1].pcolormesh(self.prd.Sb,self.prd.Tb,self.prd.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13))
-        plt.colorbar(im,ax=ax[1],orientation='horizontal')
-        ax[1].set_title(f'present day {self.model} ({self.prd.y0}-{self.prd.y1})')
-        #ax[0].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
-
-        #im = ax[2].pcolormesh(self.prd.Sc,self.prd.Tc,self.fut.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13),cmap='Greys')
-        im = ax[2].pcolormesh(self.prd.Sb,self.prd.Tb,self.fut.deltaT.T,cmap='cmo.balance',vmin=-3,vmax=3)
-        plt.colorbar(im,ax=ax[2],orientation='horizontal')
-        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
-
-        im = ax[3].pcolormesh(self.prd.Sb,self.prd.Tb,self.fut.deltaTf.T,cmap='cmo.balance',vmin=-3,vmax=3)
-
-        plt.colorbar(im,ax=ax[3],orientation='horizontal')
-        ax[3].set_title(f'Filled')
-        #ax[1].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
-
-        im = ax[4].pcolormesh(self.ref.Sb,self.ref.Tb,self.fut.dTcorb.T,cmap='cmo.balance',vmin=-3,vmax=3)
-        plt.colorbar(im,ax=ax[4],orientation='horizontal')
-        #ax[3].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
-
-    def plot_profiles(self,vlim=-1):
-        fig,ax = plt.subplots(1,3,figsize=(10,5),sharey=True)
-
-        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim,:,:]),0,self.ref.T[:vlim,:,:]),axis=(1,2),weights=self.ref.V[:vlim,:,:])
-        ax[1].plot(Tav,self.ref.depth[:vlim],label='optimised',c='tab:red',ls='--')
-
-        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim,:,:]),0,self.prd.T[:vlim,:,:]),axis=(1,2),weights=self.prd.V[:vlim,:,:])
-        ax[0].plot(Tav,self.prd.depth[:vlim],label='raw',c='.5',ls='--')
-
-        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim,:,:]),0,self.fut.dTcor[:vlim,:,:]),axis=(1,2),weights=self.ref.V[:vlim,:,:])
-        ax[2].plot(Tav,self.ref.depth[:vlim],c='tab:red')
-
-        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim,:,:]),0,self.fut.dTraw[:vlim,:,:]),axis=(1,2),weights=self.prd.V[:vlim,:,:])
-        ax[2].plot(Tav,self.prd.depth[:vlim],c='.5')
-
-        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim,:,:]),0,(self.ref.T+self.fut.dTcor)[:vlim,:,:]),axis=(1,2),weights=self.ref.V[:vlim,:,:])
-        ax[1].plot(Tav,self.ref.depth[:vlim],c='tab:red')
-
-        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim,:,:]),0,self.fut.T[:vlim,:,:]),axis=(1,2),weights=self.prd.V[:vlim,:,:])
-        ax[0].plot(Tav,self.prd.depth[:vlim],c='.5')
-
-        ax[0].set_title('Raw model')
-        ax[1].set_title('Optimised')
-        ax[2].set_xlabel('Warming T')
-        #ax[0].legend()
-
-        for Ax in ax:
-            Ax.set_ylim([-1500,0])
-            #Ax.invert_yaxis()
-
-    def get_bias(self,Tperc=.99,Sperc=.99):
-
-        # S-bias constrain 95th percentile
-        A,B = np.histogram((self.ref.S).flatten()[self.ref.V.flatten()>0],weights=self.ref.V.flatten()[self.ref.V.flatten()>0],bins=1000,density=True)
-        AA = np.cumsum(A)/np.cumsum(A)[-1]
-        aa = np.argmin((AA-Sperc)**2)
-        self.ref.S95 = B[aa]
-
-        A,B = np.histogram((self.prd.S).flatten()[self.prd.V.flatten()>0],weights=self.prd.V.flatten()[self.prd.V.flatten()>0],bins=1000,density=True)
-        AA = np.cumsum(A)/np.cumsum(A)[-1]
-        aa = np.argmin((AA-Sperc)**2)
-        self.prd.S95 = B[aa]
-
-        # S-bias
-        a = np.arange(.7,1.3,.01)
-        ref,dum = np.histogram(self.ref.S,bins=self.ref.Sb,weights=self.ref.V)
-        rmse = np.zeros((len(a)))
-        for A,AA in enumerate(a):
-            prd,dum = np.histogram(AA*(self.prd.S-self.prd.S95)+self.ref.S95,bins=self.ref.Sb,weights=self.prd.V)
-            rmse[A] = np.sum(np.where(prd==0,0,(np.log10(prd/np.sum(prd))-np.log10(ref/np.sum(ref))))**2)**.5 / np.sum(np.where(prd==0,0,1))
-            #rmse[A] = np.sum(np.where(prd==0,0,(np.log10(prd/np.sum(prd))-np.log10(ref/np.sum(ref))))**2) / np.sum(np.where(prd==0,0,1))**.5
-
-            #print(AA,rmse[A])
-        out = np.unravel_index(np.nanargmin(rmse, axis=None), rmse.shape)
-        self.dSa = a[out[0]]
-        self.prd.Sc = self.dSa*(self.prd.Sb-self.prd.S95)+self.ref.S95 #Corrected S
-        #self.fut.Sc = self.dSa*(self.fut.Sb-self.prd.S95)+self.ref.S95 #Corrected S
-
-        # T-bias (scale 95th percentile of T-Tmin)
-        A,B = np.histogram((self.ref.T-self.ref.Tb[0]).flatten()[self.ref.V.flatten()>0],weights=self.ref.V.flatten()[self.ref.V.flatten()>0],bins=1000,density=True)
-        AA = np.cumsum(A)/np.cumsum(A)[-1]
-        aa = np.argmin((AA-Tperc)**2)
-        self.ref.TF95 = B[aa]
-
-        A,B = np.histogram((self.prd.T-self.prd.Tb[0]).flatten()[self.prd.V.flatten()>0],weights=self.prd.V.flatten()[self.prd.V.flatten()>0],bins=1000,density=True)
-        AA = np.cumsum(A)/np.cumsum(A)[-1]
-        aa = np.argmin((AA-Tperc)**2)
-        self.prd.TF95 = B[aa]
-        self.dTa = self.ref.TF95/self.prd.TF95
-        self.prd.Tc = self.dTa*(self.prd.Tb-self.prd.Tb[0])+self.ref.Tb[0]
-        #self.fut.Tc = self.dTa*(self.fut.Tb-self.fut.Tb[0])+self.ref.Tb[0]
-
-    def get_delta(self):
-
-        # Get difference in T and S between future and historical period
-        self.fut.dTraw = self.fut.T-self.prd.T
-        self.fut.dSraw = self.fut.S-self.prd.S
-
-        # Compute binned deltaT and deltaS on historical S- and T- bins
-        VTdel,Sdel,Tdel = np.histogram2d(self.prd.S.flatten()[self.prd.V.flatten()>0],self.prd.T.flatten()[self.prd.V.flatten()>0],bins=100,weights=(self.prd.V*self.fut.dTraw).flatten()[self.prd.V.flatten()>0])
-        self.fut.deltaT = VTdel/self.prd.Vb
-        VSdel,Sdel,Tdel = np.histogram2d(self.prd.S.flatten()[self.prd.V.flatten()>0],self.prd.T.flatten()[self.prd.V.flatten()>0],bins=100,weights=(self.prd.V*self.fut.dSraw).flatten()[self.prd.V.flatten()>0])
-        self.fut.deltaS = VSdel/self.prd.Vb
-
-        # Create filled binned deltaT and deltaS through extrapolation
-        self.fut.deltaTf = self.fill_delta(self.fut.deltaT)
-        self.fut.deltaSf = self.fill_delta(self.fut.deltaS)
-
-    def fill_delta(self,deltavar):
-        '''Routine to fill deltaT and deltaS in full normalised T,S space'''
-
-        newval = np.nan*np.ones((len(self.prd.Sc),len(self.prd.Tc)))
-
-        newval[1:,1:] = deltavar
-        Nleft = np.sum(np.isnan(newval[1:,1:]))
-        while Nleft>0:
-            mask = np.where(np.isnan(newval),0,1)
-            newval2 = np.where(np.isnan(newval),0,newval)
-            AA = newval2*mask
-            AAm1 = np.roll(AA,-1,axis=0)
-            m1 = np.roll(mask,-1,axis=0)
-            AAp1 = np.roll(AA,1,axis=0)
-            p1 = np.roll(mask,1,axis=0)
-            newval3 = (np.roll(AAm1,-1,axis=1)+AAm1+np.roll(AAm1,1,axis=1) + np.roll(AA,-1,axis=1)+np.roll(AA,1,axis=1) + np.roll(AAp1,-1,axis=1)+AAp1+np.roll(AAp1,1,axis=1)) / (np.roll(m1,-1,axis=1)+m1+np.roll(m1,1,axis=1) + np.roll(mask,-1,axis=1)+np.roll(mask,1,axis=1) + np.roll(p1,-1,axis=1)+p1+np.roll(p1,1,axis=1))
-            newval4 = np.where(mask,newval,newval3)
-            newval = newval4
-            newval[0,:] = np.nan
-            newval[:,0] = np.nan
-
-            Nleft = np.sum(np.isnan(newval[1:,1:]))
-
-        return newval[1:,1:]
-    
-    def get_anom(self):
-        '''Get anomalies of reference T,S values'''
-
-        self.fut.dTcor = np.zeros(self.ref.T.shape)
-        self.fut.dScor = np.zeros(self.ref.S.shape)
-
-        for i in range(self.fut.dTcor.shape[0]):
-            for j in range(self.fut.dTcor.shape[1]):
-                for k in range(self.fut.dTcor.shape[2]):
-                    if self.ref.V[i,j,k]==0:
-                        continue
-                    else:
-                        isref = min(99,max(0,int(99*(self.ref.S[i,j,k]-self.prd.Sc[0])/(self.prd.Sc[-1]-self.prd.Sc[0]))))
-                        jtref = min(99,max(0,int(99*(self.ref.T[i,j,k]-self.prd.Tc[0])/(self.prd.Tc[-1]-self.prd.Tc[0]))))
-                        self.fut.dScor[i,j,k] = self.fut.deltaSf[isref,jtref]
-                        self.fut.dTcor[i,j,k] = self.fut.deltaTf[isref,jtref]
-
-        #Distributions of dS and dT, only for plotting purposes
-        VTdel,Sdel,Tdel = np.histogram2d(self.ref.S.flatten()[self.ref.V.flatten()>0],self.ref.T.flatten()[self.ref.V.flatten()>0],bins=100,range=[[self.ref.Sb[0],self.ref.Sb[-1]],[self.ref.Tb[0],self.ref.Tb[-1]]],weights=(self.ref.V*self.fut.dTcor).flatten()[self.ref.V.flatten()>0])
-        self.fut.dTcorb = VTdel/self.ref.Vb
-        VSdel,Sdel,Tdel = np.histogram2d(self.ref.S.flatten()[self.ref.V.flatten()>0],self.ref.S.flatten()[self.ref.V.flatten()>0],bins=100,range=[[self.ref.Sb[0],self.ref.Sb[-1]],[self.ref.Tb[0],self.ref.Tb[-1]]],weights=(self.ref.V*self.fut.dScor).flatten()[self.ref.V.flatten()>0])
-        self.fut.dScorb = VSdel/self.ref.Vb
-
-    def get_profiles(self):
-        # Construct vertical profiles of reference T and S
-
-        self.ref.Tz = np.nan*np.ones(len(self.ref.depth))
-        self.ref.Sz = np.nan*np.ones(len(self.ref.depth))
-
-        kmax = np.where(np.sum(self.ref.V,axis=(1,2))==0)[0][0]
-        self.ref.Tz[:kmax] = np.average(np.where(np.isnan(self.ref.T[:kmax,:,:]),0,self.ref.T[:kmax,:,:]),axis=(1,2),weights=self.ref.V[:kmax,:,:])
-        self.ref.Sz[:kmax] = np.average(np.where(np.isnan(self.ref.S[:kmax,:,:]),0,self.ref.S[:kmax,:,:]),axis=(1,2),weights=self.ref.V[:kmax,:,:])
-
-    def calc_horav(self,var,run):
-        '''Calculate horizontally averaged vertical profile'''
-
-        try:
-            out = np.average(np.where(np.isnan(var),0,var),axis=(1,2),weights=run.V)
-        except:
-            out = np.nan*np.ones(var.shape[0])
-            kmax = np.where(np.sum(run.V,axis=(1,2))==0)[0][0]
-            out[:kmax] = np.average(np.where(np.isnan(var[:kmax,:,:]),0,var[:kmax,:,:]),axis=(1,2),weights=run.V[:kmax,:,:])
-
-        return out
-    
diff --git a/biascorr/src/run.py b/biascorr/src/run.py
deleted file mode 100644
index 5b84cbe9..00000000
--- a/biascorr/src/run.py
+++ /dev/null
@@ -1,244 +0,0 @@
-import xarray as xr
-import numpy as np
-
-#Days per month
-def dpm(years):
-    # Construct an array of days per month for the required amount of years
-    return np.tile([31,28,31,30,31,30,31,31,30,31,30,31],years)
-
-# Run class, which can be model output or observations
-class Run:
-    def __init__(self,model,region,run,y0,y1,k0=0,k1=6000):
-        self.model = model
-        self.region = region
-        self.run = run
-        self.y0 = y0 # Start year
-        self.y1 = y1 # End year
-        self.k0 = k0 # Start depth (top)
-        self.k1 = k1 # End depth (bottom)
-
-        # Specify region-specific coordinate boundaries
-        self.get_coordinates()
-
-        # Compute volume per grid cell
-        self.get_volume()
-
-        # Read temperature and salinity, averaged over specified time period
-        self.get_TS()
-        self.get_bins()
-        print(f'Got run {self.model} {self.region} {self.run} {self.y0}-{self.y1}')
-
-    def get_coordinates(self):
-        # Hard-coded domain boundaries for specific regions.
-        # Currently in model-specific coordinates,
-        # Should be generalised for all models using a mask in lon-lat dimensions
-        if self.region == 'Amundsen':
-            if self.model == 'CESM2':
-                self.i0,self.i1,self.j0,self.j1 = 250,270,8,18
-            elif self.model == 'UKESM1-0-LL':
-                self.i0,self.i1,self.j0,self.j1 = 170,190,50,70
-            elif self.model == 'EC-Earth3':
-                self.i0,self.i1,self.j0,self.j1 = 170,190,12,32
-            elif self.model == 'WOA23':
-                self.i0,self.i1,self.j0,self.j1 = -116,-96,-76,-70
-        elif self.region == 'Ross':
-            if self.model == 'CESM2':
-                 self.i0,self.i1,self.j0,self.j1 = 180,220,2,7
-            elif self.model == 'UKESM1-0-LL':
-                self.i0,self.i1,self.j0,self.j1 = 85,140,38,48
-            elif self.model == 'EC-Earth3':
-                self.i0,self.i1,self.j0,self.j1 = 85,140,0,10           
-            elif self.model == 'WOA23':
-                self.i0,self.i1,self.j0,self.j1 = -180,-150,-79,-76
-        elif self.region == 'Weddell':
-            if self.model == 'CESM2':
-                 self.i0,self.i1,self.j0,self.j1 = 300,360,3,9
-            elif self.model == 'UKESM1-0-LL':
-                self.i0,self.i1,self.j0,self.j1 = 224,246,38,55
-            elif self.model == 'EC-Earth3':
-                self.i0,self.i1,self.j0,self.j1 = 226,248,0,17
-            elif self.model == 'WOA23':
-                self.i0,self.i1,self.j0,self.j1 = -65,-40,-78.5,-74.5
-        elif self.region == 'Totten':
-            if self.model == 'CESM2':
-                 self.i0,self.i1,self.j0,self.j1 = 137,146,24,28
-            elif self.model == 'UKESM1-0-LL':
-                self.i0,self.i1,self.j0,self.j1 = 40,50,78,84
-            elif self.model == 'EC-Earth3':
-                self.i0,self.i1,self.j0,self.j1 = 42,52,40,46  
-            elif self.model == 'WOA23':
-                self.i0,self.i1,self.j0,self.j1 = 113,124,-67.5,-64.5
-
-    def get_volume(self):
-        # Read volume and other relevant variables for volume-weighting of T and S
-        if self.model in ['CESM2']:
-            # Read all available volcello files
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/volcello*.nc')
-            
-            # Select specified domain (horizontal and vertical). Note: volume is time-independent for this model
-            ds = ds.sel(nlat=slice(self.j0,self.j1),nlon=slice(self.i0,self.i1),lev=slice(self.k0*100.,self.k1*100.))
-
-            # Get bunch of variables
-            self.lev = ds.lev_bnds.values
-            self.depth = -np.average(self.lev,axis=1)
-            self.lon = ds.lon.values
-            self.lat = ds.lat.values
-            self.V = ds.volcello.values
-            ds.close()
-
-            # Read thetao files
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/historical/thetao/thetao*.nc')
-            ds = ds.sel(nlat=slice(self.j0,self.j1),nlon=slice(self.i0,self.i1),lev=slice(self.k0*100.,self.k1*100.))
-            ds = ds.isel(time=0)
-            # Overwrite volume with zeros where thetao is nan
-            self.V = np.where(np.isnan(ds.thetao),0,self.V)
-            ds.close()
-
-        elif self.model in ['EC-Earth3']:
-
-            # Read thickness files
-            if self.y1-self.y0==1:
-                ds = xr.open_dataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thkcello/thkcello_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')
-            else:
-                ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thkcello/*.nc',combine='by_coords')
-
-            # Select temporal and spatial domains
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-            if self.y1-self.y0==1:
-                self.lev = ds.lev_bnds.values
-            else:
-                self.lev = np.average(ds.lev_bnds.values,axis=0,weights=dpm(self.y1-self.y0))
-
-            # Get variables including thickness, time-averaged
-            self.depth = -np.average(self.lev,axis=1)
-            self.lon = ds.longitude.values
-            self.lat = ds.latitude.values
-            self.thkcello = np.average(ds.thkcello,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-            # Read horizontal area of cells
-            ds = xr.open_dataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/areacello_Ofx_EC-Earth3_historical_r1i1p1f1_gn.nc')
-            ds = ds.sel(j=slice(self.j0,self.j1),i=slice(self.i0,self.i1))
-
-            self.areacello = ds.areacello.values
-            ds.close()
-
-            # Multiply thickness and area to get volume
-            self.V = self.thkcello*self.areacello
-
-            # Set invalid cells to zero
-            self.V = np.where(np.isnan(self.V),0,self.V)
-        
-        elif self.model in ['UKESM1-0-LL']:
-
-            # Read thickness files
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thkcello/*.nc')
-
-            # Select spatial and temporal domain
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-
-            # Get bunch of variables, time-averaged
-            self.lev = np.average(ds.lev_bnds.values,axis=0,weights=dpm(self.y1-self.y0))
-            self.depth = -np.average(self.lev,axis=1)
-            self.lon = ds.longitude.values
-            self.lat = ds.latitude.values
-            self.thkcello = np.average(ds.thkcello,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-            # Read horizontal area of cells
-            ds = xr.open_dataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/areacello_Ofx_UKESM1-0-LL_piControl_r1i1p1f2_gn.nc')
-            ds = ds.sel(j=slice(self.j0,self.j1),i=slice(self.i0,self.i1))
-
-            self.areacello = ds.areacello.values
-            ds.close()
-
-            # Multiply thickness and area to get volume
-            self.V = self.thkcello*self.areacello
-
-            # Set invalid cells to zero
-            self.V = np.where(np.isnan(self.V),0,self.V)
-        
-        elif self.model == 'WOA23':
-            # Read data file and select temporal and spatial domain
-            ds = xr.open_dataset('/usr/people/lambert/work/projects/data/woa23/woa23_decav91C0_t00_04.nc',decode_cf=False)
-            ds = ds.isel(time=0)
-            ds = ds.sel(lon=slice(self.i0,self.i1),lat=slice(self.j0,self.j1),depth=slice(self.k0,self.k1))
-
-            # Read temperature and set invalid values to nan
-            self.T = np.where(ds.t_an<1e30,ds.t_an,np.nan)
-
-            self.lon = ds.lon.values
-            self.lat = ds.lat.values
-            self.depth = -ds.depth
-
-            # Construct volume array
-            self.V = np.zeros((len(ds.depth),len(ds.lat),len(ds.lon)))
-            R = 6.371e6
-
-            # Define horizontal area
-            A = np.ones((len(ds.lat),len(ds.lon)))
-            for l in range(len(ds.lat)):
-                dy = 2*np.pi*R*(ds.lat_bnds[l,1]-ds.lat_bnds[l,0])/360
-                dx = 2*np.pi*R*(ds.lon_bnds[:,1]-ds.lon_bnds[:,0])/360 * np.cos(2*np.pi*ds.lat[l]/360)
-                A[l,:] = dx*dy
-
-            # Get thickness and multiply with area to get volume
-            for d in range(len(ds.depth)):
-                D = np.where(np.isnan(self.T[d,:,:]),0,ds.depth_bnds[d,1]-ds.depth_bnds[d,0])
-                self.V[d,:,:] = D*A
-            ds.close()
-            
-    def get_TS(self):
-        # Read temperature and salinity from required model
-        # Fields are time-averaged over the required period
-
-        if self.model in ['CESM2']:
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thetao/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),nlat=slice(self.j0,self.j1),nlon=slice(self.i0,self.i1),lev=slice(self.k0*100.,self.k1*100.))
-            self.T = np.average(ds.thetao,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/so/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),nlat=slice(self.j0,self.j1),nlon=slice(self.i0,self.i1),lev=slice(self.k0*100.,self.k1*100.))
-            self.S = np.average(ds.so,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-        elif self.model in ['EC-Earth3']:
-            if self.y1-self.y0==1:
-                ds = xr.open_dataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thetao/thetao_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')
-            else:
-                ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thetao/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-            self.T = np.average(ds.thetao,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-            if self.y1-self.y0==1:
-                ds = xr.open_dataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/so/so_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')
-            else:
-                ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/so/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-            self.S = np.average(ds.so,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-        elif self.model in ['UKESM1-0-LL']:
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/thetao/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-            self.T = np.average(ds.thetao,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()
-
-            ds = xr.open_mfdataset(f'/usr/people/lambert/work2/data/cmip6/{self.model}/{self.run}/so/*.nc',combine='by_coords')
-            ds = ds.sel(time=slice(f'{self.y0}-01-01',f'{self.y1}-01-01'),j=slice(self.j0,self.j1),i=slice(self.i0,self.i1),lev=slice(self.k0,self.k1))
-            self.S = np.average(ds.so,axis=0,weights=dpm(self.y1-self.y0))
-            ds.close()            
-
-        elif self.model == 'WOA23':
-            # Already got T, so only need to read S
-            ds = xr.open_dataset('/usr/people/lambert/work/projects/data/woa23/woa23_decav91C0_s00_04.nc',decode_cf=False)
-            ds = ds.isel(time=0)
-            ds = ds.sel(lon=slice(self.i0,self.i1),lat=slice(self.j0,self.j1),depth=slice(self.k0,self.k1))
-            self.S = np.where(ds.s_an<1e30,ds.s_an,np.nan)
-            ds.close()
-
-    def get_bins(self):
-        # Create 2D histogram of volume Vb in terms of binned salinity Sb and temperature Tb
-        self.Vb,self.Sb,self.Tb = np.histogram2d(self.S.flatten()[self.V.flatten()>0],self.T.flatten()[self.V.flatten()>0],bins=100,weights=self.V.flatten()[self.V.flatten()>0])
diff --git a/dev-spec.txt b/dev-spec.txt
index b06be4fc..9317d475 100644
--- a/dev-spec.txt
+++ b/dev-spec.txt
@@ -2,6 +2,7 @@
 # when adding dependencies here, please also add to pyproject.toml
 # if they are available on PyPI
 python >=3.10
+cmocean
 esmf
 gsw
 matplotlib
diff --git a/i7aof/biascorr/__init__.py b/i7aof/biascorr/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/i7aof/biascorr/region.py b/i7aof/biascorr/region.py
new file mode 100644
index 00000000..8690d7a2
--- /dev/null
+++ b/i7aof/biascorr/region.py
@@ -0,0 +1,354 @@
+import cmocean # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        im = ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')
+        ax[0].set_title(f'Reference {self.ref.model} '
+                        + f'({self.ref.y0}-{self.ref.y1})')
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'Present day {self.model} '
+                        + f'({self.prd.y0}-{self.prd.y1})')
+        # ax[1].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        im = ax[2].pcolormesh(self.fut.Sb, self.fut.Tb, self.fut.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                              cmap='Oranges')
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+        # ax[2].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+    def plot_delta(self):
+        fig, ax = plt.subplots(1, 5, figsize=(5 * 3.5, 4.5), sharex=True,
+                               sharey=True)
+
+        im = ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.ref.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')
+        ax[0].set_title(f'Reference {self.ref.model} '
+                        + f'({self.ref.y0}-{self.ref.y1})')
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'present day {self.model} '
+                        + f'({self.prd.y0}-{self.prd.y1})')
+        # ax[0].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        im = ax[2].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaT.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+        im = ax[3].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaTf.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+
+        plt.colorbar(im, ax=ax[3], orientation='horizontal')
+        ax[3].set_title('Filled')
+        # ax[1].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+        im = ax[4].pcolormesh(self.ref.Sb, self.ref.Tb, self.fut.dTcorb.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[4], orientation='horizontal')
+        # ax[3].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+    def plot_profiles(self, vlim=-1):
+        fig, ax = plt.subplots(1, 3, figsize=(10, 5), sharey=True)
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.ref.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], label='optimised', c='tab:red',
+                   ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.prd.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], label='raw', c='.5', ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.fut.dTcor[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[2].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.dTraw[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[2].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  (self.ref.T + self.fut.dTcor)[:vlim, :, :]),
+                         axis=(1, 2), weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        ax[0].set_title('Raw model')
+        ax[1].set_title('Optimised')
+        ax[2].set_xlabel('Warming T')
+        # ax[0].legend()
+
+        for Ax in ax:
+            Ax.set_ylim([-1500, 0])
+            # Ax.invert_yaxis()
+
+    def get_bias(self, Tperc=.99, Sperc=.99):
+
+        # S-bias constrain 95th percentile
+        A, B = np.histogram(
+            (self.ref.S).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.ref.S95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.S).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.prd.S95 = B[aa]
+
+        # S-bias
+        a = np.arange(.7, 1.3, .01)
+        ref, dum = np.histogram(self.ref.S, bins=self.ref.Sb,
+                                weights=self.ref.V)
+        rmse = np.zeros((len(a)))
+        for A, AA in enumerate(a):
+            prd, dum = np.histogram(AA * (self.prd.S - self.prd.S95) +
+                                    self.ref.S95, bins=self.ref.Sb,
+                                    weights=self.prd.V)
+            rmse[A] = (np.sum(np.where(prd == 0, 0,
+                       (np.log10(prd / np.sum(prd))
+                        - np.log10(ref / np.sum(ref))) ** 2)) ** .5
+                      / np.sum(np.where(prd == 0, 0, 1)))
+
+            # print(AA,rmse[A])
+        out = np.unravel_index(np.nanargmin(rmse, axis=None), rmse.shape)
+        self.dSa = a[out[0]]
+        self.prd.Sc = self.dSa * (self.prd.Sb - self.prd.S95) + self.ref.S95
+
+        # T-bias (scale 95th percentile of T-Tmin)
+        A, B = np.histogram(
+            (self.ref.T - self.ref.Tb[0]).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.ref.TF95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.T - self.prd.Tb[0]).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.prd.TF95 = B[aa]
+        self.dTa = self.ref.TF95 / self.prd.TF95
+        self.prd.Tc = (
+            self.dTa * (self.prd.Tb - self.prd.Tb[0]) + self.ref.Tb[0])
+        # self.fut.Tc = self.dTa*(self.fut.Tb-self.fut.Tb[0])+self.ref.Tb[0]
+
+    def get_delta(self):
+
+        # Get difference in T and S between future and historical period
+        self.fut.dTraw = self.fut.T - self.prd.T
+        self.fut.dSraw = self.fut.S - self.prd.S
+
+        # Compute binned deltaT and deltaS on historical S- and T- bins
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dTraw).flatten()
+                [self.prd.V.flatten() > 0])
+        self.fut.deltaT = VTdel / self.prd.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dSraw).flatten()
+                [self.prd.V.flatten() > 0])
+        self.fut.deltaS = VSdel / self.prd.Vb
+
+        # Create filled binned deltaT and deltaS through extrapolation
+        self.fut.deltaTf = self.fill_delta(self.fut.deltaT)
+        self.fut.deltaSf = self.fill_delta(self.fut.deltaS)
+
+    def fill_delta(self, deltavar):
+        """Routine to fill deltaT and deltaS in full normalised T,S space"""
+
+        newval = np.nan * np.ones((len(self.prd.Sc), len(self.prd.Tc)))
+
+        newval[1:, 1:] = deltavar
+        Nleft = np.sum(np.isnan(newval[1:, 1:]))
+        while Nleft > 0:
+            mask = np.where(np.isnan(newval), 0, 1)
+            newval2 = np.where(np.isnan(newval), 0, newval)
+            AA = newval2 * mask
+            AAm1 = np.roll(AA, -1, axis=0)
+            m1 = np.roll(mask, -1, axis=0)
+            AAp1 = np.roll(AA, 1, axis=0)
+            p1 = np.roll(mask, 1, axis=0)
+            newval3 = (
+                (np.roll(AAm1, -1, axis=1) + AAm1 + np.roll(AAm1, 1, axis=1) +
+                 np.roll(AA, -1, axis=1) + np.roll(AA, 1, axis=1) +
+                 np.roll(AAp1, -1, axis=1) + AAp1 + np.roll(AAp1, 1, axis=1)) /
+                (np.roll(m1, -1, axis=1) + m1 + np.roll(m1, 1, axis=1) +
+                 np.roll(mask, -1, axis=1) + np.roll(mask, 1, axis=1) +
+                 np.roll(p1, -1, axis=1) + p1 + np.roll(p1, 1, axis=1)))
+            newval4 = np.where(mask, newval, newval3)
+            newval = newval4
+            newval[0, :] = np.nan
+            newval[:, 0] = np.nan
+
+            Nleft = np.sum(np.isnan(newval[1:, 1:]))
+
+        return newval[1:, 1:]
+
+    def get_anom(self):
+        """Get anomalies of reference T,S values"""
+
+        self.fut.dTcor = np.zeros(self.ref.T.shape)
+        self.fut.dScor = np.zeros(self.ref.S.shape)
+
+        for i in range(self.fut.dTcor.shape[0]):
+            for j in range(self.fut.dTcor.shape[1]):
+                for k in range(self.fut.dTcor.shape[2]):
+                    if self.ref.V[i, j, k] == 0:
+                        continue
+                    else:
+                        isref = min(99, max(0, int(
+                            99 * (self.ref.S[i, j, k] - self.prd.Sc[0]) /
+                            (self.prd.Sc[-1] - self.prd.Sc[0]))))
+                        jtref = min(99, max(0, int(
+                            99 * (self.ref.T[i, j, k] - self.prd.Tc[0]) /
+                            (self.prd.Tc[-1] - self.prd.Tc[0]))))
+                        self.fut.dScor[i, j, k] = \
+                            self.fut.deltaSf[isref, jtref]
+                        self.fut.dTcor[i, j, k] = \
+                            self.fut.deltaTf[isref, jtref]
+
+        # Distributions of dS and dT, only for plotting purposes
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.T.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dTcor).flatten()
+                [self.ref.V.flatten() > 0])
+        self.fut.dTcorb = VTdel / self.ref.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dScor).flatten()
+                [self.ref.V.flatten() > 0])
+        self.fut.dScorb = VSdel / self.ref.Vb
+
+    def get_profiles(self):
+        # Construct vertical profiles of reference T and S
+
+        self.ref.Tz = np.nan * np.ones(len(self.ref.depth))
+        self.ref.Sz = np.nan * np.ones(len(self.ref.depth))
+
+        kmax = np.where(np.sum(self.ref.V, axis=(1, 2)) == 0)[0][0]
+        self.ref.Tz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.T[:kmax, :, :]),
+                0,
+                self.ref.T[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+        self.ref.Sz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.S[:kmax, :, :]),
+                0,
+                self.ref.S[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+
+    def calc_horav(self, var, run):
+        """Calculate horizontally averaged vertical profile"""
+
+        try:
+            out = np.average(np.where(np.isnan(var), 0, var), axis=(1, 2),
+                             weights=run.V)
+        except ZeroDivisionError:
+            out = np.nan * np.ones(var.shape[0])
+            kmax = np.where(np.sum(run.V, axis=(1, 2)) == 0)[0][0]
+            out[:kmax] = np.average(np.where(np.isnan(var[:kmax, :, :]), 0,
+                                             var[:kmax, :, :]), axis=(1, 2),
+                                    weights=run.V[:kmax, :, :])
+
+        return out
diff --git a/i7aof/biascorr/run.py b/i7aof/biascorr/run.py
new file mode 100644
index 00000000..a8045db6
--- /dev/null
+++ b/i7aof/biascorr/run.py
@@ -0,0 +1,305 @@
+import xarray as xr
+import numpy as np
+
+
+# Days per month
+def dpm(years):
+    # Construct an array of days per month for the required amount of years
+    return np.tile([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], years)
+
+
+# Run class, which can be model output or observations
+class Run:
+    def __init__(self, model, region, run, y0, y1, k0=0, k1=6000):
+        self.model = model
+        self.region = region
+        self.run = run
+        self.y0 = y0  # Start year
+        self.y1 = y1  # End year
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Specify region-specific coordinate boundaries
+        self.get_coordinates()
+
+        # Compute volume per grid cell
+        self.get_volume()
+
+        # Read temperature and salinity, averaged over specified time period
+        self.get_TS()
+        self.get_bins()
+        print(f'Got run {self.model} {self.region} {self.run} {self.y0}-{self.y1}')  # noqa: E501
+
+    def get_coordinates(self):  # noqa: C901
+        # Hard-coded domain boundaries for specific regions.
+        # Currently in model-specific coordinates,
+        # Should be generalized for all models using a mask in lon-lat
+        # dimensions
+        if self.region == 'Amundsen':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 250, 270, 8, 18
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 50, 70
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 12, 32
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -116, -96, -76, -70
+        elif self.region == 'Ross':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 180, 220, 2, 7
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 38, 48
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 0, 10
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -180, -150, -79, -76
+        elif self.region == 'Weddell':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 300, 360, 3, 9
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 224, 246, 38, 55
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 226, 248, 0, 17
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -65, -40, -78.5, -74.5
+        elif self.region == 'Totten':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 137, 146, 24, 28
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 40, 50, 78, 84
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 42, 52, 40, 46
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = 113, 124, -67.5, -64.5
+
+    def get_volume(self):
+        # Read volume and other relevant variables for volume-weighting of T
+        # and S
+        if self.model in ['CESM2']:
+            # Read all available volcello files
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/volcello*.nc')  # noqa: E501
+
+            # Select specified domain (horizontal and vertical). Note: volume
+            # is time-independent for this model
+            ds = ds.sel(nlat=slice(self.j0, self.j1),
+                        nlon=slice(self.i0, self.i1),
+                        lev=slice(self.k0 * 100., self.k1 * 100.))
+
+            # Get bunch of variables
+            self.lev = ds.lev_bnds.values
+            self.depth = -np.average(self.lev, axis=1)
+            self.lon = ds.lon.values
+            self.lat = ds.lat.values
+            self.V = ds.volcello.values
+            ds.close()
+
+            # Read thetao files
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/historical/thetao/thetao*.nc')  # noqa: E501
+            ds = ds.sel(nlat=slice(self.j0, self.j1),
+                        nlon=slice(self.i0, self.i1),
+                        lev=slice(self.k0 * 100., self.k1 * 100.))
+            ds = ds.isel(time=0)
+            # Overwrite volume with zeros where thetao is nan
+            self.V = np.where(np.isnan(ds.thetao), 0, self.V)
+            ds.close()
+
+        elif self.model in ['EC-Earth3']:
+            # Read thickness files
+            if self.y1 - self.y0 == 1:
+                ds = xr.open_dataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thkcello/thkcello_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')  # noqa: E501
+            else:
+                ds = xr.open_mfdataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thkcello/*.nc', combine='by_coords')  # noqa: E501
+
+            # Select temporal and spatial domains
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+            if self.y1 - self.y0 == 1:
+                self.lev = ds.lev_bnds.values
+            else:
+                self.lev = np.average(ds.lev_bnds.values, axis=0,
+                                      weights=dpm(self.y1 - self.y0))
+
+            # Get variables including thickness, time-averaged
+            self.depth = -np.average(self.lev, axis=1)
+            self.lon = ds.longitude.values
+            self.lat = ds.latitude.values
+            self.thkcello = np.average(ds.thkcello, axis=0,
+                                       weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+            # Read horizontal area of cells
+            ds = xr.open_dataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/areacello_Ofx_EC-Earth3_historical_r1i1p1f1_gn.nc')  # noqa: E501
+            ds = ds.sel(j=slice(self.j0, self.j1), i=slice(self.i0, self.i1))
+
+            self.areacello = ds.areacello.values
+            ds.close()
+
+            # Multiply thickness and area to get volume
+            self.V = self.thkcello * self.areacello
+
+            # Set invalid cells to zero
+            self.V = np.where(np.isnan(self.V), 0, self.V)
+
+        elif self.model in ['UKESM1-0-LL']:
+            # Read thickness files
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thkcello/*.nc')  # noqa: E501
+
+            # Select spatial and temporal domain
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+
+            # Get bunch of variables, time-averaged
+            self.lev = np.average(ds.lev_bnds.values, axis=0,
+                                  weights=dpm(self.y1 - self.y0))
+            self.depth = -np.average(self.lev, axis=1)
+            self.lon = ds.longitude.values
+            self.lat = ds.latitude.values
+            self.thkcello = np.average(ds.thkcello, axis=0,
+                                       weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+            # Read horizontal area of cells
+            ds = xr.open_dataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/areacello_Ofx_UKESM1-0-LL_piControl_r1i1p1f2_gn.nc')  # noqa: E501
+            ds = ds.sel(j=slice(self.j0, self.j1), i=slice(self.i0, self.i1))
+
+            self.areacello = ds.areacello.values
+            ds.close()
+
+            # Multiply thickness and area to get volume
+            self.V = self.thkcello * self.areacello
+
+            # Set invalid cells to zero
+            self.V = np.where(np.isnan(self.V), 0, self.V)
+
+        elif self.model == 'WOA23':
+            # Read data file and select temporal and spatial domain
+            ds = xr.open_dataset(
+                '//home/erwin/data/woa23/woa23_decav91C0_t00_04.nc', decode_cf=False)  # noqa: E501
+            ds = ds.isel(time=0)
+            ds = ds.sel(lon=slice(self.i0, self.i1),
+                        lat=slice(self.j0, self.j1),
+                        depth=slice(self.k0, self.k1))
+
+            # Read temperature and set invalid values to nan
+            self.T = np.where(ds.t_an < 1e30, ds.t_an, np.nan)
+
+            self.lon = ds.lon.values
+            self.lat = ds.lat.values
+            self.depth = -ds.depth
+
+            # Construct volume array
+            self.V = np.zeros((len(ds.depth), len(ds.lat), len(ds.lon)))
+            R = 6.371e6
+
+            # Define horizontal area
+            A = np.ones((len(ds.lat), len(ds.lon)))
+            for i in range(len(ds.lat)):
+                dy = R * np.deg2rad(ds.lat_bnds[i, 1] - ds.lat_bnds[i, 0])
+                dx = (R * np.deg2rad(ds.lon_bnds[:, 1] - ds.lon_bnds[:, 0]) *
+                      np.cos(np.deg2rad(ds.lat[i])))
+                A[i, :] = dx * dy
+
+            # Get thickness and multiply with area to get volume
+            for d in range(len(ds.depth)):
+                D = np.where(np.isnan(self.T[d, :, :]), 0,
+                             ds.depth_bnds[d, 1] - ds.depth_bnds[d, 0])
+                self.V[d, :, :] = D * A
+            ds.close()
+
+    def get_TS(self):
+        # Read temperature and salinity from required model
+        # Fields are time-averaged over the required period
+
+        if self.model in ['CESM2']:
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thetao/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        nlat=slice(self.j0, self.j1),
+                        nlon=slice(self.i0, self.i1),
+                        lev=slice(self.k0 * 100., self.k1 * 100.))
+            self.T = np.average(ds.thetao, axis=0,
+                                weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/so/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        nlat=slice(self.j0, self.j1),
+                        nlon=slice(self.i0, self.i1),
+                        lev=slice(self.k0 * 100., self.k1 * 100.))
+            self.S = np.average(ds.so, axis=0, weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+        elif self.model in ['EC-Earth3']:
+            if self.y1 - self.y0 == 1:
+                ds = xr.open_dataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thetao/thetao_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')  # noqa: E501
+            else:
+                ds = xr.open_mfdataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thetao/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+            self.T = np.average(ds.thetao, axis=0,
+                                weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+            if self.y1 - self.y0 == 1:
+                ds = xr.open_dataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/so/so_Omon_{self.model}_{self.run}_r1i1p1f1_gn_{self.y0}01-{self.y0}12.nc')  # noqa: E501
+            else:
+                ds = xr.open_mfdataset(
+                    f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/so/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+            self.S = np.average(ds.so, axis=0, weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+        elif self.model in ['UKESM1-0-LL']:
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/thetao/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+            self.T = np.average(ds.thetao, axis=0,
+                                weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+            ds = xr.open_mfdataset(
+                f'/home/erwin/data/cmip6/cmip6/{self.model}/{self.run}/so/*.nc', combine='by_coords')  # noqa: E501
+            ds = ds.sel(time=slice(f'{self.y0}-01-01', f'{self.y1}-01-01'),
+                        j=slice(self.j0, self.j1), i=slice(self.i0, self.i1),
+                        lev=slice(self.k0, self.k1))
+            self.S = np.average(ds.so, axis=0, weights=dpm(self.y1 - self.y0))
+            ds.close()
+
+        elif self.model == 'WOA23':
+            # Already got T, so only need to read S
+            ds = xr.open_dataset(
+                '/home/erwin/data/woa23/woa23_decav91C0_s00_04.nc', decode_cf=False)  # noqa: E501
+            ds = ds.isel(time=0)
+            ds = ds.sel(lon=slice(self.i0, self.i1),
+                        lat=slice(self.j0, self.j1),
+                        depth=slice(self.k0, self.k1))
+            self.S = np.where(ds.s_an < 1e30, ds.s_an, np.nan)
+            ds.close()
+
+    def get_bins(self):
+        # Create 2D histogram of volume Vb in terms of binned salinity Sb and
+        # temperature Tb
+        self.Vb, self.Sb, self.Tb = np.histogram2d(
+            self.S.flatten()[self.V.flatten() > 0],
+            self.T.flatten()[self.V.flatten() > 0],
+            bins=100,
+            weights=self.V.flatten()[self.V.flatten() > 0]
+        )
diff --git a/biascorr/notebooks/plot_series.ipynb b/notebooks/biascorr/plot_series.ipynb
similarity index 97%
rename from biascorr/notebooks/plot_series.ipynb
rename to notebooks/biascorr/plot_series.ipynb
index 804d829d..73ee22c1 100644
--- a/biascorr/notebooks/plot_series.ipynb
+++ b/notebooks/biascorr/plot_series.ipynb
@@ -14,10 +14,10 @@
     "import cmocean as cmo\n",
     "\n",
     "import sys\n",
-    "sys.path.append('../src')\n",
+    "sys.path.append('../..')\n",
     "\n",
-    "from run import Run\n",
-    "from region import Region\n",
+    "from i7aof.biascorr.run import Run\n",
+    "from i7aof.biascorr.region import Region\n",
     "\n",
     "import warnings\n",
     "warnings.filterwarnings('ignore')\n",
@@ -251,7 +251,7 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python 3 (ipykernel)",
+   "display_name": "ufelad",
    "language": "python",
    "name": "python3"
   },
@@ -265,7 +265,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.12"
+   "version": "3.13.2"
   }
  },
  "nbformat": 4,
diff --git a/pyproject.toml b/pyproject.toml
index efb9081d..02bde7b2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,7 @@ classifiers = [
 ]
 # dependencies that are available on PyPI, a subset of those on conda-forge
 dependencies = [
+    "cmocean",
     "gsw",
     "matplotlib",
     "netcdf4",
@@ -42,7 +43,9 @@ version = { attr = "i7aof.version.__version__" }
 "Bug Tracker" = "https://github.com/ismip/ismip7-antarctic-ocean-forcing/issues"
 
 [tool.ruff]
-exclude = [".git", "docs"]
+exclude = [".git",
+           "docs",
+           "*.ipynb"]
 line-length = 79
 
 [tool.ruff.lint]


Reviews:
user: login: xylar
body: @erwinlambert, this is my suggestion for how to reorganize and format your code.  Let me know what you think.  Please help me address the few remaining issues that I point out here and please test the notebook to make sure it still works.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-02-11T14:46:34Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:38:16Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:40:24Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:48:16Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T08:48:54Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T09:22:22Z
user: login: erwinlambert
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T09:23:03Z

Review comments:
path: notebooks/biascorr/plot_series.ipynb
user: login: xylar
body: @erwinlambert, I'd like to keep notebooks outside of the `i7aof` package because they mostly don't make sense inside of packages.  I anticipate other notebooks so I think it makes sense for `notebooks` to be the outer directory and specific functionality like `biascorr` inside of it.  Let me know if that works for you.
created_at: 2025-02-11T14:39:05Z
author_association: MEMBER
diff_hunk: @@ -16,8 +16,8 @@
     "import sys\n",
     "sys.path.append('../src')\n",
     "\n",
-    "from run import Run\n",
-    "from region import Region\n",
+    "from i7aof.biascorr.run import Run\n",
+    "from i7aof.biascorr.region import Region\n",
path: notebooks/biascorr/plot_series.ipynb
user: login: xylar
body: This would be the new location of these classes (available to any python code that has `ismip7-antarctic-ocean-forcing` installed.
created_at: 2025-02-11T14:39:54Z
author_association: MEMBER
path: i7aof/biascorr/__init__.py
user: login: xylar
body: This is a file required to make this directory visible to the python package (`i7aof`).
created_at: 2025-02-11T14:40:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
path: i7aof/biascorr/region.py
user: login: xylar
body: It would be nice to avoid lines longer than 79 characters but this tag `  # noqa: E501` allows a line to go over the limit.
created_at: 2025-02-11T14:41:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'Present day {self.model} ({self.prd.y0}-{self.prd.y1})')  # noqa: E501
+        # ax[1].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        im = ax[2].pcolormesh(self.fut.Sb, self.fut.Tb, self.fut.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                              cmap='Oranges')
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+        # ax[2].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+    def plot_delta(self):
+        fig, ax = plt.subplots(1, 5, figsize=(5 * 3.5, 4.5), sharex=True,
+                               sharey=True)
+
+        im = ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.ref.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'present day {self.model} ({self.prd.y0}-{self.prd.y1})')  # noqa: E501
+        # ax[0].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        # im = ax[2].pcolormesh(self.prd.Sc,self.prd.Tc,self.fut.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13),cmap='Greys')  # noqa: E501
+        im = ax[2].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaT.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+        im = ax[3].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaTf.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+
+        plt.colorbar(im, ax=ax[3], orientation='horizontal')
+        ax[3].set_title('Filled')
+        # ax[1].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+        im = ax[4].pcolormesh(self.ref.Sb, self.ref.Tb, self.fut.dTcorb.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[4], orientation='horizontal')
+        # ax[3].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+    def plot_profiles(self, vlim=-1):
+        fig, ax = plt.subplots(1, 3, figsize=(10, 5), sharey=True)
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.ref.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], label='optimised', c='tab:red',
+                   ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.prd.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], label='raw', c='.5', ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.fut.dTcor[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[2].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.dTraw[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[2].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  (self.ref.T + self.fut.dTcor)[:vlim, :, :]),
+                         axis=(1, 2), weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        ax[0].set_title('Raw model')
+        ax[1].set_title('Optimised')
+        ax[2].set_xlabel('Warming T')
+        # ax[0].legend()
+
+        for Ax in ax:
+            Ax.set_ylim([-1500, 0])
+            # Ax.invert_yaxis()
+
+    def get_bias(self, Tperc=.99, Sperc=.99):
+
+        # S-bias constrain 95th percentile
+        A, B = np.histogram(
+            (self.ref.S).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.ref.S95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.S).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.prd.S95 = B[aa]
+
+        # S-bias
+        a = np.arange(.7, 1.3, .01)
+        ref, dum = np.histogram(self.ref.S, bins=self.ref.Sb,
+                                weights=self.ref.V)
+        rmse = np.zeros((len(a)))
+        for A, AA in enumerate(a):
+            prd, dum = np.histogram(AA * (self.prd.S - self.prd.S95) +
+                                    self.ref.S95, bins=self.ref.Sb,
+                                    weights=self.prd.V)
+            rmse[A] = (
+                np.sum(np.where(prd == 0, 0, (np.log10(prd / np.sum(prd)) - np.log10(ref / np.sum(ref))) ** 2)) ** .5 /  # noqa: E501
+                np.sum(np.where(prd == 0, 0, 1)))
+            # rmse[A] = np.sum(np.where(prd==0,0,(np.log10(prd/np.sum(prd))-np.log10(ref/np.sum(ref))))**2) / np.sum(np.where(prd==0,0,1))**.5  # noqa: E501
+
+            # print(AA,rmse[A])
+        out = np.unravel_index(np.nanargmin(rmse, axis=None), rmse.shape)
+        self.dSa = a[out[0]]
+        self.prd.Sc = self.dSa * (self.prd.Sb - self.prd.S95) + self.ref.S95
+        # self.fut.Sc = self.dSa*(self.fut.Sb-self.prd.S95)+self.ref.S95 #Corrected S  # noqa: E501
+
+        # T-bias (scale 95th percentile of T-Tmin)
+        A, B = np.histogram(
+            (self.ref.T - self.ref.Tb[0]).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.ref.TF95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.T - self.prd.Tb[0]).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.prd.TF95 = B[aa]
+        self.dTa = self.ref.TF95 / self.prd.TF95
+        self.prd.Tc = (
+            self.dTa * (self.prd.Tb - self.prd.Tb[0]) + self.ref.Tb[0])
+        # self.fut.Tc = self.dTa*(self.fut.Tb-self.fut.Tb[0])+self.ref.Tb[0]
+
+    def get_delta(self):
+
+        # Get difference in T and S between future and historical period
+        self.fut.dTraw = self.fut.T - self.prd.T
+        self.fut.dSraw = self.fut.S - self.prd.S
+
+        # Compute binned deltaT and deltaS on historical S- and T- bins
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dTraw).flatten()[self.prd.V.flatten() > 0])  # noqa: E501
+        self.fut.deltaT = VTdel / self.prd.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dSraw).flatten()[self.prd.V.flatten() > 0])  # noqa: E501
+        self.fut.deltaS = VSdel / self.prd.Vb
+
+        # Create filled binned deltaT and deltaS through extrapolation
+        self.fut.deltaTf = self.fill_delta(self.fut.deltaT)
+        self.fut.deltaSf = self.fill_delta(self.fut.deltaS)
+
+    def fill_delta(self, deltavar):
+        """Routine to fill deltaT and deltaS in full normalised T,S space"""
+
+        newval = np.nan * np.ones((len(self.prd.Sc), len(self.prd.Tc)))
+
+        newval[1:, 1:] = deltavar
+        Nleft = np.sum(np.isnan(newval[1:, 1:]))
+        while Nleft > 0:
+            mask = np.where(np.isnan(newval), 0, 1)
+            newval2 = np.where(np.isnan(newval), 0, newval)
+            AA = newval2 * mask
+            AAm1 = np.roll(AA, -1, axis=0)
+            m1 = np.roll(mask, -1, axis=0)
+            AAp1 = np.roll(AA, 1, axis=0)
+            p1 = np.roll(mask, 1, axis=0)
+            newval3 = (
+                (np.roll(AAm1, -1, axis=1) + AAm1 + np.roll(AAm1, 1, axis=1) +
+                 np.roll(AA, -1, axis=1) + np.roll(AA, 1, axis=1) +
+                 np.roll(AAp1, -1, axis=1) + AAp1 + np.roll(AAp1, 1, axis=1)) /
+                (np.roll(m1, -1, axis=1) + m1 + np.roll(m1, 1, axis=1) +
+                 np.roll(mask, -1, axis=1) + np.roll(mask, 1, axis=1) +
+                 np.roll(p1, -1, axis=1) + p1 + np.roll(p1, 1, axis=1)))
+            newval4 = np.where(mask, newval, newval3)
+            newval = newval4
+            newval[0, :] = np.nan
+            newval[:, 0] = np.nan
+
+            Nleft = np.sum(np.isnan(newval[1:, 1:]))
+
+        return newval[1:, 1:]
+
+    def get_anom(self):
+        """Get anomalies of reference T,S values"""
+
+        self.fut.dTcor = np.zeros(self.ref.T.shape)
+        self.fut.dScor = np.zeros(self.ref.S.shape)
+
+        for i in range(self.fut.dTcor.shape[0]):
+            for j in range(self.fut.dTcor.shape[1]):
+                for k in range(self.fut.dTcor.shape[2]):
+                    if self.ref.V[i, j, k] == 0:
+                        continue
+                    else:
+                        isref = min(99, max(0, int(
+                            99 * (self.ref.S[i, j, k] - self.prd.Sc[0]) /
+                            (self.prd.Sc[-1] - self.prd.Sc[0]))))
+                        jtref = min(99, max(0, int(
+                            99 * (self.ref.T[i, j, k] - self.prd.Tc[0]) /
+                            (self.prd.Tc[-1] - self.prd.Tc[0]))))
+                        self.fut.dScor[i, j, k] = \
+                            self.fut.deltaSf[isref, jtref]
+                        self.fut.dTcor[i, j, k] = \
+                            self.fut.deltaTf[isref, jtref]
+
+        # Distributions of dS and dT, only for plotting purposes
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.T.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dTcor).flatten()[self.ref.V.flatten() > 0])  # noqa: E501
+        self.fut.dTcorb = VTdel / self.ref.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dScor).flatten()[self.ref.V.flatten() > 0])  # noqa: E501
+        self.fut.dScorb = VSdel / self.ref.Vb
+
+    def get_profiles(self):
+        # Construct vertical profiles of reference T and S
+
+        self.ref.Tz = np.nan * np.ones(len(self.ref.depth))
+        self.ref.Sz = np.nan * np.ones(len(self.ref.depth))
+
+        kmax = np.where(np.sum(self.ref.V, axis=(1, 2)) == 0)[0][0]
+        self.ref.Tz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.T[:kmax, :, :]),
+                0,
+                self.ref.T[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+        self.ref.Sz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.S[:kmax, :, :]),
+                0,
+                self.ref.S[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+
+    def calc_horav(self, var, run):
+        """Calculate horizontally averaged vertical profile"""
+
+        try:
+            out = np.average(np.where(np.isnan(var), 0, var), axis=(1, 2),
+                             weights=run.V)
+        except:  # noqa: E722
path: i7aof/biascorr/region.py
user: login: xylar
body: @erwinlambert, it would be good to fix this.  It isn't safe to just use `except` without an error name like `ValueError` or `KeyError`.  Could you find out what error is raised that you are hoping to catch here?
created_at: 2025-02-11T14:42:20Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
path: i7aof/biascorr/region.py
user: login: xylar
body: `im` isn't define.  I told the linter to skip it for now but this should be fixed in this PR if possible.
created_at: 2025-02-11T14:43:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,305 @@
+import xarray as xr
+import numpy as np
+
+
+# Days per month
+def dpm(years):
+    # Construct an array of days per month for the required amount of years
+    return np.tile([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], years)
+
+
+# Run class, which can be model output or observations
+class Run:
+    def __init__(self, model, region, run, y0, y1, k0=0, k1=6000):
+        self.model = model
+        self.region = region
+        self.run = run
+        self.y0 = y0  # Start year
+        self.y1 = y1  # End year
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Specify region-specific coordinate boundaries
+        self.get_coordinates()
+
+        # Compute volume per grid cell
+        self.get_volume()
+
+        # Read temperature and salinity, averaged over specified time period
+        self.get_TS()
+        self.get_bins()
+        print(f'Got run {self.model} {self.region} {self.run} {self.y0}-{self.y1}')  # noqa: E501
+
+    def get_coordinates(self):  # noqa: C901
+        # Hard-coded domain boundaries for specific regions.
+        # Currently in model-specific coordinates,
+        # Should be generalized for all models using a mask in lon-lat
+        # dimensions
+        if self.region == 'Amundsen':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 250, 270, 8, 18
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 50, 70
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 12, 32
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -116, -96, -76, -70
+        elif self.region == 'Ross':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 180, 220, 2, 7
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 38, 48
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 0, 10
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -180, -150, -79, -76
+        elif self.region == 'Weddell':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 300, 360, 3, 9
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 224, 246, 38, 55
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 226, 248, 0, 17
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -65, -40, -78.5, -74.5
+        elif self.region == 'Totten':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 137, 146, 24, 28
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 40, 50, 78, 84
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 42, 52, 40, 46
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = 113, 124, -67.5, -64.5
+
+    def get_volume(self):
+        # Read volume and other relevant variables for volume-weighting of T
+        # and S
+        if self.model in ['CESM2']:
+            # Read all available volcello files
+            ds = xr.open_mfdataset(
+                f'/usr/people/lambert/work2/data/cmip6/{self.model}/volcello*.nc')  # noqa: E501
path: i7aof/biascorr/run.py
user: login: xylar
body: It would be really great to have some sort of a config file or flag to attribute in the `Run` class that determines the path here.  Hard-coding paths in the code is not going to be flexible enough in the long run and is counter to the philosophy of open-source code.
created_at: 2025-02-11T14:46:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,305 @@
+import xarray as xr
+import numpy as np
+
+
+# Days per month
+def dpm(years):
+    # Construct an array of days per month for the required amount of years
+    return np.tile([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], years)
+
+
+# Run class, which can be model output or observations
+class Run:
+    def __init__(self, model, region, run, y0, y1, k0=0, k1=6000):
+        self.model = model
+        self.region = region
+        self.run = run
+        self.y0 = y0  # Start year
+        self.y1 = y1  # End year
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Specify region-specific coordinate boundaries
+        self.get_coordinates()
+
+        # Compute volume per grid cell
+        self.get_volume()
+
+        # Read temperature and salinity, averaged over specified time period
+        self.get_TS()
+        self.get_bins()
+        print(f'Got run {self.model} {self.region} {self.run} {self.y0}-{self.y1}')  # noqa: E501
+
+    def get_coordinates(self):  # noqa: C901
+        # Hard-coded domain boundaries for specific regions.
+        # Currently in model-specific coordinates,
+        # Should be generalized for all models using a mask in lon-lat
+        # dimensions
+        if self.region == 'Amundsen':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 250, 270, 8, 18
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 50, 70
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 12, 32
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -116, -96, -76, -70
+        elif self.region == 'Ross':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 180, 220, 2, 7
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 38, 48
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 0, 10
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -180, -150, -79, -76
+        elif self.region == 'Weddell':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 300, 360, 3, 9
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 224, 246, 38, 55
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 226, 248, 0, 17
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -65, -40, -78.5, -74.5
+        elif self.region == 'Totten':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 137, 146, 24, 28
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 40, 50, 78, 84
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 42, 52, 40, 46
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = 113, 124, -67.5, -64.5
+
+    def get_volume(self):
+        # Read volume and other relevant variables for volume-weighting of T
+        # and S
+        if self.model in ['CESM2']:
+            # Read all available volcello files
+            ds = xr.open_mfdataset(
+                f'/usr/people/lambert/work2/data/cmip6/{self.model}/volcello*.nc')  # noqa: E501
path: i7aof/biascorr/run.py
user: login: xylar
body: Let's follow up on this in the future, though, no in this PR.
created_at: 2025-02-11T14:46:28Z
author_association: MEMBER
path: notebooks/biascorr/plot_series.ipynb
user: login: erwinlambert
body: I agree, I like to use these as an interface, but they should not be part of the package. I'll open an issue to move most code to the `i7aof` package in regular `.py` files
created_at: 2025-03-12T08:38:16Z
author_association: COLLABORATOR
diff_hunk: @@ -16,8 +16,8 @@
     "import sys\n",
     "sys.path.append('../src')\n",
     "\n",
-    "from run import Run\n",
-    "from region import Region\n",
+    "from i7aof.biascorr.run import Run\n",
+    "from i7aof.biascorr.region import Region\n",
path: notebooks/biascorr/plot_series.ipynb
user: login: erwinlambert
body: Excellent
created_at: 2025-03-12T08:40:23Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
path: i7aof/biascorr/region.py
user: login: erwinlambert
body: Solved
created_at: 2025-03-12T08:48:16Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,305 @@
+import xarray as xr
+import numpy as np
+
+
+# Days per month
+def dpm(years):
+    # Construct an array of days per month for the required amount of years
+    return np.tile([31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31], years)
+
+
+# Run class, which can be model output or observations
+class Run:
+    def __init__(self, model, region, run, y0, y1, k0=0, k1=6000):
+        self.model = model
+        self.region = region
+        self.run = run
+        self.y0 = y0  # Start year
+        self.y1 = y1  # End year
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Specify region-specific coordinate boundaries
+        self.get_coordinates()
+
+        # Compute volume per grid cell
+        self.get_volume()
+
+        # Read temperature and salinity, averaged over specified time period
+        self.get_TS()
+        self.get_bins()
+        print(f'Got run {self.model} {self.region} {self.run} {self.y0}-{self.y1}')  # noqa: E501
+
+    def get_coordinates(self):  # noqa: C901
+        # Hard-coded domain boundaries for specific regions.
+        # Currently in model-specific coordinates,
+        # Should be generalized for all models using a mask in lon-lat
+        # dimensions
+        if self.region == 'Amundsen':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 250, 270, 8, 18
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 50, 70
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 170, 190, 12, 32
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -116, -96, -76, -70
+        elif self.region == 'Ross':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 180, 220, 2, 7
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 38, 48
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 85, 140, 0, 10
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -180, -150, -79, -76
+        elif self.region == 'Weddell':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 300, 360, 3, 9
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 224, 246, 38, 55
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 226, 248, 0, 17
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = -65, -40, -78.5, -74.5
+        elif self.region == 'Totten':
+            if self.model == 'CESM2':
+                self.i0, self.i1, self.j0, self.j1 = 137, 146, 24, 28
+            elif self.model == 'UKESM1-0-LL':
+                self.i0, self.i1, self.j0, self.j1 = 40, 50, 78, 84
+            elif self.model == 'EC-Earth3':
+                self.i0, self.i1, self.j0, self.j1 = 42, 52, 40, 46
+            elif self.model == 'WOA23':
+                self.i0, self.i1, self.j0, self.j1 = 113, 124, -67.5, -64.5
+
+    def get_volume(self):
+        # Read volume and other relevant variables for volume-weighting of T
+        # and S
+        if self.model in ['CESM2']:
+            # Read all available volcello files
+            ds = xr.open_mfdataset(
+                f'/usr/people/lambert/work2/data/cmip6/{self.model}/volcello*.nc')  # noqa: E501
path: i7aof/biascorr/run.py
user: login: erwinlambert
body: Yes, good point. I'll open an issue to set up a config file for the bias correction routines
created_at: 2025-03-12T08:48:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
path: i7aof/biascorr/region.py
user: login: erwinlambert
body: Alright, I broke these lines. Will keep this in mind
created_at: 2025-03-12T09:22:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,348 @@
+import cmocean  # noqa: F401
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+from i7aof.biascorr.run import Run
+
+
+# Region class with functions for processing and plotting
+class Region:
+    def __init__(self, region, model, refmodel='UKESM1-0-LL', k0=0, k1=6000):
+        # Get volume CMIP
+        self.region = region
+        self.model = model  # Reference model considered 'truth'
+        self.refmodel = refmodel  # Model to be bias corrected
+        self.k0 = k0  # Start depth (top)
+        self.k1 = k1  # End depth (bottom)
+
+        # Read historical period of both refmodel and model to get started
+        self.get_historical()
+
+    def get_historical(self):
+        # Get data for refmodel over historical period
+        self.ref = Run(self.refmodel, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Get data for model over historical period
+        self.prd = Run(self.model, self.region, 'historical', 1995, 2015,
+                       k0=self.k0, k1=self.k1)
+
+        # Define the bias in the model with respect to the reference model
+        self.get_bias()
+
+    def get_future(self, run, y0, y1):
+        # Get data for model over some future period y0 - y1
+        self.fut = Run(self.model, self.region, run, y0, y1, k0=self.k0,
+                       k1=self.k1)
+
+        # Compute change in T and S between future and historical in T,S space
+        # of model
+        # Example: between y0 and y1, the water mass that historically had a
+        # temperature of X degrees and a salinity of Y psu has warmed by an
+        # amount of Z degrees.
+        self.get_delta()
+
+        # Construct deltaT and deltaS on the grid of the reference model for
+        # this future period
+        self.get_anom()
+
+    def plot_region_cmip(self, ii, jj):
+        plt.pcolormesh(self.lonc, self.latc, np.nansum(self.thkcello, axis=0),
+                       cmap='Blues', norm=mpl.colors.LogNorm(vmin=10,
+                                                             vmax=1e4))
+        plt.scatter(self.lonc[jj, ii], self.latc[jj, ii], 50, c='tab:red')
+
+    def plot_volumes(self):
+        fig, ax = plt.subplots(1, 3, figsize=(3 * 5, 6), sharex=True,
+                               sharey=True)
+
+        ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.prd.Vb.T,
+                         norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                         cmap='Greys')
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')  # noqa: F821
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'Present day {self.model} ({self.prd.y0}-{self.prd.y1})')  # noqa: E501
+        # ax[1].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        im = ax[2].pcolormesh(self.fut.Sb, self.fut.Tb, self.fut.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13),
+                              cmap='Oranges')
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+        # ax[2].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+    def plot_delta(self):
+        fig, ax = plt.subplots(1, 5, figsize=(5 * 3.5, 4.5), sharex=True,
+                               sharey=True)
+
+        im = ax[0].pcolormesh(self.ref.Sb, self.ref.Tb, self.ref.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[0], orientation='horizontal')
+        ax[0].set_title(f'Reference ({self.ref.model} {self.ref.y0}-{self.ref.y1})')  # noqa: E501
+        ax[0].set_ylabel(self.region)
+
+        im = ax[1].pcolormesh(self.prd.Sb, self.prd.Tb, self.prd.Vb.T,
+                              norm=mpl.colors.LogNorm(vmin=1e9, vmax=1e13))
+        plt.colorbar(im, ax=ax[1], orientation='horizontal')
+        ax[1].set_title(f'present day {self.model} ({self.prd.y0}-{self.prd.y1})')  # noqa: E501
+        # ax[0].plot(self.prd.S[:,jj,ii],self.prd.T[:,jj,ii],c='tab:red')
+
+        # im = ax[2].pcolormesh(self.prd.Sc,self.prd.Tc,self.fut.Vb.T,norm=mpl.colors.LogNorm(vmin=1e9,vmax=1e13),cmap='Greys')  # noqa: E501
+        im = ax[2].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaT.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[2], orientation='horizontal')
+        ax[2].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+        im = ax[3].pcolormesh(self.prd.Sb, self.prd.Tb, self.fut.deltaTf.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+
+        plt.colorbar(im, ax=ax[3], orientation='horizontal')
+        ax[3].set_title('Filled')
+        # ax[1].plot(self.fut.S[:,jj,ii],self.fut.T[:,jj,ii],c='tab:red')
+
+        im = ax[4].pcolormesh(self.ref.Sb, self.ref.Tb, self.fut.dTcorb.T,
+                              cmap='cmo.balance', vmin=-3, vmax=3)
+        plt.colorbar(im, ax=ax[4], orientation='horizontal')
+        # ax[3].set_title(f'{self.fut.run} ({self.fut.y0}-{self.fut.y1})')
+
+    def plot_profiles(self, vlim=-1):
+        fig, ax = plt.subplots(1, 3, figsize=(10, 5), sharey=True)
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.ref.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], label='optimised', c='tab:red',
+                   ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.prd.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], label='raw', c='.5', ls='--')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  self.fut.dTcor[:vlim, :, :]), axis=(1, 2),
+                         weights=self.ref.V[:vlim, :, :])
+        ax[2].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.dTraw[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[2].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        Tav = np.average(np.where(np.isnan(self.ref.T[:vlim, :, :]), 0,
+                                  (self.ref.T + self.fut.dTcor)[:vlim, :, :]),
+                         axis=(1, 2), weights=self.ref.V[:vlim, :, :])
+        ax[1].plot(Tav, self.ref.depth[:vlim], c='tab:red')
+
+        Tav = np.average(np.where(np.isnan(self.prd.T[:vlim, :, :]), 0,
+                                  self.fut.T[:vlim, :, :]), axis=(1, 2),
+                         weights=self.prd.V[:vlim, :, :])
+        ax[0].plot(Tav, self.prd.depth[:vlim], c='.5')
+
+        ax[0].set_title('Raw model')
+        ax[1].set_title('Optimised')
+        ax[2].set_xlabel('Warming T')
+        # ax[0].legend()
+
+        for Ax in ax:
+            Ax.set_ylim([-1500, 0])
+            # Ax.invert_yaxis()
+
+    def get_bias(self, Tperc=.99, Sperc=.99):
+
+        # S-bias constrain 95th percentile
+        A, B = np.histogram(
+            (self.ref.S).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.ref.S95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.S).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Sperc) ** 2)
+        self.prd.S95 = B[aa]
+
+        # S-bias
+        a = np.arange(.7, 1.3, .01)
+        ref, dum = np.histogram(self.ref.S, bins=self.ref.Sb,
+                                weights=self.ref.V)
+        rmse = np.zeros((len(a)))
+        for A, AA in enumerate(a):
+            prd, dum = np.histogram(AA * (self.prd.S - self.prd.S95) +
+                                    self.ref.S95, bins=self.ref.Sb,
+                                    weights=self.prd.V)
+            rmse[A] = (
+                np.sum(np.where(prd == 0, 0, (np.log10(prd / np.sum(prd)) - np.log10(ref / np.sum(ref))) ** 2)) ** .5 /  # noqa: E501
+                np.sum(np.where(prd == 0, 0, 1)))
+            # rmse[A] = np.sum(np.where(prd==0,0,(np.log10(prd/np.sum(prd))-np.log10(ref/np.sum(ref))))**2) / np.sum(np.where(prd==0,0,1))**.5  # noqa: E501
+
+            # print(AA,rmse[A])
+        out = np.unravel_index(np.nanargmin(rmse, axis=None), rmse.shape)
+        self.dSa = a[out[0]]
+        self.prd.Sc = self.dSa * (self.prd.Sb - self.prd.S95) + self.ref.S95
+        # self.fut.Sc = self.dSa*(self.fut.Sb-self.prd.S95)+self.ref.S95 #Corrected S  # noqa: E501
+
+        # T-bias (scale 95th percentile of T-Tmin)
+        A, B = np.histogram(
+            (self.ref.T - self.ref.Tb[0]).flatten()[self.ref.V.flatten() > 0],
+            weights=self.ref.V.flatten()[self.ref.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.ref.TF95 = B[aa]
+
+        A, B = np.histogram(
+            (self.prd.T - self.prd.Tb[0]).flatten()[self.prd.V.flatten() > 0],
+            weights=self.prd.V.flatten()[self.prd.V.flatten() > 0],
+            bins=1000, density=True)
+        AA = np.cumsum(A) / np.cumsum(A)[-1]
+        aa = np.argmin((AA - Tperc) ** 2)
+        self.prd.TF95 = B[aa]
+        self.dTa = self.ref.TF95 / self.prd.TF95
+        self.prd.Tc = (
+            self.dTa * (self.prd.Tb - self.prd.Tb[0]) + self.ref.Tb[0])
+        # self.fut.Tc = self.dTa*(self.fut.Tb-self.fut.Tb[0])+self.ref.Tb[0]
+
+    def get_delta(self):
+
+        # Get difference in T and S between future and historical period
+        self.fut.dTraw = self.fut.T - self.prd.T
+        self.fut.dSraw = self.fut.S - self.prd.S
+
+        # Compute binned deltaT and deltaS on historical S- and T- bins
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dTraw).flatten()[self.prd.V.flatten() > 0])  # noqa: E501
+        self.fut.deltaT = VTdel / self.prd.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.prd.S.flatten()[self.prd.V.flatten() > 0],
+            self.prd.T.flatten()[self.prd.V.flatten() > 0],
+            bins=100,
+            weights=(self.prd.V * self.fut.dSraw).flatten()[self.prd.V.flatten() > 0])  # noqa: E501
+        self.fut.deltaS = VSdel / self.prd.Vb
+
+        # Create filled binned deltaT and deltaS through extrapolation
+        self.fut.deltaTf = self.fill_delta(self.fut.deltaT)
+        self.fut.deltaSf = self.fill_delta(self.fut.deltaS)
+
+    def fill_delta(self, deltavar):
+        """Routine to fill deltaT and deltaS in full normalised T,S space"""
+
+        newval = np.nan * np.ones((len(self.prd.Sc), len(self.prd.Tc)))
+
+        newval[1:, 1:] = deltavar
+        Nleft = np.sum(np.isnan(newval[1:, 1:]))
+        while Nleft > 0:
+            mask = np.where(np.isnan(newval), 0, 1)
+            newval2 = np.where(np.isnan(newval), 0, newval)
+            AA = newval2 * mask
+            AAm1 = np.roll(AA, -1, axis=0)
+            m1 = np.roll(mask, -1, axis=0)
+            AAp1 = np.roll(AA, 1, axis=0)
+            p1 = np.roll(mask, 1, axis=0)
+            newval3 = (
+                (np.roll(AAm1, -1, axis=1) + AAm1 + np.roll(AAm1, 1, axis=1) +
+                 np.roll(AA, -1, axis=1) + np.roll(AA, 1, axis=1) +
+                 np.roll(AAp1, -1, axis=1) + AAp1 + np.roll(AAp1, 1, axis=1)) /
+                (np.roll(m1, -1, axis=1) + m1 + np.roll(m1, 1, axis=1) +
+                 np.roll(mask, -1, axis=1) + np.roll(mask, 1, axis=1) +
+                 np.roll(p1, -1, axis=1) + p1 + np.roll(p1, 1, axis=1)))
+            newval4 = np.where(mask, newval, newval3)
+            newval = newval4
+            newval[0, :] = np.nan
+            newval[:, 0] = np.nan
+
+            Nleft = np.sum(np.isnan(newval[1:, 1:]))
+
+        return newval[1:, 1:]
+
+    def get_anom(self):
+        """Get anomalies of reference T,S values"""
+
+        self.fut.dTcor = np.zeros(self.ref.T.shape)
+        self.fut.dScor = np.zeros(self.ref.S.shape)
+
+        for i in range(self.fut.dTcor.shape[0]):
+            for j in range(self.fut.dTcor.shape[1]):
+                for k in range(self.fut.dTcor.shape[2]):
+                    if self.ref.V[i, j, k] == 0:
+                        continue
+                    else:
+                        isref = min(99, max(0, int(
+                            99 * (self.ref.S[i, j, k] - self.prd.Sc[0]) /
+                            (self.prd.Sc[-1] - self.prd.Sc[0]))))
+                        jtref = min(99, max(0, int(
+                            99 * (self.ref.T[i, j, k] - self.prd.Tc[0]) /
+                            (self.prd.Tc[-1] - self.prd.Tc[0]))))
+                        self.fut.dScor[i, j, k] = \
+                            self.fut.deltaSf[isref, jtref]
+                        self.fut.dTcor[i, j, k] = \
+                            self.fut.deltaTf[isref, jtref]
+
+        # Distributions of dS and dT, only for plotting purposes
+        VTdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.T.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dTcor).flatten()[self.ref.V.flatten() > 0])  # noqa: E501
+        self.fut.dTcorb = VTdel / self.ref.Vb
+        VSdel, Sdel, Tdel = np.histogram2d(
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            self.ref.S.flatten()[self.ref.V.flatten() > 0],
+            bins=100,
+            range=[[self.ref.Sb[0], self.ref.Sb[-1]],
+                   [self.ref.Tb[0], self.ref.Tb[-1]]],
+            weights=(self.ref.V * self.fut.dScor).flatten()[self.ref.V.flatten() > 0])  # noqa: E501
+        self.fut.dScorb = VSdel / self.ref.Vb
+
+    def get_profiles(self):
+        # Construct vertical profiles of reference T and S
+
+        self.ref.Tz = np.nan * np.ones(len(self.ref.depth))
+        self.ref.Sz = np.nan * np.ones(len(self.ref.depth))
+
+        kmax = np.where(np.sum(self.ref.V, axis=(1, 2)) == 0)[0][0]
+        self.ref.Tz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.T[:kmax, :, :]),
+                0,
+                self.ref.T[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+        self.ref.Sz[:kmax] = np.average(
+            np.where(
+                np.isnan(self.ref.S[:kmax, :, :]),
+                0,
+                self.ref.S[:kmax, :, :]),
+            axis=(1, 2),
+            weights=self.ref.V[:kmax, :, :])
+
+    def calc_horav(self, var, run):
+        """Calculate horizontally averaged vertical profile"""
+
+        try:
+            out = np.average(np.where(np.isnan(var), 0, var), axis=(1, 2),
+                             weights=run.V)
+        except:  # noqa: E722
path: i7aof/biascorr/region.py
user: login: erwinlambert
body: Added ZeroDivisionError
created_at: 2025-03-12T09:23:03Z
author_association: COLLABORATOR

PR comments:
user: login: xylar
created_at: 2025-02-11T14:47:24Z
body: @erwinlambert, I've made you a collaborator on my fork.  If you accept the invitation, you should be able to push to this branch to make the changes I requested (or even edit directly on GitHub).
author_association: MEMBER
user: login: erwinlambert
created_at: 2025-02-11T15:44:41Z
body: Great, I put this on my to do list

On 11 Feb 2025, at 15:47, Xylar Asay-Davis ***@***.***> wrote:


@erwinlambert<https://github.com/erwinlambert>, I've made you a collaborator on my fork. If you accept the invitation, you should be able to push to this branch to make the changes I requested (or even edit directly on GitHub).

—
Reply to this email directly, view it on GitHub<https://github.com/ismip/ismip7-antarctic-ocean-forcing/pull/6#issuecomment-2651039173>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AKY2VBBLYVGPCQOFUNXQEE32PIERFAVCNFSM6AAAAABW5GYHRSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDMNJRGAZTSMJXGM>.
You are receiving this because you were mentioned.Message ID: ***@***.***>

[xylar]xylar left a comment (ismip/ismip7-antarctic-ocean-forcing#6)<https://github.com/ismip/ismip7-antarctic-ocean-forcing/pull/6#issuecomment-2651039173>

@erwinlambert<https://github.com/erwinlambert>, I've made you a collaborator on my fork. If you accept the invitation, you should be able to push to this branch to make the changes I requested (or even edit directly on GitHub).

—
Reply to this email directly, view it on GitHub<https://github.com/ismip/ismip7-antarctic-ocean-forcing/pull/6#issuecomment-2651039173>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AKY2VBBLYVGPCQOFUNXQEE32PIERFAVCNFSM6AAAAABW5GYHRSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDMNJRGAZTSMJXGM>.
You are receiving this because you were mentioned.Message ID: ***@***.***>


author_association: COLLABORATOR
user: login: xylar
created_at: 2025-03-12T09:48:27Z
body: @erwinlambert, feel free to push changes to this branch.  You are a collaborator on my fork so you should have permission.
author_association: MEMBER
user: login: erwinlambert
created_at: 2025-03-12T10:09:27Z
body: Yes, I can push. And never mind the intermediate remarks :) 
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-03-12T10:22:07Z
body: @erwinlambert, It's fine to keep the long lines where they are needed and just have the `  #noqa: <whatever_code>` at the end of them to indicate that they're on purpose.  
author_association: MEMBER
user: login: xylar
created_at: 2025-03-12T10:25:19Z
body: @erwinlambert, for the future, the customary practice I'm used to is that the reviewer (you) submits a review (which is available in the `Files changed` tab).  Then, the asignee will merge.  It's fine if the asignee is the same as the reviewer, in which case they can review and merge.
author_association: MEMBER
user: login: erwinlambert
created_at: 2025-03-12T10:29:13Z
body: @xylar Ah sorry, I mixed that up, as I was going through 'my' old code. Got little experience with these collaboration tools, so please allow me to mess up here and there while learning
author_association: COLLABORATOR
user: login: xylar
created_at: 2025-03-12T10:31:08Z
body: @erwinlambert, absolutely no problem. This was ready to merge.  It's just nice to have the formality of a review (with a sort of official check mark that someone looked over the code).  It's quite obvious in this case that you did.
author_association: MEMBER
user: login: erwinlambert
created_at: 2025-03-12T10:32:46Z
body: @xylar All good, got it!
author_association: COLLABORATOR