"""
Compare catalogs of candidates and benchmarks.
"""
from __future__ import annotations

# __all__ = ['*']
__author__ = "Fernando Aristizabal"

from typing import Iterable, Optional, Callable, Tuple
import os

import pandas as pd
from rioxarray import open_rasterio as rxr_or
import xarray as xr
import dask.dataframe as dd

def catalog_compare(
    candidate_catalog: pd.DataFrame | dd.DataFrame,
    benchmark_catalog: pd.DataFrame | dd.DataFrame,
    map_ids: str | Iterable[str],
    how: str = "inner",
    on: Optional[str | Iterable[str]] = None,
    left_on: Optional[str | Iterable[str]] = None,
    right_on: Optional[str | Iterable[str]] = None,
    suffixes: tuple[str, str] = ("_candidate", "_benchmark"),
    merge_kwargs: Optional[dict] = None,
    open_kwargs: Optional[dict] = None,
    compare_type: str | Callable = "continuous",
    compare_kwargs: Optional[dict] = None,
    agreement_map_field: Optional[str] = None,
    agreement_map_write_kwargs: Optional[dict] = None,
) -> pd.DataFrame | dd.DataFrame:
    """
    Compare catalogs of candidate and benchmark maps.

    Parameters
    ----------
    candidate_catalog : pandas.DataFrame | dask.DataFrame
        Candidate catalog.
    benchmark_catalog : pandas.DataFrame | dask.DataFrame
        Benchmark catalog.
    map_ids : str | Iterable of str
        Column name(s) where maps or paths to maps occur. If str is given, then the same value should occur in both catalogs. If Iterable[str] is given of length 2, then the column names where maps are will be in [candidate, benchmark] respectively.

        The columns corresponding to map_ids should have either str, xarray.DataArray, xarray.Dataset, rasterio.io.DatasetReader, rasterio.vrt.WarpedVRT, or os.PathLike objects.
    how : str, default = "inner"
        Type of merge to perform. See pandas.DataFrame.merge for more information.
    on : str | Iterable of str, default = None
        Column(s) to join on. Must be found in both catalogs. If None, and left_on and right_on are also None, then the intersection of the columns in both catalogs will be used.
    left_on : str | Iterable of str, default = None
        Column(s) to join on in left catalog. Must be found in left catalog.
    right_on : str | Iterable of str, default = None
        Column(s) to join on in right catalog. Must be found in right catalog.
    suffixes : tuple of str, default = ("_candidate", "_benchmark")
        Suffixes to apply to overlapping column names in candidate and benchmark catalogs, respectively. Length two tuple of strings.
    merge_kwargs : dict, default = None
        Keyword arguments to pass to pandas.DataFrame.merge.
    compare_type : str | Callable, default = "continuous"
        Type of comparison to perform. If str, then must be one of {"continuous", "categorical", "probabilistic"}. If Callable, then must be a function that takes two xarray.DataArray or xarray.Dataset objects and returns a tuple of length 2. The first element of the tuple must be an xarray.DataArray or xarray.Dataset object representing the agreement map. The second element of the tuple must be a pandas.DataFrame object representing the metrics.
    compare_kwargs : dict, default = None
        Keyword arguments to pass to the compare_type function.
    agreement_map_field : str, default = None
        Column name to write agreement maps to. If None, then agreement maps will not be written to file.
    agreement_map_write_kwargs : dict, default = None
        Keyword arguments to pass to xarray.DataArray.rio.to_raster when writing agreement maps to file.

    Raises
    ------
    ValueError
        If map_ids is not str or Iterable of str.
        If compare_type is not str or Callable.
        If compare_type is str and not one of {"continuous", "categorical", "probabilistic"}.
    NotImplementedError
        If compare_type is "probabilistic".

    Returns
    -------
    pandas.DataFrame | dask.DataFrame
        Agreement catalog.
    """

    # unpack map_ids
    if isinstance(map_ids, str):
        candidate_map_ids, benchmark_map_ids = map_ids, map_ids
    elif isinstance(map_ids, Iterable):
        candidate_map_ids, benchmark_map_ids = map_ids
    else:
        raise ValueError("map_ids must be str or Iterable of str")

    # set merge_kwargs to empty dict if None
    if merge_kwargs is None:
        merge_kwargs = dict()

    # create agreement catalog
    agreement_catalog = candidate_catalog.merge(
        benchmark_catalog,
        how=how,
        on=on,
        left_on=left_on,
        right_on=right_on,
        suffixes=suffixes,
        **merge_kwargs,
    )

    def compare_row(
        row,
        compare_type: str | Callable,
        compare_kwargs: dict,
        open_kwargs: dict,
        agreement_map_field: str,
        agreement_map_write_kwargs: dict,
    ) -> Tuple[xr.DataArray | xr.Dataset, pd.DataFrame]:
        """Compares catalog and benchmark maps by rows"""

        def loadxr(map, open_kwargs):
            """load xarray object if not already"""
            return (
                map
                if isinstance(map, (xr.DataArray, xr.Dataset))
                else rxr_or(map, **open_kwargs)
            )

        # load maps
        candidate_map = loadxr(row[candidate_map_ids + suffixes[0]], open_kwargs)
        benchmark_map = loadxr(row[benchmark_map_ids + suffixes[1]], open_kwargs)

        # set compare_kwargs to empty dict if None
        if compare_kwargs is None:
            compare_kwargs = dict()

        # set agreement_map_write_kwargs to empty dict if None
        if agreement_map_write_kwargs is None:
            agreement_map_write_kwargs = dict()

        if isinstance(compare_type, str):
            if compare_type == "categorical":
                results = candidate_map.gval.categorical_compare(
                    benchmark_map, **compare_kwargs
                )

                # results is a tuple of length 3 or 4
                # agreement_map, crosstab_df, metrics_df, attrs_df = results
                # where attrs_df is optional
                agreement_map, metrics_df = results[0], results[2]

            elif compare_type == "continuous":
                results = candidate_map.gval.continuous_compare(
                    benchmark_map, **compare_kwargs
                )

                # results is a tuple of length 2 or 3
                # agreement_map, metrics_df, attrs_df = results
                # where attrs_df is optional
                agreement_map, metrics_df = results[:2]

            elif compare_type == "probabilistic":
                raise NotImplementedError(
                    "probabilistic comparison not implemented yet"
                )

            else:
                raise ValueError(
                    "compare_type of type str must be one of {'continuous', 'categorical', 'probabilistic'}"
                )

        elif isinstance(compare_type, Callable):
            agreement_map, metrics_df = compare_type(
                candidate_map, benchmark_map, **compare_kwargs
            )

        else:
            raise ValueError("compare_type must be str or Callable")

        # write agreement map to file
        if (agreement_map_field is not None) & isinstance(
            agreement_map, (xr.DataArray, xr.Dataset)
        ):
            if isinstance(row[agreement_map_field], (str, os.PathLike)):
                agreement_map.rio.to_raster(
                    row[agreement_map_field], **agreement_map_write_kwargs
                )

        return metrics_df

    # make kwargs for dask apply
    if isinstance(agreement_catalog, dd.DataFrame):
        dask_kwargs = {"meta": ("output", "f8")}
    else:
        dask_kwargs = {}

    # set open_kwargs to empty dict if None
    if open_kwargs is None:
        open_kwargs = dict()

    # apply compare_row to each row of agreement_catalog
    metrics_df = agreement_catalog.apply(
        compare_row,
        axis=1,
        **dask_kwargs,
        compare_type=compare_type,
        open_kwargs=open_kwargs,
        compare_kwargs=compare_kwargs,
        agreement_map_field=agreement_map_field,
        agreement_map_write_kwargs=agreement_map_write_kwargs,
    )

    def nested_merge(i, sub_df) -> pd.DataFrame:
        """Duplicated agreement row for each row in sub_df"""
        try:
            agreement_row = agreement_catalog.iloc[i].to_frame().T
        except NotImplementedError:
            agreement_row = agreement_catalog.loc[agreement_catalog.index == i]

        sub_df.index = [i] * len(sub_df)
        return agreement_row.join(sub_df)

    # merge agreement_catalog with metrics_df
    if isinstance(metrics_df, dd.Series):
        return dd.concat(
            [nested_merge(i, sub_df) for i, sub_df in enumerate(metrics_df)]
        ).reset_index(drop=True)

    if isinstance(metrics_df, pd.Series):
        return pd.concat(
            [nested_merge(i, sub_df) for i, sub_df in enumerate(metrics_df)]
        ).reset_index(drop=True)


from settings import *

# Import Data
df = pd.read_csv("data/mpg_ggplot2.csv")

# Draw Stripplot
fig, ax = plt.subplots(figsize=(16, 10), dpi=80)
sns.stripplot(df.cty, df.hwy, jitter=0.25, size=8, ax=ax, linewidth=.5)

# Decorations
plt.title('Use jittered plots to avoid overlapping of points', fontsize=22)
plt.show()


# -*- coding: utf-8 -*-
# (c) 2015 Alfredo de la Fuente - AvanzOSC
# License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html
from openerp import models, fields, api
from dateutil.relativedelta import relativedelta

class ProcurementOrder(models.Model):
    _inherit = 'procurement.order'

    @api.multi
    def _compute_protect_date_planned(self):
        for proc in self:
            proc.protect_date_planned = False
            if (proc.purchase_line_id and
                    proc.purchase_line_id.order_id.state != 'draft'):
                proc.protect_date_planned = True

    plan = fields.Many2one('procurement.plan', string='Plan')
    location_type = fields.Selection([
        ('supplier', 'Supplier Location'),
        ('view', 'View'),
        ('internal', 'Internal Location'),
        ('customer', 'Customer Location'),
        ('inventory', 'Inventory'),
        ('procurement', 'Procurement'),
        ('production', 'Production'),
        ('transit', 'Transit Location')],
        string='Location Type', related="location_id.usage", store=True)
    protect_date_planned = fields.Boolean(
        string='Protect Date Planned', compute='_compute_protect_date_planned')

    @api.model
    def create(self, data):
        if 'plan' in self.env.context and 'plan' not in data:
            data['plan'] = self.env.context.get('plan')
        procurement = super(ProcurementOrder, self).create(data)
        return procurement

    @api.multi
    def button_remove_plan(self):
        self.ensure_one()
        template_obj = self.env['product.template']
        result = template_obj._get_act_window_dict(
            'procurement_plan.action_procurement_plan')
        result['domain'] = "[('id', '=', " + str(self.plan.id) + ")]"
        result['res_id'] = self.plan.id
        result['view_mode'] = 'form'
        result['views'] = []
        self.plan.write({'procurement_ids': [[3, self.id]]})
        return result

    @api.multi
    def button_run(self, autocommit=False):
        for procurement in self:
            procurement.with_context(plan=procurement.plan.id).run(
                autocommit=autocommit)
            procurement.plan._get_state()
        plans = self.mapped('plan')
        if not plans:
            return True
        res = {'view_type': 'form,tree',
               'res_model': 'procurement.plan',
               'view_id': False,
               'type': 'ir.actions.act_window',
               }
        if len(plans) == 1:
            res.update({'view_mode': 'form',
                        'res_id': plans[0].id,
                        'target': 'current'})
        else:
            res.update({'view_mode': 'tree',
                        'domain': [('id', 'in', plans.ids)],
                        'target': 'new'})
        return res

    @api.multi
    def button_check(self, autocommit=False):
        for procurement in self:
            procurement.with_context(plan=procurement.plan.id).check(
                autocommit=autocommit)
            procurement.plan._get_state()
        plans = self.mapped('plan')
        if not plans:
            return True
        if not plans:
            return True
        res = {'view_type': 'form,tree',
               'res_model': 'procurement.plan',
               'view_id': False,
               'type': 'ir.actions.act_window',
               }
        if len(plans) == 1:
            res.update({'view_mode': 'form',
                        'res_id': plans[0].id,
                        'target': 'current'})
        else:
            res.update({'view_mode': 'tree',
                        'domain': [('id', 'in', plans.ids)],
                        'target': 'new'})
        return res

    @api.multi
    def cancel(self):
        super(ProcurementOrder, self).cancel()
        for procurement in self:
            if procurement.plan:
                procurement.plan._get_state()
        plans = self.mapped('plan')
        if not plans:
            return True
        if not plans:
            return True
        res = {'view_type': 'form,tree',
               'res_model': 'procurement.plan',
               'view_id': False,
               'type': 'ir.actions.act_window',
               }
        if len(plans) == 1:
            res.update({'view_mode': 'form',
                        'res_id': plans[0].id,
                        'target': 'current'})
        else:
            res.update({'view_mode': 'tree',
                        'domain': [('id', 'in', plans.ids)],
                        'target': 'new'})
        return res

    @api.multi
    def reset_to_confirmed(self):
        super(ProcurementOrder, self).reset_to_confirmed()
        for procurement in self:
            if procurement.plan:
                procurement.plan._get_state()
        plans = self.mapped('plan')
        if not plans:
            return True
        if not plans:
            return True
        res = {'view_type': 'form,tree',
               'res_model': 'procurement.plan',
               'view_id': False,
               'type': 'ir.actions.act_window',
               }
        if len(plans) == 1:
            res.update({'view_mode': 'form',
                        'res_id': plans[0].id,
                        'target': 'current'})
        else:
            res.update({'view_mode': 'tree',
                        'domain': [('id', 'in', plans.ids)],
                        'target': 'new'})
        return res

    @api.multi
    def _change_date_planned_from_plan_for_po(self, days_to_sum):
        for proc in self:
            new_date = (fields.Datetime.from_string(proc.date_planned) +
                        (relativedelta(days=days_to_sum)))
            proc.write({'date_planned': new_date})
            if (proc.purchase_line_id and
                    proc.purchase_line_id.order_id.state == 'draft'):
                proc.purchase_line_id.write({'date_planned': new_date})


import pygame, sys, operator, random, time
from pygame.locals import *

# Global variables
WIDTH = 800
HEIGHT = 500
SUB_SPEED = 3
BUBBLE_MAX_SPEED = 1
TIME_LIMIT = 30
BONUS_SCORE = 1500

BLACK = (0, 0, 0)
BLUE = (12,34,56)
RED = (255,0,0)
WHITE = (255,255,255)

x_sub = 40
y_sub = 250
score = 0
game_end = time.time() + TIME_LIMIT
bonus = 0

# bubbles_id = list()
bubbles_pos = list()
bubbles_speed = list()
bubbles_state = list()
bubbles_size = list()

# Quit the game
def leave_game():
    pygame.display.quit()
    pygame.quit()
    sys.exit()

# Update the screen display
def update_screen ():
    screen.blit(background_image, (0,0))
    screen.blit(sub, (x_sub, y_sub))

    for i in range(len(bubbles_pos) - 1, -1, -1):
        if bubbles_state[i] == "Good":
            screen.blit(pygame.transform.scale(blue_bubble, (bubbles_size[i], bubbles_size[i])), bubbles_pos[i])
        else:
            screen.blit(pygame.transform.scale(bad_bubble, (bubbles_size[i], bubbles_size[i])), bubbles_pos[i])

    message = "Score : " +  str(score)
    display_text (message, BLACK, 'Calibri', 20, 10, 15)

    # print ("Time : ", int(game_end - time.time()))

    message = "Time : " +  str(int(game_end - time.time()))
    display_text (message, BLACK, 'Calibri', 20, 700, 15)

    pygame.display.flip()

# Move the submarine on the scene
def sub_control():
    global x_sub, y_sub

    key = pygame.key.get_pressed()

    if key[pygame.K_RIGHT]:
        x_sub += SUB_SPEED

    if key[pygame.K_LEFT]:
        x_sub -= SUB_SPEED

    if key[pygame.K_UP]:
        y_sub -= SUB_SPEED

    if key[pygame.K_DOWN]:
        y_sub += SUB_SPEED

    sub_in_scene()

# Check if the sub is still on the visible part of the screen
def sub_in_scene():
   global x_sub, y_sub

   if x_sub < 0:
      x_sub = 0

   if y_sub < 0:
      y_sub = 0

   if x_sub + sub.get_width() > WIDTH:
      x_sub = WIDTH - sub.get_width()

   if y_sub + sub.get_height() > HEIGHT:
      y_sub = HEIGHT - sub.get_height()

# Create many bubbles
def create_bubbles(state) :

    x_bubble = WIDTH
    y_bubble = random.randint(0, HEIGHT)

    if state == "Good":
        #bubble = pygame.image.load("Ressources/bulle.png")
        size_bubble = random.randint(blue_bubble.get_width() / 3, blue_bubble.get_width() * 2)
    else:
        #bubble = pygame.image.load("Ressources/red_bulle.png")
        size_bubble = random.randint(bad_bubble.get_width(), bad_bubble.get_width() * 3)

    # bubble = pygame.transform.scale (bubble, (size_bubble, size_bubble))

    # bubbles_id.append(bubble)
    bubbles_pos.append((x_bubble, y_bubble))
    bubbles_speed.append(random.randint(1, BUBBLE_MAX_SPEED))
    bubbles_state.append(state)
    bubbles_size.append(size_bubble)

# Move the bubble on the screen at set speed
def move_bubbles():
    for i in range (len(bubbles_pos) - 1, -1, -1) :
        bubbles_pos[i] = tuple(map(operator.sub, bubbles_pos[i], (bubbles_speed[i], 0)))

# Update bubble position
def update_game():

    global bonus, game_end

    if (random.randint(1, 20) == 1):
        create_bubbles("Good")

    if (random.randint(1, 60) == 1):
        create_bubbles("Bad")

    collision()

    if (int(score / BONUS_SCORE)) > bonus:
        bonus += 1
        game_end += TIME_LIMIT

    move_bubbles()
    clean_bubbles()

# Collision between the sub and the bubbles
def collision () :

    global score, game_end

    for bubble in range(len(bubbles_pos) -1, -1, -1):
      if (x_sub < bubbles_pos[bubble][0] + bubbles_size[bubble]
          and x_sub + sub.get_width() > bubbles_pos[bubble][0]
          and y_sub < bubbles_pos[bubble][1] + bubbles_size[bubble]
          and y_sub + sub.get_height() > bubbles_pos[bubble][1]) :
        # print ("La bulle ", bubble, "se superpose au sous-marin")
        print("etat de la bulle : ", bubbles_state[bubble])
        if bubbles_state[bubble] == "Good":
            score += bubbles_size[bubble] + bubbles_speed[bubble]
        else:
            game_end -= 5

        # print ("points : ", score)
        pop_sound.play(0)
        delete_bubble (bubble)

# Delete Bubble when it collides with the submarine
def delete_bubble (bubble):
    del bubbles_state[bubble]
    del bubbles_speed[bubble]
    del bubbles_pos[bubble]
    del bubbles_size[bubble]
    # del bubbles_id[bubble]

# Remove bubbles who leave the screen
def clean_bubbles ():
    for i in range (len(bubbles_pos) - 1, -1, -1) :
        if (bubbles_pos[i][0] + bubbles_size[i] < 0) :
            delete_bubble(i)

# Display colored text in position X and Y
def display_text(text, color, font, font_size, x, y):

    myfont = pygame.font.SysFont(font, font_size, True)
    message = myfont.render(text, True, color)

    screen.blit(message, (x,y))

# Game Over Screen
def game_over_message():

    pygame.mixer.stop()

    lose_sound.play(0)

    screen.fill(BLUE)

    display_text("GAME OVER !", RED, 'Calibri', 40, WIDTH * 0.4, HEIGHT * 0.2 )

    message = "Ton Score : " +  str(score)
    display_text(message, RED, 'Calibri', 40, WIDTH * 0.37, HEIGHT * 0.4 )

    display_text("Appuie sur R pour rejouer !", WHITE, 'Calibri', 30, WIDTH * 0.33, HEIGHT * 0.6)

# Initialize game variables when restart
def init_game():
    global score, x_sub, y_sub, game_end, bubbles_pos, bubbles_size, bubbles_speed, bubbles_state

    game_end = time.time() + TIME_LIMIT
    score = 0
    x_sub = 40
    y_sub = 250

    # bubbles_id = list()
    bubbles_pos = list()
    bubbles_size = list()
    bubbles_speed = list()
    bubbles_state = list()

# Window Init
pygame.init()

# Display creation
screen = pygame.display.set_mode ((WIDTH, HEIGHT))

# Set the repetition rate of the key
pygame.key.set_repeat(1, 1)

# Window Name
pygame.display.set_caption("Bubble Blaster")

# The Background image
background_image = pygame.image.load("Ressources/ocean.jpg")

# The submarine
sub = pygame.image.load("Ressources/submarine.png")

# The bubble
blue_bubble = pygame.image.load("Ressources/blue_bubble.png")
bad_bubble = pygame.image.load("Ressources/red_bubble.png")

pop_sound = pygame.mixer.Sound("Ressources/collect.wav")
ambient_sound = pygame.mixer.Sound("Ressources/ambient_music.wav")
lose_sound = pygame.mixer.Sound("Ressources/lose.wav")
ambient_sound.set_volume(0.05)

#create_bubble()

# Main loop
while True:

    pygame.mixer.stop()

    ambient_sound.play(-1)

    # Time loop
    while time.time() < game_end:
    #    move_bubble()
        update_game()
        update_screen()

        # Main event loop
        for event in pygame.event.get() :
            if event.type == pygame.QUIT:
                leave_game()
        sub_control()

    game_over_message()
    pygame.display.flip()

    restart = False

    while not restart:

        # Event Manager Loop
        for event in pygame.event.get() :
            if event.type == pygame.QUIT:
                leave_game()
            if not hasattr (event, 'key'):
                continue
            if event.key == K_r:
                restart = True
                init_game()
##            if event.key == K_ESCAPE:
##                leave_game()


# CSC_120 Logbook : Pg 9, Exercise 4

# Start Program

# Variable declaration and initialization

v = 512
w = 282
x = 47.48
y = 5

# Calculation phase
z = (v - w) / (x + y)

# Outputs the result of the computation
print("The result of the computation is : ", z)

# End Program


import os.path
import math
import numpy
import json
import bz2

import platereader
from platereader.replicate import Replicate
from platereader.statusmessage import StatusMessage, Severity
from platereader.csvunicode import CsvFileUnicodeWriter, CsvFileUnicodeReader
from platereader.parser import tecan, bioscreen

class Plate(object):
    """
    Class containing the wells and holding plate-wide parameters.
    """

    _parser2module = platereader.parser.modulenameToModule(
        list(platereader.parser.getModulesOfNamespace(platereader.parser)),
        replace='platereader.parser.',
        lower=True)

    _isNotPlateParameter={
        'allowMaxGrowthrateAtLowerCutoff': True,
        'allowGrowthyieldSlopeNStderrAwayFromZero': True,
        }

    def __init__(self,filename=None,fileformat=None,
                 time=None,rawOds=None,
                 sampleIds=None,conditions=None,wellids=None,plateId=None):
        """
        Constructor.

        If filename is not None and fileformat is None some heuristics
        are used to identify the file format.

        :param filename: name of serialised Plate or ascii file exported by the plate reader.
        :type filename: str

        :param fileformat: string indicating the format ('gat', 'tecan')
        :type fileformat: str

        :param time: array of timepoints when optical density was measured
        :type time: numpy.array(float)

        :param rawOds: list of optical density arrays
        :type rawOds: list( numpy.array(float) )

        :param sampleIds: list of sample names corresponding to the array of optical densities
        :type sampleIds: list(str)

        :param conditions: list of conditions under which the samples where grown
        :type conditions: list(str)

        :param plateId: name of this plate
        :type plateId: str
        """
        self.plateId=None
        self._rawOd=None
        self.wells=None
        self.time=None
        self.temperature=None
        self.timeunit=None
        self._inheritableParameters={}
        # default parameters
        self._inheritableParameters['maxGrowthLowerTimeCutoff']=None
        self._inheritableParameters['maxGrowthUpperTimeCutoff']=None
        self._inheritableParameters['allowMaxGrowthrateAtLowerCutoff']=False
        self._inheritableParameters['allowGrowthyieldSlopeNStderrAwayFromZero']=1
        # pure plate parameters
        self._inheritableParameters['logOdCutoff']=None
        self._inheritableParameters['lagAtLogOdEquals']=-5
        self._inheritableParameters['slidingWindowSize']=10
        self._inheritableParameters['hdCorrectionLinear']=None
        self._inheritableParameters['hdCorrectionQuadratic']=None
        self._inheritableParameters['hdCorrectionCubic']=None
        self._inheritableParameters['smoothingK']=5
        self._inheritableParameters['smoothingS']=0.01
        self._loadStatus=StatusMessage()
        self._capitaliseBackgroundIds=['blank','background']
        self._clearMetadata()
        if filename is not None:
            if not os.path.exists(filename):
                raise IOError("No such file or directory: '"+filename+"'")
            if fileformat is None:
                if filename.endswith('.gat'):
                    fileformat='gat'
                else:
                    scorefileformat=[]
                    for fileformat in Plate._parser2module:
                        score=Plate._parser2module[fileformat].isPlateFormat(filename)
                        if score > 0.:
                            scorefileformat.append({'score': score, 'fileformat': fileformat})
                    scorefileformat = sorted(scorefileformat, key=lambda k: k['score'],reverse=True)
                    if not len(scorefileformat):
                        raise Plate.UnknownFileFormat(filename,detailedError='Cannot determine file format')
                    fileformat=scorefileformat[0]['fileformat']
            if fileformat == 'gat':
                self._load(filename)
            elif fileformat in Plate._parser2module:
                time, rawOd, sampleIds, conditions, plateId, temperature, wellids=Plate._parser2module[fileformat].parse(filename)
                self._initFromArrays(time,rawOd,sampleIds,conditions,plateId=plateId,temperature=temperature,wellids=wellids)
            else:
                raise Plate.UnknownFileFormat(filename,serFormat=fileformat)
            self.readfileformat=fileformat
        elif rawOds is not None:
            self._initFromArrays(time,rawOds,sampleIds,conditions,plateId=plateId,wellids=wellids)
        else:
            raise RuntimeError('could not construct Plate, neither filename nor arrays given')
        self.modified=False

    def _clearReplicateGroups(self):
        if hasattr(self,'replicateGroups'):
            for tc in self.replicateGroups:
                # NOTE invalidating here so code holding references to these fails
                tc._invalidate()

        self.replicateGroups=None
        self._backgroundGroupIndices=None
        self._sampleConditionToReplicateGroupIdcs=None # an associative array mapping replicate groups by sample ID to a list of Replicate object indices
        self._conditionToReplicateGroupIdx=None  # an associative array mapping condition to a list of replicate group object indices

    def _clearMetadata(self):
        self._clearReplicateGroups()
        self._setBackgroundForAllReplicates(None)
        self._conditionToWellIdx=None # an associative array mapping condition to a list of Replicate objects
        self._sampleConditionToWellIdcs=None # an associative array mapping wells (sample IDs) to a list of Replicate object indices

    def _load(self,filename):
        with bz2.BZ2File(filename, 'r') as rfile:
            pickled=rfile.read().decode("utf-8")

        try:
            unpickled = json.loads(pickled)
        except ValueError as err:
            raise Plate.UnknownFileFormat(filename,detailedError=str(err))
        return self._deserialise(unpickled,filename)

    def _deserialise(self,unpickled,filename):
        if 'format' not in unpickled:
            raise Plate.UnknownFileFormat(filename,detailedError='no "format" keyword found in file')

        serFormatVersion=unpickled['formatversion'] if 'formatversion' in unpickled else 'undefined'
        if unpickled['format'] != 'opticaldensityplate' or serFormatVersion != '1':
            raise Plate.UnknownFileFormat(filename,serFormat=unpickled['format'],serFormatVersion=serFormatVersion)

        parkeys=[
            # default parameters
            'maxGrowthLowerTimeCutoff',
            'maxGrowthUpperTimeCutoff',
            'allowMaxGrowthrateAtLowerCutoff',
            'allowGrowthyieldSlopeNStderrAwayFromZero',
            # pure plate parameters
            'logOdCutoff',
            'lagAtLogOdEquals',
            'slidingWindowSize',
            'hdCorrectionLinear',
            'hdCorrectionQuadratic',
            'hdCorrectionCubic',
            'smoothingK',
            'smoothingS'
            ]

        # reset these to make sure defaults given to constructor are not used for serialised plate
        for par in self._inheritableParameters:
            self._inheritableParameters[par]=None

        self.plateId=unpickled['plateId']
        self.time=numpy.array(unpickled['time'],dtype=float)
        self.timeunit=unpickled['timeunit']
        # defaut parameters, some of which can be overridden by the individual replicates
        for par in parkeys:
            self._inheritableParameters[par]=unpickled[par]

        if 'temperature' in unpickled:
            self.temperature=numpy.array(unpickled['temperature'],dtype=float)
        self._rawOd=[]
        for lst in unpickled['rawOd']:
            self._rawOd.append(numpy.array(lst,dtype=float))
        self.wells=[]
        for tcup in unpickled['wells']:
            self.wells.append(Replicate(_unpickled=tcup,parentPlate=self,_serialiseFormat=unpickled['format']))
        self.replicateGroups=[]
        for tcup in unpickled['replicateGroup']:
            comptc=Replicate(_unpickled=tcup,parentPlate=self,_serialiseFormat=unpickled['format'],isReplicateGroup=True)
            self.replicateGroups.append(comptc)
            # set parental replicate group of the children
            for childtc in comptc.childWells():
                childtc._setReplicateGroupParent(comptc)

        # deferred to here: set the background index
        for tc in self.wells:
            if tc._tmp_backgroundIndex is not None:
                tc._setBackgroundIndex(tc._tmp_backgroundIndex)
        for tc in self.replicateGroups:
            if tc._tmp_backgroundIndex is not None:
                tc._setBackgroundIndex(tc._tmp_backgroundIndex)
        # reset background indices, as these have been initialised
        # before setting the replicate's backgrounds
        self._backgroundWellIndices=None
        self._backgroundGroupIndices=None

        self._setBackgroundStatus()

    def _serialise(self):
        """
        Generates a dictionary of the plate data and parameters.

        For internal use only.
        """
        parkeys=[
            # default parameters
            'maxGrowthLowerTimeCutoff',
            'maxGrowthUpperTimeCutoff',
            'allowMaxGrowthrateAtLowerCutoff',
            'allowGrowthyieldSlopeNStderrAwayFromZero',
            # pure plate parameters
            'logOdCutoff',
            'lagAtLogOdEquals',
            'slidingWindowSize',
            'hdCorrectionLinear',
            'hdCorrectionQuadratic',
            'hdCorrectionCubic',
            'smoothingK',
            'smoothingS'
            ]
        sr=dict()
        sr["format"]='opticaldensityplate'
        sr["formatversion"]='1' # this is an unsigned integer
        sr['plateId']=self.plateId
        sr['time']=self.time.tolist()
        sr['timeunit']=self.timeunit
        for key in parkeys:
            sr[key]=self._inheritableParameters[key]
        if self.temperature is not None:
            sr['temperature']=self.temperature.tolist()
        sr['rawOd']=[]
        for raw in self._rawOd:
            sr['rawOd'].append(raw.tolist())
        sr['wells']=[]
        for tc in self.wells:
            sr['wells'].append(tc._serialise())
        sr['replicateGroup']=[]
        for tc in self.replicateGroups:
            sr['replicateGroup'].append(tc._serialise())

        return sr

    def save(self,filename):
        """
        Saves the plate content in a file.

        :param filename: Name of the file.
        :type filename: str

        :return: StatusMessage/None -- non-fatal notifications.
        """
        status=None
        if not filename.endswith('.gat'):
            root, ext = os.path.splitext(filename)
            status=StatusMessage(
                key='Saving file',shortmsg='wrongExtension',
                longmsg=('GATHODE uses a file extension that is different from"'+ext+'". '
                         +'This means that a future version of this program will not be able to open this file with the graphical user interface. '
                         +'Please make save the file with the ".gat" extension.'),
                severity=Severity.warning)
        sr=self._serialise()
        pickled = json.dumps(sr)
        with bz2.BZ2File(filename, 'w') as wfile:
            wfile.write(pickled.encode('utf-8'))
        self.modified=False
        return status

    def _explicitlySetParsInChildWells(self,par):
        """
        Explicitly set parameters in wells to their inherited values.

        This can be used when replicate groups get removed
        (e.g. setting new metadata) but the parameters should be
        preserved. You most likely want to call
        _reduceExplicitParameter once new replicate groups have been
        created.

        For internal use only.
        """
        for tc in self.replicateGroups:
            for child in tc.childWells():
                # copy parameters from replicate group to the child
                child._setExplicitParameter(par,child.getParameter(par))

    def _reduceExplicitParameter(self,par):
        """
        Sets the parameter par of self and wells such that it is shared by most of its children.

        For internal use only.

        :param par: the parameter for which a smaller set of values is created
        :type par: string
        """
        # check what could be the plate default for this parameter
        parvals=Plate._getChildParvalOccurrence(self,par)
        platedefault=Plate._chooseDefaultFromOccurrences(parvals)
        # set parameters in replicate groups; if one of a groups's children has the same value
        # as the platedefault use that one, otherwise try find another value for the group
        for tc in self.replicateGroups:
            Plate._reduceExplicitParametersHelper(tc,par,platedefault)
        # now set the plate default (important: this has to be done *after* the Replicates are changed!)
        Plate._reduceExplicitParametersHelper(self,par,platedefault)

        return platedefault

    @staticmethod
    def _reduceExplicitParametersHelper(obj,par,parentdefault):
        """
        Helper function for _reduceExplicitParameter

        For internal use only.

        :param obj: the parameter for that a smaller set of values is created
        :type obj: Plate/Replicates
        :param par: the parameter for that a smaller set of values is created
        :type par: string

        Will be called with plate and Replicate objects.
        """
        # gather occurrence of each value for this parameter in children
        parvals=Plate._getChildParvalOccurrence(obj,par)
        # the value that occurs most often will become the replicate group's value
        newdefaultval=Plate._chooseDefaultFromOccurrences(parvals,parentdefault)
        # only if none of the children got value None we can copy values up
        if newdefaultval is None:
            return
        # delete consensus value from children
        for child in Plate._getChildren(obj):
            if newdefaultval == child.getParameter(par):
                child._setExplicitParameter(par,None)
        # set consensus value for replicate group parent
        obj._setExplicitParameter(par,newdefaultval)

    @staticmethod
    def _getChildParvalOccurrence(obj,par):
        """
        Return count of parameter values of all leaf children (at the lowest level of the hierarchy).

        For internal use only.

        :return: dict -- { value1: countValue1, value2: countValue2, ...}
        """
        if isinstance(obj, Replicate) and not obj.isReplicateGroup():
            # this is a single well
            val=obj.getParameter(par)
            return {val: 1}
        else:
            parvals={}
            for child in Plate._getChildren(obj):
                childparvals=Plate._getChildParvalOccurrence(child,par)
                # assemble childrens' results into the main dictionary
                for val in childparvals:
                    if val not in parvals:
                        parvals[val]=0
                    parvals[val]+=childparvals[val]
            return parvals

    @staticmethod
    def _chooseDefaultFromOccurrences(parvals,parentdefault=None):
        """
        Return the value of a parameter that occurs most often in leaf children.

        For internal use only.

        Can be called both without parentdefault (for the whole plate)
        and with parentdefault (for ReplicateGroups).

        :param parvals: output from _getChildParvalOccurrence
        :type parvals: dict

        :return: float -- the most occuring parameter value for this plate or ReplicateGroup
        """
        if None in parvals:
            return None
        maxcnt=0
        maxval=None
        parvalkeys=list(parvals.keys())
        parvalkeys.sort()
        for val in parvalkeys:
            # if there is a value corresponding to the plate default choose that one
            if parentdefault is not None and val == parentdefault:
                return parentdefault
            # choose maximal occurring as default
            if parvals[val] > maxcnt:
                maxval=val
                maxcnt=parvals[val]

        return maxval

    @staticmethod
    def _getChildren(obj):
        if isinstance(obj, Plate):
            # this is a plate
            return obj.replicateGroups
        else:
            # this is a replicate group
            return obj.childWells()

    @staticmethod
    def capitaliseId(sampleId,capitaliseThese):
        """
        Capitalise id if in given list.

        :param sampleId: sample id; if this matches capitaliseThese it will be capitalised
        :type sampleId: str

        :param capitaliseThese: list of sample ids that correspond to samples that should be capitalised
        :type capitaliseThese: list(str)

        :return: str -- sample id (capitalised if it matches one of capitaliseThese)
        """
        for bgid in capitaliseThese:
            if sampleId.upper() == bgid.upper():
                return bgid.upper()
        return sampleId

    def _initFromArrays(self,time,rawOd,sampleIds,conditions,plateId=None,temperature=None,wellids=None):
        """
        Initialises a plate from numpy arrays.

        For internal use only.

        :param time: array of timepoints when optical density was measured
        :type time: numpy.array(float)

        :param rawOd: list of optical density arrays
        :type rawOd: list( numpy.array(float) )

        :param sampleIds: list of sample names corresponding to the array of optical densities
        :type sampleIds: list(str)

        :param conditions: list of conditions under which the samples where grown
        :type conditions: list(str)

        :param plateId: name of this plate
        :type plateId: str

        :param temperature: array of the temperature
        :type time: numpy.array(float)

        :param wellids: array of ids for the wells (e.g. A1 to P24)
        :type wellids: list(str)
        """

        if len(rawOd) != len(sampleIds):
            raise RuntimeError('number of raw optical density arrays is different from number of sample ids')
        if len(sampleIds) != len(conditions):
            raise RuntimeError('number of sample ids is different from number of conditions')
        if wellids is not None and len(wellids) != len(set(wellids)):
            raise RuntimeError('ids in wellids are not unique')

        self.plateId=plateId

        self.time=time/3600.
        self.timeunit="h"

        self._rawOd=rawOd

        # make sure that background is correctly identified even if case is different
        newSampleIds=[]
        for sampleid in sampleIds:
            newSampleIds.append(Plate.capitaliseId(sampleid,self._capitaliseBackgroundIds))

        # create replicate objects for single wells from data (NOTE ids may exist multiple times, therefore this is not an associative array)
        self.wells=[]
        tcidx=0
        for sampleid in newSampleIds:
            wellid = [wellids[tcidx]] if wellids is not None else None
            self.wells.append(Replicate(self,[tcidx],sampleid,conditions[tcidx],wellid))
            # NOTE that on purpose this index is only increased for samples (not for time, temperature, ...)
            tcidx+=1

        self._createReplicateGroupsFromSampleIdsNConditions()
        # use guessed background sampleIds to set background of single well and replicate groups
        self._setBackgroundForAllReplicates(self._guessBackgroundSampleIds())

    def wellMetadataOk(self,metadata):
        """
        Check that the given metadata (i.e. sample id, growth condition) is valid and can be applied.

        This basically checks that there is the right amount of
        metadata entries and these contain sample ids and conditions.

        :param metadata: array of metadata dictionaries
        :type metadata: list(dict)

        :return: bool, StatusMessage -- True if ok, False otherwise (and a StatusMessage with details)
        """

        if len(metadata) != len(self.wells):
            return False, StatusMessage(
                key='Wrong metadata length:',shortmsg='metadata:wrongLength',
                longmsg=('Number of metadata entries ('+str(len(metadata))+
                         ') is different from number of wells '+str(len(self.wells))),
                severity=Severity.failed)

        idx=0
        for metdat in metadata:
            idx+=1
            if len(metdat.keys()) != 2 or 'sample' not in metdat or 'condition' not in metdat:
                thekeys='"'+('" "'.join(sorted(metdat.keys())))+'"' if len(metdat.keys()) else 'nothing'
                return False, StatusMessage(
                    key='Wrong metadata elements:',shortmsg='metadata:wrongLength',
                    longmsg=('metadata for entry '+str(idx)+' contains '+thekeys+
                             ', but should contain "condition" and "sample"'),
                    severity=Severity.failed)

        return True, StatusMessage()

    def setWellMetadata(self,metadata):
        """
        Set the metadata (e.g. sample id, growth condition) of the wells.

        :param metadata: array of metadata dictionaries
        :type metadata: list(dict)
        """
        metok, message = self.wellMetadataOk(metadata)
        if not metok:
            raise Plate.BadMetadata(str(message))

        # propagate parameters to the wells before deleting replicate groups
        for par in self.wells[0]._inheritableParameters.keys():
            self._explicitlySetParsInChildWells(par)

        # clear everything that depends on metadata
        self._clearMetadata()

        # set metadata of the wells
        wellit=self.wells.__iter__()
        for metdat in metadata:
            metdat['sample']=Plate.capitaliseId(metdat['sample'],self._capitaliseBackgroundIds)
            well=next(wellit)
            well._setMetadata(metdat)

        # create replicate groups based on sample ids and conditions
        self._createReplicateGroupsFromSampleIdsNConditions()
        # use guessed background sampleIds to set background of single well and replicate groups
        self._setBackgroundForAllReplicates(self._guessBackgroundSampleIds())

        # propagate parameters from the wells to the replicate groups (or plate) if possible
        for par in self.wells[0]._inheritableParameters.keys():
            self._reduceExplicitParameter(par)

    def wellMetadata(self):
        """
        Return the metadata of the wells.

        :return: list(dict) -- metadata
        """
        metadata=[]
        for well in self.wells:
            metadata.append(well._getMetadata())
        return metadata

    def _setupBackgroundIndices(self):
        """
        Set self._backgroundGroupIndices and self._backgroundWellIndices.

        Records the indices of tc.background (which are rpelicate
        groups) for all wells and replicateGroups and also the indices
        of the underlying background wells.

        For internal use only.
        """
        self._backgroundGroupIndices=set()
        self._backgroundWellIndices=set()

        if self.wells:
            for tc in self.wells:
                if tc.background:
                    self._backgroundGroupIndices.add(self._indexOfReplicateGroup(tc.background))
        if self.replicateGroups:
            for tc in self.replicateGroups:
                if tc.background:
                    self._backgroundGroupIndices.add(self._indexOfReplicateGroup(tc.background))

        for idx in self._backgroundGroupIndices:
            for chldidx in self.replicateGroups[idx].childWellIndices():
                self._backgroundWellIndices.add(chldidx)

    def _guessBackgroundSampleIds(self):
        """
        Guess sample ids of background wells ("BLANK" or "BACKGROUND")

        For internal use only.
        """
        backgroundKeys={}
        for tc in self.wells:
            if tc.sampleid == "BLANK" or tc.sampleid == "BACKGROUND":
                backgroundKeys[tc.sampleid]=1

        backgroundSampleIds=sorted(list(backgroundKeys.keys()))
        return backgroundSampleIds

    def _setBackgroundStatus(self):
        """
        Add conditions/samples for which no background was found to self._loadStatus

        This should be called when the background was set for some wells/replicate groups.

        For internal use only.
        """
        self._loadStatus.removeStatusesWithKey('No background samples:')
        self._loadStatus.removeStatusesWithKey('No background for some samples:')

        backgroundSampleIds=set()
        for idx in self.backgroundReplicateGroupIndices():
            backgroundSampleIds.add(self.replicateGroups[idx].sampleid)
        for idx in self.backgroundWellIndices():
            backgroundSampleIds.add(self.wells[idx].sampleid)

        if len(backgroundSampleIds) < 1:
            self._loadStatus.addStatus(
                StatusMessage(
                    key='No background samples:',shortmsg='plateinit:noBackground',
                    longmsg=('No background (blank) wells could be identified.'+
                             ' This means no growth parameters will be extracted'),
                    severity=Severity.warning)
                )
            return

        noBackground={}
        for tc in self.nonBackgroundWells():
            if tc.background is None:
                if tc.condition not in noBackground:
                    noBackground[tc.condition]={}
                if tc.sampleid not in noBackground[tc.condition]:
                    noBackground[tc.condition][tc.sampleid]=[]
                noBackground[tc.condition][tc.sampleid].append(tc)

        for tc in self.nonBackgroundReplicates():
            if tc.background is None:
                if tc.condition not in noBackground:
                    noBackground[tc.condition]={}
                if tc.sampleid not in noBackground[tc.condition]:
                    noBackground[tc.condition][tc.sampleid]=[]
                noBackground[tc.condition][tc.sampleid].append(tc)

        if len(noBackground.keys()):
            affected=''
            for condition in sorted(noBackground):
                if condition is None or condition == '':
                    affected+='no condition:'
                else:
                    affected+=condition+':'
                for sampleid in sorted(noBackground[condition]):
                    affected+=' '+sampleid
                affected+='\n'
            self._loadStatus.addStatus(
                StatusMessage(
                    key='No background for some samples:',shortmsg='plateinit:noBackgroundForSomeSamples',
                    longmsg=('For some conditions no background (blank) could be identified.'+
                             ' This means no growth parameters will be extracted. The affected samples are:\n'+
                             affected),
                    severity=Severity.warning)
                )

    def backgroundReplicateGroupIndices(self):
        """
        Return indices into self.replicateGroups for replicate groups being listed as background.

        :return: list(int) -- indices of background replicate groups
        """
        if self._backgroundGroupIndices is None:
            self._setupBackgroundIndices()
        return self._backgroundGroupIndices

    def backgroundReplicateGroups(self):
        """
        Return replicate groups being listed as background.

        :return: list(Replicate) -- replicate groups listed as background
        """
        tcs=[]
        for idx in self.backgroundReplicateGroupIndices():
            tcs.append(self.replicateGroups[idx])
        return tcs

    def backgroundWellIndices(self):
        """
        Return indices into self.wells for wells being listed as background.

        :return: list(int) -- indices of background wells
        """
        if self._backgroundWellIndices is None:
            self._setupBackgroundIndices()
        return self._backgroundWellIndices

    def backgroundWells(self):
        """
        Return wells being listed as background.

        :return: list(Replicate) -- wells listed as background
        """
        tcs=[]
        for idx in self.backgroundWellIndices():
            tcs.append(self.wells[idx])
        return tcs

    def _createSampleConditionToWellIndices(self):
        """
        Create a mapping to quickly find single-well objects based on sample id and condition.

        For internal use only.
        """
        # gather sampleids and conditions
        self._conditionToWellIdx={}
        self._sampleConditionToWellIdcs={}
        tcidx=0
        for tc in self.wells:
            # add well to the condition mapping
            if tc.condition not in self._conditionToWellIdx:
                self._conditionToWellIdx[tc.condition]=[]
            self._conditionToWellIdx[tc.condition].append(tcidx)

            # add well to the replicate mapping (sampleid and condition)
            if tc.sampleid not in self._sampleConditionToWellIdcs:
                self._sampleConditionToWellIdcs[tc.sampleid]={}
            if tc.condition not in self._sampleConditionToWellIdcs[tc.sampleid]:
                self._sampleConditionToWellIdcs[tc.sampleid][tc.condition]=[]
            self._sampleConditionToWellIdcs[tc.sampleid][tc.condition].append(tcidx)

            tcidx+=1

    def _createReplicateGroupsFromSampleIdsNConditions(self):
        """
        Create replicate groups by grouping wells of the same sample id and condition.

        For internal use only.
        """
        if self._sampleConditionToWellIdcs is None:
            self._createSampleConditionToWellIndices()
        sampleids=list(self._sampleConditionToWellIdcs.keys())
        sampleids.sort()
        self.replicateGroups=[]
        for sampleid in sampleids:
            conditions=list(self._sampleConditionToWellIdcs[sampleid].keys())
            conditions.sort()
            for condition in conditions:
                comptc=Replicate(self,self._sampleConditionToWellIdcs[sampleid][condition],
                                                      None,condition,isReplicateGroup=True)
                self.replicateGroups.append(comptc)
                # set parental replicate group of the children
                for childtc in comptc.childWells():
                    childtc._setReplicateGroupParent(comptc)

    def _createSampleConditionToReplicateGroupIndices(self):
        """
        Create a mapping to quickly find replicate groups based on sample id and condition.

        For internal use only.
        """
        self._sampleConditionToReplicateGroupIdcs={}
        coidx=0
        for tc in self.replicateGroups:
            if tc.sampleid not in self._sampleConditionToReplicateGroupIdcs:
                self._sampleConditionToReplicateGroupIdcs[tc.sampleid]={}
            if tc.condition not in self._sampleConditionToReplicateGroupIdcs[tc.sampleid]:
                self._sampleConditionToReplicateGroupIdcs[tc.sampleid][tc.condition]=[]
            self._sampleConditionToReplicateGroupIdcs[tc.sampleid][tc.condition].append(coidx)
            coidx+=1

    def _createConditionToReplicateGroupIndices(self):
        """
        Create a mapping to quickly find all replicate groups for a specific condition.

        For internal use only.
        """
        self._conditionToReplicateGroupIdx={}
        coidx=0
        for tc in self.replicateGroups:
            # add replicate group to the condition mapping
            if tc.condition not in self._conditionToReplicateGroupIdx:
                self._conditionToReplicateGroupIdx[tc.condition]=[]
            self._conditionToReplicateGroupIdx[tc.condition].append(coidx)
            coidx+=1

    def _setBackgroundForAllReplicates(self,backgroundSampleIds):
        """
        Set background replicate group for single-wells and replicate groups.

        Currently, if there are multiple background ids, an exception is raised.

        For internal use only.
        """
        self._backgroundWellIndices=None
        self._backgroundGroupIndices=None

        if backgroundSampleIds is None or not len(backgroundSampleIds):
            if self.wells is not None:
                for tc in self.wells:
                    tc._setBackgroundIndex(None)
            if self.replicateGroups is not None:
                for tc in self.replicateGroups:
                    tc._setBackgroundIndex(None)
            self._setBackgroundStatus()
            return
        if len(backgroundSampleIds) > 1:
            raise Plate.MultipleBackgroundIdsError(backgroundSampleIds)
        backgroundSampleId=backgroundSampleIds[0]

        if self._sampleConditionToReplicateGroupIdcs is None:
            self._createSampleConditionToReplicateGroupIndices()

        # set background index for the single (non-averaged) wells
        for tc in self.wells:
            if tc.sampleid not in backgroundSampleIds:
                if tc.condition in self._sampleConditionToReplicateGroupIdcs[backgroundSampleId]:
                    # NOTE there should be only one element in self._sampleConditionToReplicateGroupIdcs[backgroundSampleId][tc.condition]
                    tc._setBackgroundIndex(self._sampleConditionToReplicateGroupIdcs[backgroundSampleId][tc.condition][0])

        # set background for replicate groups
        for tc in self.replicateGroups:
            if tc.sampleid not in backgroundSampleIds:
                if tc.condition in self._sampleConditionToReplicateGroupIdcs[backgroundSampleId]:
                    # NOTE there should be only one element in self._sampleConditionToReplicateGroupIdcs[backgroundSampleId][tc.condition]
                    tc._setBackgroundIndex(self._sampleConditionToReplicateGroupIdcs[backgroundSampleId][tc.condition][0])

        # append warnings to self._loadStatus if for some replicates no background was set
        self._setBackgroundStatus()

    def replicateGroupIdxForSampleCondition(self,sampleid,condition):
        """
        Return index of replicate group with the given sample Id and condition.

        :param sampleid: Id of the sample.
        :type sampleid: string
        :param condition: Condition under which the sample was grown.
        :type condition: string

        :return: int -- Index (into self.replicateGroups) of Replicate with given id and condition.
        """
        if self._sampleConditionToReplicateGroupIdcs is None:
            self._createSampleConditionToReplicateGroupIndices()
        if sampleid not in self._sampleConditionToReplicateGroupIdcs:
            return None
        if condition not in self._sampleConditionToReplicateGroupIdcs[sampleid]:
            return None
        if len(self._sampleConditionToReplicateGroupIdcs[sampleid][condition]) != 1:
            raise RuntimeError('more than one replicate group for '+sampleid+' '+condition)
        return self._sampleConditionToReplicateGroupIdcs[sampleid][condition][0]

    def replicateGroupForSampleCondition(self,sampleid,condition):
        """
        Return index of replicate group with the given sample Id and condition.

        :param sampleid: Id of the sample.
        :type sampleid: string
        :param condition: Condition under which the sample was grown.
        :type condition: string

        :return: Replicate -- replicate group with given id and condition.
        """
        idx=self.replicateGroupIdxForSampleCondition(sampleid,condition)
        if idx is None:
            return None
        return self.replicateGroups[idx]

    def replicateGroupIdcsForCondition(self,condition):
        """
        Return a list of indices of replicate groups with the given condition.

        :param condition: Condition under which the samples were grown.
        :type condition: string

        :return: list(int) -- Indices (into self.replicateGroups) of replicate groups with the given condition.
        """
        if self._conditionToReplicateGroupIdx is None:
            self._createConditionToReplicateGroupIndices()
        if condition not in self._conditionToReplicateGroupIdx:
            return None
        return self._conditionToReplicateGroupIdx[condition]

    def replicateGroupsForCondition(self,condition):
        """
        Return a list of replicate groups with the given condition.

        :param condition: Condition under which the samples were grown.
        :type condition: string

        :return: list(Replicate) -- Replicate groups with given condition.
        """
        idcs=self.replicateGroupIdcsForCondition(condition)
        if idcs is None:
            return None
        tcs=[]
        for idx in idcs:
            tcs.append(self.replicateGroups[idx])
        return tcs

    def conditions(self):
        """
        Return a list of conditions.

        :return: list(str) -- Conditions.
        """
        if self._conditionToReplicateGroupIdx is None:
            self._createConditionToReplicateGroupIndices()
        conditions=list(self._conditionToReplicateGroupIdx.keys())
        conditions.sort()
        return conditions

    def nonBackgroundReplicates(self):
        """
        :return: list(Replicate) -- replicate groups that are not background samples.
        """
        backgroundIndices=self.backgroundReplicateGroupIndices()
        nbckg=[]
        idx=0
        for tc in self.replicateGroups:
            if idx not in backgroundIndices:
                nbckg.append(tc)
            idx+=1
        return nbckg

    def nonBackgroundReplicateIndices(self):
        """
        :return: list(Replicate) -- Indices of replicate groups that are not background samples.
        """
        backgroundIndices=self.backgroundReplicateGroupIndices()
        nbckgidcs=[]
        idx=0
        for tc in self.replicateGroups:
            if idx not in backgroundIndices:
                nbckgidcs.append(idx)
            idx+=1
        return nbckgidcs

    def nonBackgroundWells(self):
        """
        :return: list(Replicate) -- wells that are not background samples.
        """
        backgroundIndices=self.backgroundWellIndices()
        nbckg=[]
        idx=0
        for tc in self.wells:
            if idx not in backgroundIndices:
                nbckg.append(tc)
            idx+=1
        return nbckg

    def _indexOfReplicateGroup(self,ctc):
        """
        Determine the index of the given replicate group.

        For internal use only.

        :return: int -- Index of replicate group.
        """

        if self.replicateGroups is None:
            return None

        idx=0
        idxOfTc=None
        for ttc in self.replicateGroups:
            if ttc._wellIndices == ctc._wellIndices:
                if idxOfTc is not None:
                    raise RuntimeError("multiple similar replicate groups?")
                else:
                    idxOfTc=idx
            idx+=1

        return idxOfTc

    def _parametersUpdated(self,par=None):
        """
        Notify replicate(s) that a parameter changed and memoised results should be deleted.

        For internal use only.

        :param par: The name of the parameter that was changed.
        :type par: str

        The Replicate objects memoise some results that are expensive
        to calculate. When a parameter is updated, the results may not
        be valid anymore and should get removed from the "cache".
        If par is given, this method can decide which results should
        be removed.
        """

        # only needed for non-background replicate groups (as background does not depend on parameters)
        for tc in self.nonBackgroundWells():
            tc._parametersUpdated(par,dontRecurse=True)
        for tc in self.nonBackgroundReplicates():
            tc._parametersUpdated(par,dontRecurse=True)
        self.modified=True

    def _replicateChanged(self,tc,par=None):
        """
        Update replicates that depend on the given replicate.

        For internal use only.
        """

        if self.replicateGroups is None:
            # for startup code: there are no replicate groups yet
            return

        idxOfTc=self._indexOfReplicateGroup(tc)

        if idxOfTc is None:
            raise RuntimeError("no matching tc for "+tc.fullId())

        for ptc in self.wells:
            if ptc._backgroundIndex == idxOfTc:
                ptc._parametersUpdated(par='backgroundRawOd')
        for ctc in self.replicateGroups:
            if ctc._backgroundIndex == idxOfTc:
                ctc._parametersUpdated(par='backgroundRawOd')

    def _getDefaultParameter(self,par):
        """
        Get default value of parameter.

        For internal use only.

        :param par: The name of the parameter.
        :type par: str

        The Plate stores values of plate-wide parameters
        and default parameters.
        """
        if par not in self._inheritableParameters:
            raise RuntimeError('_getDefaultParameter: unknown parameter '+par)
        return self._inheritableParameters[par]

    def _getExplicitParameter(self,par):
        """
        Get explicit value of parameter (alias for _getDefaultParameter).

        For internal use only.

        :param par: The name of the parameter.
        :type par: str
        """
        return self._getDefaultParameter(par)

    def getParameter(self,par):
        """
        Return the requested parameter.

        :param par: The name of the parameter.
        :type par: str

        If the parameter is explicitly set for the plate, this value
        returned. Otherwise return None.

        See chapter :ref:`parameters <gat parameters>` for details of
        parameter handling and available parameters.
        """
        return self._getDefaultParameter(par)

    def parameterIsEditible(self,par):
        """
        Return True if this is a parameter can have a plate-wide default.

        :return: bool -- True if parameter can be edited.

        Some parameters can only be changed per Replicate, some only
        per Plate. This method is used to distinguish between them.

        See chapter :ref:`parameters <gat parameters>` for details of
        parameter handling and available parameters.
        """
        if par in Plate._isNotPlateParameter and Plate._isNotPlateParameter[par]:
            return False
        if par not in self._inheritableParameters:
            raise RuntimeError("parameterIsEditible: unknown parameter "+par)
        return True

    def parameterIsExplicitlySet(self,par):
        """
        Return True if this is parameter is explicitly set.

        :param par: The name of the parameter.
        :type par: str

        :return: bool -- True if parameter is explicitly set.

        If a parameter is explicitly set for a replicate it overrides
        an inherited value. This method is used to tell whether this
        is the case. Since this object is a plate it tells whether a
        default value has been set.

        See chapter :ref:`parameters <gat parameters>` for details of
        parameter handling and available parameters.
        """
        return self._getExplicitParameter(par) is not None

    def activeChildReplicatesHaveExplicitParameter(self,par):
        """
        Return True if for at least one of the replicate groups the given parameter is explicitly set.

        :param par: The name of the parameter.
        :type par: str

        :return: bool -- True if parameter is explicitly set in one of the replicate groups.

        See chapter :ref:`parameters <gat parameters>` for details of
        parameter handling and available parameters.
        """
        for childtc in self.nonBackgroundReplicates():
            if childtc._getExplicitParameter(par) is not None:
                return True
            if childtc.activeChildReplicatesHaveExplicitParameter(par):
                return True
        return False

    def _setDefaultParameter(self,par,val):
        """
        Change the (default) value of the given parameter.

        For internal use only.

        :param par: The name of the parameter that will be changed.
        :type par: str
        :param val: The new value.
        """
        if par not in self._inheritableParameters:
            raise RuntimeError('_setDefaultParameter: unknown parameter '+par)
        self._inheritableParameters[par]=val
        self._parametersUpdated(par)

    def _setExplicitParameter(self,par,val):
        """
        Change the value of the given parameter (alias for _setDefaultParameter).

        For internal use only.

        :param par: The name of the parameter that will be changed.
        :type par: str
        :param val: The new value.
        """
        self._setDefaultParameter(par,val)

    def setMaxGrowthLowerTimeCutoff(self,t):
        """Set lower limit of interval in which the maximal growth should be searched."""
        self._setDefaultParameter('maxGrowthLowerTimeCutoff',t)

    def setMaxGrowthUpperTimeCutoff(self,t):
        """Set upper limit of interval in which the maximal growth should be searched."""
        self._setDefaultParameter('maxGrowthUpperTimeCutoff',t)

    def setLogOdCutoff(self,lod):
        """Set cutoff value of log(OD)."""
        self._setDefaultParameter('logOdCutoff',lod)

    def setLagAtLogOdEquals(self,lagat):
        """Set value of log(OD) used to define the lag time."""
        self._setDefaultParameter('lagAtLogOdEquals',lagat)

    def setHighDensityCorrectionLinear(self,hdCorrectionLinear=None):
        """Set coefficient of linear term of high density correction."""
        self._setDefaultParameter('hdCorrectionLinear',hdCorrectionLinear)

    def setHighDensityCorrectionQuadratic(self,hdCorrectionQuadratic=None):
        """Set coefficient of quadratic term of high density correction."""
        self._setDefaultParameter('hdCorrectionQuadratic',hdCorrectionQuadratic)

    def setHighDensityCorrectionCubic(self,hdCorrectionCubic=None):
        """Set coefficient of cubic term of high density correction."""
        self._setDefaultParameter('hdCorrectionCubic',hdCorrectionCubic)

    def setSmoothingK(self,k):
        """Set degree of the smoothing spline."""
        self._setDefaultParameter('smoothingK',k)

    def setSmoothingS(self,s):
        """Set smoothing factor used to choose the number of knots."""
        self._setDefaultParameter('smoothingS',s)

    def setSlidingWindowSize(self,win):
        """
        Set number of datapoints of sliding windows.

        The value that is used for local exponential fit (growth rate) and linear regression (growth yield).
        """
        self._setDefaultParameter('slidingWindowSize',win)

    @staticmethod
    def guessWellIds(numberOfWells):
        """
        Return well ids by guessing the plate layout based on number of wells.

        This function will return A1-P24 or A1-H12.

        :param numberOfWells: number of wells of the plate
        :type numberOfWells: int

        :return: list(str) -- the guessed well ids (None if layout could not be guessed)
        """
        # some "heuristics" about well ids: A1-P24 or A1-H12
        if numberOfWells == 384:
            labeldivisor=24
        elif numberOfWells == 96:
            labeldivisor=12
        else:
            return None
        rowlabels=[chr(x) for x in range(ord('A'), ord('P') + 1)]
        wellids=[]
        for i in range(numberOfWells):
            (lblchar,lblnum)=divmod(i, labeldivisor)
            wellids.append(str(rowlabels[lblchar])+str(lblnum+1))
        return wellids

    @staticmethod
    def availableColumnsForCsvExport(logOdDerivativeProperties=True):
        """
        List the available properties that can be chosen for csv export.

        :param logOdDerivativeProperties: include properties determined from log(OD) derivative
        :type logOdDerivativeProperties: bool

        :return: list(str), list(str) -- fixed columns (ids), properties

        The 'fixed columns' list contains the sample/condition tuples
        which should always be exported in order to identify the
        replicates. For the other properties (except 'wellids') the
        variance can be chosen by adding '_var' to the property name.
        """
        fixedcolumns=['sample','condition']
        columns=[]
        columns.extend(['slope_linear',
                        'intercept_linear',
                        'timeOfMax_linear',
                        'lag_linear'])
        columns.extend(['doublingtime_expfit',
                        'growthrate_expfit',
                        'od0_expfit',
                        'timeOfMax_expfit',
                        'lag_expfit'])
        if logOdDerivativeProperties:
            columns.extend(['doublingtime_local',
                            'growthrate_local',
                            'od0_local',
                            'timeOfMax_local',
                            'lag_local'])
        columns.extend(['yield',
                        'timeOfYield'])
        columns.extend(['wellids'])
        return fixedcolumns, columns

    def growthParametersToCsv(self,filename,addVarianceColumns=True,singleWells=False, columns=None, progressCall=None,
                              **csvkwargs):
        """
        Write a "comma seperated values" (csv) file of properties for all replicate groups.

        :param filename: Filename.
        :type filename: string
        :param columns: List of properties that shall get exported (in that order).
        :type columns: list(str)
        :param addVarianceColumns: For each entry in columns add the corresponding variance
        :type addVarianceColumns: bool
        :param singleWells: Export properties of single well replicates instead of replicate groups
        :type singleWells: bool
        :param progressCall: Function that will be called on each iteration.
        :type progressCall: @fun(int)
        :param csvkwargs: Parameters which are passed on to the csv module; defaults to { 'dialect': 'excel' }
        :type csvkwargs: dict()
        """
        if 'dialect' not in csvkwargs:
            csvkwargs['dialect']='excel'

        col2collabel={
            'lag_expfit': 'lag_expfit (ln(OD) == lagAtCutoff)',
            'lag_expfit_var': 'lag_expfit_var (ln(OD) == lagAtCutoff)',
            'lag_local': 'lag_local (ln(OD) == lagAtCutoff)',
            'lag_local_var': 'lag_local_var (ln(OD) == lagAtCutoff)',
            }

        if columns is None:
            columns, morecolumns=Plate.availableColumnsForCsvExport()
            columns.extend(morecolumns)
        if addVarianceColumns and not singleWells:
            newcolums=[]
            for col in columns:
                newcolums.append(col)
                if col in ['sample','condition','wellids']:
                    continue
                if not col.endswith('_var') and col+'_var' not in columns:
                    newcolums.append(col+'_var')
            columns=newcolums
        if singleWells:
            replicates=self.nonBackgroundWells()
        else:
            replicates=self.nonBackgroundReplicates()

        with CsvFileUnicodeWriter(filename,**csvkwargs) as sliwriter:
            descrow=[]
            for col in columns:
                if col in col2collabel:
                    descrow.append(col2collabel[col])
                else:
                    descrow.append(col)
            sliwriter.writerow(descrow)

            allcnt=-1
            for tc in replicates:
                allcnt+=1
                if progressCall is not None:
                    progressCall(allcnt)

                if tc.od() is not None:
                    doublingtime_ef=None
                    doublingtimevar_ef=None
                    doublingtime_nls=None
                    doublingtimevar_nls=None
                    lag_linear=None
                    lagVar_linear=None
                    mu_ef, mu_ef_var, od0_ef, od0_ef_var, maxt_ef, maxt_ef_var, lag_ef, lag_ef_var, method_ef, status = tc.maxGrowthrate()
                    mu_nls, mu_nls_var, od0_nls, od0_nls_var, maxt_nls, maxt_nls_var, lag_nls, lag_nls_var, method_nls, status = tc.maxGrowthrateFromLogOdDerivative()
                    growthyield, growthyield_var, tgrowthyield, tgrowthyield_var, status=tc.growthyield()
                    slope_linear, slopeVar_linear, intercept_linear, interceptVar_linear, timeOfMax_linear, timeOfMaxVar_linear, timeOfMaxIndices_linear, plainSlopeStatus=tc.odSlopemaxIntercept()
                    doublingtime_ef, doublingtimevar_ef=Replicate.growthrateToDoublingTime(mu_ef,mu_ef_var)
                    doublingtime_nls, doublingtimevar_nls=Replicate.growthrateToDoublingTime(mu_nls,mu_nls_var)
                    if slope_linear is not None and slope_linear != 0:
                        lag_linear=-intercept_linear/(slope_linear)
                        if slopeVar_linear is not None and interceptVar_linear is not None:
                            lagVar_linear=((intercept_linear/(slope_linear**2))**2 * slopeVar_linear +
                                            1/slope_linear**2 * interceptVar_linear)
                else:
                    (doublingtime_ef, doublingtimevar_ef, doublingtime_nls, doublingtimevar_nls)=(None,None,None,None)
                    (mu_ef, mu_ef_var, od0_ef, od0_ef_var, maxt_ef, maxt_ef_var, lag_ef, lag_ef_var)=([None,None,None,None,None,None,None,None])
                    (mu_nls, mu_nls_var, od0_nls, od0_nls_var, maxt_nls, maxt_nls_var, lag_nls, lag_nls_var)=([None,None,None,None,None,None,None,None])
                    (growthyield,growthyield_var,tgrowthyield,tgrowthyield_var)=([None,None,None,None])
                    (slope_linear, slopeVar_linear, intercept_linear, interceptVar_linear,
                     timeOfMax_linear, timeOfMaxVar_linear, lag_linear, lagVar_linear)=([None,None,None,None,None,None,None,None])

                thisrow=[]
                for col in columns:
                    if col == 'sample':
                        thisrow.append(tc.sampleid)
                    elif col == 'condition':
                        thisrow.append(tc.condition)
                    elif col == 'slope_linear':
                        thisrow.append(slope_linear)
                    elif col == 'slope_linear_var':
                        thisrow.append(slopeVar_linear)
                    elif col == 'intercept_linear':
                        thisrow.append(intercept_linear)
                    elif col == 'intercept_linear_var':
                        thisrow.append(interceptVar_linear)
                    elif col == 'timeOfMax_linear':
                        thisrow.append(timeOfMax_linear)
                    elif col == 'timeOfMax_linear_var':
                        thisrow.append(timeOfMaxVar_linear)
                    elif col == 'lag_linear':
                        thisrow.append(lag_linear)
                    elif col == 'lag_linear_var':
                        thisrow.append(lagVar_linear)
                    elif col == 'doublingtime_expfit':
                        thisrow.append(doublingtime_ef)
                    elif col == 'doublingtime_expfit_var':
                        thisrow.append(doublingtimevar_ef)
                    elif col == 'growthrate_expfit':
                        thisrow.append(mu_ef)
                    elif col == 'growthrate_expfit_var':
                        thisrow.append(mu_ef_var)
                    elif col == 'od0_expfit':
                        thisrow.append(od0_ef)
                    elif col == 'od0_expfit_var':
                        thisrow.append(od0_ef_var)
                    elif col == 'timeOfMax_expfit':
                        thisrow.append(maxt_ef)
                    elif col == 'timeOfMax_expfit_var':
                        thisrow.append(maxt_ef_var)
                    elif col == 'lag_expfit':
                        thisrow.append(lag_ef)
                    elif col == 'lag_expfit_var':
                        thisrow.append(lag_ef_var)
                    elif col == 'doublingtime_local':
                        thisrow.append(doublingtime_nls)
                    elif col == 'doublingtime_local_var':
                        thisrow.append(doublingtimevar_nls)
                    elif col == 'growthrate_local':
                        thisrow.append(mu_nls)
                    elif col == 'growthrate_local_var':
                        thisrow.append(mu_nls_var)
                    elif col == 'od0_local':
                        thisrow.append(od0_nls)
                    elif col == 'od0_local_var':
                        thisrow.append(od0_nls_var)
                    elif col == 'timeOfMax_local':
                        thisrow.append(maxt_nls)
                    elif col == 'timeOfMax_local_var':
                        thisrow.append(maxt_nls_var)
                    elif col == 'lag_local':
                        thisrow.append(lag_nls)
                    elif col == 'lag_local_var':
                        thisrow.append(lag_nls_var)
                    elif col == 'yield':
                        thisrow.append(growthyield)
                    elif col == 'yield_var':
                        thisrow.append(growthyield_var)
                    elif col == 'timeOfYield':
                        thisrow.append(tgrowthyield)
                    elif col == 'timeOfYield_var':
                        thisrow.append(tgrowthyield_var)
                    elif col == 'wellids':
                        thisrow.append(tc.activeChildWellIdStr())
                    else:
                        raise RuntimeError('unknown property '+col)

                sliwriter.writerow(thisrow)

    def timeseriesToCsv(self,filename,
                        addVarianceColumns=True,
                        singleWells=False,
                        columns=None,
                        fullId=False,
                        progressCall=None,
                        **csvkwargs):
        """
        Write a "comma seperated values" (csv) file of time series for all replicate groups.

        :param filename: Filename.
        :type filename: string
        :param columns: List of time series that shall get exported for each replicate.
        :type columns: list(str)
        :param addVarianceColumns: For each entry in columns add the corresponding variance
        :type addVarianceColumns: bool
        :param singleWells: Export time series of single well replicates instead of replicate groups
        :type singleWells: bool
        :param fullId: Label the columns with the full id (including well ids) instead of "sample condition"
        :type fullId: bool
        :param progressCall: Function that will be called on each iteration.
        :type progressCall: @fun(int)
        :param csvkwargs: Parameters which are passed on to the csv module; defaults to { 'dialect': 'excel' }
        :type csvkwargs: dict()
        """
        if 'dialect' not in csvkwargs:
            csvkwargs['dialect']='excel'
        col2collabel={
            'od': 'OD',
            'od_var': 'var(OD)',
            'lnod': 'ln(OD)',
            }
        if columns is None:
            columns=['od']
        if addVarianceColumns and not singleWells:
            newcolums=[]
            for col in columns:
                newcolums.append(col)
                if col in ['lnod']:
                    continue
                if not col.endswith('_var') and col+'_var' not in columns:
                    newcolums.append(col+'_var')
            columns=newcolums
        if singleWells:
            replicates=self.nonBackgroundWells()
        else:
            replicates=self.nonBackgroundReplicates()

        with CsvFileUnicodeWriter(filename,**csvkwargs) as sliwriter:
            # header
            descrow=['t']
            for tc in replicates:
                for col in columns:
                    if col in col2collabel:
                        lbl=col2collabel[col]
                    else:
                        lbl=col
                    if fullId:
                        lbl+=' '+tc.fullId()
                    else:
                        lbl+=' '+tc.sampleid+' '+tc.condition
                        if singleWells:
                            lbl+=' '+tc.activeChildWellIdStr()
                    descrow.append(lbl)
            sliwriter.writerow(descrow)
            # data
            allcnt=-1
            for ti in range(len(self.time)):
                allcnt+=1
                if progressCall is not None:
                    progressCall(allcnt)
                thisrow=[]
                thisrow.append(self.time[ti])
                for tc in replicates:
                    for col in columns:
                        if col == 'od':
                            if tc.od() is not None:
                                thisrow.append(tc.od()[ti])
                            else:
                                thisrow.append(None)
                        elif col == 'od_var':
                            if tc.odVar() is not None:
                                thisrow.append(tc.odVar()[ti])
                            else:
                                thisrow.append(None)
                        elif col == 'lnod':
                            if tc.logOd() is not None:
                                thisrow.append(tc.logOd()[ti])
                            else:
                                thisrow.append(None)
                        else:
                            raise RuntimeError('unknown property '+col)
                sliwriter.writerow(thisrow)

    @staticmethod
    def _numWellsToFormatString(numWells):
        """
        Return a string uniquely identifying a plate format.

        NOTE this function is subject to change.
        """
        if numWells == 100:
            return '100honeycomb'
        elif numWells == 200:
            return '200honeycomb'
        return str(numWells)

    @staticmethod
    def writeMetadata(filename,metadata,metadataKeys,plateformat='96',**csvkwargs):
        """
        :param metadata: the metadata
        :type metadata: list(dict)
        """
        columnMajorOrder=False
        if plateformat == '96':
            if len(metadata) != 96:
                raise RuntimeError('metadata is not of length 96')
            numcols=12
            numrows=8
        elif plateformat == '384':
            if len(metadata) != 384:
                raise RuntimeError('metadata is not of length 384')
            numcols=24
            numrows=16
        elif plateformat == '200honeycomb':
            if len(metadata) != 200:
                raise RuntimeError('metadata is not of length 200')
            columnMajorOrder=True
            numcols=20 # number of columns in the layout of the exported metadata
            numrows=10 # number of rows

        if plateformat == '96' or plateformat == '384':
            rowlabels=[chr(x) for x in range(ord('A'), ord('A') + numrows)]
            collabels=[str(i+1) for i in range(numcols)]
        elif plateformat == '200honeycomb':
            rowlabels=[str(i) for i in range(1,numrows+1)]
            collabels=[str(i+1) for i in range(0,len(metadata),numrows)]
        else:
            raise RuntimeError('not implemented for format other than 96, 384 or 200 honeycomb')

        if columnMajorOrder:
            reordered=[]
            # transpose
            for rowidx in range(numrows):
                for colidx in range(numcols):
                    metentryidx=rowidx + colidx * numrows
                    reordered.append(metadata[metentryidx])
        else:
            reordered=metadata # keep order

        if 'dialect' not in csvkwargs:
            csvkwargs['dialect']='excel'
        with CsvFileUnicodeWriter(filename,**csvkwargs) as writer:
            # header: just the name of the metadata
            for key in metadataKeys:
                row=[key]
                writer.writerow(row)
                # the column ids
                row=['<>']
                row.extend(collabels)
                writer.writerow(row)
                # now the data, divided into rows of numcols columns
                colit=reordered.__iter__()
                for rowlab in rowlabels:
                    row=[rowlab]
                    for j in range(numcols):
                        thismeta=next(colit)
                        val=thismeta[key] if key in thismeta else None
                        row.append(val)
                    writer.writerow(row)
                # an empty row
                row=[]
                writer.writerow(row)

    @staticmethod
    def readMetadata(filename,plateformat='96',**csvkwargs):
        """
        Read metadata from a csv file.

        For each metadata key a table is read. The table should be laid
        out as according to the plate layout. To get a template, call
        writeMetadata(outfile,[{} for i in range(numOfColumns)],Plate.metadataKeys)
        """
        columnMajorOrder=False
        if plateformat == '96':
            numcols=12
            numrows=8
        elif plateformat == '384':
            numcols=24
            numrows=16
        elif plateformat == '200honeycomb':
            columnMajorOrder=True
            numcols=20 # number of columns in the layout of the exported metadata
            numrows=10 # number of rows

        if plateformat == '96' or plateformat == '384':
            rowlabels=[chr(x) for x in range(ord('A'), ord('A') + numrows)]
            collabels=[str(i+1) for i in range(numcols)]
        elif plateformat == '200honeycomb':
            rowlabels=[str(i) for i in range(1,numrows+1)]
            collabels=[str(i+1) for i in range(0,numcols*numrows,numrows)]
        else:
            raise RuntimeError('not implemented for format other than 96, 384 or 200 honeycomb')

        # initialise the metadata list
        metadata=[{} for i in range(numcols*numrows)]
        if 'dialect' not in csvkwargs:
            csvkwargs['dialect']='excel'
        with CsvFileUnicodeReader(filename,**csvkwargs) as odreader:
            nextlinemode='nada'
            metkey=None
            lineno=0
            for row in odreader:
                lineno+=1
                if nextlinemode == 'nada':
                    if len(row) == 0 or row[0] == '':
                        # skip empty row
                        continue
                    else:
                        metkey=row[0]
                        nextlinemode='starttable'
                elif nextlinemode == 'starttable':
                    if len(row) == 0:
                        raise Plate.BadMetadata('Row at start of table is empty',lineno,filename=filename)
                    if row[0] != '<>' or row[1] != '1' or len(row) != numcols+1:
                        raise Plate.BadMetadata('This does not look like the beginning of a table'+
                                                ', expected row[0] == "<>", row[1] == "1" and len(row) == '+str(numcols+1)+
                                                ', but got row[0]=="'+str(row[0])+'", row[1] == "'+str(row[1])+
                                                '" and len(row) == '+str(len(row)),
                                                lineno,filename=filename)
                    nextlinemode='intable'
                    rowcnt=0
                    metit=metadata.__iter__()
                elif nextlinemode == 'intable':
                    rowcnt+=1
                    if len(row) == 0:
                        raise Plate.BadMetadata('Row '+str(rowcnt)+' is empty',lineno,filename=filename)
                    if row[0].upper() != rowlabels[rowcnt-1]:
                        raise Plate.BadMetadata('Row '+str(rowcnt)+' does not start with '+rowlabels[rowcnt-1]+
                                                ' (found "'+row[0].upper()+'")',lineno,filename=filename)
                    row.pop(0)
                    numOfValsThisRow=len(row)
                    if numOfValsThisRow > numcols:
                        numOfValsThisRow=numcols
                    # read the columns of this row
                    colit=row.__iter__()
                    for i in range(numOfValsThisRow):
                        val=next(colit)
                        if val == '':
                            # map empty sting to None
                            val=None
                        metentry=next(metit)
                        metentry[metkey]=val
                    # if the last columns are empty, fill them up with None
                    for i in range(numcols-numOfValsThisRow):
                        metentry=next(metit)
                        metentry[metkey]=None
                    if rowcnt == numrows:
                        nextlinemode='nada'

        if columnMajorOrder:
            reordered=[]
            for colidx in range(numcols):
                for rowidx in range(numrows):
                    metentryidx=rowidx * numcols + colidx
                    reordered.append(metadata[metentryidx])
            metadata=reordered

        return metadata

    class Error(Exception):
        """Base class for exceptions in this module."""
        pass

    class MultipleBackgroundIdsError(Error):
        """Exception raised if there are different IDs for background wells."""

        def __init__(self, backgroundSampleIds):
            self._backgroundSampleIds = backgroundSampleIds

        def __str__(self):
            return str('multiple keys were found that could be background (blank) samples, make sure there is only one.' +
                       '\nThe keys are:\n'+str(self._backgroundSampleIds))

    class UnknownFileFormat(Error):
        """Exception raised when an unsupported serialisation format is opened."""

        def __init__(self,filename,serFormat=None,serFormatVersion=None,detailedError=None):
            self.filename = filename
            self.serFormat = serFormat
            self.serFormatVersion = serFormatVersion
            self.detailedError = detailedError

        def __str__(self):
            if self.serFormat is not None:
                if self.serFormat.startswith('clsplates'):
                    message= 'You tried to open a Chronological Life Span (CLS) file ("'+self.filename+'"), please use the CLS analyser for this'
                else:
                    message = 'Unsupported file format "'+self.serFormat+'"'
                    if self.serFormatVersion is not None:
                        message += ' version "'+self.serFormatVersion+'"'
                    message += ' in file "'+self.filename+'"'
            else:
                message = 'Unsupported file format in file "'+self.filename+'"'
            if self.detailedError is not None:
                message+=': '+self.detailedError+'.'
            else:
                message+='.'
            return message

    class BadMetadata(Error):
        """Exception raised when an unsupported serialisation format is opened."""

        def __init__(self,detailedError=None,lineno=None,filename=None):
            self.detailedError = detailedError
            self.filename = filename
            self.lineno = lineno

        def __str__(self):
            message = self.detailedError
            if self.lineno is not None:
                message += ' around line '+str(self.lineno)
            if self.filename is not None:
                message += ' in file '+str(self.filename)
            return message